ZyrisDocs
Addon crate guides

zyris-transfer

Configure inboxes, compose send and listen features, wire peer discovery, handle retries and understand current trust and quota limits.

zyris-transfer supplies two surfaces on different connections:

  • file_transfer v1 is the agent-facing capability on the connection to Attacca: send_to and inbox_list, implemented by LocalFileTransfer behind the send feature.
  • peer_transfer v1 is the peer-to-peer exchange: push_offer and streaming pull, implemented by LocalPeerTransfer. Serve this on the direct peer link, not as a general host-control surface.

Do not put file, shell or desktop capabilities on the direct transfer connection. The listener builds a peer-side node with only peer_transfer.

Dependencies and feature selection

[dependencies]
zyris = { git = "https://github.com/attacca-cc/zyris-protocol", features = ["caps", "tls-ring"] }
zyris-transfer = { git = "https://github.com/attacca-cc/zyris-protocol", features = ["send", "listen"] }
zyris-attacca = { git = "https://github.com/attacca-cc/zyris-protocol" }
zyris-p2p = { git = "https://github.com/attacca-cc/zyris-protocol" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
FeaturesAvailable behavior
NoneFilesystem inbox, undo, audit and LocalPeerTransfer; no network listener or agent-facing LocalFileTransfer.
sendLocalFileTransfer, discovery and outgoing iroh connections.
listenserve_peers, peer directory and incoming connections.
BothBidirectional transfer node.

The network features pull in iroh through zyris-p2p and can require a C toolchain for TLS. Cargo features are additive across your dependency graph. An inbox-only implementation need not build QUIC, but accepting network deliveries does require a transport and listener.

Configure receiving storage

use zyris_transfer::TransferConfig;

fn receiving_config() -> TransferConfig {
    TransferConfig {
        inbox: "/srv/transfers/inbox".into(),
        undo: "/srv/transfers/undo".into(),
        audit: Some("/srv/transfers/audit.jsonl".into()),
        max_file_bytes: 1024 * 1024 * 1024,
        ..TransferConfig::default()
    }
}
FieldDefault / current meaning
inbox.; deliveries are grouped under sanitized sender-path subdirectories.
undo.; existing destinations are moved here before replacement.
auditNone: audit logging is off until configured.
max_file_bytes8 GiB; enforced against the offered size.
max_inbox_bytes32 GiB in the struct, but not enforced by the current receiving implementation.
in_flightShared writer guard across config clones; preserve it by cloning one config.

Do not rely on the inbox quota field

max_inbox_bytes currently exists as configuration without a corresponding enforcement path. Use filesystem quotas and application admission control to bound total storage, including partial files and undo data. A per-file size limit is not a total-inbox quota.

The receiver sanitizes names, checks destination paths for symlinks, validates SHA-256 and serializes writers for a transfer. On Unix partial-file opens additionally use O_NOFOLLOW; Windows relies on the pre-check. These are specific protections, not a claim of complete OS isolation or race-free behavior against a hostile local process modifying the same directories.

Construct the sending node before discovery is available

use std::sync::Arc;
use std::time::Duration;
use zyris::caps::FileTransferServer;
use zyris::{Node, NodeKind};
use zyris_attacca::AttaccaApiClient;
use zyris_p2p::{fingerprint::DenyUnknown, iroh, tofu::TofuStore};
use zyris_transfer::{FileTransferConfig, IrohPeerLink, LocalFileTransfer};

fn outgoing_node(endpoint: iroh::Endpoint, node_id: String) -> zyris::Result<Node> {
    let sending = LocalFileTransfer::pending(
        FileTransferConfig {
            root: "/srv/work".into(),
            inbox: "/srv/transfers/inbox".into(),
            node_id,
            ..FileTransferConfig::default()
        },
        TofuStore::new("/srv/transfers/peers.json"),
        Arc::new(DenyUnknown),
        Arc::new(IrohPeerLink::new(endpoint)),
    );
    Node::builder()
        .name("transfers")
        .kind(NodeKind::Service)
        .capability(FileTransferServer(sending.clone()))
        .on_connect(move |conn| {
            let sending = sending.clone();
            async move {
                match conn.wait_capability::<AttaccaApiClient>(Duration::from_secs(10)).await {
                    Ok(api) => sending.set_api(api),
                    Err(error) => eprintln!("peer discovery unavailable: {error}"),
                }
            }
        })
        .build()
}

pending resolves the construction cycle: capabilities are registered before the Attacca connection can provide AttaccaApiClient. Update set_api on every reconnect, not only startup. Until discovery is plugged in, sending fails rather than pretending it can reach a peer. new is the alternate constructor when a live client is already available.

DenyUnknown is intentionally fail-closed. The example can send only to already trusted peers; it is not a click-through trust policy. Supply your own PeerConfirmer to ask a person to compare the fingerprint over a separate trusted channel, or to validate an operator-provisioned allowlist. Do not substitute unconditional approval merely to make the example send successfully.

Wire the peer transport and listener

Complete the network lifecycle in your application:

  1. Persist a private iroh endpoint key and load it on restart. Changing it changes the peer identity. zyris_p2p::endpoint::private_endpoint creates an endpoint without public n0 discovery, with either no relay or an explicitly supplied relay.
  2. Connect the control node to Attacca with peers:write. Publish the endpoint ID and current IP addresses using AttaccaApi::peer_publish, and republish when addresses change.
  3. Refresh the AttaccaApiClient in on_connect. sending.rendezvous() returns the shared, replaceable handle; a listener can use it as its PeerDirectory too.
  4. Start serve_peers once for the endpoint, with receiving configuration and the pin store. Retain its task handle and stop it during application shutdown; do not spawn another listener on every websocket reconnect.
  5. Keep endpoint and control connection alive; closing only the websocket is not endpoint shutdown.

The listener entry point is:

use std::sync::Arc;
use zyris_p2p::{iroh, tofu::TofuStore};
use zyris_transfer::{serve_peers, PeerDirectory, TransferConfig};

async fn listen_for_files(
    endpoint: iroh::Endpoint,
    directory: Arc<dyn PeerDirectory>,
    config: TransferConfig,
    pins: TofuStore,
    node_id: String,
) {
    serve_peers(endpoint, directory, config, pins, node_id).await;
}

The directory cache has a 60-second TTL and throttles refreshes to 10-second intervals by default; an unknown peer cannot trigger a lookup refresh on every connection. A newly published peer may therefore take time to appear.

Receiving trust differs from sending trust

The current listener checks membership through the directory and calls TofuStore::check. A known pin rejects a changed key, but an unknown key passes and remains unpinned; the listener does not call PeerConfirmer for first use. If your policy requires operator approval before accepting any new sender, provide an appropriately restricted directory/admission boundary. Do not assume DenyUnknown on the sending object also guards incoming connections.

Send, resume and inspect receipts

use std::time::Duration;
use zyris::caps::{FileTransfer, FileTransferClient, SendReceipt};
use zyris::Connection;

async fn send_report(conn: &Connection) -> zyris::Result<SendReceipt> {
    let files: FileTransferClient = conn.wait_capability(Duration::from_secs(5)).await?;
    files.send_to(
        "laptop/transfer-node/inbox".into(),
        "report.pdf".into(),
        Some("report.pdf".into()),
        Some(false),
    ).await
}

The path is on the sending node, and the node argument is the recipient's full system/program/node path. FileTransferConfig.root is a sending read boundary, unlike the unrestricted root defaults in zyris-fs and zyris-terminal.

The default sending wire deadline is 55 seconds, intentionally below Attacca's 60-second call cutoff. A pending receipt is not success or an error: wait and retry with the same arguments to resume. Do not report its zero byte count or empty destination as completed progress. Bound retries in your application and inspect the next explanation.

Receipt fieldMeaning
pending, nextWhether another call is required and what the caller should do.
written, bytes, sha256Confirmed destination and integrity details when completed.
replaced, undoWhether an existing file was replaced and any recovery identifier.
directWhether bytes traveled directly instead of through the relay.

Hashing a source that cannot be measured before the deadline fails with source_too_slow_to_measure; repeating that operation is not a resumable transfer. Do not mutate a source during a retry sequence. inbox_list reports completed files, newest first; .part files are excluded, including a completed file whose actual name ends in .part.

Low-level peer implementation

LocalPeerTransfer::receiver_pending(config, sender_path) must receive a PeerTransferClient through set_peer after the peer handshake, so push_offer can pull bytes back. The built-in listener does this wiring. LocalPeerTransfer::sender(root) offers files through offer_file or offer_bytes. Never call set_peer on the sender object: its state is not configured as a receiving inbox.

Keep node_id as a transfer-uniqueness salt, not a trust anchor; peer key trust belongs to the pin/confirmation policy. Preserve pins across restarts and investigate changed-key errors rather than deleting the ledger automatically.

Reference

Source and API · Published crate · Released rustdoc

On this page