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