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