1use api::v2::client::{ClientError, RpcTransport};
5use prost::Message;
6use tokio::io::{AsyncRead, AsyncReadExt};
7
8use crate::{Remote, contract::*, rpc, transport};
9
10#[cfg(feature = "source-transfer")]
11mod acceptance;
12#[cfg(feature = "source-transfer")]
13pub use acceptance::{
14 PreparedPublication, ProposedAcceptance, PublicationAcceptancePlan, proposed_publication,
15 publication_intent,
16};
17#[cfg(feature = "source-transfer")]
18mod source;
19#[cfg(feature = "source-transfer")]
20mod staging;
21#[cfg(feature = "source-transfer")]
22pub use source::{PublicationOptions, SourceBudget, SourcePack, VisibleSourcePack};
23#[cfg(feature = "source-transfer")]
24pub use staging::{
25 ProposedSourceArtifacts, validate_proposed_source_artifacts, validate_source_artifacts,
26};
27
28#[derive(Clone)]
32pub struct PublicationOriginals {
33 pub geneses: Vec<ThreadGenesisRecord>,
34 pub operations: Vec<ReplicationOperations>,
35}
36impl PublicationOriginals {
37 fn validate_bounds(&self) -> Result<(), Error> {
38 if self.geneses.is_empty()
39 || self.geneses.len() > 128
40 || self.operations.is_empty()
41 || self.operations.len() > 10_000
42 || self
43 .operations
44 .iter()
45 .map(|batch| batch.operations.len())
46 .sum::<usize>()
47 > 10_000
48 || self.operations.iter().any(|batch| {
49 batch.operations.is_empty()
50 || batch.authority_admissions.len() > batch.operations.len()
51 })
52 {
53 return Err(Error::Invalid(
54 "bounded original genesis and source operations required",
55 ));
56 }
57 let mut acceptances = std::collections::BTreeSet::new();
58 for record in self
59 .geneses
60 .iter()
61 .flat_map(|value| &value.boundary_acceptances)
62 .chain(
63 self.operations
64 .iter()
65 .flat_map(|value| &value.boundary_acceptances),
66 )
67 {
68 if record.canonical_record.len() > 96 * 1024
69 || record.signatures.len() != 1
70 || record.signatures[0].signature.len() != 64
71 || record.signatures[0].public_key.len() != 32
72 {
73 return Err(Error::Invalid("boundary evidence shape exceeds bounds"));
74 }
75 acceptances.insert(record.canonical_record.as_slice());
76 if acceptances.len() > 128 {
77 return Err(Error::Invalid("boundary acceptance count exceeded"));
78 }
79 }
80 let mut bytes = 0usize;
81 for length in self
82 .geneses
83 .iter()
84 .map(Message::encoded_len)
85 .chain(self.operations.iter().map(Message::encoded_len))
86 {
87 bytes = bytes
88 .checked_add(length)
89 .ok_or(Error::Invalid("publication original size overflow"))?;
90 if length > 256 * 1024 || bytes > 16 * 1024 * 1024 {
91 return Err(Error::Invalid(
92 "publication original metadata budget exceeded",
93 ));
94 }
95 }
96 if self.geneses.iter().any(|genesis| {
97 genesis.genesis.as_ref().is_none_or(|record| {
98 record.canonical_record.is_empty() || record.signatures.is_empty()
99 })
100 }) || self
101 .operations
102 .iter()
103 .flat_map(|batch| {
104 batch
105 .operations
106 .iter()
107 .chain(&batch.authority_admissions)
108 .chain(&batch.boundary_acceptances)
109 })
110 .any(|record| record.canonical_record.is_empty() || record.signatures.is_empty())
111 {
112 return Err(Error::Invalid(
113 "original signatures and canonical records required",
114 ));
115 }
116 Ok(())
117 }
118}
119
120#[derive(Debug, thiserror::Error)]
121pub enum Error {
122 #[error(transparent)]
123 Client(#[from] ClientError<transport::Error>),
124 #[error(transparent)]
125 Transport(#[from] transport::Error),
126 #[error("publication source: {0}")]
127 Source(#[from] std::io::Error),
128 #[error("invalid publication: {0}")]
129 Invalid(&'static str),
130}
131
132impl<T: RpcTransport<Error = transport::Error>> Remote<T> {
133 pub async fn publish_content<R: AsyncRead + Unpin + Send>(
138 &self,
139 opening: &PublishContentClientFrame,
140 originals: &PublicationOriginals,
141 mut artifacts: [R; 2],
142 ) -> Result<PublicationReceipt, Error> {
143 originals.validate_bounds()?;
144 let Some(publish_content_client_frame::Body::Open(open)) = &opening.body else {
145 return Err(Error::Invalid("Open required"));
146 };
147 if open.destination != self.description.endpoint
148 || open.thread.is_none()
149 || open.revision.is_none()
150 || (!open.sharing_policy_version.is_empty() && open.sharing_policy_version.len() != 32)
151 || open.packs.len() != 2
152 || open.packs[0].kind != pack_extent::Kind::NativePack as i32
153 || open.packs[1].kind != pack_extent::Kind::NativeIndex as i32
154 {
155 return Err(Error::Invalid(
156 "endpoint, capture and ordered native artifacts required",
157 ));
158 }
159 let inventory = inventory_digest(&open.packs)?;
160 let mut logical = opening.clone();
161 if let Some(publish_content_client_frame::Body::Open(open)) = logical.body.as_mut() {
162 open.checkpoint = None;
163 }
164 let digest = typed_digest("thread-source-transfer-v1", &logical.encode_to_vec());
165 let expected = TransferCheckpoint {
166 transfer_id: digest[..16].to_vec(),
167 plan_digest: digest.to_vec(),
168 resume_token: Vec::new(),
169 committed_bytes: 0,
170 };
171 let (mut sender, mut responses) = self
172 .api
173 .exchange::<rpc::SyncServicePublishContent>(opening)
174 .await?;
175 let first = responses
176 .next()
177 .await?
178 .ok_or(Error::Invalid("publication ended before admission"))?;
179 let ready = match first.body {
180 Some(publish_content_server_frame::Body::Receipt(receipt)) => {
181 return validate_receipt(receipt, opening, &inventory);
182 }
183 Some(publish_content_server_frame::Body::Ready(ready)) => ready,
184 _ => return Err(Error::Invalid("Ready or replay receipt required")),
185 };
186 if ready.endpoint != open.destination
187 || ready.thread != open.thread
188 || ready.current != open.revision
189 || ready.checkpoint.as_ref() != Some(&expected)
190 {
191 return Err(Error::Invalid("admission differs from publication plan"));
192 }
193 let budget = ready
194 .budget
195 .ok_or(Error::Invalid("publication frame budget required"))?;
196 let frame_limit = budget.max_frame_bytes as usize;
197 if !(1024..=512 * 1024).contains(&frame_limit) {
198 return Err(Error::Invalid("unsupported publication frame budget"));
199 }
200 let upload = async {
201 for body in originals
202 .geneses
203 .iter()
204 .cloned()
205 .map(publish_content_client_frame::Body::ThreadGenesis)
206 .chain(
207 originals
208 .operations
209 .iter()
210 .cloned()
211 .map(publish_content_client_frame::Body::Operations),
212 )
213 {
214 let frame = PublishContentClientFrame {
215 client_operation_id: opening.client_operation_id.clone(),
216 body: Some(body),
217 };
218 if frame.encoded_len() > frame_limit {
219 return Err(Error::Invalid(
220 "original exceeds negotiated publication frame budget",
221 ));
222 }
223 sender.send(&frame).await?;
224 }
225 for (artifact, planned) in artifacts.iter_mut().zip(&open.packs) {
226 let mut offset = 0;
227 let mut digest = blake3::Hasher::new();
228 let mut buffer = vec![0; frame_limit / 2];
229 while offset < planned.length {
230 let length = (planned.length - offset).min(buffer.len() as u64) as usize;
231 let read = artifact.read(&mut buffer[..length]).await?;
232 if read == 0 {
233 return Err(Error::Invalid("artifact ended before declared length"));
234 }
235 let data = &buffer[..read];
236 digest.update(data);
237 let chunk = PublishContentClientFrame {
238 client_operation_id: opening.client_operation_id.clone(),
239 body: Some(publish_content_client_frame::Body::Pack(PackChunk {
240 extent: Some(PackExtent {
241 pack: planned.pack.clone(),
242 kind: planned.kind,
243 offset,
244 length: read as u64,
245 extent_digest: Some(ObjectAddress {
246 algorithm: "blake3".into(),
247 digest: blake3::hash(data).as_bytes().to_vec(),
248 }),
249 }),
250 data: data.to_vec(),
251 })),
252 };
253 if chunk.encoded_len() > frame_limit {
254 return Err(Error::Invalid("chunk exceeds negotiated frame budget"));
255 }
256 sender.send(&chunk).await?;
257 offset += read as u64;
258 }
259 if artifact.read(&mut buffer[..1]).await? != 0
260 || planned
261 .pack
262 .as_ref()
263 .is_none_or(|address| address.digest != digest.finalize().as_bytes())
264 {
265 return Err(Error::Invalid(
266 "artifact differs from declared length or digest",
267 ));
268 }
269 }
270 sender
271 .send(&PublishContentClientFrame {
272 client_operation_id: opening.client_operation_id.clone(),
273 body: Some(publish_content_client_frame::Body::Finish(
274 PublishContentFinish {
275 checkpoint: Some(expected.clone()),
276 },
277 )),
278 })
279 .await?;
280 sender.finish().await?;
281 Ok::<_, Error>(())
282 };
283 let receive = async {
284 while let Some(frame) = responses.next().await? {
285 match frame.body {
286 Some(publish_content_server_frame::Body::Checkpoint(checkpoint))
287 if checkpoint == expected => {}
288 Some(publish_content_server_frame::Body::Receipt(receipt)) => {
289 return validate_receipt(receipt, opening, &inventory);
290 }
291 _ => return Err(Error::Invalid("unexpected publication response")),
292 }
293 }
294 Err(Error::Invalid(
295 "publication ended without a durable receipt",
296 ))
297 };
298 let (_, receipt) = tokio::try_join!(upload, receive)?;
299 Ok(receipt)
300 }
301}
302
303fn validate_receipt(
304 receipt: PublicationReceipt,
305 opening: &PublishContentClientFrame,
306 inventory: &[u8; 32],
307) -> Result<PublicationReceipt, Error> {
308 let Some(publish_content_client_frame::Body::Open(open)) = &opening.body else {
309 return Err(Error::Invalid("Open required"));
310 };
311 if receipt.client_operation_id != opening.client_operation_id
312 || receipt.destination != open.destination
313 || receipt.thread != open.thread
314 || receipt.revision != open.revision
315 || receipt.sharing_policy_version.len() != 32
316 || (!open.sharing_policy_version.is_empty()
317 && receipt.sharing_policy_version != open.sharing_policy_version)
318 {
319 return Err(Error::Invalid("receipt differs from requested publication"));
320 }
321 match &receipt.outcome {
322 Some(publication_receipt::Outcome::Accepted(_))
323 if receipt.accepted_inventory.as_ref().is_some_and(|address| {
324 address.algorithm == "blake3" && address.digest == inventory
325 }) =>
326 {
327 Ok(receipt)
328 }
329 Some(publication_receipt::Outcome::Rejected(error)) => {
330 Err(transport::Error::Remote(error.clone().into()).into())
331 }
332 _ => Err(Error::Invalid(
333 "receipt did not accept the complete inventory",
334 )),
335 }
336}
337
338pub fn inventory_digest(packs: &[PackExtent]) -> Result<[u8; 32], Error> {
341 if packs.len() != 2
342 || packs[0].kind != pack_extent::Kind::NativePack as i32
343 || packs[1].kind != pack_extent::Kind::NativeIndex as i32
344 {
345 return Err(Error::Invalid("ordered native pack and index required"));
346 }
347 let mut inventory = Vec::new();
348 let mut total = 0_u64;
349 for extent in packs {
350 let address = extent
351 .pack
352 .as_ref()
353 .ok_or(Error::Invalid("artifact address required"))?;
354 total = total
355 .checked_add(extent.length)
356 .ok_or(Error::Invalid("artifact length overflow"))?;
357 if address.algorithm != "blake3"
358 || address.digest.len() != 32
359 || extent.length == 0
360 || extent.offset != 0
361 || extent.extent_digest.as_ref() != Some(address)
362 || total > 256 * 1024 * 1024
363 {
364 return Err(Error::Invalid("complete bounded BLAKE3 artifacts required"));
365 }
366 extent
367 .encode_length_delimited(&mut inventory)
368 .map_err(|_| Error::Invalid("inventory encoding failed"))?;
369 }
370 Ok(typed_digest("thread-source-inventory-v1", &inventory))
371}
372
373fn typed_digest(kind: &str, bytes: &[u8]) -> [u8; 32] {
374 let mut hasher = blake3::Hasher::new();
375 hasher.update(kind.as_bytes());
376 hasher.update(&(bytes.len() as u64).to_le_bytes());
377 hasher.update(&[0]);
378 hasher.update(bytes);
379 *hasher.finalize().as_bytes()
380}
381
382#[cfg(test)]
383mod tests {
384 use api::v2::{
385 MethodDescriptor,
386 client::{MessageReader, MessageWriter},
387 };
388 use tokio::sync::mpsc;
389
390 use super::*;
391
392 pub(super) struct Reader(mpsc::Receiver<Vec<u8>>);
393 pub(super) struct Writer(Option<mpsc::Sender<Vec<u8>>>);
394 impl MessageReader for Reader {
395 type Error = transport::Error;
396 async fn next(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
397 Ok(self.0.recv().await)
398 }
399 fn cancel(&mut self) {
400 self.0.close();
401 }
402 }
403 impl MessageWriter for Writer {
404 type Error = transport::Error;
405 async fn send(&mut self, bytes: Vec<u8>) -> Result<(), Self::Error> {
406 self.0
407 .as_ref()
408 .ok_or(transport::Error::Protocol("closed"))?
409 .send(bytes)
410 .await
411 .map_err(|_| transport::Error::Protocol("peer closed"))
412 }
413 async fn finish(&mut self) -> Result<(), Self::Error> {
414 self.0.take();
415 Ok(())
416 }
417 fn abort(&mut self) {
418 self.0.take();
419 }
420 }
421 pub(super) struct Peer {
422 wrong_receipt: bool,
423 }
424 impl RpcTransport for Peer {
425 type Error = transport::Error;
426 type Reader = Reader;
427 type Writer = Writer;
428 async fn unary(
429 &self,
430 _: &'static MethodDescriptor,
431 _: Vec<u8>,
432 ) -> Result<Vec<u8>, Self::Error> {
433 unreachable!("test exercises only publication");
434 }
435 async fn observe(
436 &self,
437 _: &'static MethodDescriptor,
438 _: Vec<u8>,
439 ) -> Result<Reader, Self::Error> {
440 unreachable!("test exercises only publication");
441 }
442 async fn exchange(
443 &self,
444 _: &'static MethodDescriptor,
445 bytes: Vec<u8>,
446 ) -> Result<(Writer, Reader), Self::Error> {
447 let opening = PublishContentClientFrame::decode(bytes.as_slice())?;
448 let Some(publish_content_client_frame::Body::Open(open)) = opening.body.clone() else {
449 panic!("Open");
450 };
451 let mut logical = opening.clone();
452 if let Some(publish_content_client_frame::Body::Open(open)) = logical.body.as_mut() {
453 open.checkpoint = None;
454 }
455 let digest = typed_digest("thread-source-transfer-v1", &logical.encode_to_vec());
456 let checkpoint = TransferCheckpoint {
457 transfer_id: digest[..16].to_vec(),
458 plan_digest: digest.to_vec(),
459 committed_bytes: 0,
460 resume_token: vec![],
461 };
462 let (tx, mut incoming) = mpsc::channel::<Vec<u8>>(1);
463 let (outgoing, rx) = mpsc::channel(1);
464 let wrong_receipt = self.wrong_receipt;
465 tokio::spawn(async move {
466 let send = |body| {
467 let outgoing = &outgoing;
468 async move {
469 outgoing
470 .send(PublishContentServerFrame { body: Some(body) }.encode_to_vec())
471 .await
472 }
473 };
474 send(publish_content_server_frame::Body::Ready(TransferReady {
475 endpoint: open.destination.clone(),
476 thread: open.thread.clone(),
477 current: open.revision.clone(),
478 checkpoint: Some(checkpoint.clone()),
479 budget: Some(ReadBudget {
480 max_frame_bytes: 2048,
481 ..Default::default()
482 }),
483 ..Default::default()
484 }))
485 .await
486 .expect("Ready");
487 let mut lengths = [0_u64; 2];
488 let mut original_counts = [0usize; 2];
489 while let Some(bytes) = incoming.recv().await {
490 let frame = PublishContentClientFrame::decode(bytes.as_slice()).expect("frame");
491 assert_eq!(frame.client_operation_id, opening.client_operation_id);
492 match frame.body {
493 Some(publish_content_client_frame::Body::ThreadGenesis(genesis)) => {
494 assert_eq!(lengths, [0, 0]);
495 assert!(genesis.genesis.is_some());
496 original_counts[0] += 1;
497 send(publish_content_server_frame::Body::Checkpoint(
498 checkpoint.clone(),
499 ))
500 .await
501 .expect("original checkpoint");
502 }
503 Some(publish_content_client_frame::Body::Operations(batch)) => {
504 assert_eq!(lengths, [0, 0]);
505 assert_eq!(batch.operations.len(), 1);
506 assert!(!batch.operations[0].canonical_record.is_empty());
507 original_counts[1] += batch.operations.len();
508 send(publish_content_server_frame::Body::Checkpoint(
509 checkpoint.clone(),
510 ))
511 .await
512 .expect("original checkpoint");
513 }
514 Some(publish_content_client_frame::Body::Pack(chunk)) => {
515 assert_eq!(
516 original_counts,
517 [1, 1],
518 "original proofs precede source artifacts"
519 );
520 let extent = chunk.extent.expect("extent");
521 let index = if extent.kind == pack_extent::Kind::NativePack as i32 {
522 0
523 } else {
524 1
525 };
526 assert_eq!(extent.offset, lengths[index]);
527 assert_eq!(extent.length, chunk.data.len() as u64);
528 assert_eq!(
529 extent.extent_digest.expect("chunk digest").digest,
530 blake3::hash(&chunk.data).as_bytes()
531 );
532 lengths[index] += extent.length;
533 send(publish_content_server_frame::Body::Checkpoint(
537 checkpoint.clone(),
538 ))
539 .await
540 .expect("checkpoint");
541 }
542 Some(publish_content_client_frame::Body::Finish(finish)) => {
543 assert_eq!(finish.checkpoint, Some(checkpoint.clone()));
544 assert_eq!(lengths, [open.packs[0].length, open.packs[1].length]);
545 let mut inventory = Vec::new();
546 for extent in &open.packs {
547 extent
548 .encode_length_delimited(&mut inventory)
549 .expect("inventory");
550 }
551 let mut receipt = PublicationReceipt {
552 client_operation_id: opening.client_operation_id.clone(),
553 destination: open.destination.clone(),
554 thread: open.thread.clone(),
555 revision: open.revision.clone(),
556 sharing_policy_version: if open.sharing_policy_version.is_empty() {
557 vec![3; 32]
558 } else {
559 open.sharing_policy_version.clone()
560 },
561 accepted_inventory: Some(ObjectAddress {
562 algorithm: "blake3".into(),
563 digest: typed_digest("thread-source-inventory-v1", &inventory)
564 .to_vec(),
565 }),
566 outcome: Some(publication_receipt::Outcome::Accepted(
567 Applied::default(),
568 )),
569 };
570 if wrong_receipt {
571 receipt.thread = None;
572 }
573 let _ =
574 send(publish_content_server_frame::Body::Receipt(receipt)).await;
575 break;
576 }
577 _ => panic!("unexpected frame"),
578 }
579 }
580 });
581 Ok((Writer(Some(tx)), Reader(rx)))
582 }
583 }
584
585 pub(super) fn fixture(
586 wrong_receipt: bool,
587 ) -> (
588 Remote<Peer>,
589 PublishContentClientFrame,
590 [std::io::Cursor<Vec<u8>>; 2],
591 ) {
592 let endpoint = EndpointRef {
593 public_key: vec![8; 32],
594 kind: EndpointKind::Weft as i32,
595 };
596 let remote = Remote {
597 api: api::v2::client::Client::new(
598 Peer { wrong_receipt },
599 ["/heddle.api.v1alpha2.SyncService/PublishContent".into()],
600 ),
601 description: DescribeEndpointResponse {
602 endpoint: Some(endpoint.clone()),
603 ..Default::default()
604 },
605 };
606 let artifacts = [vec![1; 128 * 1024], vec![2; 16 * 1024]];
607 let packs = artifacts
608 .iter()
609 .zip([
610 pack_extent::Kind::NativePack,
611 pack_extent::Kind::NativeIndex,
612 ])
613 .map(|(data, kind)| {
614 let address = ObjectAddress {
615 algorithm: "blake3".into(),
616 digest: blake3::hash(data).as_bytes().to_vec(),
617 };
618 PackExtent {
619 pack: Some(address.clone()),
620 kind: kind as i32,
621 offset: 0,
622 length: data.len() as u64,
623 extent_digest: Some(address),
624 }
625 })
626 .collect();
627 let open = PublishContentClientFrame {
628 client_operation_id: "op-test".into(),
629 body: Some(publish_content_client_frame::Body::Open(
630 PublishContentOpen {
631 destination: Some(endpoint),
632 thread: Some(ThreadRef::default()),
633 revision: Some(RevisionRef::default()),
634 packs,
635 ..Default::default()
636 },
637 )),
638 };
639 (remote, open, artifacts.map(std::io::Cursor::new))
640 }
641 fn originals() -> PublicationOriginals {
644 let record = SignedRecord {
645 format: "transport-fixture".into(),
646 canonical_record: vec![1],
647 signatures: vec![RecordSignature {
648 public_key: vec![2; 32],
649 signature: vec![3; 64],
650 }],
651 };
652 PublicationOriginals {
653 geneses: vec![ThreadGenesisRecord {
654 boundary_acceptances: Vec::new(),
655 genesis: Some(record.clone()),
656 ..Default::default()
657 }],
658 operations: vec![ReplicationOperations {
659 boundary_acceptances: Vec::new(),
660 operations: vec![record],
661 authority_admissions: vec![],
662 }],
663 }
664 }
665
666 #[test]
667 fn publication_originals_require_bounded_complete_metadata() {
668 let valid = originals();
669 assert!(valid.validate_bounds().is_ok());
670 let mut missing = valid.clone();
671 missing.operations.clear();
672 assert!(matches!(
673 missing.validate_bounds(),
674 Err(Error::Invalid(
675 "bounded original genesis and source operations required"
676 ))
677 ));
678 let mut unsigned = valid.clone();
679 unsigned.operations[0].operations[0].signatures.clear();
680 assert!(matches!(
681 unsigned.validate_bounds(),
682 Err(Error::Invalid(
683 "original signatures and canonical records required"
684 ))
685 ));
686 let mut oversized = valid.clone();
687 oversized.operations[0].operations[0].canonical_record = vec![1; 256 * 1024];
688 assert!(matches!(
689 oversized.validate_bounds(),
690 Err(Error::Invalid(
691 "publication original metadata budget exceeded"
692 ))
693 ));
694 let mut too_many = valid;
695 too_many.geneses = vec![too_many.geneses[0].clone(); 129];
696 assert!(matches!(
697 too_many.validate_bounds(),
698 Err(Error::Invalid(
699 "bounded original genesis and source operations required"
700 ))
701 ));
702 }
703
704 #[tokio::test]
705 async fn publication_drains_checkpoints_while_uploading_under_backpressure() {
706 let (remote, open, artifacts) = fixture(false);
707 let receipt = tokio::time::timeout(
708 std::time::Duration::from_secs(2),
709 remote.publish_content(&open, &originals(), artifacts),
710 )
711 .await
712 .expect("must not deadlock")
713 .expect("publication");
714 assert_eq!(receipt.client_operation_id, "op-test");
715 }
716 #[tokio::test]
717 async fn publication_refuses_a_receipt_for_another_scope() {
718 let (remote, open, artifacts) = fixture(true);
719 let error = remote
720 .publish_content(&open, &originals(), artifacts)
721 .await
722 .expect_err("mismatched scope");
723 assert!(matches!(
724 error,
725 Error::Invalid("receipt differs from requested publication")
726 ));
727 }
728 #[test]
729 fn publication_policy_is_optional_cas_and_receipt_reports_actual_frontier() {
730 let (_, mut opening, _) = fixture(false);
731 let inventory = [7; 32];
732 let Some(publish_content_client_frame::Body::Open(open)) = &opening.body else {
733 panic!("opening")
734 };
735 let receipt = PublicationReceipt {
736 client_operation_id: opening.client_operation_id.clone(),
737 destination: open.destination.clone(),
738 thread: open.thread.clone(),
739 revision: open.revision.clone(),
740 sharing_policy_version: vec![3; 32],
741 accepted_inventory: Some(ObjectAddress {
742 algorithm: "blake3".into(),
743 digest: inventory.to_vec(),
744 }),
745 outcome: Some(publication_receipt::Outcome::Accepted(Applied::default())),
746 };
747 validate_receipt(receipt.clone(), &opening, &inventory)
748 .expect("one-shot upload returns actual policy without CAS");
749 let mut absent = receipt.clone();
750 absent.sharing_policy_version.clear();
751 assert!(
752 validate_receipt(absent, &opening, &inventory).is_err(),
753 "receipt must report actual policy frontier"
754 );
755 let Some(publish_content_client_frame::Body::Open(open)) = &mut opening.body else {
756 panic!("opening")
757 };
758 open.sharing_policy_version = vec![4; 32];
759 assert!(
760 validate_receipt(receipt.clone(), &opening, &inventory).is_err(),
761 "explicit policy CAS cannot silently accept another version"
762 );
763 let Some(publish_content_client_frame::Body::Open(open)) = &mut opening.body else {
764 panic!("opening")
765 };
766 open.sharing_policy_version = vec![3; 32];
767 validate_receipt(receipt, &opening, &inventory).expect("matching explicit policy version");
768 }
769
770 #[cfg(feature = "replication")]
771 #[test]
772 fn publication_digests_match_the_native_typed_hash_format() {
773 assert_eq!(
774 typed_digest("thread-source-inventory-v1", b"bytes"),
775 *heddle_object_model::object::ContentHash::compute_typed(
776 "thread-source-inventory-v1",
777 b"bytes"
778 )
779 .as_bytes()
780 );
781 }
782
783 #[cfg(feature = "source-transfer")]
784 #[tokio::test]
785 async fn thread_publication_prepares_only_selected_source_and_binds_its_revision() {
786 use objects::{
787 object::{Attribution, Blob, Principal, State, Tree, TreeEntry},
788 store::{FsStore, ObjectStore},
789 };
790 let root = tempfile::tempdir().expect("local source scratch");
791 let store = FsStore::new(root.path().join("objects"));
792 store.init().expect("store");
793 let blob = Blob::new(vec![1; 128 * 1024]);
794 store.put_blob(&blob).expect("source");
795 let tree = Tree::from_entries(vec![
796 TreeEntry::file("source.rs", blob.hash(), false).expect("entry"),
797 ]);
798 store.put_tree(&tree).expect("tree");
799 let state = State::new_snapshot(
800 tree.hash(),
801 vec![],
802 Attribution::human(Principal::new("user", "user@example.test")),
803 );
804 let prepared = SourcePack::prepare(
805 &store,
806 &state,
807 root.path(),
808 SourceBudget {
809 max_objects: 16,
810 max_decoded_bytes: 256 * 1024,
811 },
812 )
813 .expect("prepare exact source");
814 let (remote, _, _) = fixture(false);
815 let thread = ThreadRef {
816 spool: Some(SpoolRef {
817 id: "spool-test".into(),
818 }),
819 id: Some(ThreadId { value: vec![1; 32] }),
820 };
821 let receipt = remote
822 .thread(thread.clone())
823 .publish_source(
824 &prepared,
825 &originals(),
826 PublicationOptions {
827 client_operation_id: "source-upload".into(),
828 source: EndpointRef {
829 public_key: vec![2; 32],
830 kind: EndpointKind::Device as i32,
831 },
832 sharing_policy_version: vec![],
833 checkpoint: None,
834 },
835 )
836 .await
837 .expect("one Thread-bound publication");
838 assert_eq!(receipt.thread, Some(thread.clone()));
839 assert_eq!(
840 receipt.revision,
841 Some(RevisionRef {
842 spool: thread.spool,
843 revision: Some(revision_ref::Revision::State(
844 api::heddle::api::common::StateId {
845 value: state.id().as_bytes().to_vec()
846 }
847 ))
848 })
849 );
850 }
851}