Skip to main content

prikk_store/refs/
verify.rs

1//! Joint ref pointer and ref-log verification.
2
3use std::collections::BTreeSet;
4use std::path::Path;
5
6use prikk_error::{PrikkError, Result};
7
8use crate::fsutil::{EntryKind, list_directory};
9use crate::layout::RepositoryLayout;
10use crate::object_store::ObjectReadSnapshot;
11use crate::signature_diagnostics::{
12    SignatureEnvelopeIssue, SignatureEnvelopeSource, classify_signature_envelope,
13};
14
15mod scan;
16
17pub(crate) use scan::ensure_ref_target_valid;
18pub use scan::{RefFileOutcome, RefFileStatus};
19
20use scan::{LogState, PointerState, read_logs, read_pointers};
21
22/// One recognized interrupted-publication or local-debris condition.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RefPublicationIssue {
25    /// Stable diagnostic code.
26    pub code: &'static str,
27    /// Ref name when the condition belongs to one ref.
28    pub ref_name: Option<String>,
29    /// Human-readable diagnosis without host paths.
30    pub message: String,
31    /// Whether verification must return non-zero and unrelated mutation must remain blocked.
32    pub blocking: bool,
33}
34
35/// Outcome of attempting to classify one ref by name (DC-95 Stage 2 Level 2), after its pointer
36/// and/or log file (whichever exist) were themselves read. No `NotEvaluated` distinct from
37/// `Failed`: unlike Level 1's stages or Phase B's blocks, a ref has no *peer* ref it depends on --
38/// its own pointer/log files are its own data, the same footing as an object's own file in
39/// `verify_objects` Phase A -- so a failure attributable to this ref, whether from its own file
40/// read or from `classify_ref_state` itself, is `Failed`, not a dependency-graph claim about
41/// another item.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum RefItemStatus {
44    /// This ref's own classification (or its file reads, if either failed) resolved cleanly.
45    Evaluated,
46    /// This ref's pointer read, log read, or `classify_ref_state` call itself failed. Carries
47    /// whichever failure applies -- a ref whose *own* pointer or log entry did not read is reported
48    /// through that entry's real failure message, not reinterpreted as "pointer/log absent" (see
49    /// `verify_refs`'s own cross-referencing by `ref_name_key_bytes`, RFC 102 Stage 4).
50    Failed {
51        /// The error that applies to this ref.
52        message: String,
53    },
54}
55
56/// One ref's resolved outcome.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct RefItemOutcome {
59    /// The ref's human-readable name.
60    pub ref_name: String,
61    /// How this ref's own verification resolved.
62    pub status: RefItemStatus,
63}
64
65/// Ref verification counters and publication-state issues.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub(crate) struct RefVerification {
68    pub pointer_count: usize,
69    pub log_record_count: usize,
70    pub ref_update_envelopes: Vec<prikk_object::ObjectEnvelope>,
71    pub publication_issues: Vec<RefPublicationIssue>,
72    pub signature_envelope_issues: Vec<SignatureEnvelopeIssue>,
73    /// One outcome per pointer file scanned under `refs/by-id/`, in scan order (DC-95 Stage 2
74    /// Level 2).
75    pub pointer_outcomes: Vec<RefFileOutcome>,
76    /// One outcome per log file scanned under `refs/logs/`, in scan order. A log file that is
77    /// legitimately empty with no trailing bytes is not an item at all (nothing to report), same
78    /// as the pre-Level-2 behavior of skipping it entirely.
79    pub log_outcomes: Vec<RefFileOutcome>,
80    /// One outcome per ref name reached via a successfully-read pointer or log (DC-95 Stage 2
81    /// Level 2). A ref whose own pointer/log file failed to read is still included here -- see
82    /// `RefItemStatus::Failed`'s own doc -- so no ref name known to exist is silently absent.
83    pub ref_item_outcomes: Vec<RefItemOutcome>,
84}
85
86impl RefVerification {
87    /// Return true when any pointer file, log file, or ref-name classification failed (DC-95
88    /// Stage 2 Level 2). Item containment means `verify_refs` itself now returns `Ok` for these
89    /// cases -- callers that need "is this repository's ref state fully sound," not just "did the
90    /// scan run at all," must check this alongside any hard `Err`.
91    pub(crate) fn has_item_failure(&self) -> bool {
92        self.pointer_outcomes
93            .iter()
94            .any(|outcome| matches!(outcome.status, RefFileStatus::Failed { .. }))
95            || self
96                .log_outcomes
97                .iter()
98                .any(|outcome| matches!(outcome.status, RefFileStatus::Failed { .. }))
99            || self
100                .ref_item_outcomes
101                .iter()
102                .any(|outcome| matches!(outcome.status, RefItemStatus::Failed { .. }))
103    }
104}
105
106pub(crate) fn verify_refs(layout: &RepositoryLayout) -> Result<RefVerification> {
107    // RFC 111 §6.1: `verify_refs` is read-only (never calls `write_object`), so it takes its own
108    // decoded index snapshot here rather than sharing `verify_repository_with_options`'s -- they are
109    // two separate top-level constructions today (this one predates this change), and unifying them
110    // into one shared snapshot across the whole `verify` run is a further optimization this RFC does
111    // not require: RFC 111's own gate measures decode *count*, not construction count, and each of
112    // these two snapshots is still exactly one decode regardless of repository size.
113    let objects = ObjectReadSnapshot::open(layout)?;
114    let (pointers, pointer_failures_by_key, pointer_outcomes) = read_pointers(layout, &objects)?;
115    let (logs, log_record_count, ref_log_envelopes, log_failures_by_key, log_outcomes) =
116        read_logs(layout, &objects, &pointers)?;
117    // DC-95 Stage 2 Level 2 handoff §7 Q4, ruled: stays a whole-set precheck. RFC 103: with format-1
118    // retired, this is no longer "a format-2 repository contaminated by format-1 records" -- it is
119    // simply malformed data, and the check is unconditional rather than format-gated. Still a claim
120    // about the whole repository's history, not a per-ref defect, so still deliberately not
121    // contained to the one ref that happens to carry it.
122    if logs.values().any(|state| state.has_legacy_timestamp) {
123        return Err(PrikkError::Integrity(
124            "format-2 RefUpdate requires created_at == 0".to_string(),
125        ));
126    }
127    let mut ref_update_envelopes = Vec::with_capacity(ref_log_envelopes.len());
128    let mut signature_envelope_issues = Vec::new();
129    for record in ref_log_envelopes {
130        signature_envelope_issues.extend(classify_signature_envelope(
131            &record.envelope,
132            SignatureEnvelopeSource::RefLog {
133                ref_name: record.ref_name,
134                sequence: record.sequence,
135                object_id: record.envelope.object_id(),
136            },
137        )?);
138        ref_update_envelopes.push(record.envelope);
139    }
140    let mut names = BTreeSet::new();
141    names.extend(pointers.keys().cloned());
142    names.extend(logs.keys().cloned());
143    let mut publication_issues = candidate_issues(layout)?;
144    let mut ref_item_outcomes = Vec::with_capacity(names.len());
145    for ref_name in names {
146        let pointer = pointers.get(&ref_name);
147        let log = logs.get(&ref_name);
148        // DC-95 Stage 2 Level 2: a ref reached only through its log (or only through its pointer)
149        // might have a pointer (or log) that genuinely does not exist -- or might have one that
150        // exists but failed to read, which `read_pointers`/`read_logs` recorded as its own
151        // `ref_name_key -> message` entry rather than silently omitting it. These are different
152        // facts: the first is legitimate business logic `classify_ref_state`'s own match arms
153        // already handle; the second must not be reinterpreted as the first. RFC 102 Stage 4:
154        // cross-reference by `ref_name_key_bytes(ref_name)`, not by a per-ref file path -- a shared
155        // container has no such path, and `RefFileOutcome::path` is a display-only container
156        // locator now (see its own doc), not a stable key a failed entry can be found by.
157        let ref_name_key = crate::layout::ref_name_key_bytes(&ref_name);
158        let pointer_failure = pointer
159            .is_none()
160            .then(|| pointer_failures_by_key.get(&ref_name_key).cloned());
161        let log_failure = log
162            .is_none()
163            .then(|| log_failures_by_key.get(&ref_name_key).cloned());
164        if let Some(message) = pointer_failure.flatten().or(log_failure.flatten()) {
165            ref_item_outcomes.push(RefItemOutcome {
166                ref_name,
167                status: RefItemStatus::Failed { message },
168            });
169            continue;
170        }
171        // This ref's own classification is caught here, at the item boundary, rather than
172        // propagated -- every other ref is still attempted.
173        match classify_ref_state(&ref_name, pointer, log, &mut publication_issues) {
174            Ok(()) => ref_item_outcomes.push(RefItemOutcome {
175                ref_name,
176                status: RefItemStatus::Evaluated,
177            }),
178            Err(err) => ref_item_outcomes.push(RefItemOutcome {
179                ref_name,
180                status: RefItemStatus::Failed {
181                    message: err.to_string(),
182                },
183            }),
184        }
185    }
186    Ok(RefVerification {
187        pointer_count: pointers.len(),
188        log_record_count,
189        ref_update_envelopes,
190        publication_issues,
191        signature_envelope_issues,
192        pointer_outcomes,
193        log_outcomes,
194        ref_item_outcomes,
195    })
196}
197
198/// A code this function pushes is not necessarily the code `verify_repository` reports:
199/// `POINTER-LEADS-LOG` is piped through `ref_publication::require_retained_evidence` afterward,
200/// which overwrites it in place -- code, message, and blocking flag -- to
201/// `PRIKK-VERIFY-REF-DIVERGENCE` unless retained active-WAL evidence (matching ref, valid trust, and
202/// a target `Block` whose `patch_ids` match the queued WAL records) proves the divergence is a
203/// genuinely interrupted publication rather than an unexplained one. RFC 103: the two format-1-only
204/// codes this function used to choose between here (`LEGACY-LOG-LEADS`, `POINTER-MISSING`) and their
205/// format-2 `DIVERGENCE` counterparts are gone along with format-1 itself -- both conditions now
206/// report `DIVERGENCE` unconditionally, which is what they always resolved to under format-2 and
207/// what `LEGACY-LOG-LEADS` was already downstream-redundant with (DC-95 Stage 1 round 10).
208fn classify_ref_state(
209    ref_name: &str,
210    pointer: Option<&PointerState>,
211    log: Option<&LogState>,
212    issues: &mut Vec<RefPublicationIssue>,
213) -> Result<()> {
214    match (pointer, log) {
215        (Some(pointer), Some(log)) if Some(pointer.id) == log.tip => {
216            if log.trailing_partial_bytes != 0 {
217                return Err(PrikkError::Integrity(format!(
218                    "ref {ref_name} has an incomplete log tail without a pointer lead"
219                )));
220            }
221            Ok(())
222        }
223        (Some(pointer), log)
224            if pointer.payload.previous_ref_state_id == log.and_then(|state| state.tip)
225                && pointer.payload.update_seq == next_log_sequence(log)? =>
226        {
227            let partial = log.map_or(0, |state| state.trailing_partial_bytes);
228            issues.push(blocking_issue(
229                "PRIKK-VERIFY-REF-POINTER-LEADS-LOG",
230                ref_name,
231                if partial == 0 {
232                    "authoritative pointer leads committed ref log by one transition".to_string()
233                } else {
234                    format!(
235                        "authoritative pointer leads ref log by one transition with {partial} incomplete trailing byte(s)"
236                    )
237                },
238            ));
239            Ok(())
240        }
241        (Some(pointer), Some(log)) if log.previous_tip == Some(pointer.id) => {
242            issues.push(blocking_issue(
243                "PRIKK-VERIFY-REF-DIVERGENCE",
244                ref_name,
245                "format-2 ref log leads the authoritative pointer".to_string(),
246            ));
247            Ok(())
248        }
249        (None, Some(log)) if log.record_count == 1 && log.previous_tip.is_none() => {
250            issues.push(blocking_issue(
251                "PRIKK-VERIFY-REF-DIVERGENCE",
252                ref_name,
253                "format-2 ref pointer is missing while committed log history exists".to_string(),
254            ));
255            Ok(())
256        }
257        (None, None) => Ok(()),
258        _ => Err(PrikkError::Integrity(format!(
259            "unexplained pointer/log divergence for ref {ref_name}"
260        ))),
261    }
262}
263
264fn next_log_sequence(log: Option<&LogState>) -> Result<u64> {
265    u64::try_from(log.map_or(0, |state| state.record_count))
266        .ok()
267        .and_then(|value| value.checked_add(1))
268        .ok_or_else(|| PrikkError::Integrity("ref-log sequence overflow".to_string()))
269}
270
271fn candidate_issues(layout: &RepositoryLayout) -> Result<Vec<RefPublicationIssue>> {
272    let mut issues = Vec::new();
273    let relative = Path::new("refs/tmp");
274    for entry in list_directory(layout.repository_mutation_root(), relative)? {
275        if entry.kind == EntryKind::Regular {
276            issues.push(RefPublicationIssue {
277                code: "PRIKK-VERIFY-REF-CANDIDATE-DEBRIS",
278                ref_name: None,
279                message: "non-authoritative ref pointer candidate remains".to_string(),
280                blocking: false,
281            });
282        } else {
283            return Err(PrikkError::Integrity(
284                "unexpected non-file in ref candidate directory".to_string(),
285            ));
286        }
287    }
288    Ok(issues)
289}
290
291fn blocking_issue(code: &'static str, ref_name: &str, message: String) -> RefPublicationIssue {
292    RefPublicationIssue {
293        code,
294        ref_name: Some(ref_name.to_string()),
295        message,
296        blocking: true,
297    }
298}