Skip to main content

prikk_store/verify/
objects.rs

1//! Container-based object verification (RFC 102 Stage 3) and dormant loose-file temp-debris
2//! classification (design-v1.md §12.3 item 3: kept, cannot fire under format-3, not removed as a
3//! side effect of this stage).
4
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8use prikk_error::{PrikkError, Result};
9use prikk_object::{BlockPayload, ObjectEnvelope, ObjectId, ObjectType};
10
11use super::{
12    AuthorSignatureVerification, BlockSealVerification, ObjectVerification,
13    PublicationTrustVerifier, verify_block_payload,
14};
15use crate::block_state::{BlockStateOutcome, LineageStateMemo, verify_blocks_topological};
16use crate::container::{self, ContainerRecordStatus};
17use crate::fsutil::{EntryKind, inspect_entry, list_directory, read_file_if_exists};
18use crate::index::replay_index;
19use crate::layout::{ContainerSlot, RepositoryLayout, persisted_object_types};
20use crate::object_store::ObjectReader;
21use crate::signature_diagnostics::{
22    SignatureEnvelopeIssue, SignatureEnvelopeSource, classify_signature_envelope,
23};
24
25/// Outcome of attempting to verify one persisted object record (DC-95 Stage 2 Level 2, Phase A). No
26/// `NotEvaluated` variant: Phase A's per-object checks (decode, schema, signature, trust, reference
27/// existence) have no real dependency on any *other* object's own outcome (Step 0 §1.1) -- every
28/// object is independently attempted.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ObjectItemStatus {
31    /// The object's own checks all passed, and it has a matching index entry.
32    Evaluated(ObjectVerification),
33    /// The object's own checks all passed, but no index entry names it (design-v1.md §12/§10.2's
34    /// ruling): rebuildable, so **not** a failure -- explicitly excluded from `has_item_failure()`,
35    /// the same way `Evaluated` is. Carries the same data `Evaluated` does; only the classification
36    /// differs.
37    Unindexed(ObjectVerification),
38    /// Some check for this specific object failed -- either the container record's own framing
39    /// (bad checksum, malformed envelope) or a downstream check (schema, signature, trust,
40    /// reference existence). Its signature-envelope findings and (for a `Block`) merge-baseline
41    /// divergence and `pending_v3_blocks` contribution are *not* recorded -- this object's own
42    /// verification did not run to completion, so nothing derived partway through it is reported.
43    Failed {
44        /// The error the check raised.
45        message: String,
46    },
47}
48
49/// One object record's resolved outcome.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ObjectItemOutcome {
52    /// The object-type container this record was scanned under.
53    pub object_type: ObjectType,
54    /// A display-only locator for this record -- `container_slot_path`'s own path with the record's
55    /// byte offset appended (`#1234`). Not a real filesystem path (a container holds many records
56    /// per file); kept as `PathBuf` because every consumer of this field only ever calls `.display()`
57    /// on it.
58    pub path: PathBuf,
59    /// How this object's own verification resolved.
60    pub status: ObjectItemStatus,
61}
62
63pub(super) struct ObjectSummary {
64    /// Phase A: one outcome per object record scanned, in scan order (container order, per type, in
65    /// `persisted_object_types()` order).
66    pub(super) item_outcomes: Vec<ObjectItemOutcome>,
67    /// Phase B: one outcome per `CurrentV6` Block whose Phase A check succeeded, in the
68    /// state-dependency order `verify_blocks_topological` resolved them (DC-92 §4.2) -- not scan
69    /// order.
70    pub(super) topological_outcomes: Vec<BlockStateOutcome>,
71    pub(super) temp_paths: Vec<PathBuf>,
72    pub(super) signature_issues: Vec<SignatureEnvelopeIssue>,
73    pub(super) merge_baseline_divergences: Vec<super::MergeBaselineDivergence>,
74    pub(super) block_seals: Vec<BlockSealVerification>,
75}
76
77impl ObjectSummary {
78    fn empty() -> Self {
79        Self {
80            item_outcomes: Vec::new(),
81            topological_outcomes: Vec::new(),
82            temp_paths: Vec::new(),
83            signature_issues: Vec::new(),
84            merge_baseline_divergences: Vec::new(),
85            block_seals: Vec::new(),
86        }
87    }
88
89    fn add(&mut self, other: Self) {
90        self.item_outcomes.extend(other.item_outcomes);
91        self.topological_outcomes.extend(other.topological_outcomes);
92        self.temp_paths.extend(other.temp_paths);
93        self.signature_issues.extend(other.signature_issues);
94        self.merge_baseline_divergences
95            .extend(other.merge_baseline_divergences);
96        self.block_seals.extend(other.block_seals);
97    }
98}
99
100pub(super) fn verify_objects(
101    layout: &RepositoryLayout,
102    object_store: &impl ObjectReader,
103    trust_verifier: &mut PublicationTrustVerifier<'_>,
104) -> Result<ObjectSummary> {
105    // DC-92 §4.2: Phase A (below) collects every CurrentV6 Block's already-decoded payload instead
106    // of verifying its state inline, in whatever order the generic scan visits objects. Phase B
107    // (`verify_blocks_topological`, after the loop) verifies them in state-dependency order instead,
108    // against one shared memo constructed here and evicted from as it goes.
109    //
110    // DC-95 Stage 2 Level 2: Phase A and Phase B are independently item-contained (Step 0 §1). A
111    // single object's own Phase A failure does not prevent scanning every other object. Phase B's
112    // own item containment lives in `verify_blocks_topological` itself.
113    let mut lineage_memo = LineageStateMemo::new();
114    let mut pending_v3_blocks: Vec<(ObjectId, BlockPayload)> = Vec::new();
115    let mut summary = ObjectSummary::empty();
116
117    // RFC 102 Stage 2: a damaged index entry blocks `index::lookup_object_location` (a single
118    // lookup), but Phase A here needs *membership*, not a single lookup -- a container-scale
119    // question the index's own item-containment already answers via `record_outcomes`, not
120    // something this loop needs to re-derive. A damaged index is therefore reported as this whole
121    // stage's own structural failure (matching how a damaged repository directory shape already
122    // aborts `verify_objects` today), not silently treated as "nothing is indexed."
123    let index_replay = replay_index(layout)?;
124    if index_replay.has_item_failure() {
125        return Err(PrikkError::Integrity(
126            "object index has a damaged entry; run doctor before verify can classify indexing"
127                .to_string(),
128        ));
129    }
130    let indexed_ids: HashSet<ObjectId> = index_replay
131        .entries
132        .iter()
133        .map(|entry| entry.object_id)
134        .collect();
135
136    // design-v1.md §12/§10.2's ruling: "the bytes found are validated by recomputing the content
137    // hash... a mismatch is a reported defect." Ordinary reads (`FileObjectStore::read_object`) check
138    // this lazily, one id at a time. `verify`'s own job is the full, proactive scan (the same ruling:
139    // "`verify` does the full scan") -- so every index entry is cross-checked here against what its
140    // own claimed location actually decodes to, not left to be discovered only if and when something
141    // happens to read that exact id later.
142    //
143    // A *decode* failure at the entry's own location (checksum mismatch, malformed envelope) is
144    // deliberately **not** escalated here -- it is already reported as its own item-level
145    // `ObjectItemStatus::Failed` by the per-record container scan below (RFC 102 Stage 2's
146    // isolate-and-continue containment), and re-erroring on it here would turn an already-contained
147    // item defect into a whole-stage abort. Only a location that decodes *successfully but to the
148    // wrong id* is a genuine index-integrity defect, not merely a damaged record the index happens to
149    // point at.
150    for entry in &index_replay.entries {
151        let Ok(envelope) = crate::index::read_object_envelope_at(layout, entry) else {
152            continue;
153        };
154        let computed = envelope.object_id();
155        if computed != entry.object_id {
156            return Err(PrikkError::Integrity(format!(
157                "index entry for {} resolves to an envelope with computed id {computed}",
158                entry.object_id
159            )));
160        }
161    }
162
163    for object_type in persisted_object_types() {
164        summary.add(verify_object_type_container(
165            layout,
166            object_store,
167            object_type,
168            trust_verifier,
169            &mut pending_v3_blocks,
170            &indexed_ids,
171        )?);
172    }
173    summary.temp_paths = scan_loose_file_temp_debris(layout)?;
174
175    let topological =
176        verify_blocks_topological(object_store, &pending_v3_blocks, &mut lineage_memo)?;
177    summary.topological_outcomes = topological.outcomes;
178    Ok(summary)
179}
180
181fn verify_object_type_container(
182    layout: &RepositoryLayout,
183    object_store: &impl ObjectReader,
184    object_type: ObjectType,
185    trust_verifier: &mut PublicationTrustVerifier<'_>,
186    pending_v3_blocks: &mut Vec<(ObjectId, BlockPayload)>,
187    indexed_ids: &HashSet<ObjectId>,
188) -> Result<ObjectSummary> {
189    let mut summary = ObjectSummary::empty();
190    let container_path = layout.container_slot_path(object_type, ContainerSlot::A);
191    let relative = layout.repository_relative(&container_path)?;
192    let Some(bytes) = read_file_if_exists(layout.repository_mutation_root(), &relative)? else {
193        // Every container name is allocated at `init` (handoff criterion 1); a missing file here is
194        // the same "nothing to scan" case an empty container already reads as, not a structural
195        // error -- mirrors `Wal::replay()`'s own missing-file tolerance.
196        return Ok(summary);
197    };
198    let replay = container::decode_container_records(object_type, &bytes)?;
199
200    // RFC 102 Stage 2: `records` holds only sound frames, in the same order `record_outcomes`
201    // visits its `Evaluated` entries -- both built in lockstep by `decode_container_records`.
202    let mut records = replay.records.into_iter();
203    for outcome in &replay.record_outcomes {
204        let locator = container_path.join(format!("#{}", outcome.offset));
205        let ContainerRecordStatus::Evaluated { .. } = &outcome.status else {
206            let ContainerRecordStatus::Failed { message } = &outcome.status else {
207                unreachable!("record_outcomes only ever holds Evaluated or Failed")
208            };
209            summary.item_outcomes.push(ObjectItemOutcome {
210                object_type,
211                path: locator,
212                status: ObjectItemStatus::Failed {
213                    message: message.clone(),
214                },
215            });
216            continue;
217        };
218        let Some(record) = records.next() else {
219            return Err(PrikkError::Integrity(
220                "container replay outcome/record count mismatch".to_string(),
221            ));
222        };
223        // DC-95 Stage 2 Level 2: this object's own failure is caught here, at the item boundary,
224        // rather than propagated -- every *other* record in this and every other container is
225        // still attempted.
226        match verify_object_record(
227            layout,
228            object_store,
229            object_type,
230            &locator,
231            &record.envelope,
232            trust_verifier,
233            pending_v3_blocks,
234        ) {
235            Ok((object, signature_issues, merge_baseline_divergence)) => {
236                summary.signature_issues.extend(signature_issues);
237                summary
238                    .merge_baseline_divergences
239                    .extend(merge_baseline_divergence);
240                if object.object_type == ObjectType::Block {
241                    if let Some(sealed_by_key_id) = object.sealed_by_key_id.clone() {
242                        summary.block_seals.push(BlockSealVerification {
243                            block_id: object.object_id,
244                            sealed_by_key_id,
245                        });
246                    }
247                }
248                let status = if indexed_ids.contains(&object.object_id) {
249                    ObjectItemStatus::Evaluated(object)
250                } else {
251                    ObjectItemStatus::Unindexed(object)
252                };
253                summary.item_outcomes.push(ObjectItemOutcome {
254                    object_type,
255                    path: locator,
256                    status,
257                });
258            }
259            Err(err) => {
260                summary.item_outcomes.push(ObjectItemOutcome {
261                    object_type,
262                    path: locator,
263                    status: ObjectItemStatus::Failed {
264                        message: err.to_string(),
265                    },
266                });
267            }
268        }
269    }
270    Ok(summary)
271}
272
273fn verify_object_record(
274    layout: &RepositoryLayout,
275    object_store: &impl ObjectReader,
276    object_type: ObjectType,
277    locator: &Path,
278    envelope: &ObjectEnvelope,
279    trust_verifier: &mut PublicationTrustVerifier<'_>,
280    pending_v3_blocks: &mut Vec<(ObjectId, BlockPayload)>,
281) -> Result<(
282    ObjectVerification,
283    Vec<SignatureEnvelopeIssue>,
284    Option<super::MergeBaselineDivergence>,
285)> {
286    // `object_type` mismatch is impossible to reach here: `container::parse_frame_at` checks
287    // `envelope.object_type != object_type` itself, right after decoding, so a frame whose body
288    // claims a different type than the container it lives in already surfaced as
289    // `ContainerRecordStatus::Failed` and never reaches this function at all.
290    crate::format::validate_read_schema(layout.format(), envelope)?;
291    let object_id = envelope.object_id();
292    let signature_issues = classify_signature_envelope(
293        envelope,
294        SignatureEnvelopeSource::Object {
295            object_type,
296            object_id,
297        },
298    )?;
299    let sealed_by_key_id = if matches!(object_type, ObjectType::Block | ObjectType::RefState) {
300        trust_verifier.verify(envelope)?
301    } else {
302        None
303    };
304    // DC-53 Stage 1: a Patch's AUTHOR signature is checked against recorded key material here. A
305    // signature that fails to verify against *recorded* material propagates as an `Err` via `?`
306    // below -- it never reaches `author_verification` -- because that outcome is a genuine
307    // authorship-integrity defect (forgery or corruption), not a trust opinion (D3).
308    let author_verification = if object_type == ObjectType::Patch {
309        crate::author_key_index::verify_author_signature(layout, envelope)?.map(
310            |(key_id, sound)| {
311                if sound {
312                    AuthorSignatureVerification::Sound { key_id }
313                } else {
314                    AuthorSignatureVerification::Unverifiable { key_id }
315                }
316            },
317        )
318    } else {
319        None
320    };
321    let (rollback_patch_count, merge_baseline_divergence) = if object_type == ObjectType::Block {
322        verify_block_payload(
323            object_store,
324            object_id,
325            layout.format(),
326            &envelope.canonical_payload,
327            pending_v3_blocks,
328        )?
329    } else {
330        (0, None)
331    };
332    Ok((
333        ObjectVerification {
334            object_id,
335            object_type,
336            path: locator.to_path_buf(),
337            rollback_patch_count,
338            sealed_by_key_id,
339            author_verification,
340        },
341        signature_issues,
342        merge_baseline_divergence,
343    ))
344}
345
346/// Scan every persisted object type's **loose-file** directory tree for `.pobj.tmp.` debris only
347/// (design-v1.md §12.3 item 3: `object_temp_paths`/`PRIKK-DOCTOR-OBJECT-TEMP-DEBRIS` are kept,
348/// dormant -- a format-3 repository can no longer produce this debris via `FileObjectStore`, which
349/// now writes containers, but retiring the diagnostic is an RFC-level act alongside G5, not a side
350/// effect of this stage). A real (non-temp) `.pobj` file found here is unconditionally unexpected
351/// under format-3 -- nothing writes one -- and fails closed exactly like the pre-existing structural
352/// checks below already do for a non-directory/non-file in the wrong place.
353fn scan_loose_file_temp_debris(layout: &RepositoryLayout) -> Result<Vec<PathBuf>> {
354    let mut temp_paths = Vec::new();
355    for object_type in persisted_object_types() {
356        let dir = layout.object_type_dir(object_type);
357        let relative_dir = layout.repository_relative(&dir)?;
358        match inspect_entry(layout.repository_mutation_root(), &relative_dir)? {
359            None => continue,
360            Some(EntryKind::Directory) => {}
361            Some(_) => {
362                return Err(PrikkError::Integrity(format!(
363                    "unexpected non-directory in object type directory: {}",
364                    dir.display()
365                )));
366            }
367        }
368        let mut prefix_entries = list_directory(layout.repository_mutation_root(), &relative_dir)?;
369        prefix_entries.sort_by(|left, right| {
370            left.name
371                .as_encoded_bytes()
372                .cmp(right.name.as_encoded_bytes())
373        });
374        for prefix_entry in prefix_entries {
375            let prefix_path = dir.join(&prefix_entry.name);
376            if prefix_entry.kind != EntryKind::Directory {
377                return Err(PrikkError::Integrity(format!(
378                    "unexpected non-directory in object type directory: {}",
379                    prefix_path.display()
380                )));
381            }
382            let relative_prefix = layout.repository_relative(&prefix_path)?;
383            let mut entries = list_directory(layout.repository_mutation_root(), &relative_prefix)?;
384            entries.sort_by(|left, right| {
385                left.name
386                    .as_encoded_bytes()
387                    .cmp(right.name.as_encoded_bytes())
388            });
389            for entry in entries {
390                let path = prefix_path.join(&entry.name);
391                if entry.kind != EntryKind::Regular {
392                    return Err(PrikkError::Integrity(format!(
393                        "unexpected non-file in object prefix directory: {}",
394                        path.display()
395                    )));
396                }
397                if is_object_temp_path(&path) {
398                    temp_paths.push(path);
399                    continue;
400                }
401                return Err(PrikkError::Integrity(format!(
402                    "unexpected loose object file under format-3 (containers own object storage \
403                     now): {}",
404                    path.display()
405                )));
406            }
407        }
408    }
409    Ok(temp_paths)
410}
411
412fn is_object_temp_path(path: &Path) -> bool {
413    let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
414        return false;
415    };
416    let Some((object_name, suffix)) = name.split_once(".pobj.tmp.") else {
417        return false;
418    };
419    let Some((pid, random)) = suffix.split_once('.') else {
420        return false;
421    };
422    object_name.len() == 64
423        && object_name.bytes().all(|byte| byte.is_ascii_hexdigit())
424        && !pid.is_empty()
425        && pid.bytes().all(|byte| byte.is_ascii_digit())
426        && random.len() == 32
427        && random.bytes().all(|byte| byte.is_ascii_hexdigit())
428}