Skip to main content

apcore_toolkit/output/
yaml_writer.rs

1// YAML binding file generator.
2//
3// Writes ScannedModule instances as .binding.yaml files compatible with
4// apcore::BindingLoader.
5
6use std::fs;
7use std::io::Write;
8use std::path::Path;
9use std::sync::LazyLock;
10
11use chrono::Utc;
12use regex::Regex;
13use tracing::{debug, warn};
14
15use crate::output::errors::WriteError;
16use crate::output::types::{Verifier, WriteResult};
17use crate::output::verifiers::{run_verifier_chain, YAMLVerifier};
18use crate::serializers::annotations_to_dict;
19use crate::types::ScannedModule;
20
21/// Generates `.binding.yaml` files from ScannedModule instances.
22pub struct YAMLWriter;
23
24impl YAMLWriter {
25    /// Write YAML binding files for each ScannedModule.
26    ///
27    /// - `output_dir`: Directory path to write files to.
28    /// - `dry_run`: If true, return results without writing to disk.
29    /// - `verify`: If true, verify written files are valid YAML with required fields.
30    /// - `verifiers`: Optional custom verifiers run after the built-in check.
31    ///
32    /// # Error handling vs. Python/TypeScript
33    ///
34    /// Unlike the Python and TypeScript implementations which raise/throw on I/O
35    /// failures, this method returns `Err(WriteError)` for any I/O error (e.g.
36    /// permission denied, disk full). Callers expecting the Python/TypeScript error
37    /// contract should propagate errors with `?` or handle them via `match`.
38    pub fn write(
39        &self,
40        modules: &[ScannedModule],
41        output_dir: &str,
42        dry_run: bool,
43        verify: bool,
44        verifiers: Option<&[&dyn Verifier]>,
45    ) -> Result<Vec<WriteResult>, WriteError> {
46        if modules.is_empty() {
47            return Ok(vec![]);
48        }
49
50        if !dry_run {
51            fs::create_dir_all(output_dir).map_err(|e| WriteError::io(output_dir.into(), e))?;
52        }
53
54        let output_path = if dry_run {
55            Path::new(output_dir).to_path_buf()
56        } else {
57            Path::new(output_dir)
58                .canonicalize()
59                .map_err(|e| WriteError::io(output_dir.into(), e))?
60        };
61
62        let mut results: Vec<WriteResult> = Vec::new();
63        let timestamp = Utc::now().to_rfc3339();
64        // Track filenames written in this batch to detect collisions within a single
65        // write() call. When two module_ids sanitize to the same filename, the second
66        // and subsequent modules receive a numeric suffix (e.g. `foo_1.binding.yaml`).
67        // This matches the TypeScript YAMLWriter collision-avoidance behaviour.
68        let mut written_names: std::collections::HashMap<String, String> =
69            std::collections::HashMap::new();
70
71        for module in modules {
72            let binding_data = build_binding(module);
73
74            if dry_run {
75                results.push(WriteResult::new(module.module_id.clone()));
76                continue;
77            }
78
79            // sanitize_filename removes all unsafe chars and collapses consecutive dots,
80            // ensuring the resulting filename cannot escape output_path.
81            let safe_id = sanitize_filename(&module.module_id);
82            let base_filename = format!("{safe_id}.binding.yaml");
83
84            // Resolve filename collision within this batch.
85            let mut final_filename = base_filename.clone();
86            let mut counter = 0u32;
87            while written_names.contains_key(&final_filename) {
88                counter += 1;
89                final_filename = format!("{safe_id}_{counter}.binding.yaml");
90            }
91            written_names.insert(final_filename.clone(), module.module_id.clone());
92
93            let file_path = output_path.join(&final_filename);
94
95            // Pre-write symlink check (TOCTOU mitigation — matches the Python
96            // (`is_symlink`) and TypeScript (`lstatSync`) writers). A symlink at
97            // the target path could redirect the atomic rename to an attacker-
98            // controlled location outside `output_path`, even though the parent
99            // directory passed canonicalization. Refuse to overwrite a symlink
100            // and record the result as unverified, matching Python/TS wording.
101            if let Ok(meta) = file_path.symlink_metadata() {
102                if meta.file_type().is_symlink() {
103                    warn!(file_path = %file_path.display(), "Skipping symlink escape at target path");
104                    results.push(WriteResult::failed(
105                        module.module_id.clone(),
106                        Some(file_path.display().to_string()),
107                        "Security skip: symlink at target path".into(),
108                    ));
109                    continue;
110                }
111            }
112
113            if file_path.exists() {
114                warn!(file_path = %file_path.display(), "Overwriting existing file");
115            }
116
117            let header = format!(
118                "# Auto-generated by apcore-toolkit scanner\n\
119                 # Generated: {timestamp}\n\
120                 # Do not edit manually unless you intend to customize schemas.\n\n"
121            );
122            let yaml_content = serde_yaml_ng::to_string(&binding_data)
123                .map_err(|e| WriteError::new(file_path.display().to_string(), e.to_string()))?;
124            let full_content = format!("{header}{yaml_content}");
125
126            // Atomic write: write bytes to a sibling .yaml.tmp file, call sync_all()
127            // to flush OS page cache to durable storage, then rename atomically.
128            // fs::rename on the same filesystem is atomic on POSIX; on Windows it
129            // replaces any existing target atomically on NTFS.
130            // On Unix we also fsync the parent directory after rename to make the
131            // new directory entry durable.
132            // The tmp file is removed on any failure so no stale `.yaml.tmp` is left.
133            let tmp_path = file_path.with_extension("yaml.tmp");
134            let write_res = (|| -> std::io::Result<()> {
135                let mut tmp_file = fs::File::create(&tmp_path)?;
136                tmp_file.write_all(full_content.as_bytes())?;
137                tmp_file.flush()?;
138                tmp_file.sync_all()
139            })();
140            if let Err(e) = write_res {
141                let _ = fs::remove_file(&tmp_path);
142                return Err(WriteError::io(tmp_path.display().to_string(), e));
143            }
144            if let Err(e) = fs::rename(&tmp_path, &file_path) {
145                let _ = fs::remove_file(&tmp_path);
146                return Err(WriteError::io(file_path.display().to_string(), e));
147            }
148            // Post-rename defence-in-depth: warn if the result is a symlink
149            // (would indicate a TOCTOU race). Matches Python/TS writers.
150            if let Ok(meta) = file_path.symlink_metadata() {
151                if meta.file_type().is_symlink() {
152                    warn!(
153                        file_path = %file_path.display(),
154                        "YAMLWriter: post-rename symlink detected — possible race"
155                    );
156                }
157            }
158            #[cfg(unix)]
159            {
160                if let Some(parent) = file_path.parent() {
161                    if let Ok(dir) = fs::File::open(parent) {
162                        let _ = dir.sync_all();
163                    }
164                }
165            }
166            debug!(file_path = %file_path.display(), "Written");
167
168            let mut result =
169                WriteResult::with_path(module.module_id.clone(), file_path.display().to_string());
170
171            if verify {
172                result = verify_yaml(&result, &file_path);
173            }
174            if result.verified {
175                if let Some(vs) = verifiers {
176                    let chain_result =
177                        run_verifier_chain(vs, &file_path.display().to_string(), &module.module_id);
178                    if !chain_result.ok {
179                        result = WriteResult::failed(
180                            result.module_id,
181                            result.path,
182                            chain_result.error.unwrap_or_default(),
183                        );
184                    }
185                }
186            }
187            results.push(result);
188        }
189
190        Ok(results)
191    }
192}
193
194/// Regex matching characters unsafe for filenames.
195static UNSAFE_CHARS_RE: LazyLock<Regex> =
196    LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9._-]").expect("static regex"));
197
198/// Regex matching consecutive dots (path traversal prevention).
199static CONSECUTIVE_DOTS_RE: LazyLock<Regex> =
200    LazyLock::new(|| Regex::new(r"\.{2,}").expect("static regex"));
201
202/// Sanitize module_id for safe filename construction.
203fn sanitize_filename(module_id: &str) -> String {
204    let safe = UNSAFE_CHARS_RE.replace_all(module_id, "_");
205    // Collapse consecutive dots to prevent path traversal
206    CONSECUTIVE_DOTS_RE.replace_all(&safe, "_").to_string()
207}
208
209/// Build the YAML-serializable value for a ScannedModule.
210fn build_binding(module: &ScannedModule) -> serde_json::Value {
211    let mut binding = serde_json::Map::new();
212    binding.insert(
213        "module_id".into(),
214        serde_json::Value::from(module.module_id.clone()),
215    );
216    binding.insert(
217        "target".into(),
218        serde_json::Value::from(module.target.clone()),
219    );
220    binding.insert(
221        "description".into(),
222        serde_json::Value::from(module.description.clone()),
223    );
224    binding.insert(
225        "documentation".into(),
226        serde_json::to_value(&module.documentation).unwrap_or(serde_json::Value::Null),
227    );
228    binding.insert(
229        "tags".into(),
230        serde_json::to_value(&module.tags).unwrap_or(serde_json::json!([])),
231    );
232    binding.insert(
233        "version".into(),
234        serde_json::Value::from(module.version.clone()),
235    );
236    binding.insert(
237        "annotations".into(),
238        annotations_to_dict(module.annotations.as_ref()),
239    );
240    binding.insert(
241        "examples".into(),
242        serde_json::to_value(&module.examples).unwrap_or(serde_json::json!([])),
243    );
244    binding.insert(
245        "metadata".into(),
246        serde_json::to_value(&module.metadata).unwrap_or(serde_json::json!({})),
247    );
248    if let Some(alias) = &module.suggested_alias {
249        binding.insert(
250            "suggested_alias".into(),
251            serde_json::Value::from(alias.clone()),
252        );
253    }
254    binding.insert("input_schema".into(), module.input_schema.clone());
255    binding.insert("output_schema".into(), module.output_schema.clone());
256    if let Some(display) = &module.display {
257        binding.insert("display".into(), display.clone());
258    }
259
260    serde_json::json!({
261        "spec_version": "1.0",
262        "bindings": [serde_json::Value::Object(binding)]
263    })
264}
265
266/// Verify that a written YAML file is well-formed and contains required fields.
267fn verify_yaml(result: &WriteResult, file_path: &Path) -> WriteResult {
268    let vr = YAMLVerifier.verify(&file_path.display().to_string(), &result.module_id);
269    if vr.ok {
270        result.clone()
271    } else {
272        WriteResult::failed(
273            result.module_id.clone(),
274            result.path.clone(),
275            vr.error.unwrap_or_default(),
276        )
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284    use tempfile::TempDir;
285
286    fn sample_module() -> ScannedModule {
287        ScannedModule::new(
288            "users.get_user".into(),
289            "Get a user".into(),
290            json!({"type": "object", "properties": {"user_id": {"type": "integer"}}}),
291            json!({"type": "object"}),
292            vec!["users".into()],
293            "myapp.views:get_user".into(),
294        )
295    }
296
297    #[test]
298    fn test_sanitize_filename_basic() {
299        assert_eq!(sanitize_filename("users.get_user"), "users.get_user");
300    }
301
302    #[test]
303    fn test_sanitize_filename_special_chars() {
304        assert_eq!(sanitize_filename("a/b\\c d"), "a_b_c_d");
305    }
306
307    #[test]
308    fn test_sanitize_filename_path_traversal() {
309        let result = sanitize_filename("../../etc/passwd");
310        assert!(!result.contains(".."));
311    }
312
313    #[test]
314    fn test_write_empty_modules() {
315        let writer = YAMLWriter;
316        let result = writer.write(&[], "/tmp/test", false, false, None).unwrap();
317        assert!(result.is_empty());
318    }
319
320    #[test]
321    fn test_write_dry_run() {
322        let writer = YAMLWriter;
323        let modules = vec![sample_module()];
324        let result = writer
325            .write(&modules, "/tmp/nonexistent", true, false, None)
326            .unwrap();
327        assert_eq!(result.len(), 1);
328        assert_eq!(result[0].module_id, "users.get_user");
329        assert!(result[0].path.is_none());
330    }
331
332    #[test]
333    fn test_write_creates_file() {
334        let dir = TempDir::new().unwrap();
335        let writer = YAMLWriter;
336        let modules = vec![sample_module()];
337        let result = writer
338            .write(&modules, dir.path().to_str().unwrap(), false, false, None)
339            .unwrap();
340        assert_eq!(result.len(), 1);
341        assert!(result[0].path.is_some());
342
343        let file_path = result[0].path.as_ref().unwrap();
344        assert!(Path::new(file_path).exists());
345        let content = fs::read_to_string(file_path).unwrap();
346        assert!(content.contains("Auto-generated"));
347        assert!(content.contains("users.get_user"));
348    }
349
350    #[test]
351    fn test_write_with_verify() {
352        let dir = TempDir::new().unwrap();
353        let writer = YAMLWriter;
354        let modules = vec![sample_module()];
355        let result = writer
356            .write(&modules, dir.path().to_str().unwrap(), false, true, None)
357            .unwrap();
358        assert_eq!(result.len(), 1);
359        assert!(result[0].verified);
360    }
361
362    #[test]
363    fn test_write_multiple_modules() {
364        let dir = TempDir::new().unwrap();
365        let writer = YAMLWriter;
366        let modules = vec![
367            ScannedModule::new(
368                "mod_a".into(),
369                "Module A".into(),
370                json!({"type": "object"}),
371                json!({"type": "object"}),
372                vec![],
373                "app:a".into(),
374            ),
375            ScannedModule::new(
376                "mod_b".into(),
377                "Module B".into(),
378                json!({"type": "object"}),
379                json!({"type": "object"}),
380                vec![],
381                "app:b".into(),
382            ),
383            ScannedModule::new(
384                "mod_c".into(),
385                "Module C".into(),
386                json!({"type": "object"}),
387                json!({"type": "object"}),
388                vec![],
389                "app:c".into(),
390            ),
391        ];
392        let results = writer
393            .write(&modules, dir.path().to_str().unwrap(), false, false, None)
394            .unwrap();
395        assert_eq!(results.len(), 3);
396        // Each result should have a file path and the file should exist
397        for result in &results {
398            let path = result.path.as_ref().expect("path should be set");
399            assert!(Path::new(path).exists(), "file should exist: {path}");
400        }
401    }
402
403    #[test]
404    fn test_binding_contains_all_fields() {
405        let dir = TempDir::new().unwrap();
406        let writer = YAMLWriter;
407        let mut module = sample_module();
408        module.documentation = Some("Full docs here".into());
409        module.version = "2.0.0".into();
410        let modules = vec![module];
411        let results = writer
412            .write(&modules, dir.path().to_str().unwrap(), false, false, None)
413            .unwrap();
414        let file_path = results[0].path.as_ref().unwrap();
415        let content = fs::read_to_string(file_path).unwrap();
416        // Verify all expected fields are present in the YAML content
417        for field in &[
418            "spec_version",
419            "module_id",
420            "target",
421            "description",
422            "documentation",
423            "tags",
424            "version",
425            "annotations",
426            "examples",
427            "metadata",
428            "input_schema",
429            "output_schema",
430        ] {
431            assert!(
432                content.contains(field),
433                "YAML should contain field '{field}'"
434            );
435        }
436        assert!(content.contains("users.get_user"));
437        assert!(content.contains("Full docs here"));
438        assert!(content.contains("2.0.0"));
439    }
440
441    #[test]
442    fn test_creates_nested_output_dir() {
443        let dir = TempDir::new().unwrap();
444        let nested = dir.path().join("a").join("b").join("c");
445        let writer = YAMLWriter;
446        let modules = vec![sample_module()];
447        // The nested directory does not exist yet
448        assert!(!nested.exists());
449        let results = writer
450            .write(&modules, nested.to_str().unwrap(), false, false, None)
451            .unwrap();
452        assert_eq!(results.len(), 1);
453        assert!(nested.exists(), "nested directory should have been created");
454        let file_path = results[0].path.as_ref().unwrap();
455        assert!(Path::new(file_path).exists());
456    }
457
458    #[test]
459    fn test_filename_sanitization_dots() {
460        let result = sanitize_filename("foo..bar");
461        assert!(
462            !result.contains(".."),
463            "consecutive dots should be collapsed: got '{result}'"
464        );
465        let result2 = sanitize_filename("a...b....c");
466        assert!(
467            !result2.contains(".."),
468            "consecutive dots should be collapsed: got '{result2}'"
469        );
470    }
471
472    #[test]
473    fn test_display_omitted_when_none() {
474        let dir = TempDir::new().unwrap();
475        let writer = YAMLWriter;
476        let module = sample_module();
477        let modules = vec![module];
478        let results = writer
479            .write(&modules, dir.path().to_str().unwrap(), false, false, None)
480            .unwrap();
481        let file_path = results[0].path.as_ref().unwrap();
482        let content = fs::read_to_string(file_path).unwrap();
483        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap();
484        let bindings = parsed["bindings"].as_sequence().unwrap();
485        assert!(
486            bindings[0].get("display").is_none(),
487            "display should be absent when module.display is None"
488        );
489    }
490
491    #[test]
492    fn test_display_emitted_when_set() {
493        let dir = TempDir::new().unwrap();
494        let writer = YAMLWriter;
495        let mut module = sample_module();
496        module.display = Some(json!({"mcp": {"alias": "users_get"}, "alias": "users.get"}));
497        let modules = vec![module];
498        let results = writer
499            .write(&modules, dir.path().to_str().unwrap(), false, false, None)
500            .unwrap();
501        let file_path = results[0].path.as_ref().unwrap();
502        let content = fs::read_to_string(file_path).unwrap();
503        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap();
504        let bindings = parsed["bindings"].as_sequence().unwrap();
505        let display = bindings[0]
506            .get("display")
507            .expect("display should be present");
508        assert_eq!(
509            display["alias"],
510            serde_yaml_ng::Value::String("users.get".into())
511        );
512        assert_eq!(
513            display["mcp"]["alias"],
514            serde_yaml_ng::Value::String("users_get".into())
515        );
516    }
517
518    #[test]
519    fn test_none_annotations_in_binding() {
520        let dir = TempDir::new().unwrap();
521        let writer = YAMLWriter;
522        let mut module = sample_module();
523        module.annotations = None;
524        let modules = vec![module];
525        let results = writer
526            .write(&modules, dir.path().to_str().unwrap(), false, false, None)
527            .unwrap();
528        let file_path = results[0].path.as_ref().unwrap();
529        let content = fs::read_to_string(file_path).unwrap();
530        // The file should still be valid YAML and contain the annotations key
531        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap();
532        let bindings = parsed["bindings"].as_sequence().unwrap();
533        assert_eq!(bindings.len(), 1);
534        // annotations should be present (as null)
535        assert!(bindings[0].get("annotations").is_some());
536    }
537
538    #[test]
539    fn test_overwrite_existing_file() {
540        let dir = TempDir::new().unwrap();
541        let writer = YAMLWriter;
542
543        // Write the first version
544        let module_v1 = ScannedModule::new(
545            "overwrite_test".into(),
546            "Version 1".into(),
547            json!({"type": "object"}),
548            json!({"type": "object"}),
549            vec![],
550            "app:v1".into(),
551        );
552        let results_v1 = writer
553            .write(
554                &[module_v1],
555                dir.path().to_str().unwrap(),
556                false,
557                false,
558                None,
559            )
560            .unwrap();
561        let file_path = results_v1[0].path.as_ref().unwrap();
562        let content_v1 = fs::read_to_string(file_path).unwrap();
563        assert!(content_v1.contains("Version 1"));
564
565        // Write the second version with the same module_id
566        let module_v2 = ScannedModule::new(
567            "overwrite_test".into(),
568            "Version 2".into(),
569            json!({"type": "object"}),
570            json!({"type": "object"}),
571            vec![],
572            "app:v2".into(),
573        );
574        let results_v2 = writer
575            .write(
576                &[module_v2],
577                dir.path().to_str().unwrap(),
578                false,
579                false,
580                None,
581            )
582            .unwrap();
583        let file_path_v2 = results_v2[0].path.as_ref().unwrap();
584        let content_v2 = fs::read_to_string(file_path_v2).unwrap();
585        assert!(content_v2.contains("Version 2"));
586        assert!(!content_v2.contains("Version 1"));
587    }
588
589    #[test]
590    fn test_suggested_alias_round_trip() {
591        let dir = TempDir::new().unwrap();
592        let writer = YAMLWriter;
593        let mut module = sample_module();
594        module.suggested_alias = Some("users.get".into());
595        let results = writer
596            .write(&[module], dir.path().to_str().unwrap(), false, false, None)
597            .unwrap();
598        let file_path = results[0].path.as_ref().unwrap();
599        let content = fs::read_to_string(file_path).unwrap();
600        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap();
601        let bindings = parsed["bindings"].as_sequence().unwrap();
602        assert_eq!(
603            bindings[0]["suggested_alias"]
604                .as_str()
605                .expect("suggested_alias should be a string"),
606            "users.get"
607        );
608    }
609
610    #[test]
611    fn test_suggested_alias_absent_when_none() {
612        let dir = TempDir::new().unwrap();
613        let writer = YAMLWriter;
614        let module = sample_module();
615        let results = writer
616            .write(&[module], dir.path().to_str().unwrap(), false, false, None)
617            .unwrap();
618        let file_path = results[0].path.as_ref().unwrap();
619        let content = fs::read_to_string(file_path).unwrap();
620        let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap();
621        let bindings = parsed["bindings"].as_sequence().unwrap();
622        assert!(
623            bindings[0].get("suggested_alias").is_none(),
624            "suggested_alias should be absent when module.suggested_alias is None"
625        );
626    }
627
628    #[test]
629    fn test_filename_collision_produces_distinct_files() {
630        // D11-010: two modules whose module_ids both sanitize to the same filename
631        // must produce two distinct files in a single write() call.
632        // The second module receives a numeric suffix (e.g. `foo_1.binding.yaml`).
633        let dir = TempDir::new().unwrap();
634        let writer = YAMLWriter;
635
636        // Both module_ids sanitize to "a_b" (slash → underscore)
637        let mod1 = ScannedModule::new(
638            "a/b".into(),
639            "Module slash".into(),
640            json!({"type": "object"}),
641            json!({"type": "object"}),
642            vec![],
643            "app:slash".into(),
644        );
645        let mod2 = ScannedModule::new(
646            "a_b".into(),
647            "Module underscore".into(),
648            json!({"type": "object"}),
649            json!({"type": "object"}),
650            vec![],
651            "app:underscore".into(),
652        );
653
654        let results = writer
655            .write(
656                &[mod1, mod2],
657                dir.path().to_str().unwrap(),
658                false,
659                false,
660                None,
661            )
662            .unwrap();
663        assert_eq!(results.len(), 2, "should produce two results");
664
665        let path1 = results[0]
666            .path
667            .as_ref()
668            .expect("first result must have path");
669        let path2 = results[1]
670            .path
671            .as_ref()
672            .expect("second result must have path");
673        assert_ne!(path1, path2, "collision must produce distinct file paths");
674        assert!(Path::new(path1).exists(), "first file must exist: {path1}");
675        assert!(Path::new(path2).exists(), "second file must exist: {path2}");
676    }
677
678    #[cfg(unix)]
679    #[test]
680    fn test_refuses_to_overwrite_symlink_at_target_path() {
681        // A-D-015 parity: when a symlink occupies the target file path, the
682        // writer must refuse to overwrite it (matches Python's is_symlink and
683        // TypeScript's lstatSync guards). The result must be marked unverified
684        // with the canonical "Security skip" wording.
685        use std::os::unix::fs::symlink;
686
687        let dir = TempDir::new().unwrap();
688        let writer = YAMLWriter;
689        let module = sample_module(); // module_id = "users.get_user"
690
691        // Plant a symlink at the target file path BEFORE writing. The symlink
692        // points to a sibling decoy file so we can verify the writer did not
693        // dereference it.
694        let target_file = dir.path().join("users.get_user.binding.yaml");
695        let decoy = dir.path().join("decoy.yaml");
696        fs::write(&decoy, "original decoy content\n").unwrap();
697        symlink(&decoy, &target_file).unwrap();
698
699        let results = writer
700            .write(&[module], dir.path().to_str().unwrap(), false, false, None)
701            .unwrap();
702
703        assert_eq!(results.len(), 1);
704        assert!(
705            !results[0].verified,
706            "symlinked target must NOT be verified"
707        );
708        let err = results[0].verification_error.as_deref().unwrap_or_default();
709        assert!(
710            err.contains("symlink"),
711            "verification_error should mention symlink, got: {err}"
712        );
713
714        // The decoy file behind the symlink must be untouched.
715        let decoy_content = fs::read_to_string(&decoy).unwrap();
716        assert_eq!(decoy_content, "original decoy content\n");
717    }
718
719    #[test]
720    fn test_custom_verifier_failure_produces_failed_result() {
721        use crate::output::types::{Verifier, VerifyResult};
722
723        struct AlwaysFail;
724        impl Verifier for AlwaysFail {
725            fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
726                VerifyResult::fail("intentional failure".into())
727            }
728        }
729
730        let dir = TempDir::new().unwrap();
731        let writer = YAMLWriter;
732        let module = sample_module();
733        let verifier = AlwaysFail;
734        let verifiers: &[&dyn Verifier] = &[&verifier];
735        let results = writer
736            .write(
737                &[module],
738                dir.path().to_str().unwrap(),
739                false,
740                true,
741                Some(verifiers),
742            )
743            .unwrap();
744        assert!(!results[0].verified, "result should be marked not verified");
745        assert!(results[0]
746            .verification_error
747            .as_deref()
748            .unwrap_or("")
749            .contains("intentional failure"));
750    }
751}