Skip to main content

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;