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}