1use objects::{
3 object::{AnnotatedTag, State, Tree},
4 store::ObjectStore,
5};
6
7use crate::{ObjectData, ObjectId, ObjectRequest, ObjectType, ProtocolError, Result};
8
9pub const MAX_RECEIVED_REDACTIONS_BLOB_SIZE: u64 = 64 * 1024 * 1024;
17
18pub const MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE: u64 = 64 * 1024 * 1024;
25
26const 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
42pub 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
62pub 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
86pub 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 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 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 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 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}