1use objects::{
3 object::{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
86#[allow(dead_code)]
87pub fn chunk_count(object_size: usize, chunk_size: usize) -> usize {
88 if object_size == 0 || chunk_size == 0 {
89 return 0;
90 }
91 object_size.div_ceil(chunk_size)
92}
93
94#[allow(dead_code)]
95pub fn chunk_bounds(
96 object_size: usize,
97 chunk_size: usize,
98 chunk_index: usize,
99) -> Option<(usize, usize)> {
100 if chunk_size == 0 {
101 return None;
102 }
103
104 let start = chunk_index.checked_mul(chunk_size)?;
105 if start >= object_size {
106 return None;
107 }
108 let end = (start + chunk_size).min(object_size);
109 Some((start, end - start))
110}
111
112#[allow(dead_code)]
113pub fn chunk_offset(chunk_index: usize, chunk_size: usize) -> Option<usize> {
114 chunk_index.checked_mul(chunk_size)
115}
116
117pub fn load_requested_object(store: &impl ObjectStore, req: &ObjectRequest) -> Result<ObjectData> {
118 let (obj_type, data) = match &req.id {
125 ObjectId::Hash(hash) => {
126 if let Some(blob) = store.get_blob(hash)? {
127 (ObjectType::Blob, blob.content().to_vec())
128 } else if let Some(tree) = store.get_tree(hash)? {
129 (ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)
130 } else {
131 return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
132 }
133 }
134 ObjectId::StateId(state_id) => {
135 let state = store
136 .get_state(state_id)?
137 .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
138 (ObjectType::State, rmp_serde::to_vec_named(&state)?)
139 }
140 ObjectId::StateAttachment { state, id, kind: _ } => {
141 let attachment = store
142 .get_state_attachment(state, id)?
143 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
144 (
145 ObjectType::StateAttachment,
146 rmp_serde::to_vec_named(&attachment)?,
147 )
148 }
149 };
150
151 Ok(ObjectData {
152 id: req.id.clone(),
153 obj_type,
154 data,
155 is_delta: false,
156 })
157}
158
159pub fn load_object_data(
160 store: &impl ObjectStore,
161 id: &ObjectId,
162 obj_type: ObjectType,
163) -> Result<ObjectData> {
164 let data = match (id, obj_type) {
165 (ObjectId::Hash(hash), ObjectType::Blob) => store
166 .get_blob(hash)?
167 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?
168 .content()
169 .to_vec(),
170 (ObjectId::Hash(hash), ObjectType::Tree) => {
171 let tree = store
172 .get_tree(hash)?
173 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
174 rmp_serde::to_vec_named(&tree)?
175 }
176 (ObjectId::StateId(state_id), ObjectType::State) => {
177 let state = store
178 .get_state(state_id)?
179 .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string()))?;
180 rmp_serde::to_vec_named(&state)?
181 }
182 (ObjectId::Hash(hash), ObjectType::Redaction) => store
183 .get_redactions_bytes_for_blob(hash)?
184 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?,
185 (ObjectId::StateId(state_id), ObjectType::StateVisibility) => store
186 .get_state_visibility_bytes_for_state(state_id)?
187 .ok_or_else(|| ProtocolError::ObjectNotFound(state_id.to_string_full()))?,
188 (ObjectId::StateAttachment { state, id, kind: _ }, ObjectType::StateAttachment) => {
189 let attachment = store
190 .get_state_attachment(state, id)?
191 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
192 rmp_serde::to_vec_named(&attachment)?
193 }
194 (ObjectId::Hash(_), ObjectType::KeyBinding) => {
195 return Err(ProtocolError::InvalidState(
196 "KeyBinding registry objects must be constructed with encode_key_binding_registry"
197 .to_string(),
198 ));
199 }
200 _ => {
201 return Err(ProtocolError::InvalidState(
202 "object id/type mismatch".to_string(),
203 ));
204 }
205 };
206
207 Ok(ObjectData {
208 id: id.clone(),
209 obj_type,
210 data,
211 is_delta: false,
212 })
213}
214
215pub fn store_received_object(store: &impl ObjectStore, data: &ObjectData) -> Result<()> {
216 match (&data.id, data.obj_type) {
217 (ObjectId::Hash(hash), ObjectType::Blob) => {
218 store.put_blob_bytes_with_hash(&data.data, *hash)?;
219 }
220 (ObjectId::Hash(hash), ObjectType::Tree) => {
221 let tree: Tree = rmp_serde::from_slice(&data.data)?;
222 tree.validate().map_err(|error| {
223 ProtocolError::InvalidState(format!("invalid tree object: {error}"))
224 })?;
225 if &tree.hash() != hash {
226 return Err(ProtocolError::InvalidState(
227 "tree hash mismatch".to_string(),
228 ));
229 }
230 store.put_tree_serialized(&data.data, *hash)?;
231 }
232 (ObjectId::StateId(state_id), ObjectType::State) => {
233 let state: State = rmp_serde::from_slice(&data.data)?;
234 if state.id() != *state_id {
235 return Err(ProtocolError::InvalidState(format!(
236 "StateId mismatch: expected {state_id}, computed {}",
237 state.id()
238 )));
239 }
240 store.put_state_serialized(&data.data, *state_id)?;
241 }
242 (ObjectId::StateAttachment { state, id, kind }, ObjectType::StateAttachment) => {
243 let attachment: objects::object::StateAttachment = rmp_serde::from_slice(&data.data)?;
244 if attachment.state_id != *state || attachment.id() != *id {
245 return Err(ProtocolError::InvalidState(
246 "state attachment id mismatch".to_string(),
247 ));
248 }
249 let body_kind = attachment.body.kind();
254 if *kind != body_kind {
255 return Err(ProtocolError::InvalidState(format!(
256 "state attachment kind mismatch: descriptor {kind:?}, body {body_kind:?}"
257 )));
258 }
259 store.put_state_attachment(&attachment)?;
260 }
261 (_, ObjectType::Redaction) => {
262 return Err(ProtocolError::InvalidState(
267 "Redaction objects must be persisted via Repository::accept_wire_redactions, \
268 not store_received_object — signature verification is required"
269 .to_string(),
270 ));
271 }
272 (_, ObjectType::StateVisibility) => {
273 return Err(ProtocolError::InvalidState(
277 "StateVisibility objects must be persisted via Repository::accept_wire_state_visibility, \
278 not store_received_object — sidecar validation is required"
279 .to_string(),
280 ));
281 }
282 (_, ObjectType::KeyBinding) => {
283 return Err(ProtocolError::InvalidState(
284 "KeyBinding registry objects must be decoded and verified with decode_key_binding_registry"
285 .to_string(),
286 ));
287 }
288 _ => {
289 return Err(ProtocolError::InvalidState(
290 "object id/type mismatch".to_string(),
291 ));
292 }
293 }
294
295 Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300 use objects::{
301 object::{
302 Attribution, Blob, ContentHash, Principal, State, StateAttachment, StateAttachmentBody,
303 Tree, TreeEntry,
304 },
305 store::{FsStore, ObjectStore},
306 };
307 use tempfile::TempDir;
308
309 use super::*;
310
311 fn create_test_store() -> (TempDir, FsStore) {
312 let temp = TempDir::new().unwrap();
313 let store = FsStore::new(temp.path().join(".heddle"));
314 store.init().unwrap();
315 (temp, store)
316 }
317
318 fn test_attribution() -> Attribution {
319 Attribution::human(Principal::new("Wire Tester", "wire@example.com"))
320 }
321
322 #[test]
323 fn primary_objects_roundtrip_through_wire_data() {
324 let (_source_temp, source) = create_test_store();
325 let (_dest_temp, dest) = create_test_store();
326
327 let blob = Blob::from("wire transfer blob\n");
328 let blob_hash = source.put_blob(&blob).unwrap();
329 let tree = Tree::from_entries(vec![TreeEntry::file("lib.rs", blob_hash, false).unwrap()]);
330 let tree_hash = source.put_tree(&tree).unwrap();
331 let state = State::new(tree_hash, Vec::new(), test_attribution())
332 .with_intent("exercise wire transfer");
333 source.put_state(&state).unwrap();
334
335 let blob_data = load_requested_object(
336 &source,
337 &ObjectRequest {
338 id: ObjectId::Hash(blob_hash),
339 have_base: None,
340 },
341 )
342 .unwrap();
343 assert_eq!(blob_data.obj_type, ObjectType::Blob);
344 assert_eq!(blob_data.data, blob.content());
345 store_received_object(&dest, &blob_data).unwrap();
346 assert_eq!(
347 dest.get_blob(&blob_hash).unwrap().unwrap().content(),
348 blob.content()
349 );
350
351 let tree_data = load_requested_object(
352 &source,
353 &ObjectRequest {
354 id: ObjectId::Hash(tree_hash),
355 have_base: None,
356 },
357 )
358 .unwrap();
359 assert_eq!(tree_data.obj_type, ObjectType::Tree);
360 assert_eq!(
361 rmp_serde::from_slice::<Tree>(&tree_data.data).unwrap(),
362 tree
363 );
364 store_received_object(&dest, &tree_data).unwrap();
365 assert_eq!(dest.get_tree(&tree_hash).unwrap().unwrap(), tree);
366
367 let state_data = load_requested_object(
368 &source,
369 &ObjectRequest {
370 id: ObjectId::StateId(state.state_id),
371 have_base: None,
372 },
373 )
374 .unwrap();
375 assert_eq!(state_data.obj_type, ObjectType::State);
376 assert_eq!(
377 objects::store::codec::decode_state(&state_data.data).unwrap(),
378 state
379 );
380 store_received_object(&dest, &state_data).unwrap();
381 assert_eq!(
382 dest.get_state(&state.state_id).unwrap().unwrap().state_id,
383 state.state_id
384 );
385 }
386
387 #[test]
388 fn load_object_data_reports_missing_and_id_type_mismatch_errors() {
389 let (_temp, store) = create_test_store();
390 let missing_hash = ContentHash::from_bytes([7; 32]);
391 let missing_state = objects::object::StateId::from_bytes([9; 32]);
392
393 let missing = load_requested_object(
394 &store,
395 &ObjectRequest {
396 id: ObjectId::Hash(missing_hash),
397 have_base: None,
398 },
399 )
400 .unwrap_err();
401 assert!(
402 matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_hash.to_hex())
403 );
404
405 let missing = load_requested_object(
406 &store,
407 &ObjectRequest {
408 id: ObjectId::StateId(missing_state),
409 have_base: None,
410 },
411 )
412 .unwrap_err();
413 assert!(
414 matches!(missing, ProtocolError::ObjectNotFound(id) if id == missing_state.to_string())
415 );
416
417 let mismatch =
418 load_object_data(&store, &ObjectId::Hash(missing_hash), ObjectType::State).unwrap_err();
419 assert!(
420 matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
421 );
422
423 let mismatch =
424 load_object_data(&store, &ObjectId::StateId(missing_state), ObjectType::Blob)
425 .unwrap_err();
426 assert!(
427 matches!(mismatch, ProtocolError::InvalidState(message) if message == "object id/type mismatch")
428 );
429 }
430
431 #[test]
432 fn store_received_object_rejects_mismatched_object_identity() {
433 let (_temp, store) = create_test_store();
434 let blob = Blob::from("tree leaf");
435 let blob_hash = store.put_blob(&blob).unwrap();
436 let tree = Tree::from_entries(vec![TreeEntry::file("leaf.txt", blob_hash, false).unwrap()]);
437 let tree_bytes = rmp_serde::to_vec_named(&tree).unwrap();
438 let wrong_hash = ContentHash::from_bytes([4; 32]);
439
440 let error = store_received_object(
441 &store,
442 &ObjectData {
443 id: ObjectId::Hash(wrong_hash),
444 obj_type: ObjectType::Tree,
445 data: tree_bytes,
446 is_delta: false,
447 },
448 )
449 .unwrap_err();
450 assert!(
451 matches!(error, ProtocolError::InvalidState(message) if message == "tree hash mismatch")
452 );
453
454 let state = State::new(tree.hash(), Vec::new(), test_attribution());
455 let wrong_state_id = objects::object::StateId::from_bytes([5; 32]);
456 let error = store_received_object(
457 &store,
458 &ObjectData {
459 id: ObjectId::StateId(wrong_state_id),
460 obj_type: ObjectType::State,
461 data: rmp_serde::to_vec_named(&state).unwrap(),
462 is_delta: false,
463 },
464 )
465 .unwrap_err();
466 assert!(
467 matches!(error, ProtocolError::InvalidState(message) if message.contains("StateId mismatch"))
468 );
469 }
470
471 #[test]
472 fn store_received_object_rejects_raw_sidecar_objects() {
473 let (_temp, store) = create_test_store();
474 let blob_hash = ContentHash::from_bytes([1; 32]);
475 let state_id = objects::object::StateId::from_bytes([2; 32]);
476
477 let redaction_error = store_received_object(
478 &store,
479 &ObjectData {
480 id: ObjectId::Hash(blob_hash),
481 obj_type: ObjectType::Redaction,
482 data: b"unsigned redaction bytes".to_vec(),
483 is_delta: false,
484 },
485 )
486 .unwrap_err();
487 assert!(
488 matches!(redaction_error, ProtocolError::InvalidState(message) if message.contains("signature verification is required"))
489 );
490
491 let visibility_error = store_received_object(
492 &store,
493 &ObjectData {
494 id: ObjectId::StateId(state_id),
495 obj_type: ObjectType::StateVisibility,
496 data: b"raw visibility bytes".to_vec(),
497 is_delta: false,
498 },
499 )
500 .unwrap_err();
501 assert!(
502 matches!(visibility_error, ProtocolError::InvalidState(message) if message.contains("sidecar validation is required"))
503 );
504 }
505
506 #[test]
507 fn test_chunk_count_rounds_up() {
508 assert_eq!(chunk_count(0, 64), 0);
509 assert_eq!(chunk_count(1, 64), 1);
510 assert_eq!(chunk_count(64, 64), 1);
511 assert_eq!(chunk_count(65, 64), 2);
512 }
513
514 #[test]
515 fn test_chunk_bounds_returns_ranges() {
516 assert_eq!(chunk_bounds(100, 32, 0), Some((0, 32)));
517 assert_eq!(chunk_bounds(100, 32, 2), Some((64, 32)));
518 assert_eq!(chunk_bounds(100, 32, 3), Some((96, 4)));
519 assert_eq!(chunk_bounds(100, 32, 4), None);
520 assert_eq!(chunk_bounds(100, 0, 0), None);
521 }
522
523 #[test]
524 fn test_chunk_offset_returns_position() {
525 assert_eq!(chunk_offset(0, 64), Some(0));
526 assert_eq!(chunk_offset(3, 64), Some(192));
527 assert_eq!(chunk_offset(usize::MAX, 2), None);
528 }
529
530 #[test]
531 fn received_transfer_blob_at_limit_is_accepted() {
532 check_received_transfer_blob_size(8, 8, "redactions").unwrap();
533 }
534
535 #[test]
536 fn received_transfer_blob_over_limit_is_rejected() {
537 let error = check_received_transfer_blob_size(9, 8, "redactions").unwrap_err();
538 let message = error.to_string();
539 assert!(
540 message.contains("redactions blob exceeds receive size limit"),
541 "unexpected error: {message}"
542 );
543 assert!(
544 message.contains("9 bytes (max 8)"),
545 "unexpected error: {message}"
546 );
547 }
548
549 #[test]
550 fn received_transfer_blob_caps_are_enforced_against_production_limits() {
551 check_received_transfer_blob_size(
552 MAX_RECEIVED_REDACTIONS_BLOB_SIZE as usize,
553 MAX_RECEIVED_REDACTIONS_BLOB_SIZE,
554 "redactions",
555 )
556 .unwrap();
557 check_received_transfer_blob_size(
558 MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE as usize,
559 MAX_RECEIVED_STATE_VISIBILITY_BLOB_SIZE,
560 "state-visibility",
561 )
562 .unwrap();
563 }
564
565 #[test]
566 fn state_attachment_roundtrips_through_wire_data() {
567 let (_source_temp, source) = create_test_store();
568 let (_dest_temp, dest) = create_test_store();
569 let tree = source.put_tree(&Tree::new()).unwrap();
570 let state = State::new(tree, vec![], test_attribution());
571 source.put_state(&state).unwrap();
572 dest.put_state(&state).unwrap();
573 let attachment = StateAttachment {
574 state_id: state.id(),
575 body: StateAttachmentBody::RiskSignals(ContentHash::compute(b"signals")),
576 attribution: test_attribution(),
577 created_at: chrono::Utc::now(),
578 supersedes: None,
579 };
580 source.put_state_attachment(&attachment).unwrap();
581 let id = ObjectId::StateAttachment {
582 state: state.id(),
583 id: attachment.id(),
584 kind: attachment.body.kind(),
585 };
586 let data = load_object_data(&source, &id, ObjectType::StateAttachment).unwrap();
587 store_received_object(&dest, &data).unwrap();
588 assert_eq!(
589 dest.get_state_attachment(&state.id(), &attachment.id())
590 .unwrap(),
591 Some(attachment)
592 );
593 }
594}