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