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 } => {
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 }, 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 }, 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            store.put_state_attachment(&attachment)?;
245        }
246        (_, ObjectType::Redaction) => {
247            // Redactions ship signed and need verification before any
248            // bytes hit the sidecar. Refuse here so callers route via
249            // `Repository::accept_wire_redactions` instead of silently
250            // landing an unverified record.
251            return Err(ProtocolError::InvalidState(
252                "Redaction objects must be persisted via Repository::accept_wire_redactions, \
253                 not store_received_object — signature verification is required"
254                    .to_string(),
255            ));
256        }
257        (_, ObjectType::StateVisibility) => {
258            // State visibility must be validated and normalized at the
259            // Repository boundary (`put_state_visibility` enforces
260            // public-by-absence). Refuse raw sidecar writes here.
261            return Err(ProtocolError::InvalidState(
262                "StateVisibility objects must be persisted via Repository::accept_wire_state_visibility, \
263                 not store_received_object — sidecar validation is required"
264                    .to_string(),
265            ));
266        }
267        _ => {
268            return Err(ProtocolError::InvalidState(
269                "object id/type mismatch".to_string(),
270            ));
271        }
272    }
273
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use objects::{
280        object::{
281            Attribution, Blob, ContentHash, Principal, State, StateAttachment, StateAttachmentBody,
282            Tree, TreeEntry,
283        },
284        store::{FsStore, ObjectStore},
285    };
286    use tempfile::TempDir;
287
288    use super::*;
289
290    fn create_test_store() -> (TempDir, FsStore) {
291        let temp = TempDir::new().unwrap();
292        let store = FsStore::new(temp.path().join(".heddle"));
293        store.init().unwrap();
294        (temp, store)
295    }
296
297    fn test_attribution() -> Attribution {
298        Attribution::human(Principal::new("Wire Tester", "wire@example.com"))
299    }
300
301    #[test]
302    fn primary_objects_roundtrip_through_wire_data() {
303        let (_source_temp, source) = create_test_store();
304        let (_dest_temp, dest) = create_test_store();
305
306        let blob = Blob::from("wire transfer blob\n");
307        let blob_hash = source.put_blob(&blob).unwrap();
308        let tree = Tree::from_entries(vec![TreeEntry::file("lib.rs", blob_hash, false).unwrap()]);
309        let tree_hash = source.put_tree(&tree).unwrap();
310        let state = State::new(tree_hash, Vec::new(), test_attribution())
311            .with_intent("exercise wire transfer");
312        source.put_state(&state).unwrap();
313
314        let blob_data = load_requested_object(
315            &source,
316            &ObjectRequest {
317                id: ObjectId::Hash(blob_hash),
318                have_base: None,
319            },
320        )
321        .unwrap();
322        assert_eq!(blob_data.obj_type, ObjectType::Blob);
323        assert_eq!(blob_data.data, blob.content());
324        store_received_object(&dest, &blob_data).unwrap();
325        assert_eq!(
326            dest.get_blob(&blob_hash).unwrap().unwrap().content(),
327            blob.content()
328        );
329
330        let tree_data = load_requested_object(
331            &source,
332            &ObjectRequest {
333                id: ObjectId::Hash(tree_hash),
334                have_base: None,
335            },
336        )
337        .unwrap();
338        assert_eq!(tree_data.obj_type, ObjectType::Tree);
339        assert_eq!(
340            rmp_serde::from_slice::<Tree>(&tree_data.data).unwrap(),
341            tree
342        );
343        store_received_object(&dest, &tree_data).unwrap();
344        assert_eq!(dest.get_tree(&tree_hash).unwrap().unwrap(), tree);
345
346        let state_data = load_requested_object(
347            &source,
348            &ObjectRequest {
349                id: ObjectId::StateId(state.state_id),
350                have_base: None,
351            },
352        )
353        .unwrap();
354        assert_eq!(state_data.obj_type, ObjectType::State);
355        assert_eq!(
356            objects::store::codec::decode_state(&state_data.data).unwrap(),
357            state
358        );
359        store_received_object(&dest, &state_data).unwrap();
360        assert_eq!(
361            dest.get_state(&state.state_id).unwrap().unwrap().state_id,
362            state.state_id
363        );
364    }
365
366    #[test]
367    fn load_object_data_reports_missing_and_id_type_mismatch_errors() {
368        let (_temp, store) = create_test_store();
369        let missing_hash = ContentHash::from_bytes([7; 32]);
370        let missing_state = objects::object::StateId::from_bytes([9; 32]);
371
372        let missing = load_requested_object(
373            &store,
374            &ObjectRequest {
375                id: ObjectId::Hash(missing_hash),
376                have_base: None,
377            },
378        )
379        .unwrap_err();
380        assert!(
381            matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_hash.to_hex())
382        );
383
384        let missing = load_requested_object(
385            &store,
386            &ObjectRequest {
387                id: ObjectId::StateId(missing_state),
388                have_base: None,
389            },
390        )
391        .unwrap_err();
392        assert!(
393            matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_state.to_string())
394        );
395
396        let mismatch =
397            load_object_data(&store, &ObjectId::Hash(missing_hash), ObjectType::State).unwrap_err();
398        assert!(
399            matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
400        );
401
402        let mismatch =
403            load_object_data(&store, &ObjectId::StateId(missing_state), ObjectType::Blob)
404                .unwrap_err();
405        assert!(
406            matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
407        );
408    }
409
410    #[test]
411    fn store_received_object_rejects_mismatched_object_identity() {
412        let (_temp, store) = create_test_store();
413        let blob = Blob::from("tree leaf");
414        let blob_hash = store.put_blob(&blob).unwrap();
415        let tree = Tree::from_entries(vec![TreeEntry::file("leaf.txt", blob_hash, false).unwrap()]);
416        let tree_bytes = rmp_serde::to_vec_named(&tree).unwrap();
417        let wrong_hash = ContentHash::from_bytes([4; 32]);
418
419        let error = store_received_object(
420            &store,
421            &ObjectData {
422                id: ObjectId::Hash(wrong_hash),
423                obj_type: ObjectType::Tree,
424                data: tree_bytes,
425                is_delta: false,
426            },
427        )
428        .unwrap_err();
429        assert!(
430            matches!(error, ProtocolError::InvalidState(message) if message == "tree hash mismatch")
431        );
432
433        let state = State::new(tree.hash(), Vec::new(), test_attribution());
434        let wrong_state_id = objects::object::StateId::from_bytes([5; 32]);
435        let error = store_received_object(
436            &store,
437            &ObjectData {
438                id: ObjectId::StateId(wrong_state_id),
439                obj_type: ObjectType::State,
440                data: rmp_serde::to_vec_named(&state).unwrap(),
441                is_delta: false,
442            },
443        )
444        .unwrap_err();
445        assert!(
446            matches!(error, ProtocolError::InvalidState(message) if message.contains("StateId mismatch"))
447        );
448    }
449
450    #[test]
451    fn store_received_object_rejects_raw_sidecar_objects() {
452        let (_temp, store) = create_test_store();
453        let blob_hash = ContentHash::from_bytes([1; 32]);
454        let state_id = objects::object::StateId::from_bytes([2; 32]);
455
456        let redaction_error = store_received_object(
457            &store,
458            &ObjectData {
459                id: ObjectId::Hash(blob_hash),
460                obj_type: ObjectType::Redaction,
461                data: b"unsigned redaction bytes".to_vec(),
462                is_delta: false,
463            },
464        )
465        .unwrap_err();
466        assert!(
467            matches!(redaction_error, ProtocolError::InvalidState(message) if message.contains("signature verification is required"))
468        );
469
470        let visibility_error = store_received_object(
471            &store,
472            &ObjectData {
473                id: ObjectId::StateId(state_id),
474                obj_type: ObjectType::StateVisibility,
475                data: b"raw visibility bytes".to_vec(),
476                is_delta: false,
477            },
478        )
479        .unwrap_err();
480        assert!(
481            matches!(visibility_error, ProtocolError::InvalidState(message) if message.contains("sidecar validation is required"))
482        );
483    }
484
485    #[test]
486    fn test_chunk_count_rounds_up() {
487        assert_eq!(chunk_count(0, 64), 0);
488        assert_eq!(chunk_count(1, 64), 1);
489        assert_eq!(chunk_count(64, 64), 1);
490        assert_eq!(chunk_count(65, 64), 2);
491    }
492
493    #[test]
494    fn test_chunk_bounds_returns_ranges() {
495        assert_eq!(chunk_bounds(100, 32, 0), Some((0, 32)));
496        assert_eq!(chunk_bounds(100, 32, 2), Some((64, 32)));
497        assert_eq!(chunk_bounds(100, 32, 3), Some((96, 4)));
498        assert_eq!(chunk_bounds(100, 32, 4), None);
499        assert_eq!(chunk_bounds(100, 0, 0), None);
500    }
501
502    #[test]
503    fn test_chunk_offset_returns_position() {
504        assert_eq!(chunk_offset(0, 64), Some(0));
505        assert_eq!(chunk_offset(3, 64), Some(192));
506        assert_eq!(chunk_offset(usize::MAX, 2), None);
507    }
508
509    #[test]
510    fn received_transfer_blob_at_limit_is_accepted() {
511        check_received_transfer_blob_size(8, 8, "redactions").unwrap();
512    }
513
514    #[test]
515    fn received_transfer_blob_over_limit_is_rejected() {
516        let error = check_received_transfer_blob_size(9, 8, "redactions").unwrap_err();
517        let message = error.to_string();
518        assert!(
519            message.contains("redactions blob exceeds receive size limit"),
520            "unexpected error: {message}"
521        );
522        assert!(
523            message.contains("9 bytes (max 8)"),
524            "unexpected error: {message}"
525        );
526    }
527
528    #[test]
529    fn received_transfer_blob_caps_are_enforced_against_production_limits() {
530        check_received_transfer_blob_size(
531            MAX_RECEIVED_REDACTIONS_BLOB_SIZE as usize,
532            MAX_RECEIVED_REDACTIONS_BLOB_SIZE,
533            "redactions",
534        )
535        .unwrap();
536        check_received_transfer_blob_size(
537            MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE as usize,
538            MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE,
539            "state-visibility",
540        )
541        .unwrap();
542    }
543
544    #[test]
545    fn state_attachment_roundtrips_through_wire_data() {
546        let (_source_temp, source) = create_test_store();
547        let (_dest_temp, dest) = create_test_store();
548        let tree = source.put_tree(&Tree::new()).unwrap();
549        let state = State::new(tree, vec![], test_attribution());
550        source.put_state(&state).unwrap();
551        dest.put_state(&state).unwrap();
552        let attachment = StateAttachment {
553            state_id: state.id(),
554            body: StateAttachmentBody::RiskSignals(ContentHash::compute(b"signals")),
555            attribution: test_attribution(),
556            created_at: chrono::Utc::now(),
557            supersedes: None,
558        };
559        source.put_state_attachment(&attachment).unwrap();
560        let id = ObjectId::StateAttachment {
561            state: state.id(),
562            id: attachment.id(),
563        };
564        let data = load_object_data(&source, &id, ObjectType::StateAttachment).unwrap();
565        store_received_object(&dest, &data).unwrap();
566        assert_eq!(
567            dest.get_state_attachment(&state.id(), &attachment.id())
568                .unwrap(),
569            Some(attachment)
570        );
571    }
572}