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