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;