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}