Skip to main content

thincan_file_transfer/
lib.rs

1//! File-transfer protocol helpers for `thincan`.
2//!
3//! This crate provides Cap'n Proto message types and helpers for building a simple file transfer
4//! protocol on top of `thincan`.
5//!
6//! ## Integration steps
7//! 1. Define `FileReq` / `FileChunk` / `FileAck` in your bus atlas using [`schema`] owned types.
8//! 2. Implement [`Atlas`] for your atlas marker type.
9//! 3. Include `FileTransferBundle` in a `thincan::maplet!` bundle list.
10//! 4. Build `maplet::Bundles::new(&iface)` and call methods on the singleton bundle instance:
11//!    - sender: `bundles.file_transfer.send_file(...)` / `send_file_with_id(...)`
12//!    - receiver (`alloc`): `bundles.file_transfer.recv_file(...)`
13//!    - receiver (`heapless`): `bundles.file_transfer.recv_file_no_alloc(...)`
14//!
15//! ## Backpressure / throttling
16//! Sender flow is ack-driven and windowed. Receiver flow only sends progress/completion acks after
17//! storage writes complete, so slow storage naturally throttles transfer rate.
18//!
19//! ## Runtime note
20//! These methods rely on your external demux pump to feed incoming frames into the bus mailbox
21//! via `BusHandle::ingest`.
22//!
23//! With `--features heapless` you can encode Cap'n Proto bodies without allocation:
24//! ```rust,ignore
25//! # use thincan_file_transfer as ft;
26//! # let mut scratch = ft::CapnpScratch::<8>::new();
27//! # let mut out = [0u8; ft::file_ack_max_encoded_len()];
28//! # let transfer_id = 1u32;
29//! # let kind = ft::schema::FileAckKind::Ack;
30//! # let next_offset = 64u32;
31//! # let chunk_size = 0u32;
32//! # let error = ft::schema::FileAckError::None;
33//! let used = ft::encode_file_ack_into(
34//!     &mut scratch,
35//!     transfer_id,
36//!     kind,
37//!     next_offset,
38//!     chunk_size,
39//!     error,
40//!     &mut out,
41//! )?;
42//! // Send `&out[..used]` as the body of your bus's `FileAck` message.
43//! # Ok::<(), thincan::Error>(())
44//! ```
45//!
46//! This crate is async-first and is designed for runtimes like embassy where storage I/O must not
47//! block the executor.
48#![cfg_attr(not(feature = "std"), no_std)]
49#![allow(async_fn_in_trait)]
50
51#[cfg(not(any(feature = "alloc", feature = "heapless")))]
52compile_error!(
53    "thincan-file-transfer requires either `alloc` (or `std`) or `heapless` (for no-alloc)."
54);
55
56#[cfg(feature = "alloc")]
57extern crate alloc;
58
59/// Re-exported for integrations that need Cap'n Proto helpers without depending on `capnp`
60/// directly.
61#[doc(hidden)]
62pub use capnp;
63
64use core::marker::PhantomData;
65
66capnp::generated_code!(pub mod file_transfer_capnp);
67pub use file_transfer_capnp as schema;
68
69/// Default chunk size (bytes) used by senders.
70pub const DEFAULT_CHUNK_SIZE: usize = 64;
71
72/// Cap'n Proto "word padding" (Cap'n Proto Data blobs are padded to a word boundary).
73pub const fn capnp_padded_len(len: usize) -> usize {
74    (len + 7) & !7
75}
76
77/// Upper bound for an encoded `FileReq` body (bytes) without metadata.
78pub const fn file_req_max_encoded_len() -> usize {
79    40
80}
81
82/// Upper bound for an encoded `FileReq` body (bytes) with metadata.
83pub const fn file_offer_max_encoded_len(metadata_len: usize, hash_len: usize) -> usize {
84    40 + capnp_padded_len(metadata_len) + capnp_padded_len(hash_len)
85}
86
87/// Upper bound for an encoded `FileChunk` body (bytes).
88pub const fn file_chunk_max_encoded_len(data_len: usize) -> usize {
89    32 + capnp_padded_len(data_len)
90}
91
92/// Upper bound for an encoded `FileAck` body (bytes).
93pub const fn file_ack_max_encoded_len() -> usize {
94    24
95}
96
97/// Convert a byte count to a conservative Cap'n Proto scratch requirement (words).
98pub const fn capnp_scratch_words_for_bytes(bytes: usize) -> usize {
99    bytes.div_ceil(8)
100}
101
102/// Receiver-side configuration.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[derive(Default)]
105pub struct ReceiverConfig {
106    /// Maximum accepted `FileChunk.data` length (bytes).
107    ///
108    /// - `0` means "no limit / accept any size" (back-compat default).
109    /// - Non-zero values cause oversize chunks to be rejected as a protocol error.
110    pub max_chunk_size: u32,
111}
112
113
114/// Atlas contract for file-transfer message types.
115pub trait Atlas {
116    type FileReq: thincan::CapnpMessage;
117    type FileChunk: thincan::CapnpMessage;
118    type FileAck: thincan::CapnpMessage;
119}
120
121/// Number of message types required by [`FileTransferBundle`].
122pub const FILE_TRANSFER_MESSAGE_COUNT: usize = 3;
123
124/// Bundle type for file-transfer protocol operations.
125#[derive(Clone, Copy, Debug, Default)]
126pub struct FileTransferBundle<A>(PhantomData<A>);
127
128impl<A> thincan::BundleSpec<FILE_TRANSFER_MESSAGE_COUNT> for FileTransferBundle<A>
129where
130    A: Atlas,
131{
132    const MESSAGE_IDS: [u16; FILE_TRANSFER_MESSAGE_COUNT] = [
133        <A::FileReq as thincan::Message>::ID,
134        <A::FileChunk as thincan::Message>::ID,
135        <A::FileAck as thincan::Message>::ID,
136    ];
137}
138
139/// Async dependency required by the file-transfer bundle: a byte-addressable file store.
140///
141/// This is intended for async runtimes such as embassy, where storage I/O (flash, SD, etc.) should
142/// not block the entire executor.
143pub trait AsyncFileStore {
144    type Error;
145    type WriteHandle;
146
147    async fn begin_write(
148        &mut self,
149        transfer_id: u32,
150        total_len: u32,
151    ) -> Result<Self::WriteHandle, Self::Error>;
152
153    async fn write_at(
154        &mut self,
155        handle: &mut Self::WriteHandle,
156        offset: u32,
157        bytes: &[u8],
158    ) -> Result<(), Self::Error>;
159
160    async fn commit(&mut self, handle: Self::WriteHandle) -> Result<(), Self::Error>;
161
162    async fn abort(&mut self, handle: Self::WriteHandle);
163}
164
165/// Errors produced by the file-transfer state machine.
166#[derive(Debug)]
167pub enum Error<E> {
168    Store(E),
169    Protocol,
170    Capnp(capnp::Error),
171}
172
173/// Ack message that the receiver side would like to emit.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct PendingAck {
176    pub transfer_id: u32,
177    pub kind: schema::FileAckKind,
178    pub next_offset: u32,
179    pub chunk_size: u32,
180    pub error: schema::FileAckError,
181}
182
183/// Value type used to encode a `FileReq` message for a specific atlas marker type `A`.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub struct FileReqValue<A> {
186    pub transfer_id: u32,
187    pub total_len: u32,
188    _atlas: PhantomData<A>,
189}
190
191/// Value type used to encode a `FileReq` message including metadata and chunk negotiation.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct FileOfferValue<'a, A> {
194    pub transfer_id: u32,
195    pub total_len: u32,
196    pub file_metadata: &'a [u8],
197    pub sender_max_chunk_size: u32,
198    pub file_hash_algo: schema::FileHashAlgo,
199    pub file_hash: &'a [u8],
200    _atlas: PhantomData<A>,
201}
202
203/// Value type used to encode a `FileChunk` message for a specific atlas marker type `A`.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct FileChunkValue<'a, A> {
206    pub transfer_id: u32,
207    pub offset: u32,
208    pub data: &'a [u8],
209    _atlas: PhantomData<A>,
210}
211
212impl<A> FileReqValue<A> {
213    /// Construct a `FileReq` value.
214    pub fn new(transfer_id: u32, total_len: u32) -> Self {
215        Self {
216            transfer_id,
217            total_len,
218            _atlas: PhantomData,
219        }
220    }
221}
222
223/// Convenience constructor for [`FileReqValue`].
224pub fn file_req<A>(transfer_id: u32, total_len: u32) -> FileReqValue<A> {
225    FileReqValue::new(transfer_id, total_len)
226}
227
228impl<'a, A> FileOfferValue<'a, A> {
229    /// Construct a `FileReq` offer value.
230    pub fn new(
231        transfer_id: u32,
232        total_len: u32,
233        sender_max_chunk_size: u32,
234        file_metadata: &'a [u8],
235        file_hash_algo: schema::FileHashAlgo,
236        file_hash: &'a [u8],
237    ) -> Self {
238        Self {
239            transfer_id,
240            total_len,
241            file_metadata,
242            sender_max_chunk_size,
243            file_hash_algo,
244            file_hash,
245            _atlas: PhantomData,
246        }
247    }
248}
249
250/// Convenience constructor for [`FileOfferValue`].
251pub fn file_offer<'a, A>(
252    transfer_id: u32,
253    total_len: u32,
254    sender_max_chunk_size: u32,
255    file_metadata: &'a [u8],
256    file_hash_algo: schema::FileHashAlgo,
257    file_hash: &'a [u8],
258) -> FileOfferValue<'a, A> {
259    FileOfferValue::new(
260        transfer_id,
261        total_len,
262        sender_max_chunk_size,
263        file_metadata,
264        file_hash_algo,
265        file_hash,
266    )
267}
268
269impl<'a, A> FileChunkValue<'a, A> {
270    /// Construct a `FileChunk` value.
271    pub fn new(transfer_id: u32, offset: u32, data: &'a [u8]) -> Self {
272        Self {
273            transfer_id,
274            offset,
275            data,
276            _atlas: PhantomData,
277        }
278    }
279}
280
281/// Convenience constructor for [`FileChunkValue`].
282pub fn file_chunk<'a, A>(transfer_id: u32, offset: u32, data: &'a [u8]) -> FileChunkValue<'a, A> {
283    FileChunkValue::new(transfer_id, offset, data)
284}
285
286/// Value type used to encode a `FileAck` message.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct FileAckValue<A> {
289    pub transfer_id: u32,
290    pub kind: schema::FileAckKind,
291    pub next_offset: u32,
292    pub chunk_size: u32,
293    pub error: schema::FileAckError,
294    _atlas: PhantomData<A>,
295}
296
297impl<A> FileAckValue<A> {
298    pub fn new(
299        transfer_id: u32,
300        kind: schema::FileAckKind,
301        next_offset: u32,
302        chunk_size: u32,
303        error: schema::FileAckError,
304    ) -> Self {
305        Self {
306            transfer_id,
307            kind,
308            next_offset,
309            chunk_size,
310            error,
311            _atlas: PhantomData,
312        }
313    }
314}
315
316pub fn file_ack_accept<A>(transfer_id: u32, chunk_size: u32) -> FileAckValue<A> {
317    FileAckValue::new(
318        transfer_id,
319        schema::FileAckKind::Accept,
320        0,
321        chunk_size,
322        schema::FileAckError::None,
323    )
324}
325
326pub fn file_ack_progress<A>(transfer_id: u32, next_offset: u32) -> FileAckValue<A> {
327    FileAckValue::new(
328        transfer_id,
329        schema::FileAckKind::Ack,
330        next_offset,
331        0,
332        schema::FileAckError::None,
333    )
334}
335
336pub fn file_ack_reject<A>(transfer_id: u32, error: schema::FileAckError) -> FileAckValue<A> {
337    FileAckValue::new(transfer_id, schema::FileAckKind::Reject, 0, 0, error)
338}
339
340#[cfg(feature = "alloc")]
341mod alloc_encode;
342
343#[cfg(feature = "heapless")]
344mod heapless_encode;
345#[cfg(feature = "heapless")]
346pub use heapless_encode::{
347    CapnpScratch, decode_file_ack_fields, encode_file_ack_into, encode_file_chunk_into,
348    encode_file_offer_into,
349};
350
351#[cfg(any(feature = "tokio", feature = "embassy"))]
352mod tokio_impl;
353#[cfg(any(feature = "tokio", feature = "embassy"))]
354pub use tokio_impl::{
355    Ack, FileTransferBundleInstance, SendConfig, SendFileResult, SendState, decode_file_ack,
356};
357
358#[cfg(feature = "alloc")]
359#[cfg(any(feature = "tokio", feature = "embassy"))]
360pub use tokio_impl::RecvFileResult;
361
362#[cfg(feature = "heapless")]
363#[cfg(any(feature = "tokio", feature = "embassy"))]
364pub use tokio_impl::RecvFileResultNoAlloc;
365
366// File transfer integrations should expose the maplet-generated bundle singleton and call its
367// async methods (`send_file`, `recv_file`, etc.) from bespoke async protocol code.