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(data) = store.get_tree_serialized(hash)? {
144 (ObjectType::Tree, data)
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) => store
186 .get_tree_serialized(hash)?
187 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
188 (ObjectId::Hash(hash), ObjectType::AnnotatedTag) => store
189 .get_annotated_tag(hash)?
190 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?
191 .encode_current_msgpack(),
192 (ObjectId::StateId(state_id), ObjectType::State) => {
193 let state = store
194 .get_state(state_id)?
195 .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
196 rmp_serde::to_vec_named(&state)?
197 }
198 (ObjectId::Hash(hash), ObjectType::Redaction) => store
199 .get_redactions_bytes_for_blob(hash)?
200 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
201 (ObjectId::Hash(hash), ObjectType::Purge) => store
202 .get_redactions_bytes_for_blob(hash)?
203 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
204 (ObjectId::StateId(state_id), ObjectType::StateVisibility) => store
205 .get_state_visibility_bytes_for_state(state_id)?
206 .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string_full()))?,
207 (ObjectId::StateAttachment { state, id, kind: _ }, ObjectType::StateAttachment) => {
208 let attachment = store
209 .get_state_attachment(state, id)?
210 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
211 rmp_serde::to_vec_named(&attachment)?
212 }
213 (ObjectId::Hash(_), ObjectType::KeyBinding) => {
214 return Err(ProtocolError::InvalidState(
215 "KeyBinding registry objects must be constructed with encode_key_binding_registry"
216 .to_string(),
217 ));
218 }
219 _ => {
220 return Err(ProtocolError::InvalidState(
221 "object id/type mismatch".to_string(),
222 ));
223 }
224 };
225
226 Ok(ObjectData {
227 id: id.clone(),
228 obj_type,
229 data,
230 is_delta: false,
231 })
232}
233
234pub fn store_received_object(store: &impl ObjectStore, data: &ObjectData) -> Result<()> {
235 match (&data.id, data.obj_type) {
236 (ObjectId::Hash(hash), ObjectType::Blob) => {
237 store.put_blob_bytes_with_hash(&data.data, *hash)?;
238 }
239 (ObjectId::Hash(hash), ObjectType::Tree) => {
240 let tree = Tree::decode_canonical(&data.data).map_err(|error| {
241 ProtocolError::InvalidState(format!("invalid tree object: {error}"))
242 })?;
243 tree.validate().map_err(|error| {
244 ProtocolError::InvalidState(format!("invalid tree object: {error}"))
245 })?;
246 if &tree.hash() != hash {
247 return Err(ProtocolError::InvalidState(
248 "tree hash mismatch".to_string(),
249 ));
250 }
251 store.put_tree_serialized(&data.data, *hash)?;
252 }
253 (ObjectId::Hash(hash), ObjectType::AnnotatedTag) => {
254 let tag = AnnotatedTag::decode_current_msgpack(&data.data)
255 .map_err(|error| ProtocolError::InvalidState(error.to_string()))?;
256 if tag.hash() != *hash {
257 return Err(ProtocolError::InvalidState(
258 "annotated tag hash mismatch".to_string(),
259 ));
260 }
261 store.put_annotated_tag(&tag)?;
262 }
263 (ObjectId::StateId(state_id), ObjectType::State) => {
264 let state: State = rmp_serde::from_slice(&data.data)?;
265 if state.id() != *state_id {
266 return Err(ProtocolError::InvalidState(format!(
267 "StateId mismatch: expected {state_id}, computed {}",
268 state.id()
269 )));
270 }
271 store.put_state_serialized(&data.data, *state_id)?;
272 }
273 (ObjectId::StateAttachment { state, id, kind }, ObjectType::StateAttachment) => {
274 let attachment: objects::object::StateAttachment = rmp_serde::from_slice(&data.data)?;
275 if attachment.state_id != *state || attachment.id() != *id {
276 return Err(ProtocolError::InvalidState(
277 "state attachment id mismatch".to_string(),
278 ));
279 }
280 let body_kind = attachment.body.kind();
285 if *kind != body_kind {
286 return Err(ProtocolError::InvalidState(format!(
287 "state attachment kind mismatch: descriptor {kind:?}, body {body_kind:?}"
288 )));
289 }
290 store.put_state_attachment(&attachment)?;
291 }
292 (_, ObjectType::Redaction) => {
293 return Err(ProtocolError::InvalidState(
298 "Redaction objects must be persisted via Repository::accept_wire_redactions, \
299 not store_received_object — signature verification is required"
300 .to_string(),
301 ));
302 }
303 (_, ObjectType::Purge) => {
304 return Err(ProtocolError::InvalidState(
305 "Purge objects must be persisted via Repository::accept_wire_purge, not store_received_object — owner authorization is required"
306 .to_string(),
307 ));
308 }
309 (_, ObjectType::StateVisibility) => {
310 return Err(ProtocolError::InvalidState(
314 "StateVisibility objects must be persisted via Repository::accept_wire_state_visibility, \
315 not store_received_object — sidecar validation is required"
316 .to_string(),
317 ));
318 }
319 (_, ObjectType::KeyBinding) => {
320 return Err(ProtocolError::InvalidState(
321 "KeyBinding registry objects must be decoded and verified with decode_key_binding_registry"
322 .to_string(),
323 ));
324 }
325 _ => {
326 return Err(ProtocolError::InvalidState(
327 "object id/type mismatch".to_string(),
328 ));
329 }
330 }
331
332 Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337 use objects::{
338 object::{
339 Attribution, Blob, ContentHash, Principal, State, StateAttachment, StateAttachmentBody,
340 Tree, TreeEntry,
341 },
342 store::{FsStore, ObjectStore},
343 };
344 use tempfile::TempDir;
345
346 use super::*;
347
348 fn create_test_store() -> (TempDir, FsStore) {
349 let temp = TempDir::new().unwrap();
350 let store = FsStore::new(temp.path().join(".heddle"));
351 store.init().unwrap();
352 (temp, store)
353 }
354
355 fn test_attribution() -> Attribution {
356 Attribution::human(Principal::new("Wire Tester", "wire@example.com"))
357 }
358
359 #[test]
360 fn primary_objects_roundtrip_through_wire_data() {
361 let (_source_temp, source) = create_test_store();
362 let (_dest_temp, dest) = create_test_store();
363
364 let blob = Blob::from("wire transfer blob\n");
365 let blob_hash = source.put_blob(&blob).unwrap();
366 let tree = Tree::from_entries(vec![TreeEntry::file("lib.rs", blob_hash, false).unwrap()]);
367 let tree_hash = source.put_tree(&tree).unwrap();
368 let state = State::new(tree_hash, Vec::new(), test_attribution())
369 .with_intent("exercise wire transfer");
370 source.put_state(&state).unwrap();
371
372 let blob_data = load_requested_object(
373 &source,
374 &ObjectRequest {
375 id: ObjectId::Hash(blob_hash),
376 have_base: None,
377 },
378 )
379 .unwrap();
380 assert_eq!(blob_data.obj_type, ObjectType::Blob);
381 assert_eq!(blob_data.data, blob.content());
382 store_received_object(&dest, &blob_data).unwrap();
383 assert_eq!(
384 dest.get_blob(&blob_hash).unwrap().unwrap().content(),
385 blob.content()
386 );
387
388 let tree_data = load_requested_object(
389 &source,
390 &ObjectRequest {
391 id: ObjectId::Hash(tree_hash),
392 have_base: None,
393 },
394 )
395 .unwrap();
396 assert_eq!(tree_data.obj_type, ObjectType::Tree);
397 assert_eq!(Tree::decode_canonical(&tree_data.data).unwrap(), tree);
398 store_received_object(&dest, &tree_data).unwrap();
399 assert_eq!(dest.get_tree(&tree_hash).unwrap().unwrap(), tree);
400
401 let state_data = load_requested_object(
402 &source,
403 &ObjectRequest {
404 id: ObjectId::StateId(state.state_id),
405 have_base: None,
406 },
407 )
408 .unwrap();
409 assert_eq!(state_data.obj_type, ObjectType::State);
410 assert_eq!(
411 objects::store::codec::decode_state(&state_data.data).unwrap(),
412 state
413 );
414 store_received_object(&dest, &state_data).unwrap();
415 assert_eq!(
416 dest.get_state(&state.state_id).unwrap().unwrap().state_id,
417 state.state_id
418 );
419 }
420
421 #[test]
422 fn load_object_data_reports_missing_and_id_type_mismatch_errors() {
423 let (_temp, store) = create_test_store();
424 let missing_hash = ContentHash::from_bytes([7; 32]);
425 let missing_state = objects::object::StateId::from_bytes([9; 32]);
426
427 let missing = load_requested_object(
428 &store,
429 &ObjectRequest {
430 id: ObjectId::Hash(missing_hash),
431 have_base: None,
432 },
433 )
434 .unwrap_err();
435 assert!(
436 matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_hash.to_hex())
437 );
438
439 let missing = load_requested_object(
440 &store,
441 &ObjectRequest {
442 id: ObjectId::StateId(missing_state),
443 have_base: None,
444 },
445 )
446 .unwrap_err();
447 assert!(
448 matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_state.to_string())
449 );
450
451 let mismatch =
452 load_object_data(&store, &ObjectId::Hash(missing_hash), ObjectType::State).unwrap_err();
453 assert!(
454 matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
455 );
456
457 let mismatch =
458 load_object_data(&store, &ObjectId::StateId(missing_state), ObjectType::Blob)
459 .unwrap_err();
460 assert!(
461 matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
462 );
463 }
464
465 #[test]
466 fn store_received_object_rejects_mismatched_object_identity() {
467 let (_temp, store) = create_test_store();
468 let blob = Blob::from("tree leaf");
469 let blob_hash = store.put_blob(&blob).unwrap();
470 let tree = Tree::from_entries(vec![TreeEntry::file("leaf.txt", blob_hash, false).unwrap()]);
471 let tree_bytes = tree.encode_canonical().unwrap();
472 let wrong_hash = ContentHash::from_bytes([4; 32]);
473
474 let error = store_received_object(
475 &store,
476 &ObjectData {
477 id: ObjectId::Hash(wrong_hash),
478 obj_type: ObjectType::Tree,
479 data: tree_bytes,
480 is_delta: false,
481 },
482 )
483 .unwrap_err();
484 assert!(
485 matches!(error, ProtocolError::InvalidState(message) if message == "tree hash mismatch")
486 );
487
488 let state = State::new(tree.hash(), Vec::new(), test_attribution());
489 let wrong_state_id = objects::object::StateId::from_bytes([5; 32]);
490 let error = store_received_object(
491 &store,
492 &ObjectData {
493 id: ObjectId::StateId(wrong_state_id),
494 obj_type: ObjectType::State,
495 data: rmp_serde::to_vec_named(&state).unwrap(),
496 is_delta: false,
497 },
498 )
499 .unwrap_err();
500 assert!(
501 matches!(error, ProtocolError::InvalidState(message) if message.contains("StateId mismatch"))
502 );
503 }
504
505 #[test]
506 fn store_received_object_rejects_raw_sidecar_objects() {
507 let (_temp, store) = create_test_store();
508 let blob_hash = ContentHash::from_bytes([1; 32]);
509 let state_id = objects::object::StateId::from_bytes([2; 32]);
510
511 let redaction_error = store_received_object(
512 &store,
513 &ObjectData {
514 id: ObjectId::Hash(blob_hash),
515 obj_type: ObjectType::Redaction,
516 data: b"unsigned redaction bytes".to_vec(),
517 is_delta: false,
518 },
519 )
520 .unwrap_err();
521 assert!(
522 matches!(redaction_error, ProtocolError::InvalidState(message) if message.contains("signature verification is required"))
523 );
524
525 let visibility_error = store_received_object(
526 &store,
527 &ObjectData {
528 id: ObjectId::StateId(state_id),
529 obj_type: ObjectType::StateVisibility,
530 data: b"raw visibility bytes".to_vec(),
531 is_delta: false,
532 },
533 )
534 .unwrap_err();
535 assert!(
536 matches!(visibility_error, ProtocolError::InvalidState(message) if message.contains("sidecar validation is required"))
537 );
538 }
539
540 #[test]
541 fn test_chunk_count_rounds_up() {
542 assert_eq!(chunk_count(0, 64), 0);
543 assert_eq!(chunk_count(1, 64), 1);
544 assert_eq!(chunk_count(64, 64), 1);
545 assert_eq!(chunk_count(65, 64), 2);
546 }
547
548 #[test]
549 fn test_chunk_bounds_returns_ranges() {
550 assert_eq!(chunk_bounds(100, 32, 0), Some((0, 32)));
551 assert_eq!(chunk_bounds(100, 32, 2), Some((64, 32)));
552 assert_eq!(chunk_bounds(100, 32, 3), Some((96, 4)));
553 assert_eq!(chunk_bounds(100, 32, 4), None);
554 assert_eq!(chunk_bounds(100, 0, 0), None);
555 }
556
557 #[test]
558 fn test_chunk_offset_returns_position() {
559 assert_eq!(chunk_offset(0, 64), Some(0));
560 assert_eq!(chunk_offset(3, 64), Some(192));
561 assert_eq!(chunk_offset(usize::MAX, 2), None);
562 }
563
564 #[test]
565 fn received_transfer_blob_at_limit_is_accepted() {
566 check_received_transfer_blob_size(8, 8, "redactions").unwrap();
567 }
568
569 #[test]
570 fn received_transfer_blob_over_limit_is_rejected() {
571 let error = check_received_transfer_blob_size(9, 8, "redactions").unwrap_err();
572 let message = error.to_string();
573 assert!(
574 message.contains("redactions blob exceeds receive size limit"),
575 "unexpected error: {message}"
576 );
577 assert!(
578 message.contains("9 bytes (max 8)"),
579 "unexpected error: {message}"
580 );
581 }
582
583 #[test]
584 fn received_transfer_blob_caps_are_enforced_against_production_limits() {
585 check_received_transfer_blob_size(
586 MAX_RECEIVED_REDACTIONS_BLOB_SIZE as usize,
587 MAX_RECEIVED_REDACTIONS_BLOB_SIZE,
588 "redactions",
589 )
590 .unwrap();
591 check_received_transfer_blob_size(
592 MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE as usize,
593 MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE,
594 "state-visibility",
595 )
596 .unwrap();
597 }
598
599 #[test]
600 fn declared_receive_len_above_max_is_rejected_before_any_alloc() {
601 let error = admit_declared_received_len(9, 8, "pull raw body")
602 .expect_err("declared length above max must fail closed");
603 assert!(
604 error.to_string().contains("exceeds receive size limit"),
605 "got {error}"
606 );
607 assert!(error.to_string().contains("9 bytes (max 8)"), "got {error}");
608 }
609
610 #[test]
611 fn declared_receive_len_above_pack_cap_is_rejected_before_any_alloc() {
612 let error = admit_declared_received_len(
613 crate::MAX_RECEIVED_PACK_SIZE + 1,
614 crate::MAX_RECEIVED_PACK_SIZE,
615 "pull raw body",
616 )
617 .expect_err("attacker-chosen pack length must fail closed");
618 assert!(
619 error.to_string().contains("exceeds receive size limit"),
620 "got {error}"
621 );
622 }
623
624 #[test]
625 fn declared_receive_len_at_max_is_admitted() {
626 assert_eq!(
627 admit_declared_received_len(8, 8, "pull raw body").unwrap(),
628 8
629 );
630 }
631
632 #[test]
633 fn state_attachment_roundtrips_through_wire_data() {
634 let (_source_temp, source) = create_test_store();
635 let (_dest_temp, dest) = create_test_store();
636 let tree = source.put_tree(&Tree::new()).unwrap();
637 let state = State::new(tree, vec![], test_attribution());
638 source.put_state(&state).unwrap();
639 dest.put_state(&state).unwrap();
640 let attachment = StateAttachment {
641 state_id: state.id(),
642 body: StateAttachmentBody::RiskSignals(ContentHash::compute(b"signals")),
643 attribution: test_attribution(),
644 created_at: chrono::Utc::now(),
645 supersedes: None,
646 };
647 source.put_state_attachment(&attachment).unwrap();
648 let id = ObjectId::StateAttachment {
649 state: state.id(),
650 id: attachment.id(),
651 kind: attachment.body.kind(),
652 };
653 let data = load_object_data(&source, &id, ObjectType::StateAttachment).unwrap();
654 store_received_object(&dest, &data).unwrap();
655 assert_eq!(
656 dest.get_state_attachment(&state.id(), &attachment.id())
657 .unwrap(),
658 Some(attachment)
659 );
660 }
661}