Skip to main content

prikk_store/refs/verify/
scan.rs

1//! Ref pointer/log scanning and structural chain validation (RFC 102 Stage 4: rewritten onto the
2//! shared ref-pointer-index and ref-log containers; see `refs/container.rs` and
3//! `refs/pointer_index.rs` for the storage this now reads).
4
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use prikk_error::{PrikkError, Result};
9use prikk_object::{
10    ObjectEnvelope, ObjectId, RefKind, RefStatePayload, RefUpdatePayload, TagPayload,
11};
12
13use crate::layout::{ContainerSlot, RepositoryLayout, ref_name_key_bytes};
14use crate::object_store::ObjectReader;
15use crate::refs::container::{
16    RefContainerRecordStatus, RefLogRecordStatus, RefLogReplay, decode_ref_container_records,
17    replay_ref_subsequence,
18};
19use crate::refs::pointer_index::{
20    PointerIndexEntry, PointerIndexRecordStatus, replay_pointer_index,
21};
22use prikk_object::ObjectType;
23
24#[derive(Debug, Clone)]
25pub(super) struct PointerState {
26    pub(super) id: ObjectId,
27    pub(super) payload: RefStatePayload,
28}
29
30#[derive(Debug, Clone)]
31pub(super) struct LogState {
32    pub(super) tip: Option<ObjectId>,
33    pub(super) previous_tip: Option<ObjectId>,
34    pub(super) record_count: usize,
35    pub(super) trailing_partial_bytes: usize,
36    pub(super) has_legacy_timestamp: bool,
37}
38
39pub(super) struct RefLogEnvelope {
40    pub(super) ref_name: String,
41    pub(super) sequence: u64,
42    pub(super) envelope: ObjectEnvelope,
43}
44
45/// Outcome of attempting to read one pointer or log entry (DC-95 Stage 2 Level 2). `path` is a
46/// display-only locator (the owning container's own path, plus the specific record's byte offset
47/// where one exists) -- not a real per-ref filesystem path, the same repurposing Stage 3 already made
48/// for `ObjectItemOutcome::path`, since a container holds many refs' records, not one file per ref.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RefFileStatus {
51    /// The entry was read and validated successfully.
52    Evaluated {
53        /// The ref name it resolved to.
54        ref_name: String,
55    },
56    /// Some check for this specific entry failed.
57    Failed {
58        /// The error the check raised.
59        message: String,
60    },
61}
62
63/// One pointer or log entry's resolved outcome.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RefFileOutcome {
66    /// A display-only locator -- see the type's own doc.
67    pub path: PathBuf,
68    /// How this entry's own read/validation resolved.
69    pub status: RefFileStatus,
70}
71
72fn pointer_locator(layout: &RepositoryLayout, offset: usize) -> PathBuf {
73    // Display-only. Step 1 (RFC 102 Stage 6, design-v1.md §15.6) never resolves anywhere but `A` --
74    // hardcoded here rather than resolver-routed for the same reason `log_locator` hardcodes `A` for
75    // the (never-compacting) ref log container.
76    layout
77        .ref_pointer_index_slot_path(ContainerSlot::A)
78        .join(format!("#{offset}"))
79}
80
81fn log_locator(layout: &RepositoryLayout, offset: usize) -> PathBuf {
82    layout
83        .ref_log_container_slot_path(ContainerSlot::A)
84        .join(format!("#{offset}"))
85}
86
87/// Read every published ref pointer: the ref-pointer index's own last-entry-per-`ref_name_key` view
88/// (RFC 102 Stage 4), each cross-validated against its named RefState object exactly as before.
89///
90/// Returns, alongside the display-only `outcomes`, a `ref_name_key -> failure message` map for
91/// internal cross-referencing. `RefFileOutcome`'s own `path` is a display locator (a container
92/// offset now, not a per-ref file path — see the type's doc), so it can no longer be used to
93/// attribute a failed entry back to the ref name that owns it the way a per-ref filename could;
94/// `ref_name_key_bytes(ref_name)` is the only key both sides can compute independently.
95#[allow(clippy::type_complexity)]
96pub(super) fn read_pointers(
97    layout: &RepositoryLayout,
98    objects: &impl ObjectReader,
99) -> Result<(
100    BTreeMap<String, PointerState>,
101    BTreeMap<[u8; 32], String>,
102    Vec<RefFileOutcome>,
103)> {
104    let replay = replay_pointer_index(layout)?;
105    if replay.has_item_failure() {
106        return Err(PrikkError::Integrity(
107            "ref pointer index has a damaged entry; run doctor before verify can classify \
108             publication state"
109                .to_string(),
110        ));
111    }
112    // "Last entry wins" (Step 0 §13.4): iterate in append order, keep overwriting -- the final
113    // value per `ref_name_key` after the loop is the same one `lookup_ref_pointer`'s reverse scan
114    // would find, and each entry also carries the offset it was found at, for the locator.
115    let mut latest: BTreeMap<[u8; 32], (usize, PointerIndexEntry)> = BTreeMap::new();
116    for outcome in replay.record_outcomes.iter().zip(replay.entries) {
117        let (record_outcome, entry) = outcome;
118        if !matches!(record_outcome.status, PointerIndexRecordStatus::Evaluated) {
119            continue;
120        }
121        latest.insert(entry.ref_name_key, (record_outcome.offset, entry));
122    }
123
124    let mut pointers = BTreeMap::new();
125    let mut failures_by_key = BTreeMap::new();
126    let mut outcomes = Vec::new();
127    for (key, (offset, entry)) in latest {
128        let locator = pointer_locator(layout, offset);
129        match read_one_pointer_entry(objects, &entry) {
130            Ok((ref_name, state)) => {
131                pointers.insert(ref_name.clone(), state);
132                outcomes.push(RefFileOutcome {
133                    path: locator,
134                    status: RefFileStatus::Evaluated { ref_name },
135                });
136            }
137            Err(err) => {
138                failures_by_key.insert(key, err.to_string());
139                outcomes.push(RefFileOutcome {
140                    path: locator,
141                    status: RefFileStatus::Failed {
142                        message: err.to_string(),
143                    },
144                });
145            }
146        }
147    }
148    Ok((pointers, failures_by_key, outcomes))
149}
150
151fn read_one_pointer_entry(
152    objects: &impl ObjectReader,
153    entry: &PointerIndexEntry,
154) -> Result<(String, PointerState)> {
155    // The entry's own internal coherence: its claimed `ref_name_key` must actually be
156    // `sha256(ref_name)` -- the same defense-in-depth `verify_object_file` already applies by
157    // recomputing an object's own id from its decoded bytes.
158    if ref_name_key_bytes(&entry.ref_name) != entry.ref_name_key {
159        return Err(PrikkError::Integrity(format!(
160            "pointer index entry ref_name_key does not match sha256({})",
161            entry.ref_name
162        )));
163    }
164    let payload = verified_ref_state_payload(objects, entry.ref_state_id)?;
165    if payload.ref_name != entry.ref_name {
166        return Err(PrikkError::Integrity(format!(
167            "RefState {} name differs from pointer ref {}",
168            entry.ref_state_id, entry.ref_name
169        )));
170    }
171    ensure_ref_target_valid(
172        objects,
173        payload.kind,
174        payload.target_object_id,
175        entry.ref_state_id,
176    )?;
177    Ok((
178        entry.ref_name.clone(),
179        PointerState {
180            id: entry.ref_state_id,
181            payload,
182        },
183    ))
184}
185
186/// Read every ref's own log subsequence from the shared log container, grouped by `ref_name_key`
187/// (Step 0 §13.1/§13.2). One full-container decode discovers which keys exist at all; each key's own
188/// subsequence is then replayed through `container::replay_ref_subsequence` -- the same function
189/// `RefStore::replay_log` itself uses -- so the ref-scoped `trailing_partial_bytes` attribution
190/// (design-v1.md §13.6) is computed exactly once, in one place, not re-derived here.
191#[allow(clippy::type_complexity)]
192pub(super) fn read_logs(
193    layout: &RepositoryLayout,
194    objects: &impl ObjectReader,
195    _pointers: &BTreeMap<String, PointerState>,
196) -> Result<(
197    BTreeMap<String, LogState>,
198    usize,
199    Vec<RefLogEnvelope>,
200    BTreeMap<[u8; 32], String>,
201    Vec<RefFileOutcome>,
202)> {
203    let relative =
204        layout.repository_relative(&layout.ref_log_container_slot_path(ContainerSlot::A))?;
205    let Some(bytes) =
206        crate::fsutil::read_file_if_exists(layout.repository_mutation_root(), &relative)?
207    else {
208        return Ok((BTreeMap::new(), 0, Vec::new(), BTreeMap::new(), Vec::new()));
209    };
210    let discovery = decode_ref_container_records(&bytes)?;
211    let mut keys: std::collections::BTreeSet<[u8; 32]> = discovery
212        .records
213        .iter()
214        .map(|record| record.ref_name_key)
215        .collect();
216    keys.extend(
217        discovery
218            .record_outcomes
219            .iter()
220            .filter_map(|outcome| match &outcome.status {
221                RefContainerRecordStatus::Failed {
222                    claimed_ref_name_key: Some(key),
223                    ..
224                } => Some(*key),
225                _ => None,
226            }),
227    );
228
229    let mut logs = BTreeMap::new();
230    let mut total = 0_usize;
231    let mut envelopes = Vec::new();
232    let mut failures_by_key = BTreeMap::new();
233    let mut outcomes = Vec::new();
234    for key in keys {
235        let replay = replay_ref_subsequence(layout, key)?;
236        let first_offset = replay
237            .record_outcomes
238            .first()
239            .map_or(0, |outcome| outcome.offset);
240        // A key with any damaged record is reported as one failed outcome for that ref -- matching
241        // today's file-granularity semantics (a ref's own log failing is one item-level defect, not
242        // one per damaged record within it).
243        if replay.has_item_failure() {
244            let joined = replay
245                .record_outcomes
246                .iter()
247                .filter_map(|outcome| match &outcome.status {
248                    RefLogRecordStatus::Failed { message } => {
249                        Some(format!("offset {}: {message}", outcome.offset))
250                    }
251                    RefLogRecordStatus::Evaluated => None,
252                })
253                .collect::<Vec<_>>()
254                .join("; ");
255            let message = format!("ref log has damaged record(s): {joined}");
256            failures_by_key.insert(key, message.clone());
257            outcomes.push(RefFileOutcome {
258                path: log_locator(layout, first_offset),
259                status: RefFileStatus::Failed { message },
260            });
261            continue;
262        }
263        match validate_log_replay(objects, key, &replay) {
264            Ok(Some((ref_name, state, record_envelopes))) => {
265                total = match total.checked_add(record_envelopes.len()) {
266                    Some(value) => value,
267                    None => {
268                        return Err(PrikkError::Integrity("ref-log count overflow".to_string()));
269                    }
270                };
271                envelopes.extend(record_envelopes);
272                logs.insert(ref_name.clone(), state);
273                outcomes.push(RefFileOutcome {
274                    path: log_locator(layout, first_offset),
275                    status: RefFileStatus::Evaluated { ref_name },
276                });
277            }
278            Ok(None) => {}
279            Err(err) => {
280                failures_by_key.insert(key, err.to_string());
281                outcomes.push(RefFileOutcome {
282                    path: log_locator(layout, first_offset),
283                    status: RefFileStatus::Failed {
284                        message: err.to_string(),
285                    },
286                });
287            }
288        }
289    }
290    Ok((logs, total, envelopes, failures_by_key, outcomes))
291}
292
293#[allow(clippy::type_complexity)]
294fn validate_log_replay(
295    objects: &impl ObjectReader,
296    ref_name_key: [u8; 32],
297    replay: &RefLogReplay,
298) -> Result<Option<(String, LogState, Vec<RefLogEnvelope>)>> {
299    if replay.records.is_empty() {
300        if replay.trailing_partial_bytes == 0 {
301            return Ok(None);
302        }
303        return Err(PrikkError::Integrity(
304            "ref log has a trailing partial record with no sound records of its own".to_string(),
305        ));
306    }
307    let mut previous = None;
308    let mut previous_tip = None;
309    let mut ref_name = None;
310    let mut has_legacy_timestamp = false;
311    let mut record_envelopes = Vec::with_capacity(replay.records.len());
312    for (index, record) in replay.records.iter().enumerate() {
313        let update = RefUpdatePayload::decode_canonical(&record.envelope.canonical_payload)?;
314        // Coherence: this record's own header claimed `ref_name_key`; its decoded payload's own
315        // `ref_name` must hash to the same key, the log-side equivalent of `read_one_pointer_entry`'s
316        // own check.
317        if crate::layout::ref_name_key_bytes(&update.ref_name) != ref_name_key {
318            return Err(PrikkError::Integrity(
319                "ref container record header ref_name_key does not match its own envelope"
320                    .to_string(),
321            ));
322        }
323        let expected_seq = u64::try_from(index)
324            .ok()
325            .and_then(|value| value.checked_add(1))
326            .ok_or_else(|| PrikkError::Integrity("ref-log sequence overflow".to_string()))?;
327        if ref_name
328            .as_ref()
329            .is_some_and(|name| name != &update.ref_name)
330            || update.old_ref_state_id != previous
331            || update.update_seq != expected_seq
332        {
333            return Err(PrikkError::Integrity(format!(
334                "ref-log chain or sequence diverges for {}",
335                update.ref_name
336            )));
337        }
338        verify_update(objects, &update)?;
339        has_legacy_timestamp |= update.created_at != 0;
340        ref_name.get_or_insert_with(|| update.ref_name.clone());
341        previous_tip = previous;
342        previous = Some(update.new_ref_state_id);
343        record_envelopes.push(RefLogEnvelope {
344            ref_name: update.ref_name.clone(),
345            sequence: update.update_seq,
346            envelope: record.envelope.clone(),
347        });
348    }
349    let name = ref_name
350        .ok_or_else(|| PrikkError::Integrity("non-empty ref log has no identity".to_string()))?;
351    Ok(Some((
352        name,
353        LogState {
354            tip: previous,
355            previous_tip,
356            record_count: replay.records.len(),
357            trailing_partial_bytes: replay.trailing_partial_bytes,
358            has_legacy_timestamp,
359        },
360        record_envelopes,
361    )))
362}
363
364fn verify_update(objects: &impl ObjectReader, update: &RefUpdatePayload) -> Result<()> {
365    let state = verified_ref_state_payload(objects, update.new_ref_state_id)?;
366    if state.ref_name != update.ref_name
367        || state.previous_ref_state_id != update.old_ref_state_id
368        || state.target_object_id != update.new_target_object_id
369        || state.update_seq != update.update_seq
370    {
371        return Err(PrikkError::Integrity(format!(
372            "RefState disagrees with RefUpdate for {}",
373            update.ref_name
374        )));
375    }
376    ensure_ref_target_valid(
377        objects,
378        state.kind,
379        update.new_target_object_id,
380        update.new_ref_state_id,
381    )
382}
383
384fn verified_ref_state_payload(
385    objects: &impl ObjectReader,
386    ref_state_id: ObjectId,
387) -> Result<RefStatePayload> {
388    let envelope = objects
389        .read_typed(ref_state_id, ObjectType::RefState)?
390        .ok_or_else(|| PrikkError::Integrity(format!("missing RefState object: {ref_state_id}")))?;
391    if envelope.signatures.is_empty() {
392        return Err(PrikkError::Integrity(format!(
393            "RefState {ref_state_id} is unsigned"
394        )));
395    }
396    RefStatePayload::decode_canonical(&envelope.canonical_payload, envelope.schema_version)
397}
398
399/// Kind-aware ref-target validation, shared by both the pointer scan (`read_pointers`) and the
400/// ref-log scan (`verify_update`), which must agree: `publication.rs`'s coherence check requires
401/// `RefUpdatePayload.new_target_object_id == RefStatePayload.target_object_id`, so a log record's
402/// target and its pointer's target are the identical value for the identical kind. `RefKind::Branch`
403/// must target a `Block` directly; `RefKind::Tag` must target a `Tag` object whose own
404/// `target_block_id` is a `Block` — the two-hop indirection §6.6 requires.
405///
406/// **Not `refs::resolve_ref_tip_block`, on purpose** (ref-tip-resolver-consolidation handoff): this
407/// function *validates* and returns `()` — for a `Branch` it confirms the target block actually
408/// exists, which a resolver never checks — and its errors carry `owner`, the ref object being
409/// verified, which a resolver never has. That `owner` is load-bearing: *"ref object {owner} targets
410/// missing tag {target_object_id}"* is the exact message the DC-78 tag-gap ruling turned on (a
411/// bundle must ship the Tag object, or `verify` produces this). Folding this into the resolver would
412/// either drop that context or thread a message-customisation parameter through code three other
413/// callers don't need it in.
414pub(crate) fn ensure_ref_target_valid(
415    objects: &impl ObjectReader,
416    kind: RefKind,
417    target_object_id: ObjectId,
418    owner: ObjectId,
419) -> Result<()> {
420    match kind {
421        RefKind::Branch => ensure_block_exists(objects, target_object_id, owner),
422        RefKind::Tag => {
423            let tag_envelope = objects
424                .read_typed(target_object_id, ObjectType::Tag)?
425                .ok_or_else(|| {
426                    PrikkError::Integrity(format!(
427                        "ref object {owner} targets missing tag {target_object_id}"
428                    ))
429                })?;
430            let tag_payload = TagPayload::decode_canonical(&tag_envelope.canonical_payload)?;
431            ensure_block_exists(objects, tag_payload.target_block_id, owner)
432        }
433    }
434}
435
436fn ensure_block_exists(
437    objects: &impl ObjectReader,
438    block_id: ObjectId,
439    owner: ObjectId,
440) -> Result<()> {
441    if objects.read_typed(block_id, ObjectType::Block)?.is_some() {
442        return Ok(());
443    }
444    Err(PrikkError::Integrity(format!(
445        "ref object {owner} targets missing block {block_id}"
446    )))
447}