Skip to main content

heddle_thread_api/
publication.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Flow-controlled source publication. Sending and receiving run together so
3//! server checkpoints cannot block an upload on a full response stream.
4use 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/// Exact original creator wrappers and signed source operations, including
29/// every foreign integration dependency. The receiver verifies their authority
30/// and complete causal/source closure before any replica admission.
31#[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    /// Publish original source proofs and exact source in one exchange. Inputs are the native pack
134    /// and its index in opening order; neither is buffered in full. The caller
135    /// retains the operation ID and opening to retry after an interrupted call.
136    /// A receipt is returned only after verifying its exact publication scope.
137    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
338/// Exact ordered complete artifact inventory shared by preparation, intent,
339/// upload, and receipt verification. No pack body is buffered here.
340pub 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                            // Capacity one in BOTH directions. An upload that
534                            // waits to read responses until all sends complete
535                            // deadlocks after a few chunks here.
536                            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    // These byte fixtures exercise framing/backpressure, not original authority
642    // admission; real Iroh tests independently verify canonical signatures.
643    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}