Skip to main content

spec_driven_docs/services/
verifier.rs

1//! Offline instance verification.
2//!
3//! Reads the manifest, compares every recorded projection to the disk, holds
4//! the managed block to its recorded hash, checks the block's `sdd` entries
5//! resolve, scans for duplicate rule IDs, and compares the instance's canon
6//! version to this binary's. Everything is local: no network, no canon
7//! checkout, no ambient tools. What gets recorded is the installer's
8//! business; this only holds the record to the disk.
9
10use camino::Utf8Path;
11
12use crate::adapters::fs::{DestinationRefusal, check_destination, sha256_file};
13use crate::domain::gate_id::GateId;
14use crate::domain::manifest::{MANIFEST_PATH, Manifest, ManifestParseError};
15use crate::domain::marker;
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18
19/// What a verification run reports.
20#[derive(Debug, Default)]
21pub struct VerifyReport {
22    /// Every line to print, failures and notes alike, in order.
23    pub lines: Vec<String>,
24    /// How many lines are failures.
25    pub failures: usize,
26    /// How many managed files are missing, symlinked, or byte-drifted.
27    pub managed_drift: usize,
28    /// How many adopted files await reconciliation.
29    pub adopted_drift: usize,
30}
31
32impl VerifyReport {
33    fn fail(&mut self, line: impl Into<String>) {
34        self.lines.push(line.into());
35        self.failures += 1;
36    }
37
38    fn note(&mut self, line: impl Into<String>) {
39        self.lines.push(line.into());
40    }
41}
42
43pub(crate) fn read_manifest(target: &Utf8Path) -> Result<Manifest, AppError> {
44    let path = target.join(MANIFEST_PATH);
45    if reached_through_symlink(target, Utf8Path::new(MANIFEST_PATH)) {
46        return Err(AppError::ManifestInvalid(
47            "manifest reached through a symlink".to_string(),
48        ));
49    }
50    if !path.is_file() {
51        return Err(AppError::ManifestMissing(path));
52    }
53    let text = std::fs::read_to_string(&path)?;
54    Manifest::parse(&text).map_err(|error| match error {
55        ManifestParseError::Invalid(detail) => AppError::ManifestInvalid(detail),
56        other => AppError::ManifestInvalid(other.to_string()),
57    })
58}
59
60fn check_block_entries(block: &str, report: &mut VerifyReport) {
61    let mut sdd_entries = 0usize;
62    for line in block.lines() {
63        let Some(entry) = line.trim_start().strip_prefix("entry: ") else {
64            continue;
65        };
66        let words: Vec<&str> = entry.split_whitespace().collect();
67        let Some(gate_position) = words.iter().position(|word| *word == "gate") else {
68            if words.last() == Some(&"verify") {
69                sdd_entries += 1;
70            }
71            continue;
72        };
73        sdd_entries += 1;
74        match words.get(gate_position + 1) {
75            Some(id) if id.parse::<GateId>().is_ok() => {}
76            Some(id) => report.fail(format!(
77                "FAIL managed block entry names an unknown gate: {id}"
78            )),
79            None => report.fail(format!("FAIL managed block entry names no gate: {entry}")),
80        }
81    }
82    if sdd_entries == 0 {
83        report.fail("FAIL managed block wires no sdd entry");
84    }
85}
86
87fn reached_through_symlink(target: &Utf8Path, destination: &Utf8Path) -> bool {
88    matches!(
89        check_destination(target, destination),
90        Err(DestinationRefusal::SymlinkEscape)
91    )
92}
93
94/// The manifest must record every projection the profile declares — an
95/// omitted record is an owned file the verifier would silently stop
96/// holding. Applies when the instance is at this binary's version, in
97/// whichever of the two layouts the record claims: the installed layout,
98/// or the canon's self-manifest layout where every owned file sits at its
99/// authored path. Either way the complete expected set for that layout is
100/// required, so no hand edit of the record can shrink what is held —
101/// masquerading as the other layout only changes which files must exist
102/// and hash clean.
103fn check_projection(manifest: &Manifest, report: &mut VerifyReport) {
104    if manifest.canon_version != CanonVersion::current() {
105        return;
106    }
107    let declaration = manifest.profile.profile();
108    let managed: std::collections::BTreeSet<&str> = manifest
109        .managed_files
110        .iter()
111        .map(|entry| entry.destination.as_str())
112        .collect();
113    let adopted: std::collections::BTreeSet<&str> = manifest
114        .adopted_files
115        .iter()
116        .map(|entry| entry.destination.as_str())
117        .collect();
118
119    let self_layout = declaration
120        .managed
121        .iter()
122        .all(|projection| managed.contains(projection.source));
123
124    let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
125    let mut missing: Vec<String> = Vec::new();
126    if self_layout {
127        for projection in declaration.managed {
128            expected.insert(projection.source.to_string());
129        }
130        for file in crate::embedded::SPECS.files() {
131            if let Some(name) = file.path().as_os_str().to_str() {
132                expected.insert(format!("_docs/specs/{name}"));
133            }
134        }
135        expected.insert("_docs/decisions/TEMPLATE-adr.md".to_string());
136        expected.insert("_docs/reference/TEMPLATE-agents-digest.md".to_string());
137        for destination in &expected {
138            if !managed.contains(destination.as_str()) && !adopted.contains(destination.as_str()) {
139                missing.push(destination.clone());
140            }
141        }
142    } else {
143        for projection in declaration.managed {
144            if !managed.contains(projection.destination) {
145                missing.push(projection.destination.to_string());
146            }
147        }
148        for projection in declaration.adopted {
149            let destination = crate::domain::profile::resolve_destination(
150                projection.destination,
151                manifest.docs_root,
152            );
153            if !adopted.contains(destination.as_str()) {
154                missing.push(destination.to_string());
155            }
156        }
157    }
158    for destination in missing {
159        report.fail(format!(
160            "FAIL manifest omits a declared projection: {destination}"
161        ));
162    }
163    if !manifest
164        .integration_blocks
165        .iter()
166        .any(|block| block.path == ".pre-commit-config.yaml")
167    {
168        report.fail("FAIL manifest records no integration block for .pre-commit-config.yaml");
169    }
170}
171
172/// Verify an installed instance offline.
173///
174/// # Errors
175///
176/// [`AppError::ManifestMissing`] / [`AppError::ManifestInvalid`] when the
177/// record itself cannot be trusted, and I/O errors when the disk cannot be
178/// read; recorded-versus-disk differences are reported, not raised.
179pub fn verify(target: &Utf8Path) -> Result<VerifyReport, AppError> {
180    let manifest = read_manifest(target)?;
181    let mut report = VerifyReport::default();
182
183    for entry in &manifest.managed_files {
184        let file = target.join(&entry.destination);
185        if reached_through_symlink(target, &entry.destination) {
186            report.managed_drift += 1;
187            report.fail(format!(
188                "FAIL managed file reached through a symlink: {}",
189                entry.destination
190            ));
191            continue;
192        }
193        if !file.is_file() {
194            report.managed_drift += 1;
195            report.fail(format!("FAIL missing managed file: {}", entry.destination));
196            continue;
197        }
198        if sha256_file(&file)? != entry.sha256 {
199            report.managed_drift += 1;
200            report.fail(format!("FAIL managed drift: {}", entry.destination));
201        }
202    }
203
204    for entry in &manifest.adopted_files {
205        let file = target.join(&entry.destination);
206        if reached_through_symlink(target, &entry.destination) {
207            report.fail(format!(
208                "FAIL adopted file reached through a symlink: {}",
209                entry.destination
210            ));
211            continue;
212        }
213        if !file.is_file() {
214            report.fail(format!("FAIL missing adopted file: {}", entry.destination));
215            continue;
216        }
217        if sha256_file(&file)? != entry.sha256 {
218            report.adopted_drift += 1;
219            report.note(format!(
220                "DRIFT adopted file requires reconciliation: {}",
221                entry.destination
222            ));
223        }
224    }
225
226    check_projection(&manifest, &mut report);
227    check_integration(target, &manifest, &mut report)?;
228    check_specs(target, &manifest, &mut report)?;
229
230    let current = CanonVersion::current();
231    if manifest.canon_version > current {
232        report.fail(format!(
233            "FAIL sdd {current} is older than the installed canon {}; upgrade sdd",
234            manifest.canon_version
235        ));
236    } else if manifest.canon_version < current {
237        report.note(format!(
238            "note: sdd {current} is newer than the installed canon {}; run 'sdd upgrade'",
239            manifest.canon_version
240        ));
241    }
242
243    if report.failures == 0 {
244        report.note(format!(
245            "OK spec-driven-docs {} at {target}",
246            manifest.canon_version
247        ));
248    }
249    Ok(report)
250}
251
252fn check_integration(
253    target: &Utf8Path,
254    manifest: &Manifest,
255    report: &mut VerifyReport,
256) -> Result<(), AppError> {
257    let config_path = target.join(".pre-commit-config.yaml");
258    if reached_through_symlink(target, Utf8Path::new(".pre-commit-config.yaml")) {
259        report.fail("FAIL .pre-commit-config.yaml reached through a symlink");
260    } else if config_path.is_file() {
261        let config = std::fs::read_to_string(&config_path)?;
262        let begins = config.lines().filter(|line| *line == marker::BEGIN).count();
263        let ends = config.lines().filter(|line| *line == marker::END).count();
264        if begins != 1 {
265            report.fail("FAIL missing managed pre-commit block");
266        } else if ends != 1 {
267            report.fail("FAIL malformed managed pre-commit block");
268        } else {
269            let recorded = manifest
270                .integration_blocks
271                .iter()
272                .find(|block| block.path == ".pre-commit-config.yaml")
273                .map(|block| &block.marker_hash);
274            match (recorded, marker::block_hash(&config)) {
275                (None, _) => {
276                    report.fail("FAIL manifest records no marker hash for .pre-commit-config.yaml");
277                }
278                (Some(recorded), Some(present)) if *recorded == present => {
279                    if let Some(block) = marker::block_region(&config) {
280                        check_block_entries(&block, report);
281                    }
282                }
283                (Some(_), _) => report.fail("FAIL managed block tampered: .pre-commit-config.yaml"),
284            }
285        }
286    } else {
287        report.fail("FAIL missing .pre-commit-config.yaml");
288    }
289    Ok(())
290}
291
292fn check_specs(
293    target: &Utf8Path,
294    manifest: &Manifest,
295    report: &mut VerifyReport,
296) -> Result<(), AppError> {
297    let specs = target.join(manifest.docs_root.as_str()).join("specs");
298    if specs.is_dir() {
299        let mut counts = std::collections::BTreeMap::new();
300        let mut names: Vec<_> = specs
301            .read_dir_utf8()?
302            .filter_map(Result::ok)
303            .map(|entry| entry.file_name().to_string())
304            .filter(|name| {
305                // The corpus convention is lowercase.
306                #[allow(clippy::case_sensitive_file_extension_comparisons)]
307                name.ends_with(".md")
308            })
309            .collect();
310        names.sort();
311        for name in names {
312            let text = std::fs::read_to_string(specs.join(name))?;
313            for id in crate::embedded::rule_ids_in(&text) {
314                *counts.entry(id).or_insert(0usize) += 1;
315            }
316        }
317        let duplicated: Vec<String> = counts
318            .into_iter()
319            .filter(|(_, n)| *n > 1)
320            .map(|(id, _)| id)
321            .collect();
322        if !duplicated.is_empty() {
323            report.fail("FAIL duplicate rule ID in local specs");
324            for id in duplicated {
325                report.note(format!("### `{id}`"));
326            }
327        }
328    } else {
329        report.fail(format!(
330            "FAIL missing local specs: {}/specs",
331            manifest.docs_root
332        ));
333    }
334    Ok(())
335}