Skip to main content

prikk_store/
layout.rs

1//! Repository layout paths and initialization.
2
3use std::path::{Path, PathBuf};
4
5use prikk_error::{PrikkError, Result};
6use prikk_hash::{sha256, to_hex};
7use prikk_object::{ObjectId, ObjectType, is_windows_reserved_name};
8
9use crate::fsutil::{
10    MutationRoot, create_new_file_required, ensure_directory_required, read_file_if_exists,
11    read_file_required,
12};
13
14const REPO_DIR: &str = ".prikk";
15const LEGACY_FORMAT_VERSION: &[u8] = b"1\n";
16const LEGACY_FORMAT_2_VERSION: &[u8] = b"2\n";
17const LEGACY_FORMAT_3_VERSION: &[u8] = b"3\n";
18const LEGACY_FORMAT_4_VERSION: &[u8] = b"4\n";
19const LEGACY_FORMAT_5_VERSION: &[u8] = b"5\n";
20/// Visible at `pub(crate)` so `format_stability_gate.rs`'s pinning test can assert this byte form
21/// and `CURRENT_FORMAT_VERSION_NUMERIC` against literal expected values side by side.
22pub(crate) const CURRENT_FORMAT_VERSION: &[u8] = b"6\n";
23/// Numeric companion to `CURRENT_FORMAT_VERSION`, for `format_stability_gate.rs`'s own range
24/// arithmetic (RFC 114 §4, Gate B layer 1). Kept as an independent literal, not parsed from
25/// `CURRENT_FORMAT_VERSION` at compile or test time -- see
26/// `format_stability_gate::current_format_version_byte_and_numeric_forms_agree` for why.
27#[cfg(test)]
28pub(crate) const CURRENT_FORMAT_VERSION_NUMERIC: u32 = 6;
29
30/// Repository format selected by the authoritative `.prikk/FORMAT` marker.
31///
32/// RFC 103 retired format 1; RFC 102 Stage 3 retired format 2 the same way, RFC 102 Stage 4 did the
33/// same again for format 3, RFC 102 Stage 5 did it once more for format 4, and RFC 102 Stage 6 does it
34/// again for format 5 -- rejected at open (`read_repository_format`), not merely unsupported for
35/// mutation, no variant naming it here. **This bump was sought and decided by the owner explicitly**
36/// (design-v1.md §14.7, 2026-08-15) and Stage 6 follows the same precedent (design-v1.md §15.6):
37/// Stage 6 Step 1 adds a B slot and a generation log for each of the three compacting containers, new
38/// names `durable_append`'s strictness makes unsafe to leave undetected in an older repository. The
39/// single remaining variant is kept as an enum rather than collapsed away, per design-v1.md §12.1's
40/// own note: `require_current_format`'s disk re-read is a real runtime check (RFC 103 Increment B was
41/// abandoned specifically because of it), so the enum's *shape* still carries meaning and is not free
42/// to simplify away.
43///
44/// **"Format 2"/"format 3"/"format 4"/"format 5"/"format 6" here name the on-disk repository layout**
45/// (loose objects/refs vs. RFC 102's containers) **-- a different axis from DC-40's "format-2" wire
46/// schema** (`block_state.rs`, `state_root.rs`, `format.rs`'s Block/Patch shape and Merkle rules),
47/// which no RFC 102 stage touches and which keeps its own "format-2" name regardless of what this
48/// enum's current variant is called (design §8: "format-2's rejection of the ahead-log state" is
49/// explicitly unchanged).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum RepositoryFormat {
52    /// Current format 6: RFC 102 Stage 6 Step 1's generation-aware index containers (ref pointer
53    /// index, received-ref index, trust policy container), layered on every prior stage's container
54    /// work, still writable under the unchanged DC-40 schema and state-root rules.
55    CurrentV6,
56}
57
58/// One container's pre-allocated alternate slot (RFC's §3.2 compaction requirement: a fixed A/B pair
59/// of names, never a rotated/new name). Object and ref-log containers keep `B` reserved-but-unused
60/// forever, per design-v1.md §15.2 -- object compaction has no data model to target and the ref log
61/// must never be compacted (DC-38/DC-69). The three genuine compaction targets (ref pointer index,
62/// received-ref index, trust policy container -- design-v1.md §15.1) got their own `A`/`B` slots in
63/// Stage 6 Step 1; `B` is written only once Stage 6 Step 2's compactor exists.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ContainerSlot {
66    /// The slot every write targets until compaction (Stage 6 Step 2) exists.
67    A,
68    /// The alternate slot compaction publishes to. Unused by object/ref-log containers, which never
69    /// compact; unused by the three Step-1-generation-aware containers until Step 2 lands.
70    B,
71}
72
73impl ContainerSlot {
74    /// Return a stable lower-case label used in the container's file name.
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::A => "a",
79            Self::B => "b",
80        }
81    }
82
83    /// Return the other slot -- the compactor's own "which slot am I retiring into" question
84    /// (design-v1.md §15.6/§4: compaction always writes the slot that is *not* currently live).
85    #[must_use]
86    pub const fn other(self) -> Self {
87        match self {
88            Self::A => Self::B,
89            Self::B => Self::A,
90        }
91    }
92}
93
94/// One of the four containers RFC 102 Stage 6 Step 2 locks against concurrent writer/compactor races
95/// (design-v1.md §15.8, ruled **wide** by the project owner over the developer's own narrower lean):
96/// the three genuine compaction targets, plus the ref log, whose own tearing exposure predates RFC 102
97/// and is not caused by compaction, but is fixed here because the exclusion machinery being built for
98/// compaction closes it for free. `trust_key_container` is deliberately absent -- it never compacts,
99/// and stays protected by the unchanged, repository-wide `ActiveLock` alone, the same as before this
100/// stage.
101///
102/// `derive(Ord)` on a fieldless enum compares by declaration order, which **is** the one fixed total
103/// lock order every multi-container acquisition sorts into (`lock::acquire_container_locks`,
104/// design-v1.md §15.7's deadlock ruling) -- no call site can express an inverted order even by
105/// accident, because sorting is structural, not a discipline to remember.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub enum LockableContainer {
108    /// `refs/containers/pointer-index-{a,b}.container` -- Stage 6 Step 1's own generation-aware
109    /// target.
110    RefPointerIndex,
111    /// `refs/containers/log-{a,b}.container` -- never compacted (DC-38/DC-69), but shares the ref
112    /// pointer index's write path (`publication.rs`) and the same unserialized-appender exposure.
113    RefLog,
114    /// `refs/containers/received-index-{a,b}.container` -- Stage 6 Step 1's own generation-aware
115    /// target, and the container whose investigation (`bundle.rs:270`'s `import_bundle`, no lock at
116    /// all) is what surfaced this whole ruling.
117    ReceivedIndex,
118    /// `trust/policy-{a,b}.container` -- Stage 6 Step 1's own generation-aware target. Already
119    /// incidentally protected today by `ActiveLock` (`trust.rs:88,144`); gains its own dedicated lock
120    /// here anyway, per the owner's decision that the lock is container-scoped, not repository-wide
121    /// (design-v1.md §15.7 decision 2) -- so a `prikk compact` run on this container never contends
122    /// with unrelated `ActiveLock` holders (a `commit`, a `seal`) that never touch it.
123    TrustPolicy,
124}
125
126impl LockableContainer {
127    /// Every variant, in the declared total order -- the one enumeration `prikk unlock` (and anything
128    /// else that needs to sweep every container lock) should use, rather than hand-rolling the list
129    /// and risking it drift from the enum's own declaration.
130    pub const ALL: [Self; 4] = [
131        Self::RefPointerIndex,
132        Self::RefLog,
133        Self::ReceivedIndex,
134        Self::TrustPolicy,
135    ];
136}
137
138/// Repository layout paths.
139#[derive(Debug, Clone)]
140pub struct RepositoryLayout {
141    root: PathBuf,
142    prikk_dir: PathBuf,
143    worktree_mutation: MutationRoot,
144    repository_mutation: MutationRoot,
145    format: RepositoryFormat,
146}
147
148impl PartialEq for RepositoryLayout {
149    fn eq(&self, other: &Self) -> bool {
150        self.root == other.root && self.prikk_dir == other.prikk_dir
151    }
152}
153
154impl Eq for RepositoryLayout {}
155
156impl RepositoryLayout {
157    /// Create a layout for a working tree root.
158    pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
159        let root = root.into();
160        let prikk_dir = root.join(REPO_DIR);
161        let worktree_mutation = MutationRoot::open(&root)?;
162        let repository_mutation = worktree_mutation.open_root(Path::new(REPO_DIR))?;
163        let format = read_repository_format(&repository_mutation)?;
164        Ok(Self {
165            root,
166            prikk_dir,
167            worktree_mutation,
168            repository_mutation,
169            format,
170        })
171    }
172
173    /// Initialize a repository layout on disk.
174    pub fn init(root: impl Into<PathBuf>) -> Result<Self> {
175        let root = root.into();
176        let prikk_dir = root.join(REPO_DIR);
177        let worktree_mutation = MutationRoot::open(&root)?;
178        let repository_mutation = worktree_mutation.ensure_root(Path::new(REPO_DIR))?;
179        if let Some(version) = read_file_if_exists(&repository_mutation, Path::new("FORMAT"))? {
180            if version != CURRENT_FORMAT_VERSION {
181                // RFC 102 Stage 3, design-v1.md §12.1: this refusal inherited the format-2 -> 3 bump;
182                // Stage 4 carried it forward for format-3 -> 4, Stage 5 did the same for format-4 ->
183                // 5, and Stage 6 does it again for format-5 -> 6 (design-v1.md §15.6). Audited, not
184                // just the constant swapped: unlike `read_repository_format`'s own rejection (reached
185                // via `open`), this fires only on a redundant `init` against an already-initialized
186                // repository of some other format, so it stays terse and points at `open` (any other
187                // command) for the detailed migration message rather than duplicating it here.
188                return Err(PrikkError::Integrity(
189                    "refusing to initialize an existing non-format-6 Prikk repository (open it \
190                     with any other command for a detailed unsupported-format message)"
191                        .to_string(),
192                ));
193            }
194        }
195        let layout = Self {
196            root,
197            prikk_dir,
198            worktree_mutation,
199            repository_mutation,
200            format: RepositoryFormat::CurrentV6,
201        };
202        for dir in layout.required_repository_directories()? {
203            ensure_directory_required(layout.repository_mutation_root(), &dir)?;
204        }
205        // RFC 102 Stage 1: created at `init`, never later -- a missing file and an idempotent
206        // re-`init` on an already-initialized repository must not clobber either.
207        create_empty_file_once(&layout, &layout.worktree_unclean_shutdown_marker_path())?;
208        create_empty_file_once(&layout, &layout.default_queue_wal_path())?;
209        // RFC 102 Stage 5, design-v1.md §14.6: the active-WAL ref-ownership metadata, on the same
210        // marker pattern as the worktree marker above -- created empty at `init`, set by truncate-then
211        // -append, cleared by truncate-to-empty, never removed. Previously created lazily on the
212        // empty-to-non-empty WAL transition (`active.rs::prepare_empty_active_ref_for_append`).
213        create_empty_file_once(&layout, &layout.default_active_ref_name_path())?;
214        // RFC 102 Stage 3, design-v1.md §2: every container name, both slots, plus the index and the
215        // (currently unused) compaction generation log -- all allocated here, at `init`, and nowhere
216        // else, for the life of the repository. This is the acceptance test itself (handoff §5
217        // criterion 1): enumerate every one of these paths and confirm none of them is ever created
218        // by any other code path.
219        for object_type in persisted_object_types() {
220            create_empty_file_once(
221                &layout,
222                &layout.container_slot_path(object_type, ContainerSlot::A),
223            )?;
224            create_empty_file_once(
225                &layout,
226                &layout.container_slot_path(object_type, ContainerSlot::B),
227            )?;
228        }
229        create_empty_file_once(&layout, &layout.container_index_path())?;
230        create_empty_file_once(&layout, &layout.container_generation_log_path())?;
231        // RFC 102 Stage 4, Step 0 §13.2/§13.4: the shared ref-log container's both slots, plus the
232        // separate ref-pointer-index container -- allocated here, at `init`, and nowhere else, the
233        // same acceptance-criterion-1 discipline Stage 3 established.
234        create_empty_file_once(
235            &layout,
236            &layout.ref_log_container_slot_path(ContainerSlot::A),
237        )?;
238        create_empty_file_once(
239            &layout,
240            &layout.ref_log_container_slot_path(ContainerSlot::B),
241        )?;
242        // RFC 102 Stage 6 Step 1, design-v1.md §15.6: the three genuine compaction targets each gain
243        // an A/B pair and their own generation log here, allocated at `init` like every other name --
244        // Step 1 itself never writes B or a generation record, so every one of these stays empty
245        // until Step 2's compactor exists (handoff §2 criterion 2, "no behaviour change").
246        create_empty_file_once(
247            &layout,
248            &layout.ref_pointer_index_slot_path(ContainerSlot::A),
249        )?;
250        create_empty_file_once(
251            &layout,
252            &layout.ref_pointer_index_slot_path(ContainerSlot::B),
253        )?;
254        create_empty_file_once(&layout, &layout.ref_pointer_index_generation_log_path())?;
255        create_empty_file_once(&layout, &layout.received_index_slot_path(ContainerSlot::A))?;
256        create_empty_file_once(&layout, &layout.received_index_slot_path(ContainerSlot::B))?;
257        create_empty_file_once(&layout, &layout.received_index_generation_log_path())?;
258        create_empty_file_once(&layout, &layout.trust_key_container_path())?;
259        // DC-53 Stage 1: allocated at `init` like every other container name, for new repositories.
260        // A repository initialized before this increment simply has no such file --
261        // `author_key_index.rs` reads that identically to an empty container, so no format bump or
262        // migration step is needed for existing repositories. True for writes too, not just reads:
263        // `record_author_key_material` creates the container lazily on first write if it is still
264        // absent (`author_key_index.rs::ensure_author_key_container_exists`), since
265        // `append_file_required` -- unlike the read path -- requires the target to already exist.
266        create_empty_file_once(&layout, &layout.author_key_container_path())?;
267        create_empty_file_once(
268            &layout,
269            &layout.trust_policy_container_slot_path(ContainerSlot::A),
270        )?;
271        create_empty_file_once(
272            &layout,
273            &layout.trust_policy_container_slot_path(ContainerSlot::B),
274        )?;
275        create_empty_file_once(&layout, &layout.trust_policy_generation_log_path())?;
276        // RFC 102 Stage 5, design-v1.md §14.2: written last, once every container/marker/WAL name
277        // above is confirmed present. `FORMAT`'s presence is what certifies `init` completed --
278        // written first (the old order), a crash between it and the containers left a repository
279        // that read as a valid, empty format-4 repository with every container absent, and nothing
280        // detected it (`status`/`verify`/`doctor` all exited 0 against a probe repository with all 16
281        // container files deleted). Written last, an interrupted `init` leaves `FORMAT` absent, so a
282        // re-`init` skips the mismatched-format guard above (it only fires when `FORMAT` already
283        // exists) and re-enters this same body -- every `create_empty_file_once` call above is
284        // idempotent, so the re-run completes whichever names are still missing and finishes by
285        // writing `FORMAT`, exactly the "detectable and completable" property the reordering exists
286        // to provide.
287        //
288        // RFC 102 Stage 5, design-v1.md §14.10: `create_new_file_required` (`create_exclusive`), not
289        // `write_file_atomically` (`atomic_replace`) -- the same primitive `create_empty_file_once`
290        // already uses for every other name above. For a name that does not yet exist,
291        // `atomic_replace`'s rename-into-place is a new-directory-entry event, exactly the class this
292        // RFC exists to eliminate; `create_exclusive` is one new-name event with no temp file and no
293        // rename. Confirmed, not assumed: `create_exclusive` (`anchored/linux.rs`) syncs both the file
294        // and the parent directory, the same durability `atomic_replace` provided. The `is_none()`
295        // guard above still governs whether this runs at all -- unchanged -- but the create call
296        // itself is now exclusive, so a genuine concurrent-`init` race now errors on `FORMAT` the same
297        // way it already does on every other name `create_empty_file_once` allocates, rather than
298        // FORMAT alone silently accepting whichever racer's rename landed last. Not a new failure
299        // mode: it is FORMAT joining the behavior every other name in this function already has.
300        if read_file_if_exists(layout.repository_mutation_root(), Path::new("FORMAT"))?.is_none() {
301            create_new_file_required(
302                layout.repository_mutation_root(),
303                Path::new("FORMAT"),
304                CURRENT_FORMAT_VERSION,
305            )?;
306        }
307        Ok(layout)
308    }
309
310    /// Open an existing repository layout.
311    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
312        Self::new(root)
313    }
314
315    /// Return the repository format selected when this layout was opened.
316    #[must_use]
317    pub const fn format(&self) -> RepositoryFormat {
318        self.format
319    }
320
321    pub(crate) fn validate_format(&self) -> Result<()> {
322        let format = read_repository_format(self.repository_mutation_root())?;
323        if format != self.format {
324            return Err(PrikkError::UnsupportedFormatVersion(0));
325        }
326        Ok(())
327    }
328
329    /// Refuse ordinary repository/worktree mutation in legacy format 1.
330    pub fn require_current_format(&self) -> Result<()> {
331        self.validate_format()?;
332        if self.format == RepositoryFormat::CurrentV6 {
333            return Ok(());
334        }
335        Err(PrikkError::UnsupportedFormatVersion(1))
336    }
337
338    /// Return the working tree root.
339    #[must_use]
340    pub fn root(&self) -> &Path {
341        &self.root
342    }
343
344    /// Return the `.prikk` directory.
345    #[must_use]
346    pub fn prikk_dir(&self) -> &Path {
347        &self.prikk_dir
348    }
349
350    /// Return the repository format marker path.
351    #[must_use]
352    pub fn format_path(&self) -> PathBuf {
353        self.prikk_dir.join("FORMAT")
354    }
355
356    /// Return the unclean-shutdown worktree marker path (RFC 102 Stage 1). Created empty at `init`;
357    /// non-empty means worktree materialization was interrupted and commit-authoring must refuse to
358    /// infer deletion from absence until the worktree is re-verified against its baseline. Always
359    /// updated by append/truncate (`fsutil::append_file_required`/`truncate_file_empty_required`),
360    /// never by `atomic_replace` -- RFC 102 §3's correction: `atomic_replace` renames over the
361    /// destination unconditionally, which is a new-name event whose Windows durability is DC-87
362    /// §3.4's still-open question, exactly the gap this marker exists to close.
363    #[must_use]
364    pub fn worktree_unclean_shutdown_marker_path(&self) -> PathBuf {
365        self.prikk_dir.join("worktree.marker")
366    }
367
368    /// Return the object root directory.
369    ///
370    /// Dead-surface consolidation: no longer in `required_directories()` (RFC-format-3 repositories
371    /// write containers, not loose object files, so a fresh repository never gets this tree). Kept
372    /// `pub(crate)`, not removed, because `verify/objects.rs`'s `scan_loose_file_temp_debris` still
373    /// reads it on every `verify` -- a dormant, format-3-only-relevant diagnostic (design-v1.md §12.3
374    /// item 3) whose own retirement was explicitly reserved for an RFC-level act, not a side effect of
375    /// this consolidation. `inspect_entry` tolerates the tree's absence, so a repository that never had
376    /// it (or an old one that did) both verify cleanly.
377    #[must_use]
378    pub(crate) fn objects_dir(&self) -> PathBuf {
379        self.prikk_dir.join("objects")
380    }
381
382    /// Return the active-session root directory.
383    #[must_use]
384    pub fn active_dir(&self) -> PathBuf {
385        self.prikk_dir.join("active")
386    }
387
388    /// Return the default active-session directory.
389    #[must_use]
390    pub fn default_active_dir(&self) -> PathBuf {
391        self.active_dir().join("default")
392    }
393
394    /// Return the default active WAL path.
395    #[must_use]
396    pub fn default_queue_wal_path(&self) -> PathBuf {
397        self.default_active_dir().join("queue.wal")
398    }
399
400    /// Return the default active lock path.
401    #[must_use]
402    pub fn default_active_lock_path(&self) -> PathBuf {
403        self.default_active_dir().join("active.lock")
404    }
405
406    /// Return the default active-session ref-name metadata path.
407    #[must_use]
408    pub fn default_active_ref_name_path(&self) -> PathBuf {
409        self.default_active_dir().join("ref-name")
410    }
411
412    /// Return the ref root directory.
413    #[must_use]
414    pub fn refs_dir(&self) -> PathBuf {
415        self.prikk_dir.join("refs")
416    }
417
418    /// Return the cache directory.
419    #[must_use]
420    pub fn cache_dir(&self) -> PathBuf {
421        self.prikk_dir.join("cache")
422    }
423
424    /// Return the container root directory (RFC 102 Stage 3, design-v1.md §2).
425    #[must_use]
426    pub fn containers_dir(&self) -> PathBuf {
427        self.prikk_dir.join("containers")
428    }
429
430    /// Return the container directory for a persisted object type.
431    #[must_use]
432    pub fn container_type_dir(&self, object_type: ObjectType) -> PathBuf {
433        self.containers_dir()
434            .join(object_type_directory_name(object_type))
435    }
436
437    /// Return one object type's container file for a given slot. **Every slot's name is allocated
438    /// at `init`, including B, even though Stage 3 only ever writes A** -- compaction (Stage 6, not
439    /// authorized) is what would ever target B; the RFC's §3.2 fixed-name-set requirement applies to
440    /// the whole RFC, not per stage, so the name exists now regardless of when it is first used.
441    #[must_use]
442    pub fn container_slot_path(&self, object_type: ObjectType, slot: ContainerSlot) -> PathBuf {
443        self.container_type_dir(object_type)
444            .join(format!("{}.container", slot.as_str()))
445    }
446
447    /// Return the object index's container path. Single file, no A/B slot -- design-v1.md §4 /
448    /// RFC 102's own §6.7 answer #2: the index's publication shape is plain append-only ("A/B" for an
449    /// index reduces to "append-only wearing an A/B costume, not a second option" once forced through
450    /// this codebase's real primitives), so unlike the six object-type containers it needs only one
451    /// name.
452    #[must_use]
453    pub fn container_index_path(&self) -> PathBuf {
454        self.containers_dir().join("index.container")
455    }
456
457    /// Return the small, fixed-name compaction generation log (design-v1.md §4: "compaction publishes
458    /// by appending a generation record to a small fixed-name log; readers take the last complete
459    /// generation record"). **Reserved, not used, by Stage 3** -- its name must still be allocated at
460    /// `init` because compaction (Stage 6) is not authorized to create any name later. Absent any
461    /// generation record (the only state Stage 3 ever produces), every container type's slot A is
462    /// live by construction -- there is nothing for an empty log to disambiguate yet.
463    #[must_use]
464    pub fn container_generation_log_path(&self) -> PathBuf {
465        self.containers_dir().join("generations.log")
466    }
467
468    /// Return the ref-container root directory (RFC 102 Stage 4). Kept under `refs/`, sibling to the
469    /// now-vestigial `by-id/`/`logs/`/`tmp/`/`locks/` directories, rather than under the object
470    /// `containers/` tree -- ref containers are not object containers and the two are never confused
471    /// for the same purpose.
472    #[must_use]
473    pub fn refs_containers_dir(&self) -> PathBuf {
474        self.refs_dir().join("containers")
475    }
476
477    /// Return the shared ref-log container file for a given slot (Step 0 §13.2: one container holds
478    /// every ref's log records, forced by acceptance criterion 1 -- ref names do not exist at `init`,
479    /// so a per-ref container is architecturally impossible). **Both slots allocated at `init`**,
480    /// matching Stage 3's own A/B convention exactly (`container_slot_path`'s own doc comment) --
481    /// Stage 4 only ever writes `A`.
482    #[must_use]
483    pub fn ref_log_container_slot_path(&self, slot: ContainerSlot) -> PathBuf {
484        self.refs_containers_dir()
485            .join(format!("log-{}.container", slot.as_str()))
486    }
487
488    /// Return the ref-pointer-index container path for a given slot (RFC 102 Stage 6 Step 1,
489    /// design-v1.md §15.6: this is one of the three genuine compaction targets -- `ref_pointer_index`
490    /// is last-entry-wins, and every ref update strands the previous entry, §15.1's own finding. `A`/`B`
491    /// slots mirror `container_slot_path`'s own naming shape). Reads and writes resolve which slot is
492    /// live through `generation.rs`'s resolver; Step 1 always resolves `A` because no generation record
493    /// has ever been written -- see `ref_pointer_index_generation_log_path`.
494    #[must_use]
495    pub fn ref_pointer_index_slot_path(&self, slot: ContainerSlot) -> PathBuf {
496        self.refs_containers_dir()
497            .join(format!("pointer-index-{}.container", slot.as_str()))
498    }
499
500    /// Return the ref-pointer-index generation log path (RFC 102 Stage 6 Step 1, design-v1.md §15.6
501    /// item 3/§4: readers take the last complete generation record; empty until Step 2's compactor
502    /// ever writes one, at which point `A` stops being the unconditional answer). Its own name, not
503    /// the pre-existing `container_generation_log_path()` -- that name was allocated for object-
504    /// container compaction, which §15.1 establishes will never happen under the current content-
505    /// addressed, no-GC data model, and a shared log across independently-compacting containers would
506    /// let one corrupt record take down slot resolution for all of them at once (§15.6's own
507    /// blast-radius reasoning).
508    #[must_use]
509    pub fn ref_pointer_index_generation_log_path(&self) -> PathBuf {
510        self.refs_containers_dir()
511            .join("pointer-index-generation.log")
512    }
513
514    /// Return the received-ref-index container path for a given slot (RFC 102 Stage 6 Step 1,
515    /// design-v1.md §15.6: the second of the three genuine compaction targets, same last-entry-wins
516    /// shape as the ref pointer index). Formerly `received_index_path`, single-name -- RFC 102 Stage 5,
517    /// design-v1.md §14.1/Step 0 item 2's own reasoning for why `received.rs` belongs on the refs
518    /// container+pointer-index pattern is unaffected by gaining a slot; only the publication shape
519    /// (single-name vs. resolver-selected) changed.
520    #[must_use]
521    pub fn received_index_slot_path(&self, slot: ContainerSlot) -> PathBuf {
522        self.refs_containers_dir()
523            .join(format!("received-index-{}.container", slot.as_str()))
524    }
525
526    /// Return the received-ref-index generation log path. Its own name, for the same blast-radius
527    /// reason `ref_pointer_index_generation_log_path` has its own.
528    #[must_use]
529    pub fn received_index_generation_log_path(&self) -> PathBuf {
530        self.refs_containers_dir()
531            .join("received-index-generation.log")
532    }
533
534    /// Return all required directories for layout creation.
535    ///
536    /// Dead-surface consolidation: `objects/` and its six type subdirectories, `refs/by-id/`,
537    /// `refs/logs/`, and `quarantine/` are no longer created here -- nothing in a format-3 repository
538    /// writes into any of them (containers replaced loose objects and per-ref files years ago). This is
539    /// not a format change: `required_directories()` is consulted only here, at `init`; nothing
540    /// validates it at `open`, so an existing repository keeps its now-unused directories harmlessly,
541    /// and only a newly initialized one has fewer. `refs/tmp/` stays -- `refs/verify.rs`'s
542    /// `candidate_issues` still scans it on every `verify`.
543    #[must_use]
544    pub fn required_directories(&self) -> Vec<PathBuf> {
545        let mut dirs = Vec::new();
546        dirs.push(self.containers_dir());
547        for object_type in persisted_object_types() {
548            dirs.push(self.container_type_dir(object_type));
549        }
550        dirs.push(self.active_dir());
551        dirs.push(self.default_active_dir());
552        dirs.push(self.refs_dir());
553        dirs.push(self.refs_dir().join("locks"));
554        dirs.push(self.refs_dir().join("tmp"));
555        dirs.push(self.refs_containers_dir());
556        dirs.push(self.trust_dir());
557        dirs.push(self.cache_dir());
558        dirs
559    }
560
561    pub(crate) fn repository_mutation_root(&self) -> &MutationRoot {
562        &self.repository_mutation
563    }
564
565    pub(crate) fn worktree_mutation_root(&self) -> &MutationRoot {
566        &self.worktree_mutation
567    }
568
569    pub(crate) fn repository_relative(&self, path: &Path) -> Result<PathBuf> {
570        path.strip_prefix(&self.prikk_dir)
571            .map(Path::to_path_buf)
572            .map_err(|_| {
573                PrikkError::Io("path is outside repository mutation authority".to_string())
574            })
575    }
576
577    fn required_repository_directories(&self) -> Result<Vec<PathBuf>> {
578        self.required_directories()
579            .into_iter()
580            .map(|path| self.repository_relative(&path))
581            .collect()
582    }
583
584    /// Return the object directory for a persisted object type.
585    ///
586    /// `pub(crate)`, not public: its only reader is `verify/objects.rs`'s
587    /// `scan_loose_file_temp_debris`, a dormant format-3-only diagnostic (see `objects_dir`'s own doc).
588    #[must_use]
589    pub(crate) fn object_type_dir(&self, object_type: ObjectType) -> PathBuf {
590        self.objects_dir()
591            .join(object_type_directory_name(object_type))
592    }
593
594    /// Return the storage path for a persisted object ID and type.
595    ///
596    /// No production caller by design, not by omission: this addresses the pre-container, format-1/2
597    /// loose-object layout, which nothing in a live format-3 repository writes or reads. Kept `pub`
598    /// specifically so `prikk-cli`'s format-transition fixtures (`tests/format_transition_support/`, a
599    /// different crate) can construct legacy-shaped repositories without duplicating the private
600    /// hex-prefixing scheme (`hex_prefix`, `layout.rs`) this method wraps.
601    #[must_use]
602    pub fn object_path(&self, object_type: ObjectType, id: ObjectId) -> PathBuf {
603        let hex = id.to_hex();
604        let prefix = hex_prefix(&hex);
605        self.object_type_dir(object_type)
606            .join(prefix)
607            .join(format!("{hex}.pobj"))
608    }
609
610    /// Return the flat ref pointer path for a human-readable ref name.
611    ///
612    /// No production caller by design: this addresses the pre-container, format-1/2 flat-pointer-file
613    /// layout containers replaced. Kept `pub` so `prikk-cli`'s format-transition fixtures (a different
614    /// crate) can construct legacy-shaped repositories without reimplementing the private
615    /// `ref_name_storage_key` hash this method wraps -- that helper is `pub(crate)`, unreachable from
616    /// outside this crate.
617    #[must_use]
618    pub fn ref_pointer_path(&self, ref_name: &str) -> PathBuf {
619        self.refs_dir()
620            .join("by-id")
621            .join(format!("{}.ref", ref_name_storage_key(ref_name)))
622    }
623
624    /// Return the ref log path for a human-readable ref name.
625    ///
626    /// No production caller by design, for the same reason as `ref_pointer_path`: a legacy-format path
627    /// builder kept `pub` for `prikk-cli`'s cross-crate format-transition fixtures.
628    #[must_use]
629    pub fn ref_log_path(&self, ref_name: &str) -> PathBuf {
630        self.refs_dir()
631            .join("logs")
632            .join(format!("{}.log", ref_name_storage_key(ref_name)))
633    }
634
635    /// Return the ref lock path for a human-readable ref name.
636    #[must_use]
637    pub fn ref_lock_path(&self, ref_name: &str) -> PathBuf {
638        self.refs_dir()
639            .join("locks")
640            .join(format!("{}.lock", ref_name_storage_key(ref_name)))
641    }
642
643    /// Return the ref temporary candidate path for a human-readable ref name.
644    ///
645    /// No production writer since Stage 4 removed the candidate-write-then-promote mechanism, but this
646    /// is not dead the way `ref_pointer_path`/`ref_log_path` are: `refs/tmp/` itself stays in
647    /// `required_directories()` because `refs/verify.rs`'s `candidate_issues` scans it on every
648    /// `verify`, and this accessor is what several of that scan's own regression tests
649    /// (`refs/tests/publication_recovery/candidate_cleanup.rs`) use to construct debris inside it.
650    #[must_use]
651    pub fn ref_tmp_path(&self, ref_name: &str) -> PathBuf {
652        self.refs_dir()
653            .join("tmp")
654            .join(format!("{}.tmp", ref_name_storage_key(ref_name)))
655    }
656
657    /// Return the publication trust-store directory.
658    #[must_use]
659    pub fn trust_dir(&self) -> PathBuf {
660        self.prikk_dir.join("trust")
661    }
662
663    /// Return the trust key-material container path (RFC 102 Stage 5, design-v1.md §14/§14.9).
664    /// Replaces the one-file-per-key-id `trust/keys/maintainer/*.pub` directory entirely -- format 5
665    /// rejects every repository old enough to have one, so no repository this code can open ever
666    /// contains that directory's contents (§14.9 §3's own reasoning, applied here as it was to
667    /// `refs/received/`, not Stage 4's "keep, dead" precedent).
668    #[must_use]
669    pub fn trust_key_container_path(&self) -> PathBuf {
670        self.trust_dir().join("keys.container")
671    }
672
673    /// Return the AUTHOR key-material container path (DC-53 Stage 1,
674    /// `.git-exclude/reviewed/DC-53-stage-1-report-ruling-v1.md` §5). Its own container, not a
675    /// third role folded into `trust_key_container_path` -- that one is MAINTAINER key material
676    /// with a policy layered over it; this one is material only, populated by the authoring path
677    /// rather than an adoption command (`author_key_index.rs`'s own module doc). A repository
678    /// initialized before this container existed has no such file, which `author_key_index.rs`
679    /// reads identically to an empty one, not a structural defect.
680    #[must_use]
681    pub fn author_key_container_path(&self) -> PathBuf {
682        self.trust_dir().join("author-keys.container")
683    }
684
685    /// Return the trust policy container path for a given slot (RFC 102 Stage 5, design-v1.md
686    /// §14/§14.9, gaining a slot in Stage 6 Step 1, design-v1.md §15.6 -- the third of the three
687    /// genuine compaction targets: one complete snapshot appended per `add`/`remove`, every earlier
688    /// snapshot dead, §15.1's own finding). Each append is a **complete snapshot** of the adopted key
689    /// id list, not an incremental log entry -- see `trust_index.rs`'s own module doc for why that is
690    /// what makes revocation representable without a tombstone record; that property is unaffected by
691    /// gaining a slot.
692    #[must_use]
693    pub fn trust_policy_container_slot_path(&self, slot: ContainerSlot) -> PathBuf {
694        self.trust_dir()
695            .join(format!("policy-{}.container", slot.as_str()))
696    }
697
698    /// Return the trust-policy generation log path. Its own name, for the same blast-radius reason
699    /// `ref_pointer_index_generation_log_path` has its own -- and distinct from `trust_key_container_
700    /// path`, which is **not** one of the three compacting containers and gains no slot: TOFU history
701    /// must persist across removal (`trust.rs:77`), which compacting the key container would break.
702    #[must_use]
703    pub fn trust_policy_generation_log_path(&self) -> PathBuf {
704        self.trust_dir().join("policy-generation.log")
705    }
706
707    /// Return the lock file path for one of Stage 6 Step 2's four `LockableContainer`s
708    /// (design-v1.md §15.8). Ephemeral, like every other lock file in this codebase
709    /// (`ActiveLock`/`RefLock`): created on acquire, removed on release, never pre-allocated at
710    /// `init` -- criterion 2's "every name created at `init`" obligation is about durability-bearing
711    /// container names, not transient mutual-exclusion markers, and `ActiveLock`/`RefLock` already
712    /// establish that a lock file is exempt from it.
713    #[must_use]
714    pub fn lockable_container_lock_path(&self, container: LockableContainer) -> PathBuf {
715        match container {
716            LockableContainer::RefPointerIndex => {
717                self.refs_containers_dir().join("pointer-index.lock")
718            }
719            LockableContainer::RefLog => self.refs_containers_dir().join("log.lock"),
720            LockableContainer::ReceivedIndex => {
721                self.refs_containers_dir().join("received-index.lock")
722            }
723            LockableContainer::TrustPolicy => self.trust_dir().join("policy.lock"),
724        }
725    }
726}
727
728/// Validate a maintainer key id's storage safety: ASCII alphanumeric/`-`/`_` only, and not a
729/// Windows-reserved device stem. Split out from the retired `maintainer_trust_key_path` (which paired
730/// this check with building a per-key-id file path that no longer exists under the container model) --
731/// the validation itself is unchanged and still required before a key id is accepted.
732pub(crate) fn validate_maintainer_key_id_storage_safety(key_id: &str) -> Result<()> {
733    if key_id.is_empty()
734        || !key_id
735            .bytes()
736            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
737    {
738        return Err(PrikkError::InvalidName(
739            "maintainer key id is not storage-safe".to_string(),
740        ));
741    }
742    // DC-72: the allowlist above is character-shape only and does not exclude Windows-reserved
743    // device stems (`CON`, `PRN`, ...) — `CON` is all ASCII-alphanumeric and would otherwise
744    // pass. Checked regardless of host OS, matching `RepoPath`'s equivalent rule.
745    if is_windows_reserved_name(key_id) {
746        return Err(PrikkError::InvalidName(format!(
747            "maintainer key id is a Windows reserved device name: {key_id}"
748        )));
749    }
750    Ok(())
751}
752
753/// Create `path` empty if it does not already exist. Idempotent, so a retried or re-run `init`
754/// against an already-initialized repository never clobbers it -- the same rule RFC 102 Stage 1
755/// established for the worktree marker and the active WAL, now shared by every `init`-time file this
756/// layout creates.
757fn create_empty_file_once(layout: &RepositoryLayout, path: &Path) -> Result<()> {
758    let relative = layout.repository_relative(path)?;
759    if read_file_if_exists(layout.repository_mutation_root(), &relative)?.is_none() {
760        create_new_file_required(layout.repository_mutation_root(), &relative, &[])?;
761    }
762    Ok(())
763}
764
765#[cfg(test)]
766mod tests;
767
768fn read_repository_format(root: &MutationRoot) -> Result<RepositoryFormat> {
769    let version = read_file_required(root, Path::new("FORMAT"))?;
770    match version.as_slice() {
771        // RFC 114 §5.3 (owner decision): formats 1-5 are not supported. A real released version can
772        // still read this one -- 0.19.0 and earlier, confirmed against the release record -- so the
773        // message must not claim otherwise; it states withdrawal, not absence.
774        LEGACY_FORMAT_VERSION => Err(PrikkError::Integrity(
775            "this repository uses format 1, which prikk no longer supports (this version \
776             requires format 6). format-1 support was removed after 0.19.0; migration from \
777             format 1 is not supported."
778                .to_string(),
779        )),
780        // RFC 102 Stage 3, design-v1.md §12.1 (owner decision): bump to format 3, reject format-2 at
781        // open, no dual-layout bridge. Format 2 was current through the 0.19.0 release (the same
782        // release format-1's own rejection message above already points at), so it is the last
783        // release able to open a format-2 repository -- established from the release record
784        // (`CHANGELOG.md`, `git tag`), not guessed.
785        // RFC 114 §5.3: same as format 1's arm -- 0.19.0 genuinely reads format 2 (confirmed against
786        // the release record), so this states withdrawal, not absence of a reader.
787        LEGACY_FORMAT_2_VERSION => Err(PrikkError::Integrity(
788            "this repository uses format 2, which prikk no longer supports (this version \
789             requires format 6). format-2 support was removed after 0.19.0; migration from \
790             format 2 is not supported."
791                .to_string(),
792        )),
793        // RFC 102 Stage 4: bump to format 4, reject format-3 at open, no dual-layout bridge --
794        // applying Stage 3's own already-ruled policy (design-v1.md §12.1), not a fresh decision (see
795        // `RepositoryFormat`'s own doc comment). Unlike format 2, format 3 was never itself the
796        // subject of a tagged release (Stage 3 was still pending three-platform CI when Stage 4
797        // began) -- no specific "removed after X.Y.Z" version is named here, since none can be
798        // verified from the release record yet; naming one would be guessing, which this project's
799        // own discipline for these messages does not do.
800        // RFC 114 §5.3: unlike formats 1-2, no released version ever shipped format 3 -- the message
801        // says so plainly rather than offering a step the product cannot honour.
802        LEGACY_FORMAT_3_VERSION => Err(PrikkError::Integrity(
803            "this repository uses format 3, which prikk no longer supports (this version \
804             requires format 6). format 3 was never used in a released prikk version; there is \
805             no supported migration path."
806                .to_string(),
807        )),
808        // RFC 102 Stage 5, design-v1.md §14.7 (owner decision): bump to format 5, reject format-4 at
809        // open, no dual-layout bridge -- format 3's precedent, not format 2's. No release was ever
810        // tagged at format 4 either (Stage 4 merged and Stage 5 began before any tag), verified against
811        // the release record (`CHANGELOG.md`, `git tag`) rather than assumed from format 3's own
812        // no-version precedent -- naming one anyway would be guessing, which this project's own
813        // discipline for these messages does not do.
814        // RFC 114 §5.3: same as format 3's arm -- format 4 never shipped in a released version either.
815        LEGACY_FORMAT_4_VERSION => Err(PrikkError::Integrity(
816            "this repository uses format 4, which prikk no longer supports (this version \
817             requires format 6). format 4 was never used in a released prikk version; there is \
818             no supported migration path."
819                .to_string(),
820        )),
821        // RFC 102 Stage 6, design-v1.md §15.6 (owner decision): bump to format 6, reject format-5 at
822        // open, no dual-layout bridge -- the same precedent again. No release was ever tagged at
823        // format 5 either (still 0.19.0, format 2, verified against `git tag` fresh rather than
824        // assumed from the format-4 arm's own finding), so no version is named here for the same
825        // reason.
826        // RFC 114 §5.3: same as formats 3-4's arms -- format 5 never shipped in a released version.
827        LEGACY_FORMAT_5_VERSION => Err(PrikkError::Integrity(
828            "this repository uses format 5, which prikk no longer supports (this version \
829             requires format 6). format 5 was never used in a released prikk version; there is \
830             no supported migration path."
831                .to_string(),
832        )),
833        CURRENT_FORMAT_VERSION => Ok(RepositoryFormat::CurrentV6),
834        _ => Err(PrikkError::UnsupportedFormatVersion(0)),
835    }
836}
837
838/// Return persisted object types. RefUpdate is log-inline in v1 and is intentionally absent.
839#[must_use]
840pub fn persisted_object_types() -> [ObjectType; 7] {
841    [
842        ObjectType::Patch,
843        ObjectType::Block,
844        ObjectType::RefState,
845        ObjectType::Tag,
846        ObjectType::Attestation,
847        ObjectType::Blob,
848        ObjectType::RecognitionClaim,
849    ]
850}
851
852/// Return a stable directory name for an object type.
853#[must_use]
854pub fn object_type_directory_name(object_type: ObjectType) -> &'static str {
855    match object_type {
856        ObjectType::Patch => "patch",
857        ObjectType::Block => "block",
858        ObjectType::RefState => "ref-state",
859        ObjectType::Tag => "tag",
860        ObjectType::Attestation => "attestation",
861        ObjectType::Blob => "blob",
862        ObjectType::RecognitionClaim => "recognition-claim",
863        ObjectType::RefUpdate => "ref-update-inline-only",
864        // New FDD-03 §3 types. Full storage-layout placement (`objects/genesis/`,
865        // `cache/block-summary/`, `refs/recovery/`) is reconciled in the FDD-02
866        // layout phase; these names keep the mapper exhaustive without creating
867        // directories yet.
868        ObjectType::BlockSummaryCache => "block-summary-cache-rebuildable",
869        ObjectType::RecoveryNote => "recovery-note-inline-only",
870        ObjectType::ProjectGenesis => "genesis",
871    }
872}
873
874fn hex_prefix(hex: &str) -> String {
875    hex.chars().take(2).collect()
876}
877
878pub(crate) fn ref_name_storage_key(ref_name: &str) -> String {
879    to_hex(&ref_name_key_bytes(ref_name))
880}
881
882/// The raw 32-byte form of [`ref_name_storage_key`] (RFC 102 Stage 4, Step 0 §13.4 / design-v1.md
883/// §13.4's ruling): a fixed-width key already used to name every ref pointer/log file today, reused
884/// as the ref-pointer-index's own key rather than inventing a second one -- the "new key shape"
885/// objection Step 0 raised dissolved specifically because this already existed.
886#[must_use]
887pub(crate) fn ref_name_key_bytes(ref_name: &str) -> [u8; 32] {
888    sha256(ref_name.as_bytes())
889}