Skip to main content

macula_rust/
content.rs

1//! Content sharing (§12 of `plans/PLAN_WIRE_PROTOCOL.md`): put/get by
2//! content-address, over a dedicated QUIC stream — ordinary CALL/RESULT
3//! (§6.4) against four well-known `_content.*` procedures, ported from
4//! `macula_content_transfer.erl`. Not a separate wire protocol: nothing
5//! here is new frame types, just calls a normal [`crate::connection::Session`]
6//! could already make, sent on a stream opened via
7//! [`Session::open_dedicated_stream`](crate::connection::Session::open_dedicated_stream)
8//! instead of the control stream.
9//!
10//! **Deliberate v1 simplification (documented per spec §12.2):** chunked
11//! transfers here run strictly sequentially, one `_content.put_block` /
12//! `_content.get_block` in flight at a time on the single dedicated
13//! stream this module opens — not the reference's parallel multi-lane
14//! algorithm (round-robin chunks across up to 4 concurrent streams).
15//! Multi-lane parallelism is a throughput optimization, not a
16//! correctness requirement: every `_content.*` call, the MCID scheme,
17//! and the manifest wire format are identical either way, so this v1
18//! client interoperates fully with a station built to serve a
19//! parallel-lane peer, and lanes can be added later purely as a
20//! performance improvement with no wire change.
21//!
22//! Sequential retrieval has one incidental upside over the reference:
23//! chunks arrive and get appended in index order for free, so there's no
24//! need for the reference's "accumulate into a map keyed by index, then
25//! reassemble" step.
26
27use std::time::Duration;
28
29use crate::bolt4;
30use crate::cbor::Value;
31use crate::connection::{CallError, FrameStream, Session};
32use crate::identity::KeyPair;
33use crate::manifest::{self, Manifest, Mcid};
34
35/// Reserved realm sentinel for all `_content.*` calls — 32 zero bytes,
36/// distinct from any real realm (`plans/PLAN_WIRE_PROTOCOL.md` §12.1).
37pub const CONTENT_REALM: [u8; 32] = [0u8; 32];
38
39const PUT_BLOCK_PROC: &str = "_content.put_block";
40const GET_BLOCK_PROC: &str = "_content.get_block";
41const PUT_MANIFEST_PROC: &str = "_content.put_manifest";
42const GET_MANIFEST_PROC: &str = "_content.get_manifest";
43
44/// Matches `CONTENT_BLOCK_TIMEOUT_MS` in `macula_content_transfer.erl`.
45const BLOCK_TIMEOUT: Duration = Duration::from_secs(15);
46/// Matches `CONTENT_MANIFEST_TIMEOUT_MS`.
47const MANIFEST_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Matches §12.2's retry policy: up to 3 attempts total, 200ms backoff
50/// between them, only for a BOLT#4 code flagged retryable (§9).
51const MAX_ATTEMPTS: u32 = 3;
52const RETRY_BACKOFF: Duration = Duration::from_millis(200);
53
54#[derive(Debug)]
55pub enum PutError {
56    /// Opening the dedicated stream itself failed (e.g. the connection
57    /// is already dead) — never got as far as making a call.
58    OpenStream(quinn::ConnectionError),
59    Call(CallError),
60    /// The station rejected the call with a BOLT#4 ERROR.
61    Remote {
62        code: u8,
63        name: String,
64        detail: Option<String>,
65    },
66    /// A RESULT arrived but its payload wasn't one of the shapes this
67    /// procedure is documented to return.
68    UnexpectedReply(Value),
69    /// The station recomputed the block's hash and it didn't match the
70    /// MCID the caller sent — the block was not stored.
71    HashMismatch,
72}
73
74impl std::fmt::Display for PutError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            PutError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"),
78            PutError::Call(e) => write!(f, "{e}"),
79            PutError::Remote { code, name, detail } => {
80                write!(f, "station returned error {code} ({name}): {detail:?}")
81            }
82            PutError::UnexpectedReply(v) => write!(f, "unexpected reply shape: {v:?}"),
83            PutError::HashMismatch => write!(f, "station reported hash_mismatch"),
84        }
85    }
86}
87
88impl std::error::Error for PutError {}
89
90#[derive(Debug)]
91pub enum GetError {
92    /// Opening the dedicated stream itself failed (e.g. the connection
93    /// is already dead) — never got as far as making a call.
94    OpenStream(quinn::ConnectionError),
95    Call(CallError),
96    Remote {
97        code: u8,
98        name: String,
99        detail: Option<String>,
100    },
101    UnexpectedReply(Value),
102    NotFound,
103    ManifestDecode(manifest::FromWireError),
104    /// A fetched block or reassembled blob didn't hash to the MCID it
105    /// was fetched under — see the module-level note in §12.1: a
106    /// station may only be relaying content it doesn't itself store, so
107    /// its answer is never trusted without this client-side check.
108    HashMismatch,
109    Verify(manifest::VerifyError),
110}
111
112impl std::fmt::Display for GetError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            GetError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"),
116            GetError::Call(e) => write!(f, "{e}"),
117            GetError::Remote { code, name, detail } => {
118                write!(f, "station returned error {code} ({name}): {detail:?}")
119            }
120            GetError::UnexpectedReply(v) => write!(f, "unexpected reply shape: {v:?}"),
121            GetError::NotFound => write!(f, "station reported not_found"),
122            GetError::ManifestDecode(e) => write!(f, "decoding the fetched manifest: {e}"),
123            GetError::HashMismatch => write!(f, "fetched content does not hash to its MCID"),
124            GetError::Verify(e) => write!(f, "reassembled content failed verification: {e}"),
125        }
126    }
127}
128
129impl std::error::Error for GetError {}
130
131/// Store `data`, returning the MCID it's now addressable by.
132///
133/// `name` is attached to the manifest when `data` is large enough to be
134/// chunked; a single block (`data.len() <= manifest::DEFAULT_CHUNK_SIZE`)
135/// is addressed purely by content hash and carries no name at all,
136/// matching `macula_content_transfer:put_single_block/3` — `name` is
137/// silently unused on that path, not an oversight.
138pub async fn put(
139    session: &mut Session,
140    data: &[u8],
141    name: impl Into<String>,
142    identity: &KeyPair,
143) -> Result<Mcid, PutError> {
144    let mut stream = session
145        .open_dedicated_stream()
146        .await
147        .map_err(PutError::OpenStream)?;
148
149    if data.len() <= manifest::DEFAULT_CHUNK_SIZE {
150        let mcid = manifest::block_mcid(data);
151        put_block(&mut stream, &mcid, data, identity).await?;
152        return Ok(mcid);
153    }
154
155    let opts = manifest::CreateOptions {
156        name: name.into(),
157        ..manifest::CreateOptions::default()
158    };
159    let (manifest, chunks) = manifest::create(data, &opts);
160    for (index, chunk) in chunks.iter().enumerate() {
161        let chunk_mcid = manifest::chunk_mcid(&manifest, index)
162            .expect("index is in range: it came from iterating manifest.create's own chunks");
163        put_block(&mut stream, &chunk_mcid, chunk, identity).await?;
164    }
165    put_manifest(&mut stream, &manifest, identity).await?;
166    Ok(manifest.mcid)
167}
168
169/// Fetch and verify the content addressed by `mcid`.
170pub async fn get(
171    session: &mut Session,
172    mcid: Mcid,
173    identity: &KeyPair,
174) -> Result<Vec<u8>, GetError> {
175    let mut stream = session
176        .open_dedicated_stream()
177        .await
178        .map_err(GetError::OpenStream)?;
179
180    if !manifest::mcid_is_chunked(&mcid) {
181        let data = get_block(&mut stream, &mcid, identity).await?;
182        if manifest::block_mcid(&data) != mcid {
183            return Err(GetError::HashMismatch);
184        }
185        return Ok(data);
186    }
187
188    let manifest = get_manifest(&mut stream, &mcid, identity).await?;
189    // Deliberately NOT `Vec::with_capacity(manifest.size as usize)`:
190    // `manifest.size` is a bare claim from whichever peer served this
191    // manifest, unverified until every chunk is in hand and
192    // `manifest::verify` runs below — a single small malicious manifest
193    // could otherwise claim an enormous size and trigger an immediate,
194    // unbounded allocation attempt before a single byte of real content
195    // has been fetched. Growing the buffer as genuinely-received,
196    // individually-hash-verified chunks arrive bounds memory use to
197    // what has actually, legitimately come off the wire.
198    let mut data = Vec::new();
199    for index in 0..manifest.chunk_count {
200        let chunk_mcid = manifest::chunk_mcid(&manifest, index)
201            .expect("index < manifest.chunk_count, so manifest.chunks[index] exists");
202        let chunk = get_block(&mut stream, &chunk_mcid, identity).await?;
203        if manifest::block_mcid(&chunk) != chunk_mcid {
204            return Err(GetError::HashMismatch);
205        }
206        data.extend_from_slice(&chunk);
207    }
208    manifest::verify(&manifest, &data).map_err(GetError::Verify)?;
209    Ok(data)
210}
211
212async fn put_block(
213    stream: &mut FrameStream,
214    mcid: &Mcid,
215    bytes: &[u8],
216    identity: &KeyPair,
217) -> Result<(), PutError> {
218    let payload = Value::Map(vec![
219        (Value::text("mcid"), Value::Bytes(mcid.to_vec())),
220        (Value::text("payload"), Value::Bytes(bytes.to_vec())),
221    ]);
222    let response = call_with_retry(stream, PUT_BLOCK_PROC, payload, BLOCK_TIMEOUT, identity)
223        .await
224        .map_err(PutError::Call)?;
225    match response {
226        crate::frame::CallResponse::Result { payload, .. } => match payload {
227            Value::Text(t) if t == "ok" => Ok(()),
228            Value::Text(t) if t == "hash_mismatch" => Err(PutError::HashMismatch),
229            other => Err(PutError::UnexpectedReply(other)),
230        },
231        crate::frame::CallResponse::Error {
232            code, name, detail, ..
233        } => Err(PutError::Remote { code, name, detail }),
234    }
235}
236
237async fn put_manifest(
238    stream: &mut FrameStream,
239    manifest: &Manifest,
240    identity: &KeyPair,
241) -> Result<(), PutError> {
242    let payload = Value::Map(vec![(Value::text("manifest"), manifest::to_wire(manifest))]);
243    let response = call_with_retry(
244        stream,
245        PUT_MANIFEST_PROC,
246        payload,
247        MANIFEST_TIMEOUT,
248        identity,
249    )
250    .await
251    .map_err(PutError::Call)?;
252    match response {
253        crate::frame::CallResponse::Result { payload, .. } => match payload {
254            Value::Text(t) if t == "ok" => Ok(()),
255            other => Err(PutError::UnexpectedReply(other)),
256        },
257        crate::frame::CallResponse::Error {
258            code, name, detail, ..
259        } => Err(PutError::Remote { code, name, detail }),
260    }
261}
262
263async fn get_block(
264    stream: &mut FrameStream,
265    mcid: &Mcid,
266    identity: &KeyPair,
267) -> Result<Vec<u8>, GetError> {
268    let payload = Value::Map(vec![(Value::text("mcid"), Value::Bytes(mcid.to_vec()))]);
269    let response = call_with_retry(stream, GET_BLOCK_PROC, payload, BLOCK_TIMEOUT, identity)
270        .await
271        .map_err(GetError::Call)?;
272    match response {
273        crate::frame::CallResponse::Result { payload, .. } => match payload {
274            Value::Bytes(b) => Ok(b),
275            Value::Text(t) if t == "not_found" => Err(GetError::NotFound),
276            other => Err(GetError::UnexpectedReply(other)),
277        },
278        crate::frame::CallResponse::Error {
279            code, name, detail, ..
280        } => Err(GetError::Remote { code, name, detail }),
281    }
282}
283
284async fn get_manifest(
285    stream: &mut FrameStream,
286    mcid: &Mcid,
287    identity: &KeyPair,
288) -> Result<Manifest, GetError> {
289    let payload = Value::Map(vec![(Value::text("mcid"), Value::Bytes(mcid.to_vec()))]);
290    let response = call_with_retry(
291        stream,
292        GET_MANIFEST_PROC,
293        payload,
294        MANIFEST_TIMEOUT,
295        identity,
296    )
297    .await
298    .map_err(GetError::Call)?;
299    match response {
300        crate::frame::CallResponse::Result { payload, .. } => match payload {
301            Value::Map(_) => manifest::from_wire(&payload).map_err(GetError::ManifestDecode),
302            Value::Text(t) if t == "not_found" => Err(GetError::NotFound),
303            other => Err(GetError::UnexpectedReply(other)),
304        },
305        crate::frame::CallResponse::Error {
306            code, name, detail, ..
307        } => Err(GetError::Remote { code, name, detail }),
308    }
309}
310
311/// Send one `_content.*` CALL, retrying per §12.2's policy: up to
312/// [`MAX_ATTEMPTS`] total, [`RETRY_BACKOFF`] between them, only when the
313/// prior attempt's ERROR carries a BOLT#4 code flagged
314/// [retryable](bolt4::Code::is_retryable). A non-retryable ERROR, or a
315/// RESULT (whatever its payload turns out to mean to the caller), both
316/// return on the first attempt.
317async fn call_with_retry(
318    stream: &mut FrameStream,
319    procedure: &str,
320    payload: Value,
321    timeout: Duration,
322    identity: &KeyPair,
323) -> Result<crate::frame::CallResponse, CallError> {
324    let mut attempt = 0;
325    loop {
326        attempt += 1;
327        let deadline_ms = (now_ms() + timeout.as_millis() as u64) as i128;
328        let outcome = stream
329            .call(
330                procedure,
331                CONTENT_REALM,
332                payload.clone(),
333                deadline_ms,
334                identity,
335                timeout,
336            )
337            .await;
338
339        let should_retry = attempt < MAX_ATTEMPTS
340            && matches!(
341                &outcome,
342                Ok(crate::frame::CallResponse::Error { code, .. })
343                    if bolt4::Code::from_u8(*code).is_some_and(bolt4::Code::is_retryable)
344            );
345        if !should_retry {
346            return outcome;
347        }
348        tokio::time::sleep(RETRY_BACKOFF).await;
349    }
350}
351
352fn now_ms() -> u64 {
353    std::time::SystemTime::now()
354        .duration_since(std::time::UNIX_EPOCH)
355        .expect("system clock after epoch")
356        .as_millis() as u64
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn small_data_addresses_as_a_single_block() {
365        let data = vec![7u8; 100];
366        assert!(data.len() <= manifest::DEFAULT_CHUNK_SIZE);
367        let mcid = manifest::block_mcid(&data);
368        assert!(!manifest::mcid_is_chunked(&mcid));
369    }
370
371    #[test]
372    fn large_data_would_address_as_a_manifest() {
373        let data = vec![7u8; manifest::DEFAULT_CHUNK_SIZE + 1];
374        let opts = manifest::CreateOptions::default();
375        let (manifest, chunks) = manifest::create(&data, &opts);
376        assert!(chunks.len() > 1);
377        assert!(manifest::mcid_is_chunked(&manifest.mcid));
378    }
379
380    #[test]
381    fn call_with_retry_backoff_matches_the_spec() {
382        assert_eq!(MAX_ATTEMPTS, 3);
383        assert_eq!(RETRY_BACKOFF, Duration::from_millis(200));
384    }
385}