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 pub managed_drift: usize,
28 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
94fn 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 for template in crate::domain::profile::CANON_TEMPLATES {
136 expected.insert((*template).to_string());
137 }
138 for destination in &expected {
139 if !managed.contains(destination.as_str()) && !adopted.contains(destination.as_str()) {
140 missing.push(destination.clone());
141 }
142 }
143 } else {
144 for projection in declaration.managed {
145 if !managed.contains(projection.destination) {
146 missing.push(projection.destination.to_string());
147 }
148 }
149 for projection in declaration.adopted {
150 let destination = crate::domain::profile::resolve_destination(
151 projection.destination,
152 manifest.docs_root,
153 );
154 if !adopted.contains(destination.as_str()) {
155 missing.push(destination.to_string());
156 }
157 }
158 }
159 for destination in missing {
160 report.fail(format!(
161 "FAIL manifest omits a declared projection: {destination}"
162 ));
163 }
164 let required: &[&str] = if self_layout {
168 &[".pre-commit-config.yaml"]
169 } else {
170 &[".pre-commit-config.yaml", "AGENTS.md"]
171 };
172 for path in required {
173 if !manifest
174 .integration_blocks
175 .iter()
176 .any(|block| block.path.as_str() == *path)
177 {
178 report.fail(format!(
179 "FAIL manifest records no integration block for {path}"
180 ));
181 }
182 }
183}
184
185pub fn verify(target: &Utf8Path) -> Result<VerifyReport, AppError> {
193 let manifest = read_manifest(target)?;
194 let mut report = VerifyReport::default();
195
196 for entry in &manifest.managed_files {
197 let file = target.join(&entry.destination);
198 if reached_through_symlink(target, &entry.destination) {
199 report.managed_drift += 1;
200 report.fail(format!(
201 "FAIL managed file reached through a symlink: {}",
202 entry.destination
203 ));
204 continue;
205 }
206 if !file.is_file() {
207 report.managed_drift += 1;
208 report.fail(format!("FAIL missing managed file: {}", entry.destination));
209 continue;
210 }
211 if sha256_file(&file)? != entry.sha256 {
212 report.managed_drift += 1;
213 report.fail(format!("FAIL managed drift: {}", entry.destination));
214 }
215 }
216
217 for entry in &manifest.adopted_files {
218 let file = target.join(&entry.destination);
219 if reached_through_symlink(target, &entry.destination) {
220 report.fail(format!(
221 "FAIL adopted file reached through a symlink: {}",
222 entry.destination
223 ));
224 continue;
225 }
226 if !file.is_file() {
227 report.fail(format!("FAIL missing adopted file: {}", entry.destination));
228 continue;
229 }
230 if sha256_file(&file)? != entry.sha256 {
231 report.adopted_drift += 1;
232 report.note(format!(
233 "DRIFT adopted file requires reconciliation: {}",
234 entry.destination
235 ));
236 }
237 }
238
239 check_projection(&manifest, &mut report);
240 check_integration(target, &manifest, &mut report)?;
241 check_specs(target, &manifest, &mut report)?;
242
243 let current = CanonVersion::current();
244 if manifest.canon_version > current {
245 report.fail(format!(
246 "FAIL sdd {current} is older than the installed canon {}; upgrade sdd",
247 manifest.canon_version
248 ));
249 } else if manifest.canon_version < current {
250 report.note(format!(
251 "note: sdd {current} is newer than the installed canon {}; run 'sdd upgrade'",
252 manifest.canon_version
253 ));
254 }
255
256 if report.failures == 0 {
257 report.note(format!(
258 "OK spec-driven-docs {} at {target}",
259 manifest.canon_version
260 ));
261 }
262 Ok(report)
263}
264
265fn markers_for(path: &str) -> (&'static str, &'static str) {
267 if path == ".pre-commit-config.yaml" {
268 (marker::BEGIN, marker::END)
269 } else {
270 (marker::AGENTS_BEGIN, marker::AGENTS_END)
271 }
272}
273
274fn check_integration(
275 target: &Utf8Path,
276 manifest: &Manifest,
277 report: &mut VerifyReport,
278) -> Result<(), AppError> {
279 for block in &manifest.integration_blocks {
280 let path = block.path.as_str();
281 let (begin, end) = markers_for(path);
282 let full = target.join(&block.path);
283 if reached_through_symlink(target, &block.path) {
284 report.fail(format!("FAIL {path} reached through a symlink"));
285 continue;
286 }
287 if !full.is_file() {
288 report.fail(format!("FAIL missing integration host: {path}"));
289 continue;
290 }
291 let host = std::fs::read_to_string(&full)?;
292 let begins = host.lines().filter(|line| *line == begin).count();
293 let ends = host.lines().filter(|line| *line == end).count();
294 if begins != 1 {
295 report.fail(format!("FAIL missing managed block: {path}"));
296 continue;
297 }
298 if ends != 1 {
299 report.fail(format!("FAIL malformed managed block: {path}"));
300 continue;
301 }
302 match marker::block_hash_with(&host, begin, end) {
303 Some(present) if present == block.marker_hash => {
304 if path == ".pre-commit-config.yaml"
305 && let Some(region) = marker::block_region_with(&host, begin, end)
306 {
307 check_block_entries(®ion, report);
308 }
309 }
310 _ => report.fail(format!("FAIL managed block tampered: {path}")),
311 }
312 }
313 Ok(())
314}
315
316fn check_specs(
317 target: &Utf8Path,
318 manifest: &Manifest,
319 report: &mut VerifyReport,
320) -> Result<(), AppError> {
321 let specs = target.join(manifest.docs_root.as_str()).join("specs");
322 if specs.is_dir() {
323 let mut counts = std::collections::BTreeMap::new();
324 let mut names: Vec<_> = specs
325 .read_dir_utf8()?
326 .filter_map(Result::ok)
327 .map(|entry| entry.file_name().to_string())
328 .filter(|name| {
329 #[allow(clippy::case_sensitive_file_extension_comparisons)]
331 name.ends_with(".md")
332 })
333 .collect();
334 names.sort();
335 for name in names {
336 let text = std::fs::read_to_string(specs.join(name))?;
337 for id in crate::embedded::rule_ids_in(&text) {
338 *counts.entry(id).or_insert(0usize) += 1;
339 }
340 }
341 let duplicated: Vec<String> = counts
342 .into_iter()
343 .filter(|(_, n)| *n > 1)
344 .map(|(id, _)| id)
345 .collect();
346 if !duplicated.is_empty() {
347 report.fail("FAIL duplicate rule ID in local specs");
348 for id in duplicated {
349 report.note(format!("### `{id}`"));
350 }
351 }
352 } else {
353 report.fail(format!(
354 "FAIL missing local specs: {}/specs",
355 manifest.docs_root
356 ));
357 }
358 Ok(())
359}