Skip to main content

wire/
object_transfer.rs

1// SPDX-License-Identifier: Apache-2.0
2use objects::{
3    object::{State, Tree},
4    store::ObjectStore,
5};
6
7use crate::{ObjectData, ObjectId, ObjectRequest, ObjectType, ProtocolError, Result};
8
9/// Maximum redaction sidecar blob accepted from the pull stream, per blob.
10///
11/// Redaction sidecars are signed range lists for a single blob — orders of
12/// magnitude smaller than the blob payload they describe. 64 MiB bounds the
13/// server-controlled receive buffer on the pull stream (the same
14/// unbounded-allocation OOM class #366 closed for the native pack/index
15/// buffers) while leaving generous headroom for any legitimate record.
16pub const MAX_RECEIVED_REDACTIONS_BLOB_SIZE: u64 = 64 * 1024 * 1024;
17
18/// Maximum state-visibility sidecar blob accepted from the pull stream, per
19/// state.
20///
21/// State-visibility sidecars are per-state tier records, not object payloads.
22/// 64 MiB bounds this second server-controlled pull-stream buffer with the
23/// same receive-side cap.
24pub const MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE: u64 = 64 * 1024 * 1024;
25
26/// Envelope headroom added on top of the largest legitimate sidecar blob when
27/// sizing the pull-stream frame decode limit. Covers the protobuf fields that
28/// wrap a max-size sidecar blob in a `PullMessage` — the oneof tag, the
29/// `blob_hash`/`state_id` string, and the transfer checkpoint — none of which
30/// approach a MiB. Kept deliberately tight (not generously round): the decode
31/// limit is a per-*message* bound, so the unavoidable slop above the precise
32/// per-blob cap equals this headroom. Minimizing it keeps the worst-case
33/// attacker-forced allocation within ~1 MiB of the 64 MiB blob cap; the exact
34/// per-blob cap for that residual window is enforced by the post-decode
35/// `check_received_transfer_blob_size` defense-in-depth check.
36const PULL_DECODE_ENVELOPE_HEADROOM: u64 = 1024 * 1024;
37
38const fn max_u64(a: u64, b: u64) -> u64 {
39    if a > b { a } else { b }
40}
41
42/// Inbound protobuf-frame decode limit for the pull stream.
43///
44/// This is the *load-bearing* bound on the single-shot, server-controlled
45/// sidecar allocation. The hosted frame decoder refuses an inbound
46/// `PullServerFrame` larger than this, so an oversized `redactions_blob` /
47/// `state_visibility_blob` is rejected before its `Vec<u8>` is materialized.
48/// [`check_received_transfer_blob_size`] is retained as a cheap post-decode
49/// defense-in-depth check, but the allocation itself is bounded here.
50///
51/// Sized to the largest legitimate single message — a sidecar transfer carrying
52/// a max-size blob ([`MAX_RECEIVED_REDACTIONS_BLOB_SIZE`] /
53/// [`MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE`], 64 MiB) — plus
54/// [`PULL_DECODE_ENVELOPE_HEADROOM`]. Native pack chunks share this stream but
55/// are bounded far below this by the negotiated chunk size, so they are
56/// unaffected.
57pub const MAX_PULL_FRAME_MESSAGE_SIZE: usize = (max_u64(
58    MAX_RECEIVED_REDACTIONS_BLOB_SIZE,
59    MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE,
60) + PULL_DECODE_ENVELOPE_HEADROOM) as usize;
61
62/// Reject a received per-object transfer sidecar blob whose length exceeds
63/// `max_bytes`, before it is handed to the repository accept path.
64///
65/// Sidecar blobs (redaction, state-visibility) arrive as single
66/// server-controlled buffers on the pull stream. This is the single-shot
67/// analogue of [`crate::receive_pack_chunk`]'s running-total check: it bounds
68/// the in-memory allocation a hostile or buggy server can drive on the receive
69/// side. `kind` names the blob in the error (e.g. `"redactions"`).
70pub fn check_received_transfer_blob_size(
71    blob_len: usize,
72    max_bytes: u64,
73    kind: &str,
74) -> Result<()> {
75    let len = u64::try_from(blob_len).map_err(|_| {
76        ProtocolError::InvalidState(format!("{kind} blob length does not fit in u64"))
77    })?;
78    if len > max_bytes {
79        return Err(ProtocolError::InvalidState(format!(
80            "{kind} blob exceeds receive size limit: {len} bytes (max {max_bytes})"
81        )));
82    }
83    Ok(())
84}
85
86#[allow(dead_code)]
87pub fn chunk_count(object_size: usize, chunk_size: usize) -> usize {
88    if object_size == 0 || chunk_size == 0 {
89        return 0;
90    }
91    object_size.div_ceil(chunk_size)
92}
93
94#[allow(dead_code)]
95pub fn chunk_bounds(
96    object_size: usize,
97    chunk_size: usize,
98    chunk_index: usize,
99) -> Option<(usize, usize)> {
100    if chunk_size == 0 {
101        return None;
102    }
103
104    let start = chunk_index.checked_mul(chunk_size)?;
105    if start >= object_size {
106        return None;
107    }
108    let end = (start + chunk_size).min(object_size);
109    Some((start, end - start))
110}
111
112#[allow(dead_code)]
113pub fn chunk_offset(chunk_index: usize, chunk_size: usize) -> Option<usize> {
114    chunk_index.checked_mul(chunk_size)
115}
116
117pub fn load_requested_object(store: &impl ObjectStore, req: &ObjectRequest) -> Result<ObjectData> {
118    // Note on sidecar objects: redactions and state visibility are keyed by
119    // ids that also identify primary objects. `load_requested_object`
120    // resolves blob-vs-tree or state by id shape/probe; it cannot
121    // disambiguate a sidecar request by ObjectId alone. Callers that need to
122    // fetch a sidecar must use `load_object_data` with an explicit object
123    // type.
124    let (obj_type, data) = match &req.id {
125        ObjectId::Hash(hash) => {
126            if let Some(blob) = store.get_blob(hash)? {
127                (ObjectType::Blob, blob.content().to_vec())
128            } else if let Some(tree) = store.get_tree(hash)? {
129                (ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)
130            } else {
131                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
132            }
133        }
134        ObjectId::StateId(state_id) => {
135            let state = store
136                .get_state(state_id)?
137                .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
138            (ObjectType::State, rmp_serde::to_vec_named(&state)?)
139        }
140        ObjectId::StateAttachment { state, id, kind: _ } => {
141            let attachment = store
142                .get_state_attachment(state, id)?
143                .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
144            (
145                ObjectType::StateAttachment,
146                rmp_serde::to_vec_named(&attachment)?,
147            )
148        }
149    };
150
151    Ok(ObjectData {
152        id: req.id.clone(),
153        obj_type,
154        data,
155        is_delta: false,
156    })
157}
158
159pub fn load_object_data(
160    store: &impl ObjectStore,
161    id: &ObjectId,
162    obj_type: ObjectType,
163) -> Result<ObjectData> {
164    let data = match (id, obj_type) {
165        (ObjectId::Hash(hash), ObjectType::Blob) => store
166            .get_blob(hash)?
167            .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?
168            .content()
169            .to_vec(),
170        (ObjectId::Hash(hash), ObjectType::Tree) => {
171            let tree = store
172                .get_tree(hash)?
173                .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
174            rmp_serde::to_vec_named(&tree)?
175        }
176        (ObjectId::StateId(state_id), ObjectType::State) => {
177            let state = store
178                .get_state(state_id)?
179                .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
180            rmp_serde::to_vec_named(&state)?
181        }
182        (ObjectId::Hash(hash), ObjectType::Redaction) => store
183            .get_redactions_bytes_for_blob(hash)?
184            .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
185        (ObjectId::StateId(state_id), ObjectType::StateVisibility) => store
186            .get_state_visibility_bytes_for_state(state_id)?
187            .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string_full()))?,
188        (ObjectId::StateAttachment { state, id, kind: _ }, ObjectType::StateAttachment) => {
189            let attachment = store
190                .get_state_attachment(state, id)?
191                .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
192            rmp_serde::to_vec_named(&attachment)?
193        }
194        (ObjectId::Hash(_), ObjectType::KeyBinding) => {
195            return Err(ProtocolError::InvalidState(
196                "KeyBinding registry objects must be constructed with encode_key_binding_registry"
197                    .to_string(),
198            ));
199        }
200        _ => {
201            return Err(ProtocolError::InvalidState(
202                "object id/type mismatch".to_string(),
203            ));
204        }
205    };
206
207    Ok(ObjectData {
208        id: id.clone(),
209        obj_type,
210        data,
211        is_delta: false,
212    })
213}
214
215pub fn store_received_object(store: &impl ObjectStore, data: &ObjectData) -> Result<()> {
216    match (&data.id, data.obj_type) {
217        (ObjectId::Hash(hash), ObjectType::Blob) => {
218            store.put_blob_bytes_with_hash(&data.data, *hash)?;
219        }
220        (ObjectId::Hash(hash), ObjectType::Tree) => {
221            let tree: Tree = rmp_serde::from_slice(&data.data)?;
222            tree.validate().map_err(|error| {
223                ProtocolError::InvalidState(format!("invalid tree object: {error}"))
224            })?;
225            if &tree.hash() != hash {
226                return Err(ProtocolError::InvalidState(
227                    "tree hash mismatch".to_string(),
228                ));
229            }
230            store.put_tree_serialized(&data.data, *hash)?;
231        }
232        (ObjectId::StateId(state_id), ObjectType::State) => {
233            let state: State = rmp_serde::from_slice(&data.data)?;
234            if state.id() != *state_id {
235                return Err(ProtocolError::InvalidState(format!(
236                    "StateId mismatch: expected {state_id}, computed {}",
237                    state.id()
238                )));
239            }
240            store.put_state_serialized(&data.data, *state_id)?;
241        }
242        (ObjectId::StateAttachment { state, id, kind }, ObjectType::StateAttachment) => {
243            let attachment: objects::object::StateAttachment = rmp_serde::from_slice(&data.data)?;
244            if attachment.state_id != *state || attachment.id() != *id {
245                return Err(ProtocolError::InvalidState(
246                    "state attachment id mismatch".to_string(),
247                ));
248            }
249            // The descriptor's carried kind must agree with the decoded body's
250            // kind — kind is a pure projection of the record, so a divergence
251            // means the descriptor and the bytes disagree about what this
252            // attachment is. Refuse rather than silently trust either side.
253            let body_kind = attachment.body.kind();
254            if *kind != body_kind {
255                return Err(ProtocolError::InvalidState(format!(
256                    "state attachment kind mismatch: descriptor {kind:?}, body {body_kind:?}"
257                )));
258            }
259            store.put_state_attachment(&attachment)?;
260        }
261        (_, ObjectType::Redaction) => {
262            // Redactions ship signed and need verification before any
263            // bytes hit the sidecar. Refuse here so callers route via
264            // `Repository::accept_wire_redactions` instead of silently
265            // landing an unverified record.
266            return Err(ProtocolError::InvalidState(
267                "Redaction objects must be persisted via Repository::accept_wire_redactions, \
268                 not store_received_object — signature verification is required"
269                    .to_string(),
270            ));
271        }
272        (_, ObjectType::StateVisibility) => {
273            // State visibility must be validated and normalized at the
274            // Repository boundary (`put_state_visibility` enforces
275            // public-by-absence). Refuse raw sidecar writes here.
276            return Err(ProtocolError::InvalidState(
277                "StateVisibility objects must be persisted via Repository::accept_wire_state_visibility, \
278                 not store_received_object — sidecar validation is required"
279                    .to_string(),
280            ));
281        }
282        (_, ObjectType::KeyBinding) => {
283            return Err(ProtocolError::InvalidState(
284                "KeyBinding registry objects must be decoded and verified with decode_key_binding_registry"
285                    .to_string(),
286            ));
287        }
288        _ => {
289            return Err(ProtocolError::InvalidState(
290                "object id/type mismatch".to_string(),
291            ));
292        }
293    }
294
295    Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300    use objects::{
301        object::{
302            Attribution, Blob, ContentHash, Principal, State, StateAttachment, StateAttachmentBody,
303            Tree, TreeEntry,
304        },
305        store::{FsStore, ObjectStore},
306    };
307    use tempfile::TempDir;
308
309    use super::*;
310
311    fn create_test_store() -> (TempDir, FsStore) {
312        let temp = TempDir::new().unwrap();
313        let store = FsStore::new(temp.path().join(".heddle"));
314        store.init().unwrap();
315        (temp, store)
316    }
317
318    fn test_attribution() -> Attribution {
319        Attribution::human(Principal::new("Wire Tester", "wire@example.com"))
320    }
321
322    #[test]
323    fn primary_objects_roundtrip_through_wire_data() {
324        let (_source_temp, source) = create_test_store();
325        let (_dest_temp, dest) = create_test_store();
326
327        let blob = Blob::from("wire transfer blob\n");
328        let blob_hash = source.put_blob(&blob).unwrap();
329        let tree = Tree::from_entries(vec![TreeEntry::file("lib.rs", blob_hash, false).unwrap()]);
330        let tree_hash = source.put_tree(&tree).unwrap();
331        let state = State::new(tree_hash, Vec::new(), test_attribution())
332            .with_intent("exercise wire transfer");
333        source.put_state(&state).unwrap();
334
335        let blob_data = load_requested_object(
336            &source,
337            &ObjectRequest {
338                id: ObjectId::Hash(blob_hash),
339                have_base: None,
340            },
341        )
342        .unwrap();
343        assert_eq!(blob_data.obj_type, ObjectType::Blob);
344        assert_eq!(blob_data.data, blob.content());
345        store_received_object(&dest, &blob_data).unwrap();
346        assert_eq!(
347            dest.get_blob(&blob_hash).unwrap().unwrap().content(),
348            blob.content()
349        );
350
351        let tree_data = load_requested_object(
352            &source,
353            &ObjectRequest {
354                id: ObjectId::Hash(tree_hash),
355                have_base: None,
356            },
357        )
358        .unwrap();
359        assert_eq!(tree_data.obj_type, ObjectType::Tree);
360        assert_eq!(
361            rmp_serde::from_slice::<Tree>(&tree_data.data).unwrap(),
362            tree
363        );
364        store_received_object(&dest, &tree_data).unwrap();
365        assert_eq!(dest.get_tree(&tree_hash).unwrap().unwrap(), tree);
366
367        let state_data = load_requested_object(
368            &source,
369            &ObjectRequest {
370                id: ObjectId::StateId(state.state_id),
371                have_base: None,
372            },
373        )
374        .unwrap();
375        assert_eq!(state_data.obj_type, ObjectType::State);
376        assert_eq!(
377            objects::store::codec::decode_state(&state_data.data).unwrap(),
378            state
379        );
380        store_received_object(&dest, &state_data).unwrap();
381        assert_eq!(
382            dest.get_state(&state.state_id).unwrap().unwrap().state_id,
383            state.state_id
384        );
385    }
386
387    #[test]
388    fn load_object_data_reports_missing_and_id_type_mismatch_errors() {
389        let (_temp, store) = create_test_store();
390        let missing_hash = ContentHash::from_bytes([7; 32]);
391        let missing_state = objects::object::StateId::from_bytes([9; 32]);
392
393        let missing = load_requested_object(
394            &store,
395            &ObjectRequest {
396                id: ObjectId::Hash(missing_hash),
397                have_base: None,
398            },
399        )
400        .unwrap_err();
401        assert!(
402            matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_hash.to_hex())
403        );
404
405        let missing = load_requested_object(
406            &store,
407            &ObjectRequest {
408                id: ObjectId::StateId(missing_state),
409                have_base: None,
410            },
411        )
412        .unwrap_err();
413        assert!(
414            matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_state.to_string())
415        );
416
417        let mismatch =
418            load_object_data(&store, &ObjectId::Hash(missing_hash), ObjectType::State).unwrap_err();
419        assert!(
420            matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
421        );
422
423        let mismatch =
424            load_object_data(&store, &ObjectId::StateId(missing_state), ObjectType::Blob)
425                .unwrap_err();
426        assert!(
427            matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
428        );
429    }
430
431    #[test]
432    fn store_received_object_rejects_mismatched_object_identity() {
433        let (_temp, store) = create_test_store();
434        let blob = Blob::from("tree leaf");
435        let blob_hash = store.put_blob(&blob).unwrap();
436        let tree = Tree::from_entries(vec![TreeEntry::file("leaf.txt", blob_hash, false).unwrap()]);
437        let tree_bytes = rmp_serde::to_vec_named(&tree).unwrap();
438        let wrong_hash = ContentHash::from_bytes([4; 32]);
439
440        let error = store_received_object(
441            &store,
442            &ObjectData {
443                id: ObjectId::Hash(wrong_hash),
444                obj_type: ObjectType::Tree,
445                data: tree_bytes,
446                is_delta: false,
447            },
448        )
449        .unwrap_err();
450        assert!(
451            matches!(error, ProtocolError::InvalidState(message) if message == "tree hash mismatch")
452        );
453
454        let state = State::new(tree.hash(), Vec::new(), test_attribution());
455        let wrong_state_id = objects::object::StateId::from_bytes([5; 32]);
456        let error = store_received_object(
457            &store,
458            &ObjectData {
459                id: ObjectId::StateId(wrong_state_id),
460                obj_type: ObjectType::State,
461                data: rmp_serde::to_vec_named(&state).unwrap(),
462                is_delta: false,
463            },
464        )
465        .unwrap_err();
466        assert!(
467            matches!(error, ProtocolError::InvalidState(message) if message.contains("StateId mismatch"))
468        );
469    }
470
471    #[test]
472    fn store_received_object_rejects_raw_sidecar_objects() {
473        let (_temp, store) = create_test_store();
474        let blob_hash = ContentHash::from_bytes([1; 32]);
475        let state_id = objects::object::StateId::from_bytes([2; 32]);
476
477        let redaction_error = store_received_object(
478            &store,
479            &ObjectData {
480                id: ObjectId::Hash(blob_hash),
481                obj_type: ObjectType::Redaction,
482                data: b"unsigned redaction bytes".to_vec(),
483                is_delta: false,
484            },
485        )
486        .unwrap_err();
487        assert!(
488            matches!(redaction_error, ProtocolError::InvalidState(message) if message.contains("signature verification is required"))
489        );
490
491        let visibility_error = store_received_object(
492            &store,
493            &ObjectData {
494                id: ObjectId::StateId(state_id),
495                obj_type: ObjectType::StateVisibility,
496                data: b"raw visibility bytes".to_vec(),
497                is_delta: false,
498            },
499        )
500        .unwrap_err();
501        assert!(
502            matches!(visibility_error, ProtocolError::InvalidState(message) if message.contains("sidecar validation is required"))
503        );
504    }
505
506    #[test]
507    fn test_chunk_count_rounds_up() {
508        assert_eq!(chunk_count(0, 64), 0);
509        assert_eq!(chunk_count(1, 64), 1);
510        assert_eq!(chunk_count(64, 64), 1);
511        assert_eq!(chunk_count(65, 64), 2);
512    }
513
514    #[test]
515    fn test_chunk_bounds_returns_ranges() {
516        assert_eq!(chunk_bounds(100, 32, 0), Some((0, 32)));
517        assert_eq!(chunk_bounds(100, 32, 2), Some((64, 32)));
518        assert_eq!(chunk_bounds(100, 32, 3), Some((96, 4)));
519        assert_eq!(chunk_bounds(100, 32, 4), None);
520        assert_eq!(chunk_bounds(100, 0, 0), None);
521    }
522
523    #[test]
524    fn test_chunk_offset_returns_position() {
525        assert_eq!(chunk_offset(0, 64), Some(0));
526        assert_eq!(chunk_offset(3, 64), Some(192));
527        assert_eq!(chunk_offset(usize::MAX, 2), None);
528    }
529
530    #[test]
531    fn received_transfer_blob_at_limit_is_accepted() {
532        check_received_transfer_blob_size(8, 8, "redactions").unwrap();
533    }
534
535    #[test]
536    fn received_transfer_blob_over_limit_is_rejected() {
537        let error = check_received_transfer_blob_size(9, 8, "redactions").unwrap_err();
538        let message = error.to_string();
539        assert!(
540            message.contains("redactions blob exceeds receive size limit"),
541            "unexpected error: {message}"
542        );
543        assert!(
544            message.contains("9 bytes (max 8)"),
545            "unexpected error: {message}"
546        );
547    }
548
549    #[test]
550    fn received_transfer_blob_caps_are_enforced_against_production_limits() {
551        check_received_transfer_blob_size(
552            MAX_RECEIVED_REDACTIONS_BLOB_SIZE as usize,
553            MAX_RECEIVED_REDACTIONS_BLOB_SIZE,
554            "redactions",
555        )
556        .unwrap();
557        check_received_transfer_blob_size(
558            MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE as usize,
559            MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE,
560            "state-visibility",
561        )
562        .unwrap();
563    }
564
565    #[test]
566    fn state_attachment_roundtrips_through_wire_data() {
567        let (_source_temp, source) = create_test_store();
568        let (_dest_temp, dest) = create_test_store();
569        let tree = source.put_tree(&Tree::new()).unwrap();
570        let state = State::new(tree, vec![], test_attribution());
571        source.put_state(&state).unwrap();
572        dest.put_state(&state).unwrap();
573        let attachment = StateAttachment {
574            state_id: state.id(),
575            body: StateAttachmentBody::RiskSignals(ContentHash::compute(b"signals")),
576            attribution: test_attribution(),
577            created_at: chrono::Utc::now(),
578            supersedes: None,
579        };
580        source.put_state_attachment(&attachment).unwrap();
581        let id = ObjectId::StateAttachment {
582            state: state.id(),
583            id: attachment.id(),
584            kind: attachment.body.kind(),
585        };
586        let data = load_object_data(&source, &id, ObjectType::StateAttachment).unwrap();
587        store_received_object(&dest, &data).unwrap();
588        assert_eq!(
589            dest.get_state_attachment(&state.id(), &attachment.id())
590                .unwrap(),
591            Some(attachment)
592        );
593    }
594}