spec_driven_docs/services/
verifier.rs1use 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#[derive(Debug, Default)]
21pub struct VerifyReport {
22 pub lines: Vec<String>,
24 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
90fn 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
168pub 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 #[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}