Skip to main content

prikk_store/patch_exchange/
accept.rs

1//! RFC 115 Stage 3 handoff §4: the accept path. Implements Phase A-D in exactly the handoff's own
2//! order -- see each phase's comment below for the corresponding numbered item. §4.1's invariant
3//! governs the whole shape: **a refused exchange leaves nothing behind.** Every check that can fail
4//! runs before any write; Phase D is reached only once nothing earlier could still refuse for a
5//! reason attributable to the artifact's content.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use prikk_error::{PrikkError, Result};
10use prikk_object::{ObjectEnvelope, ObjectId, ObjectType, RecognitionClaimPayload, SignerRole};
11
12use crate::author_key_index::{
13    check_author_key_conflict, lookup_author_key_entries, record_author_key_material,
14    verify_author_signature_against_material,
15};
16use crate::layout::RepositoryLayout;
17use crate::lock::ActiveLock;
18use crate::object_store::{ObjectReadSnapshot, ObjectWriteSession, ObjectWriter};
19use crate::patch_replay::decode::{
20    DecodedDeletePreimage, DecodedOperationKind, decode_patch_operations, decode_patch_parent_ids,
21};
22use crate::patch_set_digest::compute_patch_set_digest;
23use crate::recognition_claim::{
24    check_recognition_claim_consistency, maintainer_trust_policy_or_empty, verify_claim_signature,
25};
26use crate::tag_travel::verify_tag_signature;
27use crate::verify::AuthorSignatureVerification;
28
29pub use crate::recognition_claim::ClaimSignatureVerification;
30pub use crate::tag_travel::TagSignatureVerification;
31
32use super::artifact::{
33    DEFAULT_EXCHANGE_ARTIFACT_MAX_OBJECT_COUNT, DEFAULT_EXCHANGE_ARTIFACT_MAX_TOTAL_BYTES,
34    decode_exchange_artifact,
35};
36
37/// DC-86 resource bound for [`accept_exchange_artifact`], checked before any decode or write --
38/// the same shape `BundleImportOptions` gives `import_bundle`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct AcceptOptions {
41    /// Maximum any one of the artifact's four declared counts (patches, blobs, author keys, claims)
42    /// may be. Refused before the section it governs is decoded.
43    pub max_object_count: usize,
44    /// Maximum encoded byte length the artifact may have. Refused before decoding starts at all.
45    pub max_total_bytes: usize,
46}
47
48impl AcceptOptions {
49    /// [`DEFAULT_EXCHANGE_ARTIFACT_MAX_OBJECT_COUNT`] and
50    /// [`DEFAULT_EXCHANGE_ARTIFACT_MAX_TOTAL_BYTES`].
51    #[must_use]
52    pub const fn default_limits() -> Self {
53        Self {
54            max_object_count: DEFAULT_EXCHANGE_ARTIFACT_MAX_OBJECT_COUNT,
55            max_total_bytes: DEFAULT_EXCHANGE_ARTIFACT_MAX_TOTAL_BYTES,
56        }
57    }
58
59    /// Override the maximum declared count.
60    #[must_use]
61    pub const fn with_max_object_count(mut self, max_object_count: usize) -> Self {
62        self.max_object_count = max_object_count;
63        self
64    }
65
66    /// Override the maximum total encoded byte length.
67    #[must_use]
68    pub const fn with_max_total_bytes(mut self, max_total_bytes: usize) -> Self {
69        self.max_total_bytes = max_total_bytes;
70        self
71    }
72}
73
74/// Summary of an exchange-artifact accept.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct AcceptReport {
77    /// Patches the artifact carried.
78    pub patch_count: usize,
79    /// Blobs the artifact carried.
80    pub blob_count: usize,
81    /// Recognition claims the artifact carried.
82    pub claim_count: usize,
83    /// Tag objects the artifact carried (RFC 117 stage 3 §2/§3).
84    pub tag_count: usize,
85    /// Patch, blob, claim, and tag objects that did not already exist in this repository's object store
86    /// before this accept -- content-addressed, so a replayed accept (§4.3) reports zero here.
87    pub written_object_count: usize,
88    /// AUTHOR key entries the artifact carried and this accept recorded locally. Zero on a replayed
89    /// accept, the same continuity-only semantics `BundleImportReport::recorded_author_key_count`
90    /// already has.
91    pub recorded_author_key_count: usize,
92    /// One outcome per carried patch that has an AUTHOR-role signature at all; a patch with none is
93    /// simply absent from this list, mirroring `RepositoryVerification`'s own
94    /// `author_verification: Option<..>` precedent.
95    pub author_signature_outcomes: Vec<(ObjectId, AuthorSignatureVerification)>,
96    /// One outcome per carried claim's own MAINTAINER signature.
97    pub claim_signature_outcomes: Vec<(ObjectId, ClaimSignatureVerification)>,
98    /// One outcome per carried tag's own MAINTAINER signature (RFC 117 stage 3 §3) -- reported,
99    /// never gating, the same treatment `claim_signature_outcomes` gets.
100    pub tag_signature_outcomes: Vec<(ObjectId, TagSignatureVerification)>,
101}
102
103/// Accept a `PEXCH002` exchange artifact (handoff §4). Writes patches, blobs, recognition claims,
104/// and Tag objects (RFC 117 stage 3 §3) -- and records AUTHOR key material -- **only** once every
105/// fallible check has already passed. Never touches a ref, a Block, or the received namespace: this
106/// is patch-level exchange (§0's "the unit is the patch"), and Stage 3 does not extend the accept
107/// path into sealing (§1's scope cut) or into tag adoption (RFC 117 T4 -- a received Tag is stored
108/// and reportable, never adopted; see `tag_travel::adopt_tag` for the separate, explicit act that
109/// does adopt one).
110pub fn accept_exchange_artifact(
111    layout: &RepositoryLayout,
112    bytes: &[u8],
113    options: &AcceptOptions,
114) -> Result<AcceptReport> {
115    // Phase A, item 1: total byte length, before any decoding.
116    if bytes.len() > options.max_total_bytes {
117        return Err(PrikkError::MalformedData(format!(
118            "patch-exchange artifact is {} bytes, over the configured limit of {} bytes",
119            bytes.len(),
120            options.max_total_bytes
121        )));
122    }
123
124    // Phase A item 2 (each declared count against `max_object_count`) and Phase B item 3 (decode
125    // all sections) both happen inside `decode_exchange_artifact` -- the same split `decode_bundle`
126    // keeps between its own caller-checked total-byte bound and its own declared-count bounds.
127    let decoded = decode_exchange_artifact(bytes, options.max_object_count)?;
128
129    // Phase B item 4: recompute the patch-set digest over the decoded patches; refuse on mismatch.
130    let mut decoded_patch_ids: Vec<ObjectId> = decoded
131        .patches
132        .iter()
133        .map(ObjectEnvelope::object_id)
134        .collect();
135    decoded_patch_ids.sort_unstable();
136    decoded_patch_ids.dedup();
137    let recomputed_digest = compute_patch_set_digest(&decoded_patch_ids)?;
138    if recomputed_digest != decoded.declared_digest {
139        return Err(PrikkError::Integrity(
140            "patch-exchange artifact's declared patch-set digest does not match its own decoded \
141             patches -- refusing before any signature work"
142                .to_string(),
143        ));
144    }
145
146    // Phase B item 5: artifact-internal author-key conflict -- two different public keys for one
147    // `key_id` within the artifact itself refuses the whole import (`import_bundle` learned this the
148    // hard way; same check, same reason).
149    let mut artifact_key_ids: BTreeMap<&str, [u8; 32]> = BTreeMap::new();
150    for entry in &decoded.author_keys {
151        match artifact_key_ids.get(entry.key_id.as_str()) {
152            Some(existing) if *existing != entry.public_key => {
153                return Err(PrikkError::MalformedData(format!(
154                    "patch-exchange artifact's author-key section carries two different public \
155                     keys for key_id {} -- refusing the whole exchange",
156                    entry.key_id
157                )));
158            }
159            Some(_) => {}
160            None => {
161                artifact_key_ids.insert(&entry.key_id, entry.public_key);
162            }
163        }
164    }
165
166    // Phase B item 5b: artifact-versus-repository conflict, read-only, as a cheap early refusal
167    // before any signature work. Does **not** replace Phase D's check under the lock -- that one is
168    // authoritative, because check-then-act without the lock is a race.
169    for (&key_id, &public_key) in &artifact_key_ids {
170        check_author_key_conflict(layout, key_id, public_key)?;
171    }
172
173    // Phase B item 6: closure completeness. Every blob a carried patch's operations reference must
174    // be present -- in the artifact or already in this repository. `parent_patch_ids` is always
175    // empty today; check it anyway and refuse if it is ever non-empty, because the day it stops
176    // being empty this is the code that must not silently ignore it.
177    //
178    // Deliberately schema-blind, and deliberately runs before `decode_patch_operations` below --
179    // Patch schema 2 handoff v2 amendment §3: this shadows `decode_patch_operations`'s own
180    // schema-2-and-above refusal of a present tag 2, for both schemas, since it refuses a non-empty
181    // value at *any* schema, including schema 1 where the field is legal-but-must-be-empty. It is
182    // broader, not redundant -- do not remove it as a "simplification", and do not reorder these two
183    // checks.
184    let read_snapshot = ObjectReadSnapshot::open(layout)?;
185    let artifact_blob_ids: BTreeSet<ObjectId> = decoded
186        .blobs
187        .iter()
188        .map(ObjectEnvelope::object_id)
189        .collect();
190    for envelope in &decoded.patches {
191        let parent_patch_ids = decode_patch_parent_ids(&envelope.canonical_payload)?;
192        if !parent_patch_ids.is_empty() {
193            return Err(PrikkError::Integrity(format!(
194                "patch {} carries a non-empty parent_patch_ids -- this field is always empty \
195                 today and there is nothing defined to walk there yet; refusing rather than \
196                 silently ignoring it",
197                envelope.object_id()
198            )));
199        }
200        for operation in
201            decode_patch_operations(&envelope.canonical_payload, envelope.schema_version)?
202        {
203            for blob_id in referenced_blob_ids(&operation.kind) {
204                if !artifact_blob_ids.contains(&blob_id)
205                    && !read_snapshot.contains_object(ObjectType::Blob, blob_id)
206                {
207                    return Err(PrikkError::Integrity(format!(
208                        "patch {} references blob {blob_id}, which is neither carried by this \
209                         artifact nor already present in this repository -- refusing the whole \
210                         exchange, no partial apply",
211                        envelope.object_id()
212                    )));
213                }
214            }
215        }
216    }
217
218    // Phase C item 7: every carried patch's AUTHOR signature, verified against the union of this
219    // repository's already-recorded material and the artifact's own transported material for that
220    // `key_id` -- the shared core the handoff's §4.2 item 7 rules must be reused, not duplicated.
221    let mut author_signature_outcomes = Vec::with_capacity(decoded.patches.len());
222    for envelope in &decoded.patches {
223        let Some(signature) = envelope
224            .signatures
225            .iter()
226            .find(|signature| signature.signer_role == SignerRole::Author)
227        else {
228            continue;
229        };
230        let mut candidates = lookup_author_key_entries(layout, &signature.key_id)?;
231        candidates.extend(
232            decoded
233                .author_keys
234                .iter()
235                .filter(|entry| entry.key_id == signature.key_id)
236                .cloned(),
237        );
238        let Some((key_id, verifies)) =
239            verify_author_signature_against_material(envelope, &candidates)?
240        else {
241            continue;
242        };
243        let outcome = if verifies {
244            AuthorSignatureVerification::Sound { key_id }
245        } else {
246            AuthorSignatureVerification::Unverifiable { key_id }
247        };
248        author_signature_outcomes.push((envelope.object_id(), outcome));
249    }
250
251    // Phase C items 8-9: every claim's own MAINTAINER signature, then every claim against blocks
252    // this repository already holds.
253    let trust_policy = maintainer_trust_policy_or_empty(layout)?;
254    let mut claim_signature_outcomes = Vec::with_capacity(decoded.claims.len());
255    for envelope in &decoded.claims {
256        let claim_id = envelope.object_id();
257        let outcome = verify_claim_signature(envelope, &trust_policy)?;
258        claim_signature_outcomes.push((claim_id, outcome));
259
260        let payload = RecognitionClaimPayload::decode_canonical(&envelope.canonical_payload)?;
261        match check_recognition_claim_consistency(&read_snapshot, &payload)? {
262            crate::recognition_claim::RecognitionClaimConsistency::Contradicted { .. } => {
263                return Err(PrikkError::Integrity(format!(
264                    "recognition claim {claim_id} contradicts a block this repository already \
265                     holds -- refusing the whole exchange"
266                )));
267            }
268            crate::recognition_claim::RecognitionClaimConsistency::Consistent
269            | crate::recognition_claim::RecognitionClaimConsistency::BlockAbsent => {}
270        }
271    }
272
273    // RFC 117 stage 3 §3: every carried tag's own MAINTAINER signature, reported and never gating --
274    // the same treatment claims get (Phase C items 8-9, immediately above). No consistency check
275    // against a held block: a Tag's own identity is its `patch_set_digest`/`patch_count`, which say
276    // nothing about which local block (if any) currently matches -- that is `resolve_patch_set_digest`
277    // and `sync tags`'s job, not accept's (T2/T4 keep resolution and adoption out of this path).
278    let mut tag_signature_outcomes = Vec::with_capacity(decoded.tags.len());
279    for envelope in &decoded.tags {
280        let tag_id = envelope.object_id();
281        let outcome = verify_tag_signature(envelope, &trust_policy)?;
282        tag_signature_outcomes.push((tag_id, outcome));
283    }
284
285    // Phase D item 10 (patches and blobs only -- see the claim-write note below): write the patch
286    // and blob objects. Content-addressed and idempotent -- a replayed accept (§4.3) writes nothing
287    // new here.
288    let mut object_store = ObjectWriteSession::open(layout)?;
289    let mut written_object_count = 0_usize;
290    for envelope in decoded.patches.iter().chain(decoded.blobs.iter()) {
291        let id = envelope.object_id();
292        if !object_store.contains_object(envelope.object_type, id)? {
293            written_object_count = written_object_count.checked_add(1).ok_or_else(|| {
294                PrikkError::Integrity("exchange accept written-object count overflow".to_string())
295            })?;
296        }
297        object_store.write_object(envelope)?;
298    }
299
300    // Phase D item 11: under a single `ActiveLock`, validate every entry against this repository's
301    // material, then record every entry -- never check-then-record one entry at a time
302    // (`multi-key-import-partial-write-v1.md`). `import_bundle` already does this correctly; same
303    // structure, copied.
304    let mut recorded_author_key_count = 0_usize;
305    {
306        let active_lock = ActiveLock::acquire(layout)?;
307        for (&key_id, &public_key) in &artifact_key_ids {
308            check_author_key_conflict(layout, key_id, public_key)?;
309        }
310        for entry in &decoded.author_keys {
311            record_author_key_material(layout, &entry.key_id, entry.public_key, &active_lock)?;
312            recorded_author_key_count =
313                recorded_author_key_count.checked_add(1).ok_or_else(|| {
314                    PrikkError::Integrity(
315                        "exchange accept recorded-author-key count overflow".to_string(),
316                    )
317                })?;
318        }
319    }
320
321    // Claims and tags are written last, only after item 11 has fully succeeded. Design §8.1 names
322    // claims separately from ordinary objects -- "no key material, and no claim, may be recorded
323    // from an exchange that failed" -- unlike patches and blobs, which §8.1 explicitly allows to
324    // survive a failed exchange (content-addressed and harmless). Writing them earlier, alongside
325    // patches and blobs, would leave one behind if the author-key record step above failed after an
326    // earlier write -- caught in review (`RFC-115-stage-3-exchange-artifact-review-v1.md` §2) as
327    // reachable, if narrow: a concurrent writer between Phase B's read-only conflict check and this
328    // lock, or an I/O error during `record_author_key_material`. RFC 117 stage 3 §5 row 6 puts a Tag
329    // object on the same terms explicitly: **a refused exchange records no tag.**
330    for envelope in decoded.claims.iter().chain(decoded.tags.iter()) {
331        let id = envelope.object_id();
332        if !object_store.contains_object(envelope.object_type, id)? {
333            written_object_count = written_object_count.checked_add(1).ok_or_else(|| {
334                PrikkError::Integrity("exchange accept written-object count overflow".to_string())
335            })?;
336        }
337        object_store.write_object(envelope)?;
338    }
339
340    Ok(AcceptReport {
341        patch_count: decoded.patches.len(),
342        blob_count: decoded.blobs.len(),
343        claim_count: decoded.claims.len(),
344        tag_count: decoded.tags.len(),
345        written_object_count,
346        recorded_author_key_count,
347        author_signature_outcomes,
348        claim_signature_outcomes,
349        tag_signature_outcomes,
350    })
351}
352
353/// Every blob id one decoded operation references -- the same three kinds `export_exchange_artifact`
354/// (`artifact.rs`) and `export_bundle` (`bundle.rs`) each scan for, restated here for the accept
355/// path's own closure check.
356fn referenced_blob_ids(kind: &DecodedOperationKind) -> Vec<ObjectId> {
357    match kind {
358        DecodedOperationKind::CreateFile { blob_id, .. } => vec![*blob_id],
359        DecodedOperationKind::ReplaceBinary {
360            old_blob_id,
361            new_blob_id,
362            ..
363        } => vec![*old_blob_id, *new_blob_id],
364        DecodedOperationKind::DeleteNode {
365            preimage: DecodedDeletePreimage::File { old_blob_id, .. },
366            ..
367        } => vec![*old_blob_id],
368        _ => Vec::new(),
369    }
370}
371
372#[cfg(test)]
373mod tests;