Skip to main content

wire/
object_transfer.rs

1// SPDX-License-Identifier: Apache-2.0
2use objects::{
3    object::{AnnotatedTag, 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/// Admit a declared receive length before any buffer is reserved or grown.
87///
88/// `declared` is untrusted wire input. Compare it to `max_bytes` as `u64`
89/// before converting to `usize` so a hostile header cannot pick the
90/// allocation. This function does not reserve or allocate.
91pub fn admit_declared_received_len(declared: u64, max_bytes: u64, kind: &str) -> Result<usize> {
92    if declared > max_bytes {
93        return Err(ProtocolError::InvalidState(format!(
94            "{kind} exceeds receive size limit: {declared} bytes (max {max_bytes})"
95        )));
96    }
97    usize::try_from(declared)
98        .map_err(|_| ProtocolError::InvalidState(format!("{kind} exceeds this platform")))
99}
100
101#[allow(dead_code)]
102pub fn chunk_count(object_size: usize, chunk_size: usize) -> usize {
103    if object_size == 0 || chunk_size == 0 {
104        return 0;
105    }
106    object_size.div_ceil(chunk_size)
107}
108
109#[allow(dead_code)]
110pub fn chunk_bounds(
111    object_size: usize,
112    chunk_size: usize,
113    chunk_index: usize,
114) -> Option<(usize, usize)> {
115    if chunk_size == 0 {
116        return None;
117    }
118
119    let start = chunk_index.checked_mul(chunk_size)?;
120    if start >= object_size {
121        return None;
122    }
123    let end = (start + chunk_size).min(object_size);
124    Some((start, end - start))
125}
126
127#[allow(dead_code)]
128pub fn chunk_offset(chunk_index: usize, chunk_size: usize) -> Option<usize> {
129    chunk_index.checked_mul(chunk_size)
130}
131
132pub fn load_requested_object(store: &impl ObjectStore, req: &ObjectRequest) -> Result<ObjectData> {
133    // Note on sidecar objects: redactions and state visibility are keyed by
134    // ids that also identify primary objects. `load_requested_object`
135    // resolves blob-vs-tree or state by id shape/probe; it cannot
136    // disambiguate a sidecar request by ObjectId alone. Callers that need to
137    // fetch a sidecar must use `load_object_data` with an explicit object
138    // type.
139    let (obj_type, data) = match &req.id {
140        ObjectId::Hash(hash) => {
141            if let Some(blob) = store.get_blob(hash)? {
142                (ObjectType::Blob, blob.content().to_vec())
143            } else if let Some(tree) = store.get_tree(hash)? {
144                (ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)
145            } else {
146                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
147            }
148        }
149        ObjectId::StateId(state_id) => {
150            let state = store
151                .get_state(state_id)?
152                .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
153            (ObjectType::State, rmp_serde::to_vec_named(&state)?)
154        }
155        ObjectId::StateAttachment { state, id, kind: _ } => {
156            let attachment = store
157                .get_state_attachment(state, id)?
158                .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
159            (
160                ObjectType::StateAttachment,
161                rmp_serde::to_vec_named(&attachment)?,
162            )
163        }
164    };
165
166    Ok(ObjectData {
167        id: req.id.clone(),
168        obj_type,
169        data,
170        is_delta: false,
171    })
172}
173
174pub fn load_object_data(
175    store: &impl ObjectStore,
176    id: &ObjectId,
177    obj_type: ObjectType,
178) -> Result<ObjectData> {
179    let data = match (id, obj_type) {
180        (ObjectId::Hash(hash), ObjectType::Blob) => store
181            .get_blob(hash)?
182            .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?
183            .content()
184            .to_vec(),
185        (ObjectId::Hash(hash), ObjectType::Tree) => {
186            let tree = store
187                .get_tree(hash)?
188                .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
189            rmp_serde::to_vec_named(&tree)?
190        }
191        (ObjectId::Hash(hash), ObjectType::AnnotatedTag) => store
192            .get_annotated_tag(hash)?
193            .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?
194            .encode_current_msgpack(),
195        (ObjectId::StateId(state_id), ObjectType::State) => {
196            let state = store
197                .get_state(state_id)?
198                .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
199            rmp_serde::to_vec_named(&state)?
200        }
201        (ObjectId::Hash(hash), ObjectType::Redaction) => store
202            .get_redactions_bytes_for_blob(hash)?
203            .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
204        (ObjectId::Hash(hash), ObjectType::Purge) => store
205            .get_redactions_bytes_for_blob(hash)?
206            .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
207        (ObjectId::StateId(state_id), ObjectType::StateVisibility) => store
208            .get_state_visibility_bytes_for_state(state_id)?
209            .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string_full()))?,
210        (ObjectId::StateAttachment { state, id, kind: _ }, ObjectType::StateAttachment) => {
211            let attachment = store
212                .get_state_attachment(state, id)?
213                .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
214            rmp_serde::to_vec_named(&attachment)?
215        }
216        (ObjectId::Hash(_), ObjectType::KeyBinding) => {
217            return Err(ProtocolError::InvalidState(
218                "KeyBinding registry objects must be constructed with encode_key_binding_registry"
219                    .to_string(),
220            ));
221        }
222        _ => {
223            return Err(ProtocolError::InvalidState(
224                "object id/type mismatch".to_string(),
225            ));
226        }
227    };
228
229    Ok(ObjectData {
230        id: id.clone(),
231        obj_type,
232        data,
233        is_delta: false,
234    })
235}
236
237pub fn store_received_object(store: &impl ObjectStore, data: &ObjectData) -> Result<()> {
238    match (&data.id, data.obj_type) {
239        (ObjectId::Hash(hash), ObjectType::Blob) => {
240            store.put_blob_bytes_with_hash(&data.data, *hash)?;
241        }
242        (ObjectId::Hash(hash), ObjectType::Tree) => {
243            let tree: Tree = rmp_serde::from_slice(&data.data)?;
244            tree.validate().map_err(|error| {
245                ProtocolError::InvalidState(format!("invalid tree object: {error}"))
246            })?;
247            if &tree.hash() != hash {
248                return Err(ProtocolError::InvalidState(
249                    "tree hash mismatch".to_string(),
250                ));
251            }
252            store.put_tree_serialized(&data.data, *hash)?;
253        }
254        (ObjectId::Hash(hash), ObjectType::AnnotatedTag) => {
255            let tag = AnnotatedTag::decode_current_msgpack(&data.data)
256                .map_err(|error| ProtocolError::InvalidState(error.to_string()))?;
257            if tag.hash() != *hash {
258                return Err(ProtocolError::InvalidState(
259                    "annotated tag hash mismatch".to_string(),
260                ));
261            }
262            store.put_annotated_tag(&tag)?;
263        }
264        (ObjectId::StateId(state_id), ObjectType::State) => {
265            let state: State = rmp_serde::from_slice(&data.data)?;
266            if state.id() != *state_id {
267                return Err(ProtocolError::InvalidState(format!(
268                    "StateId mismatch: expected {state_id}, computed {}",
269                    state.id()
270                )));
271            }
272            store.put_state_serialized(&data.data, *state_id)?;
273        }
274        (ObjectId::StateAttachment { state, id, kind }, ObjectType::StateAttachment) => {
275            let attachment: objects::object::StateAttachment = rmp_serde::from_slice(&data.data)?;
276            if attachment.state_id != *state || attachment.id() != *id {
277                return Err(ProtocolError::InvalidState(
278                    "state attachment id mismatch".to_string(),
279                ));
280            }
281            // The descriptor's carried kind must agree with the decoded body's
282            // kind — kind is a pure projection of the record, so a divergence
283            // means the descriptor and the bytes disagree about what this
284            // attachment is. Refuse rather than silently trust either side.
285            let body_kind = attachment.body.kind();
286            if *kind != body_kind {
287                return Err(ProtocolError::InvalidState(format!(
288                    "state attachment kind mismatch: descriptor {kind:?}, body {body_kind:?}"
289                )));
290            }
291            store.put_state_attachment(&attachment)?;
292        }
293        (_, ObjectType::Redaction) => {
294            // Redactions ship signed and need verification before any
295            // bytes hit the sidecar. Refuse here so callers route via
296            // `Repository::accept_wire_redactions` instead of silently
297            // landing an unverified record.
298            return Err(ProtocolError::InvalidState(
299                "Redaction objects must be persisted via Repository::accept_wire_redactions, \
300                 not store_received_object — signature verification is required"
301                    .to_string(),
302            ));
303        }
304        (_, ObjectType::Purge) => {
305            return Err(ProtocolError::InvalidState(
306                "Purge objects must be persisted via Repository::accept_wire_purge, not store_received_object — owner authorization is required"
307                    .to_string(),
308            ));
309        }
310        (_, ObjectType::StateVisibility) => {
311            // State visibility must be validated and normalized at the
312            // Repository boundary (`put_state_visibility` enforces
313            // public-by-absence). Refuse raw sidecar writes here.
314            return Err(ProtocolError::InvalidState(
315                "StateVisibility objects must be persisted via Repository::accept_wire_state_visibility, \
316                 not store_received_object — sidecar validation is required"
317                    .to_string(),
318            ));
319        }
320        (_, ObjectType::KeyBinding) => {
321            return Err(ProtocolError::InvalidState(
322                "KeyBinding registry objects must be decoded and verified with decode_key_binding_registry"
323                    .to_string(),
324            ));
325        }
326        _ => {
327            return Err(ProtocolError::InvalidState(
328                "object id/type mismatch".to_string(),
329            ));
330        }
331    }
332
333    Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338    use objects::{
339        object::{
340            Attribution, Blob, ContentHash, Principal, State, StateAttachment, StateAttachmentBody,
341            Tree, TreeEntry,
342        },
343        store::{FsStore, ObjectStore},
344    };
345    use tempfile::TempDir;
346
347    use super::*;
348
349    fn create_test_store() -> (TempDir, FsStore) {
350        let temp = TempDir::new().unwrap();
351        let store = FsStore::new(temp.path().join(".heddle"));
352        store.init().unwrap();
353        (temp, store)
354    }
355
356    fn test_attribution() -> Attribution {
357        Attribution::human(Principal::new("Wire Tester", "wire@example.com"))
358    }
359
360    #[test]
361    fn primary_objects_roundtrip_through_wire_data() {
362        let (_source_temp, source) = create_test_store();
363        let (_dest_temp, dest) = create_test_store();
364
365        let blob = Blob::from("wire transfer blob\n");
366        let blob_hash = source.put_blob(&blob).unwrap();
367        let tree = Tree::from_entries(vec![TreeEntry::file("lib.rs", blob_hash, false).unwrap()]);
368        let tree_hash = source.put_tree(&tree).unwrap();
369        let state = State::new(tree_hash, Vec::new(), test_attribution())
370            .with_intent("exercise wire transfer");
371        source.put_state(&state).unwrap();
372
373        let blob_data = load_requested_object(
374            &source,
375            &ObjectRequest {
376                id: ObjectId::Hash(blob_hash),
377                have_base: None,
378            },
379        )
380        .unwrap();
381        assert_eq!(blob_data.obj_type, ObjectType::Blob);
382        assert_eq!(blob_data.data, blob.content());
383        store_received_object(&dest, &blob_data).unwrap();
384        assert_eq!(
385            dest.get_blob(&blob_hash).unwrap().unwrap().content(),
386            blob.content()
387        );
388
389        let tree_data = load_requested_object(
390            &source,
391            &ObjectRequest {
392                id: ObjectId::Hash(tree_hash),
393                have_base: None,
394            },
395        )
396        .unwrap();
397        assert_eq!(tree_data.obj_type, ObjectType::Tree);
398        assert_eq!(
399            rmp_serde::from_slice::<Tree>(&tree_data.data).unwrap(),
400            tree
401        );
402        store_received_object(&dest, &tree_data).unwrap();
403        assert_eq!(dest.get_tree(&tree_hash).unwrap().unwrap(), tree);
404
405        let state_data = load_requested_object(
406            &source,
407            &ObjectRequest {
408                id: ObjectId::StateId(state.state_id),
409                have_base: None,
410            },
411        )
412        .unwrap();
413        assert_eq!(state_data.obj_type, ObjectType::State);
414        assert_eq!(
415            objects::store::codec::decode_state(&state_data.data).unwrap(),
416            state
417        );
418        store_received_object(&dest, &state_data).unwrap();
419        assert_eq!(
420            dest.get_state(&state.state_id).unwrap().unwrap().state_id,
421            state.state_id
422        );
423    }
424
425    #[test]
426    fn load_object_data_reports_missing_and_id_type_mismatch_errors() {
427        let (_temp, store) = create_test_store();
428        let missing_hash = ContentHash::from_bytes([7; 32]);
429        let missing_state = objects::object::StateId::from_bytes([9; 32]);
430
431        let missing = load_requested_object(
432            &store,
433            &ObjectRequest {
434                id: ObjectId::Hash(missing_hash),
435                have_base: None,
436            },
437        )
438        .unwrap_err();
439        assert!(
440            matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_hash.to_hex())
441        );
442
443        let missing = load_requested_object(
444            &store,
445            &ObjectRequest {
446                id: ObjectId::StateId(missing_state),
447                have_base: None,
448            },
449        )
450        .unwrap_err();
451        assert!(
452            matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_state.to_string())
453        );
454
455        let mismatch =
456            load_object_data(&store, &ObjectId::Hash(missing_hash), ObjectType::State).unwrap_err();
457        assert!(
458            matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
459        );
460
461        let mismatch =
462            load_object_data(&store, &ObjectId::StateId(missing_state), ObjectType::Blob)
463                .unwrap_err();
464        assert!(
465            matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
466        );
467    }
468
469    #[test]
470    fn store_received_object_rejects_mismatched_object_identity() {
471        let (_temp, store) = create_test_store();
472        let blob = Blob::from("tree leaf");
473        let blob_hash = store.put_blob(&blob).unwrap();
474        let tree = Tree::from_entries(vec![TreeEntry::file("leaf.txt", blob_hash, false).unwrap()]);
475        let tree_bytes = rmp_serde::to_vec_named(&tree).unwrap();
476        let wrong_hash = ContentHash::from_bytes([4; 32]);
477
478        let error = store_received_object(
479            &store,
480            &ObjectData {
481                id: ObjectId::Hash(wrong_hash),
482                obj_type: ObjectType::Tree,
483                data: tree_bytes,
484                is_delta: false,
485            },
486        )
487        .unwrap_err();
488        assert!(
489            matches!(error, ProtocolError::InvalidState(message) if message == "tree hash mismatch")
490        );
491
492        let state = State::new(tree.hash(), Vec::new(), test_attribution());
493        let wrong_state_id = objects::object::StateId::from_bytes([5; 32]);
494        let error = store_received_object(
495            &store,
496            &ObjectData {
497                id: ObjectId::StateId(wrong_state_id),
498                obj_type: ObjectType::State,
499                data: rmp_serde::to_vec_named(&state).unwrap(),
500                is_delta: false,
501            },
502        )
503        .unwrap_err();
504        assert!(
505            matches!(error, ProtocolError::InvalidState(message) if message.contains("StateId mismatch"))
506        );
507    }
508
509    #[test]
510    fn store_received_object_rejects_raw_sidecar_objects() {
511        let (_temp, store) = create_test_store();
512        let blob_hash = ContentHash::from_bytes([1; 32]);
513        let state_id = objects::object::StateId::from_bytes([2; 32]);
514
515        let redaction_error = store_received_object(
516            &store,
517            &ObjectData {
518                id: ObjectId::Hash(blob_hash),
519                obj_type: ObjectType::Redaction,
520                data: b"unsigned redaction bytes".to_vec(),
521                is_delta: false,
522            },
523        )
524        .unwrap_err();
525        assert!(
526            matches!(redaction_error, ProtocolError::InvalidState(message) if message.contains("signature verification is required"))
527        );
528
529        let visibility_error = store_received_object(
530            &store,
531            &ObjectData {
532                id: ObjectId::StateId(state_id),
533                obj_type: ObjectType::StateVisibility,
534                data: b"raw visibility bytes".to_vec(),
535                is_delta: false,
536            },
537        )
538        .unwrap_err();
539        assert!(
540            matches!(visibility_error, ProtocolError::InvalidState(message) if message.contains("sidecar validation is required"))
541        );
542    }
543
544    #[test]
545    fn test_chunk_count_rounds_up() {
546        assert_eq!(chunk_count(0, 64), 0);
547        assert_eq!(chunk_count(1, 64), 1);
548        assert_eq!(chunk_count(64, 64), 1);
549        assert_eq!(chunk_count(65, 64), 2);
550    }
551
552    #[test]
553    fn test_chunk_bounds_returns_ranges() {
554        assert_eq!(chunk_bounds(100, 32, 0), Some((0, 32)));
555        assert_eq!(chunk_bounds(100, 32, 2), Some((64, 32)));
556        assert_eq!(chunk_bounds(100, 32, 3), Some((96, 4)));
557        assert_eq!(chunk_bounds(100, 32, 4), None);
558        assert_eq!(chunk_bounds(100, 0, 0), None);
559    }
560
561    #[test]
562    fn test_chunk_offset_returns_position() {
563        assert_eq!(chunk_offset(0, 64), Some(0));
564        assert_eq!(chunk_offset(3, 64), Some(192));
565        assert_eq!(chunk_offset(usize::MAX, 2), None);
566    }
567
568    #[test]
569    fn received_transfer_blob_at_limit_is_accepted() {
570        check_received_transfer_blob_size(8, 8, "redactions").unwrap();
571    }
572
573    #[test]
574    fn received_transfer_blob_over_limit_is_rejected() {
575        let error = check_received_transfer_blob_size(9, 8, "redactions").unwrap_err();
576        let message = error.to_string();
577        assert!(
578            message.contains("redactions blob exceeds receive size limit"),
579            "unexpected error: {message}"
580        );
581        assert!(
582            message.contains("9 bytes (max 8)"),
583            "unexpected error: {message}"
584        );
585    }
586
587    #[test]
588    fn received_transfer_blob_caps_are_enforced_against_production_limits() {
589        check_received_transfer_blob_size(
590            MAX_RECEIVED_REDACTIONS_BLOB_SIZE as usize,
591            MAX_RECEIVED_REDACTIONS_BLOB_SIZE,
592            "redactions",
593        )
594        .unwrap();
595        check_received_transfer_blob_size(
596            MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE as usize,
597            MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE,
598            "state-visibility",
599        )
600        .unwrap();
601    }
602
603    #[test]
604    fn declared_receive_len_above_max_is_rejected_before_any_alloc() {
605        let error = admit_declared_received_len(9, 8, "pull raw body")
606            .expect_err("declared length above max must fail closed");
607        assert!(
608            error.to_string().contains("exceeds receive size limit"),
609            "got {error}"
610        );
611        assert!(error.to_string().contains("9 bytes (max 8)"), "got {error}");
612    }
613
614    #[test]
615    fn declared_receive_len_above_pack_cap_is_rejected_before_any_alloc() {
616        let error = admit_declared_received_len(
617            crate::MAX_RECEIVED_PACK_SIZE + 1,
618            crate::MAX_RECEIVED_PACK_SIZE,
619            "pull raw body",
620        )
621        .expect_err("attacker-chosen pack length must fail closed");
622        assert!(
623            error.to_string().contains("exceeds receive size limit"),
624            "got {error}"
625        );
626    }
627
628    #[test]
629    fn declared_receive_len_at_max_is_admitted() {
630        assert_eq!(
631            admit_declared_received_len(8, 8, "pull raw body").unwrap(),
632            8
633        );
634    }
635
636    #[test]
637    fn state_attachment_roundtrips_through_wire_data() {
638        let (_source_temp, source) = create_test_store();
639        let (_dest_temp, dest) = create_test_store();
640        let tree = source.put_tree(&Tree::new()).unwrap();
641        let state = State::new(tree, vec![], test_attribution());
642        source.put_state(&state).unwrap();
643        dest.put_state(&state).unwrap();
644        let attachment = StateAttachment {
645            state_id: state.id(),
646            body: StateAttachmentBody::RiskSignals(ContentHash::compute(b"signals")),
647            attribution: test_attribution(),
648            created_at: chrono::Utc::now(),
649            supersedes: None,
650        };
651        source.put_state_attachment(&attachment).unwrap();
652        let id = ObjectId::StateAttachment {
653            state: state.id(),
654            id: attachment.id(),
655            kind: attachment.body.kind(),
656        };
657        let data = load_object_data(&source, &id, ObjectType::StateAttachment).unwrap();
658        store_received_object(&dest, &data).unwrap();
659        assert_eq!(
660            dest.get_state_attachment(&state.id(), &attachment.id())
661                .unwrap(),
662            Some(attachment)
663        );
664    }
665}