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_transferv1 is the agent-facing capability on the connection to Attacca:send_toandinbox_list, implemented byLocalFileTransferbehind thesendfeature.peer_transferv1 is the peer-to-peer exchange:push_offerand streamingpull, implemented byLocalPeerTransfer. 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"] }| Features | Available behavior |
|---|---|
| None | Filesystem inbox, undo, audit and LocalPeerTransfer; no network listener or agent-facing LocalFileTransfer. |
send | LocalFileTransfer, discovery and outgoing iroh connections. |
listen | serve_peers, peer directory and incoming connections. |
| Both | Bidirectional 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()
}
}| Field | Default / current meaning |
|---|---|
inbox | .; deliveries are grouped under sanitized sender-path subdirectories. |
undo | .; existing destinations are moved here before replacement. |
audit | None: audit logging is off until configured. |
max_file_bytes | 8 GiB; enforced against the offered size. |
max_inbox_bytes | 32 GiB in the struct, but not enforced by the current receiving implementation. |
in_flight | Shared 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:
- Persist a private iroh endpoint key and load it on restart. Changing it changes the peer identity.
zyris_p2p::endpoint::private_endpointcreates an endpoint without public n0 discovery, with either no relay or an explicitly supplied relay. - Connect the control node to Attacca with
peers:write. Publish the endpoint ID and current IP addresses usingAttaccaApi::peer_publish, and republish when addresses change. - Refresh the
AttaccaApiClientinon_connect.sending.rendezvous()returns the shared, replaceable handle; a listener can use it as itsPeerDirectorytoo. - Start
serve_peersonce 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. - 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 field | Meaning |
|---|---|
pending, next | Whether another call is required and what the caller should do. |
written, bytes, sha256 | Confirmed destination and integrity details when completed. |
replaced, undo | Whether an existing file was replaced and any recovery identifier. |
direct | Whether 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.