Skip to main content

prikk_store/
refs.rs

1//! Ref-state pointer and ref-log publication primitives.
2//!
3//! PR-007 introduced the storage mechanics needed before a full seal command exists: a RefState is
4//! stored as a normal content-addressed object, the ref file is a durable pointer to that object,
5//! and RefUpdate entries are stored inline in an append-only log. The module does not yet perform
6//! publication-policy evaluation or patch/block sealing.
7
8mod container;
9mod evidence;
10mod pointer_index;
11mod publication;
12mod verify;
13
14// `append_ref_container_record`'s own sole consumer via this re-export is `refs::tests`
15// (DC-71-gated to `target_os = "linux"`, real repository mutation) -- gated to match it exactly,
16// not the broader `#[cfg(test)]` the other two names here still need for their own cross-platform
17// consumers in `verify::tests::ref_cluster`. A cross-target clippy run caught this as unused on
18// Windows before it shipped; see `EXECUTION-ORDER.md` §6 rule 9's own cross-target amendment.
19#[cfg(all(test, target_os = "linux"))]
20pub(crate) use container::append_ref_container_record;
21#[cfg(test)]
22pub(crate) use container::{
23    append_torn_ref_log_tail_for_test, encode_ref_container_record_for_test,
24};
25#[cfg(feature = "test-support")]
26pub use pointer_index::{
27    force_ref_pointer_to_arbitrary_state_for_test_support,
28    remove_ref_pointer_entry_for_test_support,
29};
30#[cfg(test)]
31pub(crate) use pointer_index::{
32    remove_pointer_entries_for_test,
33    write_ref_pointer_candidate_for_test as write_ref_pointer_candidate,
34    write_ref_pointer_entry_with_explicit_key_for_test,
35};
36// RFC 102 Stage 6 Step 2, design-v1.md §15.6-§15.9: `compact.rs`'s ref-pointer-index compactor is
37// outside `refs`, so these need re-exporting here the same way `verify_refs` already is below --
38// `pointer_index` itself stays a private submodule; only the specific items a caller outside `refs`
39// needs are widened.
40pub(crate) use pointer_index::{
41    PointerIndexEntry, encode_pointer_index_record, replay_pointer_index,
42};
43
44use prikk_error::{PrikkError, Result};
45use prikk_object::{
46    ObjectEnvelope, ObjectId, ObjectType, RefKind, RefStatePayload, RefUpdatePayload, TagPayload,
47};
48
49use crate::layout::RepositoryLayout;
50use crate::lock::ActiveLock;
51use crate::object_store::{FileObjectStore, ObjectReader, ObjectWriter};
52
53/// Test-only convenience matching the retired `refs/log.rs::append_log_record`'s own 3-argument
54/// call shape exactly, for fixtures that need to plant a specific log record directly without going
55/// through a real publish. Computes `ref_name_key` itself.
56#[cfg(test)]
57pub(crate) fn append_log_record_for_signature_test(
58    layout: &RepositoryLayout,
59    ref_name: &str,
60    envelope: &ObjectEnvelope,
61) -> Result<()> {
62    container::append_ref_container_record(
63        layout,
64        crate::layout::ref_name_key_bytes(ref_name),
65        envelope,
66    )
67}
68
69/// Test-only convenience matching the retired `refs/log.rs::encode_log_record_for_test`'s own
70/// single-argument call shape: derives `ref_name_key` from the envelope's own decoded
71/// `RefUpdatePayload.ref_name` rather than taking it as a separate parameter, since every caller
72/// already has an envelope whose payload names its own ref.
73#[cfg(test)]
74pub(crate) fn encode_log_record_for_test(envelope: &ObjectEnvelope) -> Result<Vec<u8>> {
75    let update = RefUpdatePayload::decode_canonical(&envelope.canonical_payload)?;
76    container::encode_ref_container_record_for_test(
77        crate::layout::ref_name_key_bytes(&update.ref_name),
78        envelope,
79    )
80}
81
82pub use container::{RefLogRecord, RefLogReplay};
83pub use verify::{
84    RefFileOutcome, RefFileStatus, RefItemOutcome, RefItemStatus, RefPublicationIssue,
85};
86pub(crate) use verify::{ensure_ref_target_valid, verify_refs};
87
88/// The two-hop ref-tip resolution `bundle.rs`, `patch_set_digest.rs`, and `patch_exchange.rs` each
89/// need: `Branch` names a Block directly; `Tag` names a Tag object one hop away, whose own
90/// `target_block_id` is the actual tip. Consolidated here (ref-tip-resolver-consolidation handoff)
91/// after the same "just a two-hop resolve" analysis had been re-derived three times across separate
92/// handoffs. Returns the Tag envelope too, whenever one was read -- every caller already decodes it
93/// to reach `target_block_id`, so returning it costs nothing, and `bundle.rs`'s own accumulator (which
94/// needs the envelope itself) can use it instead of re-reading.
95///
96/// **Not `ensure_ref_target_valid`'s replacement -- see that function's own doc for why they stay
97/// separate.** This resolves; it never validates (a `Branch`'s target block existence is never
98/// checked here, unlike that function's own `ensure_block_exists`), and it carries no `owner` id for
99/// a diagnostic-quality error, because a resolver is never given one to carry.
100///
101/// **Exhaustive match, no wildcard**: a future `RefKind` variant must fail to compile here rather
102/// than silently resolve to nothing and surface as a misleading "missing Block" error the way
103/// `export_bundle`'s own pre-consolidation defect did (`bundle-export-tag-ref-gap-v1.md`).
104pub(crate) fn resolve_ref_tip_block(
105    object_store: &impl ObjectReader,
106    ref_state_payload: &RefStatePayload,
107) -> Result<(ObjectId, Option<ObjectEnvelope>)> {
108    match ref_state_payload.kind {
109        RefKind::Branch => Ok((ref_state_payload.target_object_id, None)),
110        RefKind::Tag => {
111            let tag_id = ref_state_payload.target_object_id;
112            let tag_envelope = object_store
113                .read_typed(tag_id, ObjectType::Tag)?
114                .ok_or_else(|| PrikkError::Integrity(format!("missing Tag object: {tag_id}")))?;
115            let tag_payload = TagPayload::decode_canonical(&tag_envelope.canonical_payload)?;
116            Ok((tag_payload.target_block_id, Some(tag_envelope)))
117        }
118    }
119}
120
121pub(crate) fn ensure_no_incomplete_publication(layout: &RepositoryLayout) -> Result<()> {
122    let verification = verify_refs(layout)?;
123    // DC-95 Stage 2 Level 2: item containment means `verify_refs` now returns `Ok` for a single
124    // ref's own read/classification failure instead of aborting -- this gate must check for that
125    // directly (`has_item_failure`), the same reason `RepositoryVerification::has_stage_failure`
126    // alone stopped being sufficient once `verify_objects` gained the same containment.
127    if verification.publication_issues.is_empty()
128        && !verification.has_item_failure()
129        && !evidence::has_incomplete_active_cleanup(layout)?
130    {
131        return Ok(());
132    }
133    Err(PrikkError::LockConflict(
134        "repository mutation is blocked by incomplete ref publication; run verify/doctor and use signer-backed seal retry"
135            .to_string(),
136    ))
137}
138
139/// Diagnostic ref candidate derived from an append-only format-1 ref log.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RefRecoveryCandidate {
142    /// Human-readable ref name.
143    pub ref_name: String,
144    /// RefState ID selected by the latest valid ref-log record.
145    pub ref_state_id: ObjectId,
146    /// Target Block ID selected by the RefState.
147    pub target_object_id: ObjectId,
148    /// Update sequence of the latest ref-log record.
149    pub update_seq: u64,
150}
151
152/// One enumerated ref pointer, for deterministic listing.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct RefPointerSummary {
155    /// Human-readable ref name recovered from the pointer file body.
156    pub ref_name: String,
157    /// Current RefState object ID selected by this pointer.
158    pub ref_state_id: ObjectId,
159}
160
161/// Inputs for a single ref publication primitive.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct RefPublication {
164    /// Human-readable ref name, such as `heads/main`.
165    pub ref_name: String,
166    /// Expected current RefState ID for CAS. Use `None` to create a new ref.
167    pub expected_previous_ref_state_id: Option<ObjectId>,
168    /// Signed RefState object envelope to persist before publishing the pointer.
169    pub ref_state: ObjectEnvelope,
170    /// Signed RefUpdate envelope to append after the ref pointer is durable.
171    pub ref_update: ObjectEnvelope,
172}
173
174/// File-backed ref-state and ref-log store.
175#[derive(Debug, Clone)]
176pub struct RefStore {
177    layout: RepositoryLayout,
178}
179
180impl RefStore {
181    /// Create a ref store for a repository layout.
182    #[must_use]
183    pub fn new(layout: RepositoryLayout) -> Self {
184        Self { layout }
185    }
186
187    /// Return the repository layout.
188    #[must_use]
189    pub fn layout(&self) -> &RepositoryLayout {
190        &self.layout
191    }
192
193    /// Publish a signed RefState with ref-specific locking and CAS. Writes through a freshly
194    /// decoded `FileObjectStore` -- the safe default for any caller not holding its own session. See
195    /// [`Self::publish_with_object_store`] for a caller that already has one (RFC 111 §6.1 Stage 2).
196    pub fn publish(&self, publication: &RefPublication) -> Result<ObjectId> {
197        self.publish_with_object_store(&mut FileObjectStore::new(self.layout.clone()), publication)
198    }
199
200    /// Same as [`Self::publish`], but writes the RefState through the caller's own object store
201    /// instead of constructing a fresh one -- required for any caller holding an `ObjectWriteSession`
202    /// (RFC 111 §6.1 Stage 2 addendum, C1: this is the nested-writer site that must be threaded at or
203    /// before the first writer migration, not after).
204    pub fn publish_with_object_store(
205        &self,
206        object_store: &mut impl ObjectWriter,
207        publication: &RefPublication,
208    ) -> Result<ObjectId> {
209        self.layout.require_current_format()?;
210        crate::format::validate_object_envelope(self.layout.format(), &publication.ref_state)?;
211        crate::format::validate_object_envelope(self.layout.format(), &publication.ref_update)?;
212        publication::publish(self, object_store, publication)
213    }
214
215    /// Finish an exact signer-backed interrupted publication, including a framing-incomplete tail.
216    /// Writes through a freshly decoded `FileObjectStore` -- see
217    /// [`Self::finish_interrupted_publication_with_object_store`] for a caller that already has one.
218    pub fn finish_interrupted_publication(
219        &self,
220        active_lock: &ActiveLock,
221        publication: &RefPublication,
222    ) -> Result<ObjectId> {
223        self.finish_interrupted_publication_with_object_store(
224            &mut FileObjectStore::new(self.layout.clone()),
225            active_lock,
226            publication,
227        )
228    }
229
230    /// Same as [`Self::finish_interrupted_publication`], but writes the RefState through the
231    /// caller's own object store instead of constructing a fresh one (RFC 111 §6.1 Stage 2 addendum,
232    /// C1).
233    pub fn finish_interrupted_publication_with_object_store(
234        &self,
235        object_store: &mut impl ObjectWriter,
236        active_lock: &ActiveLock,
237        publication: &RefPublication,
238    ) -> Result<ObjectId> {
239        self.layout.validate_format()?;
240        active_lock.require_layout(&self.layout)?;
241        crate::format::validate_read_schema(self.layout.format(), &publication.ref_state)?;
242        crate::format::validate_read_schema(self.layout.format(), &publication.ref_update)?;
243        evidence::validate_signer_backed_recovery(&self.layout, publication)?;
244        publication::finish_interrupted(self, object_store, publication)
245    }
246
247    #[cfg(all(test, target_os = "linux"))]
248    pub(crate) fn finish_interrupted_publication_for_test(
249        &self,
250        publication: &RefPublication,
251    ) -> Result<ObjectId> {
252        publication::finish_interrupted(
253            self,
254            &mut FileObjectStore::new(self.layout.clone()),
255            publication,
256        )
257    }
258
259    /// Read the current RefState object ID for a ref name.
260    pub fn read_current_ref_state_id(&self, ref_name: &str) -> Result<Option<ObjectId>> {
261        let key = crate::layout::ref_name_key_bytes(ref_name);
262        let Some(entry) = pointer_index::lookup_ref_pointer(&self.layout, key)? else {
263            return Ok(None);
264        };
265        if entry.ref_name != ref_name {
266            return Err(PrikkError::Integrity(format!(
267                "ref pointer name mismatch: expected {ref_name}, got {}",
268                entry.ref_name
269            )));
270        }
271        Ok(Some(entry.ref_state_id))
272    }
273
274    /// Replay the inline ref-update log for a ref name.
275    pub fn replay_log(&self, ref_name: &str) -> Result<RefLogReplay> {
276        let key = crate::layout::ref_name_key_bytes(ref_name);
277        container::replay_ref_subsequence(&self.layout, key)
278    }
279
280    /// Enumerate every published ref pointer, sorted by name. Reads the ref-pointer index's own
281    /// last-entry-per-`ref_name_key` view (RFC 102 Stage 4) -- the container-era equivalent of the
282    /// old `by-id/` directory listing, which named the complete set of ref pointers directly; the
283    /// index now does.
284    pub fn list_ref_pointers(&self) -> Result<Vec<RefPointerSummary>> {
285        let replay = pointer_index::replay_pointer_index(&self.layout)?;
286        if replay.has_item_failure() {
287            return Err(PrikkError::Integrity(
288                "ref pointer index has a damaged entry; run doctor before listing".to_string(),
289            ));
290        }
291        let mut latest: std::collections::BTreeMap<[u8; 32], pointer_index::PointerIndexEntry> =
292            std::collections::BTreeMap::new();
293        for entry in replay.entries {
294            latest.insert(entry.ref_name_key, entry);
295        }
296        let mut summaries: Vec<RefPointerSummary> = latest
297            .into_values()
298            .map(|entry| RefPointerSummary {
299                ref_name: entry.ref_name,
300                ref_state_id: entry.ref_state_id,
301            })
302            .collect();
303        summaries.sort_by(|left, right| left.ref_name.cmp(&right.ref_name));
304        Ok(summaries)
305    }
306
307    /// Return a diagnostic candidate when the pointer is missing but the format-1 log is valid.
308    pub fn recoverable_missing_ref(&self, ref_name: &str) -> Result<Option<RefRecoveryCandidate>> {
309        if self.read_current_ref_state_id(ref_name)?.is_some() {
310            return Ok(None);
311        }
312        let replay = self.replay_log(ref_name)?;
313        // RFC 102 Stage 2: checked before the emptiness check below -- a log whose only record is
314        // damaged would otherwise read as `replay.records.is_empty()`, and this function's whole
315        // purpose is detecting exactly this kind of condition, not passing over it.
316        if replay.has_item_failure() {
317            return Err(PrikkError::Integrity(format!(
318                "ref log for {ref_name} has a damaged record"
319            )));
320        }
321        if replay.records.is_empty() {
322            return Ok(None);
323        }
324        if replay.trailing_partial_bytes != 0 {
325            return Err(PrikkError::Integrity(format!(
326                "ref log for {ref_name} has trailing partial bytes"
327            )));
328        }
329        let object_store = FileObjectStore::new(self.layout.clone());
330        let mut previous_ref_state_id = None;
331        let mut latest = None;
332        for record in &replay.records {
333            let update = RefUpdatePayload::decode_canonical(&record.envelope.canonical_payload)?;
334            if update.ref_name != ref_name {
335                return Err(PrikkError::Integrity(format!(
336                    "ref-log record name mismatch: expected {ref_name}, got {}",
337                    update.ref_name
338                )));
339            }
340            if update.old_ref_state_id != previous_ref_state_id {
341                return Err(PrikkError::Integrity(format!(
342                    "ref-log chain mismatch for {ref_name} at update {}",
343                    update.update_seq
344                )));
345            }
346            let ref_state = verified_ref_state_payload(
347                &object_store,
348                update.new_ref_state_id,
349                ref_name,
350                update.new_target_object_id,
351            )?;
352            if ref_state.previous_ref_state_id != update.old_ref_state_id {
353                return Err(PrikkError::Integrity(format!(
354                    "RefState previous link disagrees with RefUpdate for {ref_name}"
355                )));
356            }
357            if ref_state.update_seq != update.update_seq {
358                return Err(PrikkError::Integrity(format!(
359                    "RefState update sequence disagrees with RefUpdate for {ref_name}"
360                )));
361            }
362            previous_ref_state_id = Some(update.new_ref_state_id);
363            latest = Some(update);
364        }
365        let Some(update) = latest else {
366            return Ok(None);
367        };
368        Ok(Some(RefRecoveryCandidate {
369            ref_name: ref_name.to_string(),
370            ref_state_id: update.new_ref_state_id,
371            target_object_id: update.new_target_object_id,
372            update_seq: update.update_seq,
373        }))
374    }
375
376    fn ensure_current_matches(&self, ref_name: &str, expected: Option<ObjectId>) -> Result<()> {
377        let current = self.read_current_ref_state_id(ref_name)?;
378        if current != expected {
379            return Err(PrikkError::LockConflict(format!(
380                "ref CAS mismatch for {ref_name}: expected {:?}, got {:?}",
381                expected, current
382            )));
383        }
384        Ok(())
385    }
386}
387
388fn verified_ref_state_payload(
389    object_store: &FileObjectStore,
390    ref_state_id: ObjectId,
391    ref_name: &str,
392    target_object_id: ObjectId,
393) -> Result<RefStatePayload> {
394    let Some(envelope) = object_store.read_typed(ref_state_id, ObjectType::RefState)? else {
395        return Err(PrikkError::Integrity(format!(
396            "missing RefState object for ref recovery: {ref_state_id}"
397        )));
398    };
399    if envelope.signatures.is_empty() {
400        return Err(PrikkError::Integrity(format!(
401            "RefState {ref_state_id} is unsigned"
402        )));
403    }
404    let payload =
405        RefStatePayload::decode_canonical(&envelope.canonical_payload, envelope.schema_version)?;
406    if payload.ref_name != ref_name {
407        return Err(PrikkError::Integrity(format!(
408            "RefState {ref_state_id} name mismatch: expected {ref_name}, got {}",
409            payload.ref_name
410        )));
411    }
412    if payload.target_object_id != target_object_id {
413        return Err(PrikkError::Integrity(format!(
414            "RefState {ref_state_id} target disagrees with ref log for {ref_name}"
415        )));
416    }
417    let Some(target) = object_store.read_object(target_object_id)? else {
418        return Err(PrikkError::Integrity(format!(
419            "RefState {ref_state_id} targets missing block {target_object_id}"
420        )));
421    };
422    if target.object_type != ObjectType::Block {
423        return Err(PrikkError::Integrity(format!(
424            "RefState {ref_state_id} targets {}, expected block",
425            target.object_type
426        )));
427    }
428    Ok(payload)
429}
430
431pub(crate) fn validate_publication(publication: &RefPublication) -> Result<()> {
432    require_signed_type(&publication.ref_state, ObjectType::RefState)?;
433    require_signed_type(&publication.ref_update, ObjectType::RefUpdate)?;
434    publication.ref_state.validate_strict()?;
435    publication.ref_update.validate_strict()?;
436    Ok(())
437}
438
439pub(crate) fn require_signed_type(
440    envelope: &ObjectEnvelope,
441    object_type: ObjectType,
442) -> Result<()> {
443    if envelope.object_type != object_type {
444        return Err(PrikkError::ObjectTypeMismatch {
445            expected: object_type.to_string(),
446            actual: envelope.object_type.to_string(),
447        });
448    }
449    if envelope.signatures.is_empty() {
450        return Err(PrikkError::InvalidSignature(format!(
451            "{object_type} publication envelope must be signed"
452        )));
453    }
454    envelope.validate()
455}
456
457/// Validate a local branch ref name and return its canonical identity string.
458pub fn validate_local_branch_ref(ref_name: &str) -> Result<String> {
459    if ref_name.is_empty() {
460        return Err(PrikkError::InvalidName(
461            "ref name must not be empty".to_string(),
462        ));
463    }
464    if ref_name.starts_with("tags/")
465        || ref_name.starts_with("remotes/")
466        || ref_name.starts_with("rollback/")
467    {
468        return Err(PrikkError::InvalidName(format!(
469            "ref namespace is reserved: {ref_name}"
470        )));
471    }
472    if !ref_name.starts_with("heads/") {
473        return Err(PrikkError::InvalidName(format!(
474            "ref {ref_name} is not a local branch ref; expected heads/<name>"
475        )));
476    }
477    let branch = &ref_name["heads/".len()..];
478    if branch.is_empty() {
479        return Err(PrikkError::InvalidName(
480            "branch ref must include a name after heads/".to_string(),
481        ));
482    }
483    if ref_name.chars().any(|ch| ch == '\0' || ch.is_control()) {
484        return Err(PrikkError::InvalidName(format!(
485            "ref {ref_name} contains a forbidden control character"
486        )));
487    }
488    if branch.starts_with('/') || branch.ends_with('/') || branch.contains("//") {
489        return Err(PrikkError::InvalidName(format!(
490            "branch ref {ref_name} contains an empty path component"
491        )));
492    }
493    if branch
494        .split('/')
495        .any(|component| component == "." || component == "..")
496    {
497        return Err(PrikkError::InvalidName(format!(
498            "branch ref {ref_name} contains a traversal component"
499        )));
500    }
501    Ok(ref_name.to_string())
502}
503
504/// Validate a local tag ref name and return its canonical identity string.
505///
506/// Mirrors `validate_local_branch_ref` with the prefix requirement inverted: `tags/` required,
507/// `heads/`/`remotes/`/`rollback/` reserved. Deliberately carries no case-collision rule —
508/// `validate_local_branch_ref` does not have one either (`tags/V1` and `tags/v1` both pass and
509/// coexist as distinct refs, same as branches), and a stricter rule for tags alone than branches
510/// would be arbitrary. That gap is real but is NFR-SEC-03's, unmet for both namespaces, and tracked
511/// separately rather than closed asymmetrically here.
512pub fn validate_local_tag_ref(ref_name: &str) -> Result<String> {
513    if ref_name.is_empty() {
514        return Err(PrikkError::InvalidName(
515            "ref name must not be empty".to_string(),
516        ));
517    }
518    if ref_name.starts_with("heads/")
519        || ref_name.starts_with("remotes/")
520        || ref_name.starts_with("rollback/")
521    {
522        return Err(PrikkError::InvalidName(format!(
523            "ref namespace is reserved: {ref_name}"
524        )));
525    }
526    if !ref_name.starts_with("tags/") {
527        return Err(PrikkError::InvalidName(format!(
528            "ref {ref_name} is not a local tag ref; expected tags/<name>"
529        )));
530    }
531    let tag = &ref_name["tags/".len()..];
532    if tag.is_empty() {
533        return Err(PrikkError::InvalidName(
534            "tag ref must include a name after tags/".to_string(),
535        ));
536    }
537    if ref_name.chars().any(|ch| ch == '\0' || ch.is_control()) {
538        return Err(PrikkError::InvalidName(format!(
539            "ref {ref_name} contains a forbidden control character"
540        )));
541    }
542    if tag.starts_with('/') || tag.ends_with('/') || tag.contains("//") {
543        return Err(PrikkError::InvalidName(format!(
544            "tag ref {ref_name} contains an empty path component"
545        )));
546    }
547    if tag
548        .split('/')
549        .any(|component| component == "." || component == "..")
550    {
551        return Err(PrikkError::InvalidName(format!(
552            "tag ref {ref_name} contains a traversal component"
553        )));
554    }
555    Ok(ref_name.to_string())
556}
557
558// DC-71: every test here (including the nested publication_recovery/state_matrix trees) sets up
559// its scenario via real repository mutation, which is Linux-only; the module never compiles a
560// non-Linux-meaningful test.
561#[cfg(all(test, target_os = "linux"))]
562mod tests;