Skip to main content

apcore_toolkit/output/
verifiers.rs

1// Built-in verifiers for output writers.
2//
3// Each verifier implements the Verifier trait and checks a specific
4// aspect of a written artifact.
5
6use std::fs;
7use std::panic::{catch_unwind, AssertUnwindSafe};
8
9use crate::output::types::{Verifier, VerifyResult};
10
11/// Verify that a Rust source file has valid syntax.
12///
13/// Uses the `syn` crate to parse the file as a Rust source file.
14/// Analogous to `SyntaxVerifier` in Python (which uses `ast.parse`)
15/// and TypeScript (which does a basic readability check).
16pub struct SyntaxVerifier;
17
18impl Verifier for SyntaxVerifier {
19    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
20        if path.is_empty() {
21            return VerifyResult::ok();
22        }
23        let content = match fs::read_to_string(path) {
24            Ok(c) => c,
25            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
26        };
27
28        if content.trim().is_empty() {
29            return VerifyResult::fail("File is empty".into());
30        }
31
32        match syn::parse_file(&content) {
33            Ok(_) => VerifyResult::ok(),
34            Err(e) => VerifyResult::fail(format!("Invalid Rust syntax: {e}")),
35        }
36    }
37}
38
39/// Verify that a YAML binding file is parseable and contains required fields.
40pub struct YAMLVerifier;
41
42impl Verifier for YAMLVerifier {
43    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
44        if path.is_empty() {
45            return VerifyResult::ok();
46        }
47        let content = match fs::read_to_string(path) {
48            Ok(c) => c,
49            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
50        };
51
52        let parsed: serde_yaml_ng::Value = match serde_yaml_ng::from_str(&content) {
53            Ok(v) => v,
54            Err(e) => return VerifyResult::fail(format!("Invalid YAML: {e}")),
55        };
56
57        let bindings = match parsed.get("bindings") {
58            Some(b) => b,
59            None => return VerifyResult::fail("Missing or empty 'bindings' list".into()),
60        };
61
62        let bindings_seq = match bindings.as_sequence() {
63            Some(s) if !s.is_empty() => s,
64            _ => return VerifyResult::fail("Missing or empty 'bindings' list".into()),
65        };
66
67        for (i, entry) in bindings_seq.iter().enumerate() {
68            for field in &["module_id", "target"] {
69                match entry.get(*field) {
70                    Some(serde_yaml_ng::Value::String(s)) if !s.trim().is_empty() => {}
71                    _ => {
72                        return VerifyResult::fail(format!(
73                            "Entry {i}: missing or invalid '{field}' field"
74                        ))
75                    }
76                }
77            }
78        }
79
80        VerifyResult::ok()
81    }
82}
83
84/// Verify that a file contains valid JSON, with optional schema validation.
85pub struct JSONVerifier {
86    // Schema validation is omitted (would require jsonschema crate).
87    // Only checks that the file is valid JSON.
88}
89
90impl JSONVerifier {
91    /// Create a new JSONVerifier.
92    pub fn new() -> Self {
93        Self {}
94    }
95}
96
97impl Default for JSONVerifier {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl Verifier for JSONVerifier {
104    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
105        if path.is_empty() {
106            return VerifyResult::ok();
107        }
108        let content = match fs::read_to_string(path) {
109            Ok(c) => c,
110            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
111        };
112
113        match serde_json::from_str::<serde_json::Value>(&content) {
114            Ok(_) => VerifyResult::ok(),
115            Err(e) => VerifyResult::fail(format!("Invalid JSON: {e}")),
116        }
117    }
118}
119
120/// Verify that a file starts with expected magic bytes.
121pub struct MagicBytesVerifier {
122    expected: Vec<u8>,
123}
124
125impl MagicBytesVerifier {
126    /// Create a new MagicBytesVerifier with the expected byte sequence.
127    pub fn new(expected: Vec<u8>) -> Self {
128        Self { expected }
129    }
130}
131
132impl Verifier for MagicBytesVerifier {
133    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
134        if path.is_empty() {
135            return VerifyResult::ok();
136        }
137        let content = match fs::read(path) {
138            Ok(c) => c,
139            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
140        };
141
142        if content.len() < self.expected.len() {
143            return VerifyResult::fail(format!(
144                "File too short: expected at least {} bytes, got {}",
145                self.expected.len(),
146                content.len()
147            ));
148        }
149
150        let header = &content[..self.expected.len()];
151        if header != self.expected.as_slice() {
152            return VerifyResult::fail(format!(
153                "Magic bytes mismatch: expected {:?}, got {:?}",
154                self.expected, header
155            ));
156        }
157
158        VerifyResult::ok()
159    }
160}
161
162/// Verify that a module is registered and retrievable from a registry.
163pub struct RegistryVerifier<'a> {
164    registry: &'a apcore::Registry,
165}
166
167impl<'a> RegistryVerifier<'a> {
168    /// Create a new RegistryVerifier checking against the given registry.
169    pub fn new(registry: &'a apcore::Registry) -> Self {
170        Self { registry }
171    }
172}
173
174impl Verifier for RegistryVerifier<'_> {
175    fn verify(&self, _path: &str, module_id: &str) -> VerifyResult {
176        if self.registry.has(module_id) {
177            VerifyResult::ok()
178        } else {
179            VerifyResult::fail(format!(
180                "Module '{module_id}' not found in registry after registration"
181            ))
182        }
183    }
184}
185
186/// Run verifiers in order; stop on first failure.
187///
188/// Each verifier call is wrapped in `catch_unwind` so that a panicking
189/// verifier does not crash the caller. A panic is reported as a
190/// `VerifyResult::fail` with the spec-mandated literal prefix
191/// `"Verifier crashed:"` so cross-language callers can match the same
192/// prefix produced by the Python and TypeScript SDKs.
193///
194/// Cross-language note: Python and TypeScript additionally append the
195/// concrete verifier class name to the crash message for diagnosability
196/// (e.g. `"Verifier crashed: <msg> (verifier: ExplodingVerifier)"`). Rust
197/// uses `&dyn Verifier` trait objects which erase concrete type
198/// information at runtime, so the verifier name cannot be recovered
199/// without a `name()` method on the trait. To keep the trait minimal and
200/// the public API stable, Rust crash messages omit the identity suffix —
201/// this is an intentional, language-idiomatic asymmetry, not a defect.
202pub fn run_verifier_chain(
203    verifiers: &[&dyn Verifier],
204    path: &str,
205    module_id: &str,
206) -> VerifyResult {
207    for verifier in verifiers {
208        let verifier = AssertUnwindSafe(verifier);
209        let path = path.to_string();
210        let module_id = module_id.to_string();
211        let outcome = catch_unwind(move || verifier.verify(&path, &module_id));
212        match outcome {
213            Ok(result) if !result.ok => return result,
214            Ok(_) => {} // passed, continue
215            Err(panic_info) => {
216                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
217                    (*s).to_string()
218                } else if let Some(s) = panic_info.downcast_ref::<String>() {
219                    s.clone()
220                } else {
221                    "unknown panic".to_string()
222                };
223                return VerifyResult::fail(format!("Verifier crashed: {msg}"));
224            }
225        }
226    }
227    VerifyResult::ok()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::io::Write;
234    use tempfile::NamedTempFile;
235
236    // ---- empty-path contract (RegistryWriter compatibility) ----
237
238    #[test]
239    fn test_syntax_verifier_empty_path_passes() {
240        assert!(SyntaxVerifier.verify("", "mod").ok);
241    }
242
243    #[test]
244    fn test_yaml_verifier_empty_path_passes() {
245        assert!(YAMLVerifier.verify("", "mod").ok);
246    }
247
248    #[test]
249    fn test_json_verifier_empty_path_passes() {
250        assert!(JSONVerifier::new().verify("", "mod").ok);
251    }
252
253    #[test]
254    fn test_magic_bytes_verifier_empty_path_passes() {
255        let v = MagicBytesVerifier::new(b"PNG".to_vec());
256        assert!(v.verify("", "mod").ok);
257    }
258
259    #[test]
260    fn test_yaml_verifier_valid() {
261        let mut f = NamedTempFile::new().unwrap();
262        writeln!(f, "bindings:\n  - module_id: test\n    target: app:func").unwrap();
263        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
264        assert!(result.ok);
265    }
266
267    #[test]
268    fn test_yaml_verifier_invalid_yaml() {
269        let mut f = NamedTempFile::new().unwrap();
270        writeln!(f, "{{invalid: yaml: [}}").unwrap();
271        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
272        assert!(!result.ok);
273        assert!(result.error.unwrap().contains("Invalid YAML"));
274    }
275
276    #[test]
277    fn test_yaml_verifier_missing_bindings() {
278        let mut f = NamedTempFile::new().unwrap();
279        writeln!(f, "other_key: value").unwrap();
280        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
281        assert!(!result.ok);
282    }
283
284    #[test]
285    fn test_yaml_verifier_multi_binding_second_entry_missing_target() {
286        let mut f = NamedTempFile::new().unwrap();
287        writeln!(
288            f,
289            "bindings:\n  - module_id: first\n    target: app:fn1\n  - module_id: second"
290        )
291        .unwrap();
292        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
293        assert!(!result.ok, "should fail when second entry lacks 'target'");
294        assert!(
295            result.error.unwrap().contains("target"),
296            "error should mention missing field"
297        );
298    }
299
300    #[test]
301    fn test_yaml_verifier_multi_binding_all_valid() {
302        let mut f = NamedTempFile::new().unwrap();
303        writeln!(
304            f,
305            "bindings:\n  - module_id: first\n    target: app:fn1\n  - module_id: second\n    target: app:fn2"
306        )
307        .unwrap();
308        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
309        assert!(result.ok, "should pass when all entries are valid");
310    }
311
312    #[test]
313    fn test_yaml_verifier_missing_required_field() {
314        let mut f = NamedTempFile::new().unwrap();
315        writeln!(f, "bindings:\n  - module_id: test").unwrap();
316        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
317        assert!(!result.ok);
318        assert!(result.error.unwrap().contains("target"));
319    }
320
321    #[test]
322    fn test_yaml_verifier_whitespace_only_module_id() {
323        // D11-007: whitespace-only module_id must be rejected, not accepted.
324        let mut f = NamedTempFile::new().unwrap();
325        writeln!(f, "bindings:\n  - module_id: \"   \"\n    target: app:func").unwrap();
326        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
327        assert!(!result.ok, "whitespace-only module_id should be rejected");
328        assert!(
329            result.error.unwrap().contains("module_id"),
330            "error should mention the invalid field"
331        );
332    }
333
334    #[test]
335    fn test_yaml_verifier_integer_module_id() {
336        // D11-007: integer module_id (non-string) must be rejected.
337        let mut f = NamedTempFile::new().unwrap();
338        writeln!(f, "bindings:\n  - module_id: 42\n    target: app:func").unwrap();
339        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
340        assert!(!result.ok, "integer module_id should be rejected");
341        assert!(
342            result.error.unwrap().contains("module_id"),
343            "error should mention the invalid field"
344        );
345    }
346
347    #[test]
348    fn test_json_verifier_valid() {
349        let mut f = NamedTempFile::new().unwrap();
350        writeln!(f, r#"{{"key": "value"}}"#).unwrap();
351        let result = JSONVerifier::new().verify(f.path().to_str().unwrap(), "test");
352        assert!(result.ok);
353    }
354
355    #[test]
356    fn test_json_verifier_invalid() {
357        let mut f = NamedTempFile::new().unwrap();
358        writeln!(f, "not json").unwrap();
359        let result = JSONVerifier::new().verify(f.path().to_str().unwrap(), "test");
360        assert!(!result.ok);
361    }
362
363    #[test]
364    fn test_magic_bytes_verifier_match() {
365        let mut f = NamedTempFile::new().unwrap();
366        f.write_all(b"\x89PNG\r\n\x1a\nrest of file").unwrap();
367        let verifier = MagicBytesVerifier::new(b"\x89PNG\r\n\x1a\n".to_vec());
368        let result = verifier.verify(f.path().to_str().unwrap(), "test");
369        assert!(result.ok);
370    }
371
372    #[test]
373    fn test_magic_bytes_verifier_mismatch() {
374        let mut f = NamedTempFile::new().unwrap();
375        f.write_all(b"NOT PNG").unwrap();
376        let verifier = MagicBytesVerifier::new(b"\x89PNG".to_vec());
377        let result = verifier.verify(f.path().to_str().unwrap(), "test");
378        assert!(!result.ok);
379        assert!(result.error.unwrap().contains("mismatch"));
380    }
381
382    #[test]
383    fn test_run_verifier_chain_all_pass() {
384        let v1 = JSONVerifier::new();
385        let mut f = NamedTempFile::new().unwrap();
386        writeln!(f, r#"{{"ok": true}}"#).unwrap();
387        let verifiers: Vec<&dyn Verifier> = vec![&v1];
388        let result = run_verifier_chain(&verifiers, f.path().to_str().unwrap(), "test");
389        assert!(result.ok);
390    }
391
392    #[test]
393    fn test_run_verifier_chain_stops_on_failure() {
394        let v1 = JSONVerifier::new();
395        let mut f = NamedTempFile::new().unwrap();
396        writeln!(f, "not json").unwrap();
397        let verifiers: Vec<&dyn Verifier> = vec![&v1];
398        let result = run_verifier_chain(&verifiers, f.path().to_str().unwrap(), "test");
399        assert!(!result.ok);
400    }
401
402    #[test]
403    fn test_run_verifier_chain_empty() {
404        let verifiers: Vec<&dyn Verifier> = vec![];
405        let result = run_verifier_chain(&verifiers, "", "test");
406        assert!(result.ok);
407    }
408
409    #[test]
410    fn test_yaml_verifier_nonexistent_file() {
411        let result = YAMLVerifier.verify("/tmp/nonexistent_file_abc123.yaml", "test");
412        assert!(!result.ok);
413        assert!(result.error.unwrap().contains("Cannot read file"));
414    }
415
416    #[test]
417    fn test_json_verifier_nonexistent_file() {
418        let result = JSONVerifier::new().verify("/tmp/nonexistent_file_abc123.json", "test");
419        assert!(!result.ok);
420        assert!(result.error.unwrap().contains("Cannot read file"));
421    }
422
423    #[test]
424    fn test_magic_bytes_verifier_file_too_short() {
425        let mut f = NamedTempFile::new().unwrap();
426        f.write_all(b"AB").unwrap();
427        let verifier = MagicBytesVerifier::new(b"ABCDEF".to_vec());
428        let result = verifier.verify(f.path().to_str().unwrap(), "test");
429        assert!(!result.ok);
430        let err = result.error.unwrap();
431        assert!(err.contains("File too short"), "got: {err}");
432        assert!(err.contains("6"), "should mention expected length");
433        assert!(err.contains("2"), "should mention actual length");
434    }
435
436    /// A verifier that panics, used to test catch_unwind in the chain.
437    struct PanickingVerifier;
438
439    impl Verifier for PanickingVerifier {
440        fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
441            panic!("verifier exploded");
442        }
443    }
444
445    #[test]
446    fn test_run_verifier_chain_panic_caught() {
447        let bad = PanickingVerifier;
448        let verifiers: Vec<&dyn Verifier> = vec![&bad];
449        let result = run_verifier_chain(&verifiers, "/fake", "test");
450        assert!(!result.ok);
451        let err = result.error.unwrap();
452        assert!(
453            err.contains("Verifier crashed"),
454            "expected crash message, got: {err}"
455        );
456        assert!(
457            err.contains("verifier exploded"),
458            "expected panic message, got: {err}"
459        );
460    }
461
462    /// A verifier that always returns an error, simulating a "crash" without panicking.
463    struct AlwaysFailVerifier {
464        message: String,
465    }
466
467    impl AlwaysFailVerifier {
468        fn new(message: &str) -> Self {
469            Self {
470                message: message.to_string(),
471            }
472        }
473    }
474
475    impl Verifier for AlwaysFailVerifier {
476        fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
477            VerifyResult::fail(self.message.clone())
478        }
479    }
480
481    /// A verifier that always passes.
482    struct AlwaysPassVerifier;
483
484    impl Verifier for AlwaysPassVerifier {
485        fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
486            VerifyResult::ok()
487        }
488    }
489
490    #[test]
491    fn test_run_verifier_chain_crash_caught() {
492        let bad = AlwaysFailVerifier::new("simulated crash");
493        let verifiers: Vec<&dyn Verifier> = vec![&bad];
494        let result = run_verifier_chain(&verifiers, "/fake", "test");
495        assert!(!result.ok);
496        assert_eq!(result.error.as_deref(), Some("simulated crash"));
497    }
498
499    #[test]
500    fn test_run_verifier_chain_first_failure_stops() {
501        // The first verifier fails, so the second should never matter.
502        let fail_v = AlwaysFailVerifier::new("first failed");
503        let pass_v = AlwaysPassVerifier;
504        let verifiers: Vec<&dyn Verifier> = vec![&fail_v, &pass_v];
505        let result = run_verifier_chain(&verifiers, "/fake", "test");
506        assert!(!result.ok);
507        assert_eq!(result.error.as_deref(), Some("first failed"));
508    }
509
510    #[test]
511    fn test_syntax_verifier_valid_rust() {
512        let mut f = NamedTempFile::new().unwrap();
513        writeln!(f, "fn main() {{\n    println!(\"hello\");\n}}").unwrap();
514        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
515        assert!(result.ok, "expected ok, got: {:?}", result.error);
516    }
517
518    #[test]
519    fn test_syntax_verifier_invalid_rust() {
520        let mut f = NamedTempFile::new().unwrap();
521        writeln!(f, "fn main() {{{{{{").unwrap();
522        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
523        assert!(!result.ok);
524        assert!(result.error.unwrap().contains("Invalid Rust syntax"));
525    }
526
527    #[test]
528    fn test_syntax_verifier_empty_file() {
529        let mut f = NamedTempFile::new().unwrap();
530        write!(f, "").unwrap();
531        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
532        assert!(!result.ok);
533        assert!(result.error.unwrap().contains("File is empty"));
534    }
535
536    #[test]
537    fn test_syntax_verifier_nonexistent_file() {
538        let result = SyntaxVerifier.verify("/tmp/nonexistent_rs_file_abc123.rs", "test");
539        assert!(!result.ok);
540        assert!(result.error.unwrap().contains("Cannot read file"));
541    }
542
543    #[test]
544    fn test_syntax_verifier_whitespace_only() {
545        let mut f = NamedTempFile::new().unwrap();
546        write!(f, "   \n\n  \t  ").unwrap();
547        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
548        assert!(!result.ok);
549        assert!(result.error.unwrap().contains("File is empty"));
550    }
551
552    #[test]
553    fn test_syntax_verifier_complex_valid() {
554        let mut f = NamedTempFile::new().unwrap();
555        writeln!(
556            f,
557            "use std::collections::HashMap;\n\
558             \n\
559             pub struct Foo {{\n\
560                 pub name: String,\n\
561                 pub values: HashMap<String, i32>,\n\
562             }}\n\
563             \n\
564             impl Foo {{\n\
565                 pub fn new(name: String) -> Self {{\n\
566                     Self {{ name, values: HashMap::new() }}\n\
567                 }}\n\
568             }}"
569        )
570        .unwrap();
571        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
572        assert!(result.ok, "expected ok, got: {:?}", result.error);
573    }
574}