prikk_store/refs/container.rs
1//! Shared ref-log container framing and the isolate-and-continue read path (RFC 102 Stage 4, Step 0
2//! §13.1/§13.2/§13.5, ruled in design-v1.md §13). One container holds every ref's log records,
3//! interleaved -- acceptance criterion 1 forces this: ref names do not exist at `init`, so a per-ref
4//! container is architecturally impossible (`branch create`/`tag create` mint them later, as ordinary
5//! recurring operations, not an `init`-only event).
6//!
7//! Framing mirrors the top-level object `container.rs` (magic, version, length, checksum, body), with
8//! one deliberate addition: **`ref_name_key` (`sha256(ref_name)`) lives in the frame header itself,
9//! not only inside the encoded envelope body.** Reasoning, not copied from anywhere: corruption
10//! isolation (Step 0 §13.5, promoted to an acceptance criterion) needs a damaged record attributed to
11//! its own ref, matching today's per-file granularity -- but a record whose body fails to decode has
12//! no *trusted* way to reveal which ref it belonged to from its own (corrupted) envelope. Carrying
13//! `ref_name_key` in the header gives every reader a best-effort attribution even when the checksum
14//! fails and the body cannot be decoded at all: for the overwhelming common case (corruption localized
15//! to the body, header otherwise intact), the header's own claim is correct; for pathological
16//! corruption that happens to land on the header field itself, the checksum has already failed the
17//! whole frame regardless, so nothing trusts the record's *content* either way -- only its
18//! attribution for reporting purposes is at stake, not data integrity.
19//!
20//! No sequence field. Step 0 §13.1 found `refs/log.rs`'s old per-file `validate_log` check carried
21//! three properties (ref-name uniformity, the chain link, and the positional `update_seq == index +
22//! 1`), and the positional one was positional only as a shortcut, holding today because one file
23//! happened to be exactly one ref's own subsequence. Under a shared container `expected_seq` is
24//! computed by the *reader*, from a record's position within its own ref's filtered subsequence
25//! (`refs/verify/scan.rs`'s rewritten `validate_log`) -- the container itself guarantees nothing
26//! beyond Stage 3's plain append-only, and `RefLock` (unchanged) is what keeps one ref's own writes in
27//! that order.
28//!
29//! The byte-wise resync scan is `frame_resync::resync_to_next_magic`, shared with `wal.rs`,
30//! `refs/log.rs` (the retired per-file codec), and the top-level `container.rs` -- not a fourth copy.
31//!
32//! **Every `ContainerSlot::A` reference below is hardcoded, not resolver-routed -- deliberately, not
33//! an oversight.** RFC 102 Stage 6 Step 1 (design-v1.md §15.6) gave three *other* containers
34//! (`ref_pointer_index`, `received_index`, `trust_policy_container`) their own generation logs and a
35//! `generation::resolve_live_slot` reader, because §15.1 found those three are the genuine
36//! last-entry-wins garbage producers Stage 6 exists to compact. This container is not one of them: it
37//! is DC-38's audit trail and DC-69's "prikk does not forget" ruling made durable, so it must never be
38//! compacted, and `B` stays reserved-but-unused forever (`ContainerSlot`'s own doc, §15.2's "forward
39//! reservation, not dead" framing). Routing these sites through the resolver would be uniformity
40//! ceremony on a container that will never exercise it -- exactly the staging error §15.4's original
41//! (now-superseded) Step 1 proposal made for the ref-pointer-index and received-index containers
42//! before the restructure, so it is not repeated here on purpose.
43
44use prikk_error::{PrikkError, Result};
45use prikk_hash::sha256;
46use prikk_object::{ObjectEnvelope, ObjectType, RefUpdatePayload};
47
48use crate::byte_cursor::ByteCursor;
49use crate::file_codec::{decode_envelope_file, encode_envelope_file, push_u16, push_u64};
50use crate::frame_resync::resync_to_next_magic;
51use crate::fsutil::{append_file_required, len_to_u64, read_file_if_exists};
52use crate::layout::RepositoryLayout;
53use crate::refs::require_signed_type;
54
55/// One decoded ref-log record, scoped to one ref's own subsequence. Was `refs/log.rs`'s own type
56/// before RFC 102 Stage 4 retired that per-file codec; kept the exact same name and shape since
57/// `RefStore::replay_log`'s public return type (and every one of its 13 production callers) never
58/// changed.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RefLogRecord {
61 /// Exact signed RefUpdate envelope stored in the log.
62 pub envelope: ObjectEnvelope,
63}
64
65/// Outcome of attempting to decode one ref-log record, scoped to one ref's own subsequence (RFC 102
66/// Stage 2: isolate-and-continue reading). Mirrors `wal::WalRecordOutcome`; see its doc for the
67/// reasoning this shares.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum RefLogRecordStatus {
70 /// The frame at this offset was read and validated successfully.
71 Evaluated,
72 /// The frame at this offset failed to validate (bad magic/version, checksum mismatch, or a
73 /// malformed/unsigned envelope) -- resync moved past it byte-wise to find the next candidate.
74 Failed {
75 /// The error this frame's own validation raised.
76 message: String,
77 },
78}
79
80/// One attempted ref-log record's resolved outcome, scoped to one ref's own subsequence.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct RefLogRecordOutcome {
83 /// The byte offset within the shared container this frame attempt started at.
84 pub offset: usize,
85 /// How this frame's own read/validation resolved.
86 pub status: RefLogRecordStatus,
87}
88
89/// One ref's own log replay result -- `replay_ref_subsequence`'s own return type.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RefLogReplay {
92 /// Valid records read from this ref's own subsequence, in relative order -- includes records
93 /// found after a damaged one (RFC 102 Stage 2), not merely a prefix up to the first failure.
94 pub records: Vec<RefLogRecord>,
95 /// This ref's own attributed trailing-partial byte count (design-v1.md §13.6 point 2) -- zero
96 /// unless the container's own physical trailing partial tail's header could be read and claims
97 /// this ref specifically.
98 pub trailing_partial_bytes: usize,
99 /// One outcome per attempted frame attributable to this ref, in scan order -- both `Evaluated`
100 /// and `Failed`.
101 pub record_outcomes: Vec<RefLogRecordOutcome>,
102}
103
104impl RefLogReplay {
105 /// Return true when any attempted frame attributable to this ref failed to validate.
106 #[must_use]
107 pub fn has_item_failure(&self) -> bool {
108 self.record_outcomes
109 .iter()
110 .any(|outcome| matches!(outcome.status, RefLogRecordStatus::Failed { .. }))
111 }
112}
113
114const REF_CONTAINER_MAGIC: &[u8; 8] = b"PREFCON1";
115const REF_CONTAINER_VERSION: u16 = 1;
116/// magic(8) + version(2) + ref_name_key(32) + body_len(8) + checksum(32).
117const REF_CONTAINER_HEADER_LEN: usize = 8 + 2 + 32 + 8 + 32;
118
119/// One durable ref-log container record.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub(crate) struct RefContainerRecord {
122 /// This record's own header-carried ref-name key -- trusted (the frame's checksum covers it),
123 /// since this variant is only ever produced for a frame that passed checksum validation.
124 pub(crate) ref_name_key: [u8; 32],
125 /// Exact signed RefUpdate envelope stored at append time.
126 pub(crate) envelope: ObjectEnvelope,
127}
128
129/// Outcome of attempting to decode one ref-log container record frame.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub(crate) enum RefContainerRecordStatus {
132 /// The frame at this offset was read and validated successfully.
133 Evaluated,
134 /// The frame at this offset failed to validate (bad magic/version, checksum mismatch, or a
135 /// malformed/unsigned envelope) -- resync moved past it byte-wise to find the next candidate.
136 Failed {
137 /// The error this frame's own validation raised.
138 message: String,
139 /// The header's own `ref_name_key` claim, when the header parsed structurally far enough to
140 /// read it -- **not checksum-verified** (the frame as a whole already failed that), so this
141 /// is a best-effort attribution for reporting, never trusted for anything else. `None` only
142 /// when the failure occurred before the header's own bytes were even readable (`TrailingPartial`
143 /// never reaches here at all; only a structurally-short header on a corrupted-but-not-torn
144 /// tail could leave this `None`).
145 claimed_ref_name_key: Option<[u8; 32]>,
146 },
147}
148
149/// One attempted ref-log container record frame's resolved outcome.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub(crate) struct RefContainerRecordOutcome {
152 /// The byte offset within the container this frame attempt started at.
153 pub(crate) offset: usize,
154 /// How this frame's own read/validation resolved.
155 pub(crate) status: RefContainerRecordStatus,
156}
157
158/// Ref-log container replay result -- every ref's records, interleaved, in physical (write) order.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub(crate) struct RefContainerReplay {
161 /// Valid records read from the container, in file order -- includes records found after a
162 /// damaged one, not merely a prefix up to the first failure.
163 pub(crate) records: Vec<RefContainerRecord>,
164 /// Number of trailing bytes ignored as an incomplete final record.
165 pub(crate) trailing_partial_bytes: usize,
166 /// One outcome per attempted frame, in scan order -- both `Evaluated` and `Failed`.
167 pub(crate) record_outcomes: Vec<RefContainerRecordOutcome>,
168}
169
170/// Encode one signed RefUpdate envelope as a durable ref-log container record. `ref_name_key` is
171/// supplied by the caller (already known from the decoded `RefUpdatePayload` at write time) rather
172/// than re-derived here, so this function never has to decode its own input to frame it.
173pub(crate) fn encode_ref_container_record(
174 ref_name_key: [u8; 32],
175 envelope: &ObjectEnvelope,
176) -> Result<Vec<u8>> {
177 require_signed_type(envelope, ObjectType::RefUpdate)?;
178 let body = encode_envelope_file(envelope)?;
179 frame_record(ref_name_key, &body)
180}
181
182#[cfg(test)]
183pub(crate) fn encode_ref_container_record_for_test(
184 ref_name_key: [u8; 32],
185 envelope: &ObjectEnvelope,
186) -> Result<Vec<u8>> {
187 require_signed_type(envelope, ObjectType::RefUpdate)?;
188 let body = crate::file_codec::encode_envelope_file_structural(envelope)?;
189 frame_record(ref_name_key, &body)
190}
191
192fn frame_record(ref_name_key: [u8; 32], body: &[u8]) -> Result<Vec<u8>> {
193 let body_len = len_to_u64(body.len())?;
194 let checksum = record_checksum(ref_name_key, body_len, body);
195 let mut out = Vec::with_capacity(REF_CONTAINER_HEADER_LEN + body.len());
196 out.extend_from_slice(REF_CONTAINER_MAGIC);
197 push_u16(&mut out, REF_CONTAINER_VERSION);
198 out.extend_from_slice(&ref_name_key);
199 push_u64(&mut out, body_len);
200 out.extend_from_slice(&checksum);
201 out.extend_from_slice(body);
202 Ok(out)
203}
204
205/// Result of attempting to parse one frame at a given offset. Mirrors `container::FrameAttempt`.
206enum FrameAttempt {
207 Record {
208 record: RefContainerRecord,
209 next_offset: usize,
210 },
211 TrailingPartial {
212 remaining: usize,
213 },
214 Invalid {
215 message: String,
216 claimed_ref_name_key: Option<[u8; 32]>,
217 },
218}
219
220/// Attempt to parse one ref-log container frame at `offset`. Never trusts a not-yet-checksum-validated
221/// header's own `body_len` for anything beyond locating where its claimed body would end.
222fn parse_frame_at(bytes: &[u8], offset: usize) -> FrameAttempt {
223 let remaining = bytes.len().saturating_sub(offset);
224 if remaining < REF_CONTAINER_HEADER_LEN {
225 return FrameAttempt::TrailingPartial { remaining };
226 }
227 let header_end = offset + REF_CONTAINER_HEADER_LEN;
228 let Some(header) = bytes.get(offset..header_end) else {
229 return FrameAttempt::TrailingPartial { remaining };
230 };
231 let header_values = match parse_header(header) {
232 Ok(values) => values,
233 Err(err) => {
234 return FrameAttempt::Invalid {
235 message: err.to_string(),
236 claimed_ref_name_key: None,
237 };
238 }
239 };
240 let claimed = Some(header_values.ref_name_key);
241 let Ok(body_len) = usize::try_from(header_values.body_len) else {
242 return FrameAttempt::Invalid {
243 message: "ref container body length does not fit usize".to_string(),
244 claimed_ref_name_key: claimed,
245 };
246 };
247 let Some(body_end) = header_end.checked_add(body_len) else {
248 return FrameAttempt::Invalid {
249 message: "ref container body end overflow".to_string(),
250 claimed_ref_name_key: claimed,
251 };
252 };
253 let Some(body) = bytes.get(header_end..body_end) else {
254 return FrameAttempt::TrailingPartial { remaining };
255 };
256 let expected = record_checksum(header_values.ref_name_key, header_values.body_len, body);
257 if expected != header_values.checksum {
258 return FrameAttempt::Invalid {
259 message: format!("ref container checksum mismatch at byte offset {offset}"),
260 claimed_ref_name_key: claimed,
261 };
262 }
263 let envelope = match decode_envelope_file(body) {
264 Ok(envelope) => envelope,
265 Err(err) => {
266 return FrameAttempt::Invalid {
267 message: err.to_string(),
268 claimed_ref_name_key: claimed,
269 };
270 }
271 };
272 if let Err(err) = require_signed_type(&envelope, ObjectType::RefUpdate) {
273 return FrameAttempt::Invalid {
274 message: err.to_string(),
275 claimed_ref_name_key: claimed,
276 };
277 }
278 FrameAttempt::Record {
279 record: RefContainerRecord {
280 ref_name_key: header_values.ref_name_key,
281 envelope,
282 },
283 next_offset: body_end,
284 }
285}
286
287/// Isolate-and-continue reading (RFC 102 Stage 2's reader, reused here per the same discipline Stage
288/// 3 already followed): a frame that fails to validate no longer aborts replay -- its offset and
289/// error are recorded as a `Failed` outcome, and `frame_resync::resync_to_next_magic` finds the next
290/// candidate frame so every subsequent sound record, for every ref, is still read.
291pub(crate) fn decode_ref_container_records(bytes: &[u8]) -> Result<RefContainerReplay> {
292 let mut records = Vec::new();
293 let mut record_outcomes = Vec::new();
294 let mut offset = 0_usize;
295 loop {
296 match parse_frame_at(bytes, offset) {
297 FrameAttempt::Record {
298 record,
299 next_offset,
300 } => {
301 // RFC 102 Stage 4 checkpoint review, design-v1.md §13.15: checksum decides whether
302 // this is a frame; envelope validation decides whether the record it contains is
303 // admissible -- two different questions. A frame whose checksum matches but whose
304 // envelope fails `validate_strict` is a real frame with a bad record, not a false
305 // magic match, so it must not be routed through `resync_to_next_magic` (which would
306 // scan past a genuine frame boundary and put every record after it at risk of being
307 // lost or misattributed). Recorded as a per-record `Failed` outcome instead, offset
308 // still advances to this frame's own already-known `next_offset`.
309 match record.envelope.validate_strict() {
310 Ok(()) => {
311 record_outcomes.push(RefContainerRecordOutcome {
312 offset,
313 status: RefContainerRecordStatus::Evaluated,
314 });
315 records.push(record);
316 }
317 Err(err) => {
318 record_outcomes.push(RefContainerRecordOutcome {
319 offset,
320 status: RefContainerRecordStatus::Failed {
321 message: err.to_string(),
322 claimed_ref_name_key: Some(record.ref_name_key),
323 },
324 });
325 }
326 }
327 offset = next_offset;
328 }
329 FrameAttempt::TrailingPartial { remaining } => {
330 return Ok(RefContainerReplay {
331 records,
332 trailing_partial_bytes: remaining,
333 record_outcomes,
334 });
335 }
336 FrameAttempt::Invalid {
337 message,
338 claimed_ref_name_key,
339 } => {
340 record_outcomes.push(RefContainerRecordOutcome {
341 offset,
342 status: RefContainerRecordStatus::Failed {
343 message,
344 claimed_ref_name_key,
345 },
346 });
347 match resync_to_next_magic(bytes, offset + 1, REF_CONTAINER_MAGIC.as_slice()) {
348 Some(next) => offset = next,
349 None => {
350 return Ok(RefContainerReplay {
351 records,
352 trailing_partial_bytes: 0,
353 record_outcomes,
354 });
355 }
356 }
357 }
358 }
359 }
360}
361
362/// Durably append one record to the shared log container. **No pre-append refusal on any existing
363/// trailing-partial tail** -- ruled in design-v1.md §13.6: a torn tail never enters any ref's own
364/// filtered subsequence (`replay_ref_subsequence` below only ever sees frames that parsed), so
365/// appending past one can never produce a sequence gap. Today's per-file refusal
366/// (`refs/log.rs::append_log_record`) enforced hygiene, not integrity, and hygiene enforced this way
367/// would mean one ref's crash blocks every other ref's publishes under a shared container -- exactly
368/// the availability regression the ruling rejects. Mirrors `write_object_to_container`'s own
369/// unconditional container-append exactly.
370pub(crate) fn append_ref_container_record(
371 layout: &RepositoryLayout,
372 ref_name_key: [u8; 32],
373 envelope: &ObjectEnvelope,
374) -> Result<()> {
375 // RFC 102 Stage 4 checkpoint review, design-v1.md §13.15: format-2 requires `created_at == 0`
376 // for a RefUpdate -- DC-39's implementation of a DC-34 ruling, carried from the retired
377 // `refs/log.rs::append_log_record`'s own write-time check. Placed here, the append function
378 // itself, because it is the one choke point every publish path (`Ready`/`PointerLeading`/
379 // `Complete`) already goes through -- anything upstream (e.g. `publish_locked`) is a layer a
380 // future caller could bypass, which is how this check was lost in the first place.
381 let update = RefUpdatePayload::decode_canonical(&envelope.canonical_payload)?;
382 if update.created_at != 0 {
383 return Err(PrikkError::MalformedData(
384 "format-2 RefUpdate requires created_at == 0".to_string(),
385 ));
386 }
387 let relative = layout.repository_relative(
388 &layout.ref_log_container_slot_path(crate::layout::ContainerSlot::A),
389 )?;
390 // Idempotency, preserved from `refs/log.rs::append_log_record`'s exact behavior (retired, not
391 // dropped): a retry whose own ref-scoped subsequence already ends in this exact envelope is a
392 // no-op sync, not a second record -- `publish_locked`'s `PointerLeading`/`Complete` branches
393 // call this unconditionally on every retry, and without this check a `Complete`-state retry
394 // (pointer and log already agree) would append a genuine duplicate record.
395 let existing = replay_ref_subsequence(layout, ref_name_key)?;
396 if existing
397 .records
398 .last()
399 .is_some_and(|last| last.envelope == *envelope)
400 {
401 return append_file_required(layout.repository_mutation_root(), &relative, &[]);
402 }
403 let record = encode_ref_container_record(ref_name_key, envelope)?;
404 append_file_required(layout.repository_mutation_root(), &relative, &record)
405}
406
407/// Replay one ref's own subsequence from the shared container: every sound record whose header
408/// claims `ref_name_key`, in relative physical order (Step 0 §13.1: `RefLock` already serializes one
409/// ref's own writes, so this order is already correct sequence order for that ref specifically).
410/// Reuses `refs::log::RefLogRecord`/`RefLogReplay`'s exact shape so `RefStore::replay_log`'s own
411/// public return type never changes.
412///
413/// `trailing_partial_bytes` is **ref-scoped, not container-wide** (design-v1.md §13.6 point 2): a
414/// torn tail at the container's own physical end is attributed to this ref only when enough of its
415/// header survived to read a `ref_name_key` that matches -- an unattributable or foreign-ref tail
416/// reports zero here, so this ref's own classification proceeds as if no partial tail exists (safe:
417/// the retry append lands correctly regardless, per the ruling's own point 1; an unattributed tail
418/// only loses the specific "N incomplete trailing byte(s)" diagnostic wording and the truncate-before-
419/// retry hygiene step, never the underlying detection or recovery).
420pub(crate) fn replay_ref_subsequence(
421 layout: &RepositoryLayout,
422 ref_name_key: [u8; 32],
423) -> Result<RefLogReplay> {
424 let relative = layout.repository_relative(
425 &layout.ref_log_container_slot_path(crate::layout::ContainerSlot::A),
426 )?;
427 let Some(bytes) = read_file_if_exists(layout.repository_mutation_root(), &relative)? else {
428 return Ok(RefLogReplay {
429 records: Vec::new(),
430 trailing_partial_bytes: 0,
431 record_outcomes: Vec::new(),
432 });
433 };
434 let replay = decode_ref_container_records(&bytes)?;
435 let mut records = replay.records.iter();
436 let mut ref_records = Vec::new();
437 let mut ref_outcomes = Vec::new();
438 for outcome in &replay.record_outcomes {
439 match &outcome.status {
440 RefContainerRecordStatus::Evaluated => {
441 let Some(record) = records.next() else {
442 return Err(PrikkError::Integrity(
443 "ref container replay outcome/record count mismatch".to_string(),
444 ));
445 };
446 if record.ref_name_key != ref_name_key {
447 continue;
448 }
449 ref_outcomes.push(RefLogRecordOutcome {
450 offset: outcome.offset,
451 status: RefLogRecordStatus::Evaluated,
452 });
453 ref_records.push(RefLogRecord {
454 envelope: record.envelope.clone(),
455 });
456 }
457 RefContainerRecordStatus::Failed {
458 message,
459 claimed_ref_name_key,
460 } => {
461 if *claimed_ref_name_key != Some(ref_name_key) {
462 continue;
463 }
464 ref_outcomes.push(RefLogRecordOutcome {
465 offset: outcome.offset,
466 status: RefLogRecordStatus::Failed {
467 message: message.clone(),
468 },
469 });
470 }
471 }
472 }
473 let attributed_trailing = trailing_tail_ref_name_key(&bytes, replay.trailing_partial_bytes);
474 let trailing_partial_bytes = if attributed_trailing == Some(ref_name_key) {
475 replay.trailing_partial_bytes
476 } else {
477 0
478 };
479 Ok(RefLogReplay {
480 records: ref_records,
481 trailing_partial_bytes,
482 record_outcomes: ref_outcomes,
483 })
484}
485
486/// Best-effort attribution of the container's own trailing partial tail: read just enough of its
487/// header to learn the `ref_name_key` it claims, when enough bytes survive to reach that field at
488/// all. Not checksum-verified (a torn tail's checksum is, by construction, never fully present to
489/// verify) -- see `replay_ref_subsequence`'s own doc for why this is safe to use for classification
490/// despite that.
491fn trailing_tail_ref_name_key(bytes: &[u8], trailing_partial_bytes: usize) -> Option<[u8; 32]> {
492 if trailing_partial_bytes == 0 {
493 return None;
494 }
495 let start = bytes.len().checked_sub(trailing_partial_bytes)?;
496 let key_start = start.checked_add(10)?;
497 let key_end = key_start.checked_add(32)?;
498 bytes
499 .get(key_start..key_end)
500 .map(|slice| slice.try_into().unwrap_or([0_u8; 32]))
501}
502
503/// Return whether the container's own trailing partial suffix is an exact prefix of the record
504/// `expected` would produce if appended now under `ref_name_key`. Mirrors
505/// `refs::log::incomplete_tail_matches`, generalized from "the one file this ref owns" to "the
506/// container's own physical tail".
507pub(crate) fn incomplete_tail_matches(
508 layout: &RepositoryLayout,
509 ref_name_key: [u8; 32],
510 expected: &ObjectEnvelope,
511) -> Result<bool> {
512 let relative = layout.repository_relative(
513 &layout.ref_log_container_slot_path(crate::layout::ContainerSlot::A),
514 )?;
515 let bytes =
516 read_file_if_exists(layout.repository_mutation_root(), &relative)?.unwrap_or_default();
517 let replay = decode_ref_container_records(&bytes)?;
518 if replay.trailing_partial_bytes == 0 {
519 return Ok(false);
520 }
521 let retained = bytes
522 .len()
523 .checked_sub(replay.trailing_partial_bytes)
524 .ok_or_else(|| {
525 PrikkError::Integrity("ref container retained length underflow".to_string())
526 })?;
527 let expected_record = encode_ref_container_record(ref_name_key, expected)?;
528 let suffix = bytes.get(retained..).ok_or_else(|| {
529 PrikkError::Integrity("ref container incomplete suffix range overflow".to_string())
530 })?;
531 Ok(expected_record.starts_with(suffix))
532}
533
534/// Truncate only a structurally incomplete final frame from the shared container and required-sync
535/// the retained bytes. Safe regardless of which ref (if any) the torn tail is attributable to
536/// (design-v1.md §13.6 point 3): "trailing" already means "past the last fully-parseable frame", so
537/// nothing sound is ever removed. Mirrors `refs::log::truncate_incomplete_tail`, generalized from a
538/// per-ref file to the shared container.
539pub(crate) fn truncate_incomplete_tail(layout: &RepositoryLayout) -> Result<usize> {
540 let relative = layout.repository_relative(
541 &layout.ref_log_container_slot_path(crate::layout::ContainerSlot::A),
542 )?;
543 let bytes =
544 read_file_if_exists(layout.repository_mutation_root(), &relative)?.unwrap_or_default();
545 let replay = decode_ref_container_records(&bytes)?;
546 if replay.trailing_partial_bytes == 0 {
547 return Ok(0);
548 }
549 let retained = bytes
550 .len()
551 .checked_sub(replay.trailing_partial_bytes)
552 .ok_or_else(|| {
553 PrikkError::Integrity("ref container retained length underflow".to_string())
554 })?;
555 crate::fsutil::truncate_existing_file_required(
556 layout.repository_mutation_root(),
557 &relative,
558 u64::try_from(retained)
559 .map_err(|_| PrikkError::Integrity("ref container length exceeds u64".to_string()))?,
560 )?;
561 Ok(replay.trailing_partial_bytes)
562}
563
564/// Append an attributable torn tail: encode `envelope` under `ref_name_key` exactly as a real
565/// publish would, then append only a truncated prefix of it (past the header, short of the full
566/// frame) -- the appended bytes carry a genuine, correctly-attributed `ref_name_key` without
567/// depending on any record already being durably present (a first-ever publish interrupted at its
568/// own log append has none). Fixture construction only -- see the CLI-side equivalent
569/// (`prikk-cli/tests/support/mod.rs::append_torn_ref_log_tail`, which instead duplicates whichever
570/// real record already sits last in the container, since CLI tests have no in-crate encoder) for why
571/// bare garbage bytes no longer simulate "this ref's own torn write" under the shared container: a
572/// tail shorter than `REF_CONTAINER_HEADER_LEN` cannot be attributed to any ref at all.
573#[cfg(test)]
574pub(crate) fn append_torn_ref_log_tail_for_test(
575 layout: &RepositoryLayout,
576 ref_name_key: [u8; 32],
577 envelope: &ObjectEnvelope,
578) -> Result<()> {
579 let relative = layout.repository_relative(
580 &layout.ref_log_container_slot_path(crate::layout::ContainerSlot::A),
581 )?;
582 let full = encode_ref_container_record_for_test(ref_name_key, envelope)?;
583 let torn_len = (REF_CONTAINER_HEADER_LEN + 8).min(full.len().saturating_sub(1));
584 let torn = full.get(..torn_len).ok_or_else(|| {
585 PrikkError::Integrity("torn tail length exceeds encoded record".to_string())
586 })?;
587 crate::fsutil::append_file_required(layout.repository_mutation_root(), &relative, torn)
588}
589
590struct RefContainerHeader {
591 ref_name_key: [u8; 32],
592 body_len: u64,
593 checksum: [u8; 32],
594}
595
596fn parse_header(header: &[u8]) -> Result<RefContainerHeader> {
597 let mut cursor = ByteCursor::new(header);
598 let magic = cursor.read_array::<8>()?;
599 if &magic != REF_CONTAINER_MAGIC {
600 return Err(PrikkError::MalformedData(
601 "invalid ref container record magic".to_string(),
602 ));
603 }
604 let version = cursor.read_u16()?;
605 if version != REF_CONTAINER_VERSION {
606 return Err(PrikkError::UnsupportedFormatVersion(u32::from(version)));
607 }
608 let ref_name_key = cursor.read_array::<32>()?;
609 let body_len = cursor.read_u64()?;
610 let checksum = cursor.read_array::<32>()?;
611 if !cursor.is_finished() {
612 return Err(PrikkError::MalformedData(
613 "trailing bytes in ref container header".to_string(),
614 ));
615 }
616 Ok(RefContainerHeader {
617 ref_name_key,
618 body_len,
619 checksum,
620 })
621}
622
623fn record_checksum(ref_name_key: [u8; 32], body_len: u64, body: &[u8]) -> [u8; 32] {
624 let mut preimage = Vec::new();
625 preimage.extend_from_slice(REF_CONTAINER_MAGIC);
626 preimage.extend_from_slice(&REF_CONTAINER_VERSION.to_be_bytes());
627 preimage.extend_from_slice(&ref_name_key);
628 preimage.extend_from_slice(&body_len.to_be_bytes());
629 preimage.extend_from_slice(body);
630 sha256(&preimage)
631}
632
633#[cfg(test)]
634mod tests;