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