Skip to main content

prikk_store/
doctor.rs

1//! Repository doctor diagnostics and narrowly-scoped repair helpers.
2//!
3//! Doctor repairs are deliberately conservative. Mutating repair is opt-in and limited to
4//! incomplete active-WAL tail truncation. The former format-1 missing-pointer switch remains a
5//! compatibility input but is explicitly refused.
6
7use prikk_error::{PrikkError, Result};
8
9use crate::block_state::BlockStateStatus;
10use crate::layout::RepositoryLayout;
11use crate::lock::ActiveLock;
12use crate::refs::{RefFileStatus, RefItemStatus};
13use crate::verify::{
14    ActiveWalMetadataStatus, ObjectItemStatus, RepositoryVerification, StageStatus,
15    verify_repository,
16};
17use crate::wal::{Wal, WalRecordStatus, WalRepair};
18
19/// Severity assigned to a doctor diagnostic issue.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DoctorSeverity {
22    /// Informational diagnostic that does not require user action.
23    Info,
24    /// Warning diagnostic that may require attention but does not prove corruption.
25    Warning,
26    /// Error diagnostic that blocks repository health.
27    Error,
28}
29
30impl DoctorSeverity {
31    /// Return a stable lower-case label for CLI output.
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::Info => "info",
36            Self::Warning => "warning",
37            Self::Error => "error",
38        }
39    }
40}
41
42/// One doctor diagnostic.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DoctorIssue {
45    /// Stable diagnostic code.
46    pub code: &'static str,
47    /// Diagnostic severity.
48    pub severity: DoctorSeverity,
49    /// Human-readable explanation.
50    pub message: String,
51    /// Suggested next action.
52    pub recommendation: String,
53}
54
55impl DoctorIssue {
56    /// Construct an informational diagnostic.
57    #[must_use]
58    pub fn info(
59        code: &'static str,
60        message: impl Into<String>,
61        recommendation: impl Into<String>,
62    ) -> Self {
63        Self {
64            code,
65            severity: DoctorSeverity::Info,
66            message: message.into(),
67            recommendation: recommendation.into(),
68        }
69    }
70
71    /// Construct a warning diagnostic.
72    #[must_use]
73    pub fn warning(
74        code: &'static str,
75        message: impl Into<String>,
76        recommendation: impl Into<String>,
77    ) -> Self {
78        Self {
79            code,
80            severity: DoctorSeverity::Warning,
81            message: message.into(),
82            recommendation: recommendation.into(),
83        }
84    }
85
86    /// Construct an error diagnostic.
87    #[must_use]
88    pub fn error(
89        code: &'static str,
90        message: impl Into<String>,
91        recommendation: impl Into<String>,
92    ) -> Self {
93        Self {
94            code,
95            severity: DoctorSeverity::Error,
96            message: message.into(),
97            recommendation: recommendation.into(),
98        }
99    }
100}
101
102/// Doctor report.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct DoctorReport {
105    /// Repository verification summary, when verification completed.
106    pub verification: Option<RepositoryVerification>,
107    /// Diagnostics produced by doctor.
108    pub issues: Vec<DoctorIssue>,
109}
110
111impl DoctorReport {
112    /// Return true if no error-severity issue was found.
113    #[must_use]
114    pub fn is_healthy(&self) -> bool {
115        !self
116            .issues
117            .iter()
118            .any(|issue| issue.severity == DoctorSeverity::Error)
119    }
120
121    /// Count issues with a given severity.
122    #[must_use]
123    pub fn count_by_severity(&self, severity: DoctorSeverity) -> usize {
124        self.issues
125            .iter()
126            .filter(|issue| issue.severity == severity)
127            .count()
128    }
129}
130
131/// Opt-in repair switches for doctor.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct DoctorRepairOptions {
134    /// Truncate incomplete trailing bytes from the active WAL after verification confirms that the
135    /// prefix is valid.
136    pub truncate_wal_tail: bool,
137    /// Request the refused format-1 `heads/main` reconstruction compatibility path.
138    pub reconstruct_main_ref: bool,
139}
140
141impl DoctorRepairOptions {
142    /// Return options that perform no repair.
143    #[must_use]
144    pub const fn none() -> Self {
145        Self {
146            truncate_wal_tail: false,
147            reconstruct_main_ref: false,
148        }
149    }
150
151    /// Return options that enable only safe active-WAL tail truncation.
152    #[must_use]
153    pub const fn truncate_wal_tail() -> Self {
154        Self {
155            truncate_wal_tail: true,
156            reconstruct_main_ref: false,
157        }
158    }
159
160    /// Return options that request the refused format-1 missing-pointer compatibility path.
161    #[must_use]
162    pub const fn reconstruct_main_ref() -> Self {
163        Self {
164            truncate_wal_tail: false,
165            reconstruct_main_ref: true,
166        }
167    }
168}
169
170/// Report returned by an opt-in doctor repair run.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct DoctorRepairReport {
173    /// Doctor report before any repair action.
174    pub before: DoctorReport,
175    /// WAL repair summary.
176    pub wal_repair: WalRepair,
177    /// Doctor report after repair action.
178    pub after: DoctorReport,
179}
180
181/// Run doctor diagnostics for a repository layout.
182#[must_use]
183pub fn doctor_repository(layout: &RepositoryLayout) -> DoctorReport {
184    let mut issues = Vec::new();
185    match verify_repository(layout) {
186        Ok(verification) => {
187            issues.push(DoctorIssue::info(
188                "PRIKK-DOCTOR-VERIFY-OK",
189                "repository structural verification scan completed",
190                "review the remaining diagnostics before deciding whether action is required",
191            ));
192            // DC-95 Stage 2 Level 1: a stage that failed or could not evaluate is blocking by
193            // construction (severity derives from the stage outcome itself, not a per-field decision
194            // here) -- this is what preserves `repair_repository`'s refusal gate now that
195            // `verify_repository` no longer aborts on the first hard error.
196            for outcome in &verification.stage_outcomes {
197                let message = match &outcome.status {
198                    StageStatus::Evaluated => continue,
199                    StageStatus::Failed { message } => {
200                        format!("verification stage {} failed: {message}", outcome.stage)
201                    }
202                    StageStatus::NotEvaluated { blocked_by } => {
203                        format!(
204                            "verification stage {} could not run because stage {blocked_by} did not evaluate",
205                            outcome.stage
206                        )
207                    }
208                    StageStatus::Halted { after } => {
209                        format!(
210                            "verification stage {} was not attempted because stage {after} failed and halted the walk (--stop-on-first-error)",
211                            outcome.stage
212                        )
213                    }
214                };
215                issues.push(DoctorIssue::error(
216                    "PRIKK-DOCTOR-VERIFY-STAGE-INCOMPLETE",
217                    message,
218                    "preserve the repository and inspect the failing stage before attempting repair",
219                ));
220            }
221            // DC-95 Stage 2 Level 2: item containment means the `Objects` stage above can be
222            // `Evaluated` even when one of its items individually failed -- these two loops are what
223            // preserve `repair_repository`'s refusal gate at item granularity, the same way the loop
224            // above preserves it at stage granularity.
225            for outcome in &verification.object_outcomes {
226                if let ObjectItemStatus::Failed { message } = &outcome.status {
227                    issues.push(DoctorIssue::error(
228                        "PRIKK-DOCTOR-VERIFY-OBJECT-INCOMPLETE",
229                        format!(
230                            "object {} ({}) failed verification: {message}",
231                            outcome.path.display(),
232                            outcome.object_type
233                        ),
234                        "preserve the repository and inspect the failing object before attempting repair",
235                    ));
236                }
237            }
238            for outcome in &verification.block_state_outcomes {
239                let message = match &outcome.status {
240                    BlockStateStatus::Verified => continue,
241                    BlockStateStatus::Failed { message } => {
242                        format!(
243                            "Block {} state-root verification failed: {message}",
244                            outcome.block_id
245                        )
246                    }
247                    BlockStateStatus::NotEvaluated { blocked_by } => {
248                        format!(
249                            "Block {} state root could not be verified because its state-derivation \
250                             parent {blocked_by} did not evaluate",
251                            outcome.block_id
252                        )
253                    }
254                };
255                issues.push(DoctorIssue::error(
256                    "PRIKK-DOCTOR-VERIFY-BLOCK-STATE-INCOMPLETE",
257                    message,
258                    "preserve the repository and inspect the failing block before attempting repair",
259                ));
260            }
261            // DC-95 Stage 2 Level 2 (refs half): same reasoning as the two loops above, one level
262            // in for `verify_refs`'s own items -- a single ref's pointer file, log file, or
263            // classification failing no longer fails the whole `Refs` stage.
264            for outcome in verification
265                .pointer_outcomes
266                .iter()
267                .chain(&verification.log_outcomes)
268            {
269                if let RefFileStatus::Failed { message } = &outcome.status {
270                    issues.push(DoctorIssue::error(
271                        "PRIKK-DOCTOR-VERIFY-REF-FILE-INCOMPLETE",
272                        format!("ref file {} failed verification: {message}", outcome.path.display()),
273                        "preserve the repository and inspect the failing ref file before attempting repair",
274                    ));
275                }
276            }
277            for outcome in &verification.ref_item_outcomes {
278                if let RefItemStatus::Failed { message } = &outcome.status {
279                    issues.push(DoctorIssue::error(
280                        "PRIKK-DOCTOR-VERIFY-REF-ITEM-INCOMPLETE",
281                        format!("ref {} failed verification: {message}", outcome.ref_name),
282                        "preserve the repository and inspect the failing ref before attempting repair",
283                    ));
284                }
285            }
286            // RFC 102 Stage 2: isolate-and-continue reading means a damaged WAL record no longer
287            // fails the whole `WalReplay` stage -- same shape as the two ref loops above, one level
288            // in for the WAL's own records.
289            for outcome in &verification.wal_record_outcomes {
290                if let WalRecordStatus::Failed { message } = &outcome.status {
291                    issues.push(DoctorIssue::error(
292                        "PRIKK-DOCTOR-VERIFY-WAL-RECORD-INCOMPLETE",
293                        format!(
294                            "WAL record at offset {} failed verification: {message}",
295                            outcome.offset
296                        ),
297                        "preserve the repository and inspect the failing WAL record before attempting repair",
298                    ));
299                }
300            }
301            if verification
302                .trailing_partial_wal_bytes
303                .is_some_and(|n| n != 0)
304            {
305                issues.push(DoctorIssue::warning(
306                    "PRIKK-DOCTOR-WAL-TRAILING-PARTIAL",
307                    format!(
308                        concat!(
309                            "active WAL has {} trailing byte(s) that look like an incomplete ",
310                            "final record"
311                        ),
312                        verification.trailing_partial_wal_bytes.unwrap_or_default()
313                    ),
314                    "run `prikk doctor --repair-wal-tail` to truncate only the incomplete \
315                     final WAL bytes",
316                ));
317            }
318            for issue in &verification.publication_trust_issues {
319                issues.push(DoctorIssue::error(
320                    issue.code,
321                    issue.message.clone(),
322                    "configure trusted MAINTAINER keys and re-run verification; doctor will not \
323                     auto-trust keys or repair signatures",
324                ));
325            }
326            for issue in &verification.signature_envelope_issues {
327                issues.push(DoctorIssue::warning(
328                    issue.code,
329                    format!("{}: {}", issue.source, issue.message),
330                    "preserve the format-1 bytes for inspection; do not normalize or reuse the envelope for mutation",
331                ));
332            }
333            for path in &verification.object_temp_paths {
334                let name = path
335                    .file_name()
336                    .and_then(|value| value.to_str())
337                    .unwrap_or("<non-UTF-8 object temp>");
338                issues.push(DoctorIssue::warning(
339                    "PRIKK-DOCTOR-OBJECT-TEMP-DEBRIS",
340                    format!("non-authoritative object publication temp remains: {name}"),
341                    "preserve it for inspection; doctor does not infer ownership or remove object temps",
342                ));
343            }
344            for issue in &verification.ref_publication_issues {
345                let recommendation = match issue.code {
346                    "PRIKK-VERIFY-REF-POINTER-LEADS-LOG"
347                    | "PRIKK-VERIFY-REF-LEGACY-LOG-LEADS"
348                    | "PRIKK-VERIFY-REF-ACTIVE-CLEANUP-PENDING" => {
349                        "run signer-backed `prikk seal --allow-no-audit` for the affected ref; doctor does not sign or append"
350                    }
351                    "PRIKK-VERIFY-REF-POINTER-MISSING" => {
352                        "preserve the repository; use signer-backed seal retry only with matching retained active state, otherwise restore from backup"
353                    }
354                    "PRIKK-VERIFY-REF-LEGACY-TIMESTAMP" => {
355                        "treat the value as non-authoritative legacy data; do not normalize signed bytes in place"
356                    }
357                    "PRIKK-VERIFY-REF-DIVERGENCE" => {
358                        "preserve the repository for manual recovery; signer-backed retry is not authorized without exact retained evidence"
359                    }
360                    _ => {
361                        "preserve the candidate for inspection; doctor does not infer ownership or remove it"
362                    }
363                };
364                let doctor_issue = if issue.blocking {
365                    DoctorIssue::error(issue.code, issue.message.clone(), recommendation)
366                } else {
367                    DoctorIssue::warning(issue.code, issue.message.clone(), recommendation)
368                };
369                issues.push(doctor_issue);
370            }
371            add_active_wal_metadata_issues(&verification, &mut issues);
372            DoctorReport {
373                verification: Some(verification),
374                issues,
375            }
376        }
377        Err(error) => {
378            issues.push(issue_for_verification_error(error));
379            DoctorReport {
380                verification: None,
381                issues,
382            }
383        }
384    }
385}
386
387/// Run an explicitly requested, narrow repair action.
388///
389/// The repair is refused if verification fails for any reason other than a trailing partial WAL
390/// record reported by normal replay. This preserves data until a future, more specific repair
391/// command is implemented.
392pub fn repair_repository(
393    layout: &RepositoryLayout,
394    options: DoctorRepairOptions,
395) -> Result<DoctorRepairReport> {
396    layout.require_current_format()?;
397    if options.reconstruct_main_ref {
398        return Err(PrikkError::Integrity(
399            "format-1 missing-pointer doctor repair is unsupported in 0.18.0; preserve the repository for signer-backed retry or later recovery tooling"
400                .to_string(),
401        ));
402    }
403    let _active_lock = ActiveLock::acquire(layout)?;
404    crate::refs::ensure_no_incomplete_publication(layout)?;
405    let before = doctor_repository(layout);
406    if !before.is_healthy() {
407        return Err(PrikkError::Integrity(
408            "doctor repair refused because repository verification has errors".to_string(),
409        ));
410    }
411    let wal_repair = if options.truncate_wal_tail {
412        let wal = Wal::for_layout(layout);
413        wal.truncate_trailing_partial()?
414    } else {
415        WalRepair {
416            preserved_records: 0,
417            truncated_bytes: 0,
418            preserved_patch_ids: Vec::new(),
419        }
420    };
421    let after = doctor_repository(layout);
422    Ok(DoctorRepairReport {
423        before,
424        wal_repair,
425        after,
426    })
427}
428
429fn add_active_wal_metadata_issues(
430    verification: &RepositoryVerification,
431    issues: &mut Vec<DoctorIssue>,
432) {
433    // `None` (the active-WAL-metadata stage did not evaluate) is already surfaced, more precisely, by
434    // the stage-outcome loop above this function's own call site.
435    let Some(status) = &verification.active_wal_metadata_status else {
436        return;
437    };
438    match status {
439        ActiveWalMetadataStatus::MissingForNonEmptyWal => issues.push(DoctorIssue::error(
440            "PRIKK-DOCTOR-ACTIVE-REF-METADATA-MISSING",
441            "active WAL has records but active ref metadata is missing",
442            "preserve the repository and inspect the active WAL before sealing or appending",
443        )),
444        ActiveWalMetadataStatus::InvalidForNonEmptyWal { reason } => {
445            issues.push(DoctorIssue::error(
446                "PRIKK-DOCTOR-ACTIVE-REF-METADATA-MALFORMED",
447                format!("active WAL has records but active ref metadata is malformed: {reason}"),
448                "preserve the repository and inspect the active WAL before sealing or appending",
449            ));
450        }
451        ActiveWalMetadataStatus::ValidForEmptyWal { ref_name } => issues.push(
452            DoctorIssue::warning(
453                "PRIKK-DOCTOR-ACTIVE-REF-METADATA-DEBRIS",
454                format!("active WAL is empty but stale ref metadata remains for {ref_name}"),
455                "no repair is required; the next guarded active-WAL append will replace stale metadata",
456            ),
457        ),
458        ActiveWalMetadataStatus::InvalidForEmptyWal { reason } => issues.push(
459            DoctorIssue::warning(
460                "PRIKK-DOCTOR-ACTIVE-REF-METADATA-MALFORMED-DEBRIS",
461                format!("active WAL is empty but malformed ref metadata remains: {reason}"),
462                "no repair is required; the next guarded active-WAL append will replace stale metadata",
463            ),
464        ),
465        ActiveWalMetadataStatus::MissingForEmptyWal
466        | ActiveWalMetadataStatus::ValidForNonEmptyWal { .. } => {}
467    }
468}
469
470fn issue_for_verification_error(error: PrikkError) -> DoctorIssue {
471    DoctorIssue::error(
472        "PRIKK-DOCTOR-VERIFY-ERROR",
473        format!("repository verification failed: {error}"),
474        "do not run seal or publish operations; preserve the repository and inspect the \
475         failing path before attempting repair",
476    )
477}
478
479#[cfg(test)]
480mod tests;