1use 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
21pub struct YAMLWriter;
23
24impl YAMLWriter {
25 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 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 let safe_id = sanitize_filename(&module.module_id);
82 let base_filename = format!("{safe_id}.binding.yaml");
83
84 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 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 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 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
194static UNSAFE_CHARS_RE: LazyLock<Regex> =
196 LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9._-]").expect("static regex"));
197
198static CONSECUTIVE_DOTS_RE: LazyLock<Regex> =
200 LazyLock::new(|| Regex::new(r"\.{2,}").expect("static regex"));
201
202fn sanitize_filename(module_id: &str) -> String {
204 let safe = UNSAFE_CHARS_RE.replace_all(module_id, "_");
205 CONSECUTIVE_DOTS_RE.replace_all(&safe, "_").to_string()
207}
208
209fn 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
266fn 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 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 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 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 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 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 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 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 let dir = TempDir::new().unwrap();
634 let writer = YAMLWriter;
635
636 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 use std::os::unix::fs::symlink;
686
687 let dir = TempDir::new().unwrap();
688 let writer = YAMLWriter;
689 let module = sample_module(); 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 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}