Skip to main content

prikk_store/
bundle.rs

1//! History exchange artifact (DC-78 §D6): a **verifiable subset**, never a summary. A bundle carries
2//! the exported ref's RefState plus every object reachable from its target Block back to genesis
3//! (ruling 2) — the same objects `verify_repository` already checks for any locally sealed history,
4//! unconditionally, regardless of which ref (if any) points to them. Import writes exactly those
5//! objects plus one `received` pointer (`crate::received`); it never touches `refs/by-id/`, never
6//! advances a local ref, and never adopts a MAINTAINER key into the local trust policy — adopting
7//! trust for an imported key remains the operator's own explicit `trust maintainer add` call. The
8//! receiver's confidence comes from running ordinary, unmodified `verify_repository` afterward: this
9//! module adds a serialization boundary and an import path, and deliberately no new verification
10//! machinery (§D6's "no new verification path").
11//!
12//! **`heads/*` and `tags/*` both export** (`resolve_ref_target_block`). A branch's `target_object_id`
13//! names its Block directly; a tag's names a Tag object one hop away
14//! (`TagPayload.target_block_id` is the Block), mirroring the two-hop model
15//! `refs/verify/scan.rs`'s own `ensure_block_exists`/tag-target check already uses. **The Tag
16//! envelope itself is part of the exported object set, not just the Block closure it points to**:
17//! the tag ref's `RefState` travels unconditionally either way, and a receiver holding a signed
18//! RefState that names a Tag object they do not have fails `verify` with a message naming exactly
19//! that (DC-78 bundle-export tag gap follow-up, `bundle-export-tag-ref-gap-v1.md` — found while
20//! investigating RFC 115 Stage 1's own reachable-set question, fixed independently of it).
21//!
22//! **DC-53 Stage 2, D6: `PBNDL002` carries an AUTHOR key-material section.** The section's scope is
23//! exactly the AUTHOR `key_id`s of the Patches this bundle carries, derived from the exported objects
24//! themselves, never the whole local `author_key_index` container -- exporting everything this
25//! repository has ever seen would leak every author it has observed to every recipient, a disclosure
26//! the sender did not choose. Material is optional per-`key_id`: a bundle omits a `key_id` this
27//! repository never recorded material for (import still succeeds; the Patch reads Unverifiable, not
28//! Sound and not a failure). **Import records material; `verify` decides (D7)** -- import performs no
29//! cryptographic check of whether a transported key actually matches any Patch's signature, the same
30//! way it already writes objects without re-verifying them; a transported key that doesn't verify a
31//! Patch's signature is recorded anyway and `verify` reports that Patch `Failed`, reached through the
32//! transport path rather than local authoring but the same underlying check (D3's third row).
33//!
34//! **Two independent conflict checks, and both are fully pre-write.** Before any write: the bundle's
35//! own author-key section must not itself claim two different public keys for one `key_id` -- a
36//! hostile or merely stale bundle must fail the whole import rather than leave a receiver with an
37//! unresolvable, permanently unverifiable `key_id` (`author_key_index.rs`'s own container has no
38//! prune/repair path). The second check -- a transported key conflicting with material this
39//! repository *already* has -- is checked the same way: **every** transported key is validated
40//! against local material, via `check_author_key_conflict`, before any of them is recorded, both
41//! inside the one `ActiveLock` held for the section. This is why: a refused import must leave the
42//! author-key container exactly as untouched as a refused bundle-internal check leaves it -- checking
43//! and recording one entry at a time let a conflict at entry `k` leave entries `1..k-1` durably
44//! appended first (DC-53 Stage 2 follow-up, `multi-key-import-partial-write-v1.md`). Recording itself
45//! still goes through `record_author_key_material`, Step 1's own function, unchanged -- no separate
46//! transport-side pinning rule, and no second copy of the conflict definition (Step 1's C2 ruling).
47//!
48//! **`PBNDL001` (Stage 1 and earlier) is accepted on import, never emitted on export** (DC-53 Stage 2
49//! follow-up, `bundle-v1-import-regression-v1.md`). `layout.rs`'s own retired-repository-format
50//! messages instruct a user to open an old repository with an old prikk build and `bundle export`
51//! from it; that build only ever produces `PBNDL001`, and refusing to import it here severed the one
52//! migration path those messages promise, in both directions at once -- found and fixed the same day
53//! it shipped. A `PBNDL001` bundle decodes exactly like a `PBNDL002` one with an empty author-key
54//! section: not a special case, since DC-53 already defines "no recorded material" as `Unverifiable`
55//! (vector 7). Read compatibility only, the same asymmetry every repository-format transition in this
56//! project already has -- read what the past wrote, write only the present.
57//!
58//! **`import_bundle` validates closure completeness before any write** (DC-78
59//! `import-closure-validation-handoff-v1.md`): the exported ref's target must resolve, and every blob
60//! (whether a Patch's own operations or a Block's own `snapshot_blob_ref` names it), patch, and block
61//! parent a carried object names must be present -- carried by this bundle, or already in this
62//! repository. `accept_exchange_artifact` (`patch_exchange/accept.rs`) already refused
63//! the same class of defect at receipt; `import_bundle` did not, so a bundle whose target object it
64//! never shipped used to import successfully and land a dangling received pointer, visible only at the
65//! next `verify`, long after the import that caused it. **This is an intentional behaviour change: a
66//! bundle that previously imported may now be refused.** That is the point, not a regression -- a tag
67//! bundle produced before the DC-78 tag-export fix (`d605c10`), which carries the RefState but not the
68//! Tag object, is the concrete case: it now fails at import, naming the bundle at the moment it is
69//! offered, instead of importing and failing a later `verify` with no indication of which import caused
70//! it.
71
72use std::collections::{BTreeMap, BTreeSet};
73
74use prikk_error::{PrikkError, Result};
75use prikk_object::{
76    BlockPayload, ObjectEnvelope, ObjectId, ObjectType, RefStatePayload, Signature, SignerRole,
77};
78
79use crate::author_key_index::{
80    AuthorKeyEntry, check_author_key_conflict, lookup_author_key_entries,
81    record_author_key_material,
82};
83use crate::byte_cursor::ByteCursor;
84use crate::file_codec::{decode_envelope_file, encode_envelope_file, push_bytes_u64, push_u64};
85use crate::fsutil::len_to_u64;
86use crate::layout::{LockableContainer, RepositoryLayout};
87use crate::lock::{ActiveLock, acquire_container_locks};
88use crate::object_store::{ObjectReadSnapshot, ObjectReader, ObjectWriteSession, ObjectWriter};
89use crate::patch_replay::decode::{
90    DecodedDeletePreimage, DecodedOperationKind, decode_patch_operations,
91};
92use crate::refs::{RefStore, ensure_ref_target_valid};
93
94/// DC-53 Stage 2, D6: the format bump that made room for the author-key section. Always emitted on
95/// export; `PBNDL001` is still accepted on import (see `RETIRED_BUNDLE_MAGIC_V1`), so this is a
96/// write-side-only version, not a hard cutover.
97const BUNDLE_MAGIC: &[u8; 8] = b"PBNDL002";
98/// `PBNDL001` (Stage 1 and earlier bundles, no author-key section). Accepted on import -- see
99/// `decode_bundle`'s own doc and the module doc's follow-up note -- never emitted on export.
100const RETIRED_BUNDLE_MAGIC_V1: &[u8; 8] = b"PBNDL001";
101
102/// DC-86 default hard block on a bundle's declared object count, checked as early as the format
103/// allows — right after the count header field, before a single object is decoded. Not a claim about
104/// what any real bundle needs; a ceiling an operator can rely on existing at all.
105pub const DEFAULT_BUNDLE_MAX_OBJECT_COUNT: usize = 100_000;
106
107/// DC-86 default hard block on a bundle's total encoded byte length, checked before any decoding
108/// begins. This length-prefixed format can never decode to more logical content than its encoded
109/// input size, so bounding the input bytes is a tight, cheap proxy for bounding decoded bytes —
110/// cheaper than decoding first only to discover the result should have been refused. 256 MiB.
111pub const DEFAULT_BUNDLE_MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024;
112
113/// DC-86 resource bound for [`import_bundle`], checked before any object is decoded or written —
114/// DC-57's shape: a hard block ahead of any write, with a documented default the CLI may override.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct BundleImportOptions {
117    /// Maximum objects a bundle may declare. Refused before the decode loop runs.
118    pub max_object_count: usize,
119    /// Maximum encoded byte length a bundle may have. Refused before `decode_bundle` runs at all.
120    pub max_total_bytes: usize,
121}
122
123impl BundleImportOptions {
124    /// [`DEFAULT_BUNDLE_MAX_OBJECT_COUNT`] and [`DEFAULT_BUNDLE_MAX_TOTAL_BYTES`].
125    #[must_use]
126    pub const fn default_limits() -> Self {
127        Self {
128            max_object_count: DEFAULT_BUNDLE_MAX_OBJECT_COUNT,
129            max_total_bytes: DEFAULT_BUNDLE_MAX_TOTAL_BYTES,
130        }
131    }
132
133    /// Override the maximum object count.
134    #[must_use]
135    pub const fn with_max_object_count(mut self, max_object_count: usize) -> Self {
136        self.max_object_count = max_object_count;
137        self
138    }
139
140    /// Override the maximum total encoded byte length.
141    #[must_use]
142    pub const fn with_max_total_bytes(mut self, max_total_bytes: usize) -> Self {
143        self.max_total_bytes = max_total_bytes;
144        self
145    }
146}
147
148/// Summary of a bundle export.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct BundleExportReport {
151    /// The exported ref's own name in the source repository.
152    pub ref_name: String,
153    /// The exported ref's target Block at export time.
154    pub tip_block_id: ObjectId,
155    /// Total objects carried in the bundle (the RefState plus its full genesis-complete closure).
156    pub object_count: usize,
157    /// DC-53 Stage 2, D6: AUTHOR key entries carried in the bundle's author-key section -- one per
158    /// distinct `key_id` among the bundle's Patches for which this repository has local material,
159    /// never a count of the Patches themselves.
160    pub author_key_count: usize,
161}
162
163/// Summary of a bundle import.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct BundleImportReport {
166    /// The local received-namespace name the import was recorded under (`remotes/<origin ref name>`).
167    pub ref_name: String,
168    /// The imported RefState's object id, now the received pointer's target.
169    pub ref_state_id: ObjectId,
170    /// Total objects the bundle carried.
171    pub object_count: usize,
172    /// Objects that did not already exist in this repository's object store before this import.
173    pub written_object_count: usize,
174    /// DC-53 Stage 2, D7: AUTHOR key entries the bundle carried and this import recorded locally.
175    /// Continuity only, not a trust decision -- unlike a trusted maintainer key, recording this
176    /// grants no admission judgement, only lets `verify` distinguish Sound from Unverifiable for the
177    /// Patches it covers.
178    pub recorded_author_key_count: usize,
179}
180
181/// Export a genesis-complete, verifiable subset of objects for `ref_name` (DC-78 §D4/§D6). Walks the
182/// full Block ancestor closure (all parents, not mainline-only — ruling 2's "genesis-complete") plus
183/// every Patch and Blob those blocks reference, plus the exported RefState's own required
184/// attestations. Returns the report and the encoded bundle bytes; writing them to a file is a CLI
185/// concern, not this crate's.
186pub fn export_bundle(
187    layout: &RepositoryLayout,
188    ref_name: &str,
189) -> Result<(BundleExportReport, Vec<u8>)> {
190    let ref_store = RefStore::new(layout.clone());
191    // RFC 111 §6.1: export is read-only end to end (never calls `write_object`), so it takes one
192    // decoded index snapshot here instead of paying a fresh decode per `read_required` call below --
193    // and there can be many, one per object in the whole exported closure.
194    let object_store = ObjectReadSnapshot::open(layout)?;
195    let Some(ref_state_id) = ref_store.read_current_ref_state_id(ref_name)? else {
196        return Err(PrikkError::Integrity(format!(
197            "ref {ref_name} does not exist, nothing to export"
198        )));
199    };
200    let ref_state_envelope = object_store
201        .read_typed(ref_state_id, ObjectType::RefState)?
202        .ok_or_else(|| PrikkError::Integrity(format!("missing RefState object: {ref_state_id}")))?;
203    let ref_state_payload = RefStatePayload::decode_canonical(
204        &ref_state_envelope.canonical_payload,
205        ref_state_envelope.schema_version,
206    )?;
207    let mut tag_envelopes: Vec<ObjectEnvelope> = Vec::new();
208    let tip_block_id =
209        resolve_ref_target_block(&object_store, &ref_state_payload, &mut tag_envelopes)?;
210
211    // Genesis-complete (ruling 2) covers the publication chain too, not only the Block DAG: a
212    // received ref's `log --ref` walks `previous_ref_state_id` exactly as a local ref's does, so
213    // every earlier RefState this ref ever published is required, not only the tip.
214    let mut ref_state_chain: Vec<ObjectEnvelope> = vec![ref_state_envelope];
215    let mut required_attestation_ids: BTreeSet<ObjectId> = ref_state_payload
216        .required_attestation_ids
217        .iter()
218        .copied()
219        .collect();
220    let mut ancestors = crate::merge_evidence::ancestors_inclusive(&object_store, tip_block_id)?;
221    let mut previous = ref_state_payload.previous_ref_state_id;
222    let mut seen_ref_states: BTreeSet<ObjectId> = BTreeSet::from([ref_state_id]);
223    while let Some(previous_id) = previous {
224        if !seen_ref_states.insert(previous_id) {
225            return Err(PrikkError::Integrity(format!(
226                "RefState chain for {ref_name} contains a cycle at {previous_id}"
227            )));
228        }
229        let envelope = read_required(&object_store, previous_id, ObjectType::RefState)?;
230        let payload = RefStatePayload::decode_canonical(
231            &envelope.canonical_payload,
232            envelope.schema_version,
233        )?;
234        required_attestation_ids.extend(payload.required_attestation_ids.iter().copied());
235        let target_block_id =
236            resolve_ref_target_block(&object_store, &payload, &mut tag_envelopes)?;
237        ancestors.extend(crate::merge_evidence::ancestors_inclusive(
238            &object_store,
239            target_block_id,
240        )?);
241        previous = payload.previous_ref_state_id;
242        ref_state_chain.push(envelope);
243    }
244
245    let mut patch_ids: BTreeSet<ObjectId> = BTreeSet::new();
246    let mut blob_ids: BTreeSet<ObjectId> = BTreeSet::new();
247    for payload in ancestors.values() {
248        patch_ids.extend(payload.patch_ids.iter().copied());
249        if let Some(blob_id) = payload.snapshot_blob_ref {
250            blob_ids.insert(blob_id);
251        }
252    }
253
254    let mut objects: Vec<ObjectEnvelope> = ref_state_chain;
255    objects.append(&mut tag_envelopes);
256    for block_id in ancestors.keys() {
257        objects.push(read_required(&object_store, *block_id, ObjectType::Block)?);
258    }
259    let mut patch_envelopes: Vec<ObjectEnvelope> = Vec::with_capacity(patch_ids.len());
260    for patch_id in &patch_ids {
261        let envelope = read_required(&object_store, *patch_id, ObjectType::Patch)?;
262        // A Patch's operations can themselves reference Blobs (CreateFile, file-kind DeleteNode,
263        // ReplaceBinary) independently of any Block's snapshot_blob_ref — a repository-local
264        // verify never notices a missing one of these, since it never replays lifecycle state for
265        // an export; the receiver's ordinary verify does, so every one of these must travel too.
266        for operation in crate::patch_replay::decode::decode_patch_operations(
267            &envelope.canonical_payload,
268            envelope.schema_version,
269        )? {
270            match operation.kind {
271                crate::patch_replay::decode::DecodedOperationKind::CreateFile {
272                    blob_id, ..
273                } => {
274                    blob_ids.insert(blob_id);
275                }
276                crate::patch_replay::decode::DecodedOperationKind::ReplaceBinary {
277                    old_blob_id,
278                    new_blob_id,
279                    ..
280                } => {
281                    blob_ids.insert(old_blob_id);
282                    blob_ids.insert(new_blob_id);
283                }
284                crate::patch_replay::decode::DecodedOperationKind::DeleteNode {
285                    preimage:
286                        crate::patch_replay::decode::DecodedDeletePreimage::File { old_blob_id, .. },
287                    ..
288                } => {
289                    blob_ids.insert(old_blob_id);
290                }
291                _ => {}
292            }
293        }
294        patch_envelopes.push(envelope);
295    }
296    objects.extend(patch_envelopes);
297    for blob_id in &blob_ids {
298        objects.push(read_required(&object_store, *blob_id, ObjectType::Blob)?);
299    }
300    for attestation_id in &required_attestation_ids {
301        objects.push(read_required(
302            &object_store,
303            *attestation_id,
304            ObjectType::Attestation,
305        )?);
306    }
307
308    // DC-53 Stage 2, D6: the author-key section's scope is exactly the AUTHOR key_ids of the
309    // Patches this bundle carries -- derived from `objects` itself, never the whole local
310    // `author_key_index` container (which would leak every author this repository has ever seen).
311    let mut author_key_ids: BTreeSet<String> = BTreeSet::new();
312    for envelope in &objects {
313        if envelope.object_type != ObjectType::Patch {
314            continue;
315        }
316        if let Some(signature) = envelope
317            .signatures
318            .iter()
319            .find(|signature| signature.signer_role == SignerRole::Author)
320        {
321            author_key_ids.insert(signature.key_id.clone());
322        }
323    }
324    let mut author_keys: Vec<AuthorKeyEntry> = Vec::with_capacity(author_key_ids.len());
325    for key_id in &author_key_ids {
326        let entries = lookup_author_key_entries(layout, key_id)?;
327        let mut distinct = entries.iter().map(|entry| entry.public_key);
328        let Some(first) = distinct.next() else {
329            // No local material for this key_id -- omitted from the section, not an error (§3's
330            // "material is optional per-author", vector 7).
331            continue;
332        };
333        if distinct.any(|public_key| public_key != first) {
334            // A legacy local conflict for a key_id this export would otherwise carry. Only possible
335            // from a repository predating Stage 2's own rejection of new conflicts (Step 1's
336            // migration scan found none in this project's own fixtures). Fail rather than silently
337            // pick one: presenting the receiver with an arbitrarily-chosen key would look like a
338            // provenance claim this sender's own repository does not actually make.
339            return Err(PrikkError::Integrity(format!(
340                "author key_id {key_id} has more than one distinct recorded public key locally; \
341                 refusing to export a provenance claim this repository's own material does not \
342                 agree on -- run doctor, though no repair exists for this container"
343            )));
344        }
345        author_keys.push(AuthorKeyEntry {
346            key_id: key_id.clone(),
347            public_key: first,
348        });
349    }
350
351    let object_count = objects.len();
352    let author_key_count = author_keys.len();
353    let bytes = encode_bundle(ref_name, &objects, &author_keys)?;
354    Ok((
355        BundleExportReport {
356            ref_name: ref_name.to_string(),
357            tip_block_id,
358            object_count,
359            author_key_count,
360        },
361        bytes,
362    ))
363}
364
365/// Import a bundle's objects and record a `received` pointer for its ref (DC-78 §D4). Never touches
366/// `refs/by-id/`, never advances a local ref, and never adopts any MAINTAINER key into the local trust
367/// policy — the imported RefState/Blocks remain ordinary, structurally-checkable objects that `verify`
368/// will report as untrusted until the operator explicitly runs `trust maintainer add` for the key that
369/// sealed them. That is a deliberate choice, not an oversight: auto-adopting a key seen in imported
370/// history would be a new, unreviewed trust mechanism, exactly what §D6 rules out.
371pub fn import_bundle(
372    layout: &RepositoryLayout,
373    bytes: &[u8],
374    options: &BundleImportOptions,
375) -> Result<BundleImportReport> {
376    if bytes.len() > options.max_total_bytes {
377        return Err(PrikkError::MalformedData(format!(
378            "bundle is {} bytes, over the configured limit of {} bytes",
379            bytes.len(),
380            options.max_total_bytes
381        )));
382    }
383    let (origin_ref_name, objects, author_keys) = decode_bundle(bytes, options.max_object_count)?;
384    let Some(ref_state_envelope) = objects.first() else {
385        return Err(PrikkError::MalformedData(
386            "bundle contains no objects".to_string(),
387        ));
388    };
389    if ref_state_envelope.object_type != ObjectType::RefState {
390        return Err(PrikkError::MalformedData(
391            "bundle's first object must be the exported ref's RefState".to_string(),
392        ));
393    }
394    let ref_state_id = ref_state_envelope.object_id();
395
396    // DC-53 Stage 2, D7/C2: reject the whole import before any write if the bundle's own
397    // author-key section already disagrees with itself -- a hostile or stale bundle must not be
398    // able to leave a receiver with an unresolvable, permanently unverifiable key_id.
399    // `record_author_key_material` below still catches a conflict against *this repository's*
400    // existing material; this catches a conflict *within the bundle*, which that call alone
401    // wouldn't see if the bundle's own two conflicting entries happened to be processed in an order
402    // where the first one matched nothing local yet.
403    let mut bundle_key_ids: BTreeMap<&str, [u8; 32]> = BTreeMap::new();
404    for entry in &author_keys {
405        match bundle_key_ids.get(entry.key_id.as_str()) {
406            Some(existing) if *existing != entry.public_key => {
407                return Err(PrikkError::MalformedData(format!(
408                    "bundle's author-key section carries two different public keys for key_id {} \
409                     -- refusing the whole import",
410                    entry.key_id
411                )));
412            }
413            Some(_) => {}
414            None => {
415                bundle_key_ids.insert(&entry.key_id, entry.public_key);
416            }
417        }
418    }
419
420    // DC-78 closure-validation handoff §2/§3: everything below is read-only and must run, and pass,
421    // before the first object write below -- a refused bundle must leave no pointer at all, and the
422    // pointer is written last, so validating up front is what makes that true. §2's own definition of
423    // "present": carried by this bundle, or already in this repository -- an incremental import onto
424    // a repository that already holds part of the history must not be refused for objects it already
425    // has (D7's rule again). `accept_exchange_artifact` (`patch_exchange/accept.rs`) already gets this
426    // right for the patch-exchange path; this makes `import_bundle` match it rather than "align" it
427    // to something new (§6's own instruction).
428    let read_snapshot = ObjectReadSnapshot::open(layout)?;
429    let bundle_objects_by_id: BTreeMap<ObjectId, ObjectEnvelope> = objects
430        .iter()
431        .map(|envelope| (envelope.object_id(), envelope.clone()))
432        .collect();
433
434    // Item 1: the exported ref's target resolves. Reuses `ensure_ref_target_valid`
435    // (`refs/verify/scan.rs`) unchanged -- it is already kind-aware (one hop for a Branch, two for a
436    // Tag) and already `pub(crate)` at `crate::refs`, so no visibility widening was needed to reach it
437    // from here.
438    let ref_state_payload = RefStatePayload::decode_canonical(
439        &ref_state_envelope.canonical_payload,
440        ref_state_envelope.schema_version,
441    )?;
442    let combined_reader = BundleAndLocalReader {
443        bundle_objects: &bundle_objects_by_id,
444        local: &read_snapshot,
445    };
446    ensure_ref_target_valid(
447        &combined_reader,
448        ref_state_payload.kind,
449        ref_state_payload.target_object_id,
450        ref_state_id,
451    )?;
452
453    // Items 2 and 3: every blob a carried patch's operations reference, and every patch a carried
454    // block names, must be present. Mirrors `accept_exchange_artifact`'s Phase B item 6 exactly,
455    // including the "or already present locally" half.
456    for envelope in &objects {
457        if envelope.object_type != ObjectType::Patch {
458            continue;
459        }
460        for operation in
461            decode_patch_operations(&envelope.canonical_payload, envelope.schema_version)?
462        {
463            for blob_id in bundle_referenced_blob_ids(&operation.kind) {
464                if !bundle_objects_by_id.contains_key(&blob_id)
465                    && !read_snapshot.contains_object(ObjectType::Blob, blob_id)
466                {
467                    return Err(PrikkError::Integrity(format!(
468                        "patch {} references blob {blob_id}, which is neither carried by this \
469                         bundle nor already present in this repository -- refusing the whole \
470                         import, no partial write",
471                        envelope.object_id()
472                    )));
473                }
474            }
475        }
476    }
477    for envelope in &objects {
478        if envelope.object_type != ObjectType::Block {
479            continue;
480        }
481        let block_id = envelope.object_id();
482        let block_payload = BlockPayload::decode_canonical(&envelope.canonical_payload)?;
483        for patch_id in &block_payload.patch_ids {
484            if !bundle_objects_by_id.contains_key(patch_id)
485                && !read_snapshot.contains_object(ObjectType::Patch, *patch_id)
486            {
487                return Err(PrikkError::Integrity(format!(
488                    "block {block_id} names patch {patch_id}, which is neither carried by this \
489                     bundle nor already present in this repository -- refusing the whole import, \
490                     no partial write"
491                )));
492            }
493        }
494        // Item 4: every parent a carried block names must be present too. `export_bundle` walks the
495        // full ancestor closure, so this holds for anything the current exporter produces -- checking
496        // it is set membership and costs nothing (handoff §2 item 4).
497        for parent_block_id in &block_payload.parent_block_ids {
498            if !bundle_objects_by_id.contains_key(parent_block_id)
499                && !read_snapshot.contains_object(ObjectType::Block, *parent_block_id)
500            {
501                return Err(PrikkError::Integrity(format!(
502                    "block {block_id} names parent {parent_block_id}, which is neither carried \
503                     by this bundle nor already present in this repository -- refusing the whole \
504                     import, no partial write"
505                )));
506            }
507        }
508        // Review condition (`DC-78-import-closure-validation-review-v1.md` §3): a Block's own
509        // `snapshot_blob_ref` is a blob reference too, same rule as a Patch's operation-level ones --
510        // `export_bundle` already puts it in the same `blob_ids` set as those, so every legitimate
511        // export already satisfies this; the check exists for the untrusted, hand-crafted case, which
512        // is exactly the case this whole increment is for.
513        if let Some(snapshot_blob_id) = block_payload.snapshot_blob_ref {
514            if !bundle_objects_by_id.contains_key(&snapshot_blob_id)
515                && !read_snapshot.contains_object(ObjectType::Blob, snapshot_blob_id)
516            {
517                return Err(PrikkError::Integrity(format!(
518                    "block {block_id} names snapshot blob {snapshot_blob_id}, which is neither \
519                     carried by this bundle nor already present in this repository -- refusing \
520                     the whole import, no partial write"
521                )));
522            }
523        }
524    }
525
526    // RFC 111 §6.1 Stage 2: `import_bundle`'s ref-equivalent write is `received::write_received_-
527    // pointer`, a wholly separate mechanism (received-ref index, not the pointer-index/ref-log
528    // `RefStore::publish` touches) with no `FileObjectStore` construction of its own -- confirmed by
529    // reading `received.rs`. No ref-publication threading needed, only the plain swap.
530    let mut object_store = ObjectWriteSession::open(layout)?;
531    let mut written_object_count = 0_usize;
532    for envelope in &objects {
533        let id = envelope.object_id();
534        if !object_store.contains_object(envelope.object_type, id)? {
535            written_object_count = written_object_count.checked_add(1).ok_or_else(|| {
536                PrikkError::Integrity("bundle import written-object count overflow".to_string())
537            })?;
538        }
539        object_store.write_object(envelope)?;
540    }
541
542    // DC-53 Stage 2, D7: import records material, `verify` decides -- no cryptographic check here,
543    // matching the object writes above. `import_bundle` is now a third caller of
544    // `record_author_key_material` (`node_authoring.rs`, `rollback_draft.rs` are the other two),
545    // so it acquires `ActiveLock` around this section the same way they do -- the same container,
546    // the same check-then-act, the same unrecoverable conflict state, one lock path per
547    // repository so this also serializes against a concurrent commit or rollback-draft, not only
548    // against another import. A conflict here (against this repository's own existing material,
549    // distinct from the bundle-internal check above) fails the whole import, not just this entry.
550    //
551    // DC-53 Stage 2 follow-up (`multi-key-import-partial-write-v1.md`): with `m > 1` transported
552    // keys, checking-then-recording one entry at a time let a conflict at entry `k` leave entries
553    // `1..k-1` durably appended to a container with no prune, no compaction, and no repair --
554    // exactly the partial-write hazard layer 1's bundle-internal check exists to prevent, just one
555    // layer later. Fixed the same way: validate every entry against local material *before*
556    // recording any of it, both passes inside the one `ActiveLock` already held (a validate-then-
557    // record split across the lock boundary would be a check-then-act race across the two passes,
558    // the same defect this fixes).
559    let mut recorded_author_key_count = 0_usize;
560    {
561        let active_lock = ActiveLock::acquire(layout)?;
562        for (&key_id, &public_key) in &bundle_key_ids {
563            check_author_key_conflict(layout, key_id, public_key)?;
564        }
565        for entry in &author_keys {
566            record_author_key_material(layout, &entry.key_id, entry.public_key, &active_lock)?;
567            recorded_author_key_count =
568                recorded_author_key_count.checked_add(1).ok_or_else(|| {
569                    PrikkError::Integrity(
570                        "bundle import recorded-author-key count overflow".to_string(),
571                    )
572                })?;
573        }
574    }
575
576    let received_ref_name = format!("remotes/{origin_ref_name}");
577    // RFC 102 Stage 6 Step 2, design-v1.md §15.8: `import_bundle` held no lock at all before this
578    // stage. Scoped to the received-index write alone, not the object writes above: those have
579    // their own, separately registered concurrency gap -- see
580    // docs/src/reference/concurrency-locking.md#object-container-writes-are-not-among-the-four-locked-containers,
581    // out of this stage's scope.
582    let _received_index_lock =
583        acquire_container_locks(layout, &[LockableContainer::ReceivedIndex])?;
584    crate::received::write_received_pointer(layout, &received_ref_name, ref_state_id)?;
585
586    Ok(BundleImportReport {
587        ref_name: received_ref_name,
588        ref_state_id,
589        object_count: objects.len(),
590        written_object_count,
591        recorded_author_key_count,
592    })
593}
594
595fn read_required(
596    object_store: &impl ObjectReader,
597    id: ObjectId,
598    object_type: ObjectType,
599) -> Result<ObjectEnvelope> {
600    object_store
601        .read_typed(id, object_type)?
602        .ok_or_else(|| PrikkError::Integrity(format!("missing {object_type} object: {id}")))
603}
604
605/// `import_bundle`'s own view of §2's "present" definition: carried by this bundle, or already in
606/// this repository. Checked before any bundle object is written, so a bundle-carried object is not
607/// yet reachable through `local` even once import succeeds -- `bundle_objects` is what makes it
608/// visible during validation.
609struct BundleAndLocalReader<'a> {
610    bundle_objects: &'a BTreeMap<ObjectId, ObjectEnvelope>,
611    local: &'a ObjectReadSnapshot,
612}
613
614impl ObjectReader for BundleAndLocalReader<'_> {
615    fn read_object(&self, id: ObjectId) -> Result<Option<ObjectEnvelope>> {
616        if let Some(envelope) = self.bundle_objects.get(&id) {
617            return Ok(Some(envelope.clone()));
618        }
619        self.local.read_object(id)
620    }
621}
622
623/// Every blob id one decoded patch operation references -- restated from
624/// `patch_exchange/accept.rs`'s own `referenced_blob_ids` (the same three kinds `export_bundle`'s
625/// closure walk above also scans for), for `import_bundle`'s own closure-completeness check. Kept as
626/// a separate, per-module copy rather than a cross-module `pub(crate)` call: `accept.rs`'s own doc
627/// comment already restates this same match once for `artifact.rs`'s benefit, so a third small copy
628/// here follows the precedent this file already has, not a new one.
629fn bundle_referenced_blob_ids(kind: &DecodedOperationKind) -> Vec<ObjectId> {
630    match kind {
631        DecodedOperationKind::CreateFile { blob_id, .. } => vec![*blob_id],
632        DecodedOperationKind::ReplaceBinary {
633            old_blob_id,
634            new_blob_id,
635            ..
636        } => vec![*old_blob_id, *new_blob_id],
637        DecodedOperationKind::DeleteNode {
638            preimage: DecodedDeletePreimage::File { old_blob_id, .. },
639            ..
640        } => vec![*old_blob_id],
641        _ => Vec::new(),
642    }
643}
644
645/// Resolve `ref_state_payload.target_object_id` to the Block it ultimately names -- one hop for a
646/// `Branch` (the target *is* the Block), two hops for a `Tag` (the target is a Tag object; its own
647/// `target_block_id` is the Block). The two-hop resolution itself is `refs::resolve_ref_tip_block`
648/// (ref-tip-resolver-consolidation handoff), shared with `patch_set_digest.rs` and
649/// `patch_exchange.rs`; this wrapper's own job is pushing the resolved Tag envelope onto
650/// `tag_envelopes` so the caller can include it in the exported object set -- omitting it would hand
651/// a receiver a signed RefState naming an object they do not have, which fails their `verify` with a
652/// message naming exactly that (DC-78 bundle-export tag gap follow-up,
653/// `bundle-export-tag-ref-gap-v1.md`). The accumulator stays here, not in the shared resolver, since
654/// this is the only one of three callers that needs it.
655fn resolve_ref_target_block(
656    object_store: &impl ObjectReader,
657    ref_state_payload: &RefStatePayload,
658    tag_envelopes: &mut Vec<ObjectEnvelope>,
659) -> Result<ObjectId> {
660    let (target_block_id, tag_envelope) =
661        crate::refs::resolve_ref_tip_block(object_store, ref_state_payload)?;
662    if let Some(tag_envelope) = tag_envelope {
663        tag_envelopes.push(tag_envelope);
664    }
665    Ok(target_block_id)
666}
667
668fn encode_bundle(
669    ref_name: &str,
670    objects: &[ObjectEnvelope],
671    author_keys: &[AuthorKeyEntry],
672) -> Result<Vec<u8>> {
673    let mut out = Vec::new();
674    out.extend_from_slice(BUNDLE_MAGIC);
675    push_bytes_u64(&mut out, ref_name.as_bytes())?;
676    push_u64(&mut out, len_to_u64(objects.len())?);
677    for envelope in objects {
678        push_bytes_u64(&mut out, &encode_envelope_file(envelope)?)?;
679    }
680    // DC-53 Stage 2, D6: the author-key section, appended after the object list.
681    push_u64(&mut out, len_to_u64(author_keys.len())?);
682    for entry in author_keys {
683        push_bytes_u64(&mut out, entry.key_id.as_bytes())?;
684        out.extend_from_slice(&entry.public_key);
685    }
686    Ok(out)
687}
688
689/// Encode a `PBNDL001`-shaped bundle -- what a Stage-1-or-earlier build actually produced, and the
690/// only thing `decode_bundle`'s `PBNDL001` acceptance needs to handle correctly. Test-only: no
691/// production caller ever emits this format (`encode_bundle` above always writes `BUNDLE_MAGIC`).
692/// Built from the same encoding primitives as `encode_bundle`, mirroring its pre-author-key-section
693/// body exactly, rather than hand-editing bytes -- a hand-built fixture would prove the parser
694/// accepts a byte shape, not that the real historical format actually decodes.
695#[cfg(all(test, target_os = "linux"))]
696fn encode_bundle_v1_for_test(ref_name: &str, objects: &[ObjectEnvelope]) -> Result<Vec<u8>> {
697    let mut out = Vec::new();
698    out.extend_from_slice(RETIRED_BUNDLE_MAGIC_V1);
699    push_bytes_u64(&mut out, ref_name.as_bytes())?;
700    push_u64(&mut out, len_to_u64(objects.len())?);
701    for envelope in objects {
702        push_bytes_u64(&mut out, &encode_envelope_file(envelope)?)?;
703    }
704    Ok(out)
705}
706
707fn decode_bundle(
708    bytes: &[u8],
709    max_object_count: usize,
710) -> Result<(String, Vec<ObjectEnvelope>, Vec<AuthorKeyEntry>)> {
711    let mut cursor = ByteCursor::new(bytes);
712    let magic = cursor.read_array::<8>()?;
713    // DC-53 Stage 2 follow-up (bundle-v1-import-regression-v1.md): `PBNDL001` is accepted here, not
714    // refused. `layout.rs`'s own retired-format messages instruct a user to open an old repository
715    // with an old prikk build and `bundle export` from it -- that build only ever emits `PBNDL001`,
716    // and a current build refusing to import it severed the one migration path those messages
717    // promise, in both directions at once. Export still only ever emits `BUNDLE_MAGIC`
718    // (`PBNDL002`) -- this is read compatibility only, the same asymmetry every repository-format
719    // transition in this project already has (read what the past wrote, write only the present).
720    // A `PBNDL001` bundle is a `PBNDL002` bundle without the author-key section: not a special case,
721    // since DC-53 already defines "no recorded material" as `Unverifiable` (vector 7), so decoding
722    // one simply treats the author-key set as empty rather than reading a section that was never
723    // written.
724    let has_author_key_section = if &magic == BUNDLE_MAGIC {
725        true
726    } else if &magic == RETIRED_BUNDLE_MAGIC_V1 {
727        false
728    } else {
729        return Err(PrikkError::MalformedData(
730            "invalid bundle magic".to_string(),
731        ));
732    };
733    let ref_name_bytes = cursor.read_bytes_u64()?;
734    let ref_name = String::from_utf8(ref_name_bytes).map_err(|err| {
735        PrikkError::MalformedData(format!("invalid bundle ref name utf-8: {err}"))
736    })?;
737    let count = cursor.read_u64()?;
738    // DC-86: refused here, before the loop below decodes a single object — a declared count over
739    // the limit must not cost more than reading one u64 to reject, regardless of how large `count`
740    // claims to be or how much of `bytes` actually backs it.
741    if count > len_to_u64(max_object_count)? {
742        return Err(PrikkError::MalformedData(format!(
743            "bundle declares {count} objects, over the configured limit of {max_object_count}"
744        )));
745    }
746    let mut objects = Vec::new();
747    for _ in 0..count {
748        let encoded = cursor.read_bytes_u64()?;
749        objects.push(decode_envelope_file(&encoded)?);
750    }
751    // DC-53 Stage 2, D6/C1 (plan review): the same declared-count bound DC-86 already applies to
752    // the object count, applied here too -- a second declared count in a format a hostile sender
753    // fully controls, with no bound of its own, would reopen the hole DC-86 closed. An
754    // author-key entry can never legitimately outnumber the Patches in the same bundle, so reusing
755    // `max_object_count` as the ceiling needs no new option surface.
756    let mut author_keys = Vec::new();
757    if has_author_key_section {
758        let author_key_count = cursor.read_u64()?;
759        if author_key_count > len_to_u64(max_object_count)? {
760            return Err(PrikkError::MalformedData(format!(
761                "bundle declares {author_key_count} author key entries, over the configured limit \
762                 of {max_object_count}"
763            )));
764        }
765        for _ in 0..author_key_count {
766            let key_id_bytes = cursor.read_bytes_u64()?;
767            let key_id = String::from_utf8(key_id_bytes).map_err(|err| {
768                PrikkError::MalformedData(format!("invalid bundle author key_id utf-8: {err}"))
769            })?;
770            // DC-53 Stage 2, plan review: reuse `Signature::validate_key_id` rather than a second
771            // notion of what a legal key id is -- it is the same rule these ids must satisfy to
772            // ever match a signature's own `key_id`.
773            Signature::validate_key_id(&key_id)?;
774            let public_key = cursor.read_array::<32>()?;
775            author_keys.push(AuthorKeyEntry { key_id, public_key });
776        }
777    }
778    // A `PBNDL001` bundle's bytes end right after the object list -- no author-key section was ever
779    // written, so there is nothing further to consume and this check still catches genuine trailing
780    // garbage on either format.
781    if !cursor.is_finished() {
782        return Err(PrikkError::MalformedData(
783            "trailing bytes in bundle".to_string(),
784        ));
785    }
786    Ok((ref_name, objects, author_keys))
787}
788
789#[cfg(all(test, target_os = "linux"))]
790mod tests;