1use crate::{Adapter, AdapterError, AdapterMetadata};
22use buildfix_types::receipt::{ReceiptEnvelope, Severity};
23use std::collections::HashSet;
24use std::path::Path;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct MetadataValidationError {
32 pub field: &'static str,
34 pub message: &'static str,
36}
37
38impl std::fmt::Display for MetadataValidationError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(
41 f,
42 "Metadata validation failed for '{}': {}",
43 self.field, self.message
44 )
45 }
46}
47
48impl std::error::Error for MetadataValidationError {}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ValidationError {
53 pub field: String,
55 pub value: String,
57 pub message: String,
59}
60
61impl std::fmt::Display for ValidationError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(
64 f,
65 "Validation error on '{}': {} (value: {:?})",
66 self.field, self.message, self.value
67 )
68 }
69}
70
71impl std::error::Error for ValidationError {}
72
73#[derive(Debug, Default)]
74pub struct ValidationResult {
75 errors: Vec<ValidationError>,
76}
77
78impl ValidationResult {
79 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn add_error(
84 &mut self,
85 field: impl Into<String>,
86 value: impl Into<String>,
87 message: impl Into<String>,
88 ) {
89 self.errors.push(ValidationError {
90 field: field.into(),
91 value: value.into(),
92 message: message.into(),
93 });
94 }
95
96 pub fn is_valid(&self) -> bool {
97 self.errors.is_empty()
98 }
99
100 pub fn errors(&self) -> &[ValidationError] {
101 &self.errors
102 }
103
104 pub fn expect_valid(self) -> Result<(), Vec<ValidationError>> {
105 if self.errors.is_empty() {
106 Ok(())
107 } else {
108 Err(self.errors)
109 }
110 }
111}
112
113pub struct AdapterTestHarness<A: Adapter> {
114 adapter: A,
115}
116
117impl<A: Adapter> AdapterTestHarness<A> {
118 pub fn new(adapter: A) -> Self {
119 Self { adapter }
120 }
121
122 pub fn adapter(&self) -> &A {
123 &self.adapter
124 }
125
126 pub fn validate_receipt(&self, receipt: &ReceiptEnvelope) -> ValidationResult {
127 let mut result = ValidationResult::new();
128
129 if receipt.schema.is_empty() {
130 result.add_error("schema", "", "schema must not be empty");
131 }
132
133 if receipt.tool.name.is_empty() {
134 result.add_error("tool.name", "", "tool name must not be empty");
135 }
136
137 result
138 }
139
140 pub fn validate_receipt_fixture(
141 &self,
142 fixture_path: impl AsRef<Path>,
143 ) -> Result<ReceiptEnvelope, AdapterError> {
144 let path = fixture_path.as_ref();
145 let receipt = self.adapter.load(path)?;
146
147 let validation = self.validate_receipt(&receipt);
148 if !validation.is_valid() {
149 for err in validation.errors() {
150 eprintln!("Validation warning: {}", err);
151 }
152 }
153
154 Ok(receipt)
155 }
156
157 pub fn validate_finding_fields(&self, receipt: &ReceiptEnvelope) -> ValidationResult {
158 let mut result = ValidationResult::new();
159
160 for (i, finding) in receipt.findings.iter().enumerate() {
161 if finding.message.is_none() && finding.data.is_none() {
162 result.add_error(
163 format!("finding[{}]", i),
164 "",
165 "finding must have either message or data",
166 );
167 }
168
169 if let Some(ref loc) = finding.location
170 && loc.path.as_str().is_empty()
171 {
172 result.add_error(
173 format!("finding[{}].location.path", i),
174 "",
175 "location path must not be empty",
176 );
177 }
178
179 if finding.severity == Severity::Error && finding.check_id.is_none() {
180 result.add_error(
181 format!("finding[{}]", i),
182 "",
183 "error findings should have a check_id for actionable fixes",
184 );
185 }
186 }
187
188 result
189 }
190
191 pub fn golden_test(
192 &self,
193 fixture_path: impl AsRef<Path>,
194 expected: &ReceiptEnvelope,
195 ) -> Result<(), Box<dyn std::error::Error>> {
196 let actual = self.validate_receipt_fixture(fixture_path)?;
197
198 let expected_json = serde_json::to_string_pretty(expected)?;
199 let actual_json = serde_json::to_string_pretty(&actual)?;
200
201 assert_eq!(
202 expected_json, actual_json,
203 "Golden test failed: receipt does not match expected"
204 );
205
206 Ok(())
207 }
208
209 pub fn assert_finding_count(
210 &self,
211 receipt: &ReceiptEnvelope,
212 expected: usize,
213 severity: Option<Severity>,
214 ) -> Result<(), AdapterError> {
215 let actual = match severity {
216 Some(s) => receipt.findings.iter().filter(|f| f.severity == s).count(),
217 None => receipt.findings.len(),
218 };
219
220 if actual != expected {
221 return Err(AdapterError::InvalidFormat(format!(
222 "Expected {} findings (severity: {:?}), found {}",
223 expected, severity, actual
224 )));
225 }
226
227 Ok(())
228 }
229
230 pub fn assert_has_check_id(
231 &self,
232 receipt: &ReceiptEnvelope,
233 check_id: &str,
234 ) -> Result<(), AdapterError> {
235 let found = receipt
236 .findings
237 .iter()
238 .any(|f| f.check_id.as_deref() == Some(check_id));
239
240 if !found {
241 return Err(AdapterError::InvalidFormat(format!(
242 "Expected finding with check_id '{}', but none found",
243 check_id
244 )));
245 }
246
247 Ok(())
248 }
249
250 pub fn extract_check_ids(&self, receipt: &ReceiptEnvelope) -> HashSet<String> {
251 receipt
252 .findings
253 .iter()
254 .filter_map(|f| f.check_id.clone())
255 .collect()
256 }
257
258 pub fn validate_metadata<M: AdapterMetadata>(
297 &self,
298 adapter: &M,
299 ) -> Result<(), MetadataValidationError> {
300 if adapter.name().is_empty() {
301 return Err(MetadataValidationError {
302 field: "name",
303 message: "adapter name must not be empty",
304 });
305 }
306
307 if adapter.version().is_empty() {
308 return Err(MetadataValidationError {
309 field: "version",
310 message: "adapter version must not be empty",
311 });
312 }
313
314 if adapter.supported_schemas().is_empty() {
315 return Err(MetadataValidationError {
316 field: "supported_schemas",
317 message: "adapter must support at least one schema",
318 });
319 }
320
321 Ok(())
322 }
323
324 pub fn validate_check_id_format(
339 &self,
340 receipt: &ReceiptEnvelope,
341 ) -> Result<(), Vec<ValidationError>> {
342 let mut errors = Vec::new();
343
344 for (i, finding) in receipt.findings.iter().enumerate() {
345 if let Some(ref check_id) = finding.check_id
346 && !Self::is_valid_check_id(check_id)
347 {
348 errors.push(ValidationError {
349 field: format!("finding[{}].check_id", i),
350 value: check_id.clone(),
351 message: "check_id must follow naming convention: lowercase, at least 2 dots (3+ segments), each segment must be snake_case alphanumeric (e.g., 'cargo-deny.ban.multiple-versions')".to_string(),
352 });
353 }
354 }
355
356 if errors.is_empty() {
357 Ok(())
358 } else {
359 Err(errors)
360 }
361 }
362
363 fn is_valid_check_id(check_id: &str) -> bool {
370 if check_id != check_id.to_lowercase() {
372 return false;
373 }
374
375 let segments: Vec<&str> = check_id.split('.').collect();
377 if segments.len() < 3 {
378 return false;
379 }
380
381 for segment in segments {
383 if segment.is_empty() {
384 return false;
385 }
386 if !segment
388 .chars()
389 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
390 {
391 return false;
392 }
393 }
394
395 true
396 }
397
398 pub fn validate_location_paths(
414 &self,
415 receipt: &ReceiptEnvelope,
416 ) -> Result<(), Vec<ValidationError>> {
417 let mut errors = Vec::new();
418
419 for (i, finding) in receipt.findings.iter().enumerate() {
420 if let Some(ref location) = finding.location {
421 let path = location.path.as_str();
422
423 if path.is_empty() {
425 errors.push(ValidationError {
426 field: format!("finding[{}].location.path", i),
427 value: path.to_string(),
428 message: "location path must not be empty".to_string(),
429 });
430 continue;
431 }
432
433 if path.contains('\\') {
435 errors.push(ValidationError {
436 field: format!("finding[{}].location.path", i),
437 value: path.to_string(),
438 message: "location path must use forward slashes, not backslashes"
439 .to_string(),
440 });
441 }
442
443 if path.starts_with('/') {
445 errors.push(ValidationError {
446 field: format!("finding[{}].location.path", i),
447 value: path.to_string(),
448 message:
449 "location path must be relative, not absolute (cannot start with '/')"
450 .to_string(),
451 });
452 }
453
454 if path.contains("..") {
456 errors.push(ValidationError {
457 field: format!("finding[{}].location.path", i),
458 value: path.to_string(),
459 message: "location path must not contain '..' (parent directory traversal not allowed)".to_string(),
460 });
461 }
462 }
463 }
464
465 if errors.is_empty() {
466 Ok(())
467 } else {
468 Err(errors)
469 }
470 }
471
472 pub fn validate_all(&self, receipt: &ReceiptEnvelope) -> Result<(), Vec<ValidationError>> {
481 let mut all_errors = Vec::new();
482
483 let receipt_result = self.validate_receipt(receipt);
485 if let Err(errors) = receipt_result.expect_valid() {
486 all_errors.extend(errors);
487 }
488
489 let finding_result = self.validate_finding_fields(receipt);
491 if let Err(errors) = finding_result.expect_valid() {
492 all_errors.extend(errors);
493 }
494
495 if let Err(errors) = self.validate_check_id_format(receipt) {
497 all_errors.extend(errors);
498 }
499
500 if let Err(errors) = self.validate_location_paths(receipt) {
502 all_errors.extend(errors);
503 }
504
505 if all_errors.is_empty() {
506 Ok(())
507 } else {
508 Err(all_errors)
509 }
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::AdapterError;
517 use buildfix_types::receipt::Finding;
518 use camino::Utf8PathBuf;
519 use std::path::Path;
520 use tempfile::TempDir;
521
522 struct DummyAdapter;
523
524 impl Adapter for DummyAdapter {
525 fn sensor_id(&self) -> &str {
526 "dummy"
527 }
528
529 fn load(&self, path: &Path) -> Result<ReceiptEnvelope, AdapterError> {
530 let content = std::fs::read_to_string(path)?;
531 serde_json::from_str(&content).map_err(AdapterError::Json)
532 }
533 }
534
535 #[test]
536 fn test_validation_empty_schema() {
537 let harness = AdapterTestHarness::new(DummyAdapter);
538 let receipt = ReceiptEnvelope {
539 schema: String::new(),
540 tool: buildfix_types::receipt::ToolInfo {
541 name: "test".to_string(),
542 version: None,
543 repo: None,
544 commit: None,
545 },
546 run: Default::default(),
547 verdict: Default::default(),
548 findings: vec![],
549 capabilities: None,
550 data: None,
551 };
552
553 let result = harness.validate_receipt(&receipt);
554 assert!(!result.is_valid());
555 }
556
557 #[test]
558 fn test_validation_empty_tool_name() {
559 let harness = AdapterTestHarness::new(DummyAdapter);
560 let receipt = ReceiptEnvelope {
561 schema: "sensor.report.v1".to_string(),
562 tool: buildfix_types::receipt::ToolInfo {
563 name: String::new(),
564 version: None,
565 repo: None,
566 commit: None,
567 },
568 run: Default::default(),
569 verdict: Default::default(),
570 findings: vec![],
571 capabilities: None,
572 data: None,
573 };
574
575 let result = harness.validate_receipt(&receipt);
576 assert!(!result.is_valid());
577 }
578
579 #[test]
580 fn test_validation_valid_receipt() {
581 let harness = AdapterTestHarness::new(DummyAdapter);
582 let receipt = ReceiptEnvelope {
583 schema: "sensor.report.v1".to_string(),
584 tool: buildfix_types::receipt::ToolInfo {
585 name: "test-tool".to_string(),
586 version: Some("1.0.0".to_string()),
587 repo: None,
588 commit: None,
589 },
590 run: Default::default(),
591 verdict: Default::default(),
592 findings: vec![],
593 capabilities: None,
594 data: None,
595 };
596
597 let result = harness.validate_receipt(&receipt);
598 assert!(result.is_valid());
599 }
600
601 #[test]
602 fn test_extract_check_ids() {
603 let harness = AdapterTestHarness::new(DummyAdapter);
604 let receipt = ReceiptEnvelope {
605 schema: "sensor.report.v1".to_string(),
606 tool: buildfix_types::receipt::ToolInfo {
607 name: "test".to_string(),
608 version: None,
609 repo: None,
610 commit: None,
611 },
612 run: Default::default(),
613 verdict: Default::default(),
614 findings: vec![
615 Finding {
616 severity: Severity::Error,
617 check_id: Some("DENY001".to_string()),
618 code: None,
619 message: Some("error".to_string()),
620 location: None,
621 fingerprint: None,
622 data: None,
623 ..Default::default()
624 },
625 Finding {
626 severity: Severity::Warn,
627 check_id: Some("WARN001".to_string()),
628 code: None,
629 message: Some("warning".to_string()),
630 location: None,
631 fingerprint: None,
632 data: None,
633 ..Default::default()
634 },
635 Finding {
636 severity: Severity::Info,
637 check_id: None,
638 code: None,
639 message: Some("info".to_string()),
640 location: None,
641 fingerprint: None,
642 data: None,
643 ..Default::default()
644 },
645 ],
646 capabilities: None,
647 data: None,
648 };
649
650 let ids = harness.extract_check_ids(&receipt);
651 assert!(ids.contains("DENY001"));
652 assert!(ids.contains("WARN001"));
653 assert_eq!(ids.len(), 2);
654 }
655
656 #[test]
657 fn test_golden_test_matching() {
658 let temp_dir = TempDir::new().unwrap();
659 let receipt_path = temp_dir.path().join("report.json");
660
661 let expected = ReceiptEnvelope {
662 schema: "sensor.report.v1".to_string(),
663 tool: buildfix_types::receipt::ToolInfo {
664 name: "test".to_string(),
665 version: Some("1.0.0".to_string()),
666 repo: None,
667 commit: None,
668 },
669 run: Default::default(),
670 verdict: Default::default(),
671 findings: vec![],
672 capabilities: None,
673 data: None,
674 };
675
676 let content = serde_json::to_string_pretty(&expected).unwrap();
677 std::fs::write(&receipt_path, content).unwrap();
678
679 let harness = AdapterTestHarness::new(DummyAdapter);
680 let result = harness.golden_test(&receipt_path, &expected);
681
682 assert!(result.is_ok());
683 }
684
685 #[test]
686 fn test_assert_finding_count() {
687 let harness = AdapterTestHarness::new(DummyAdapter);
688 let receipt = ReceiptEnvelope {
689 schema: "sensor.report.v1".to_string(),
690 tool: buildfix_types::receipt::ToolInfo {
691 name: "test".to_string(),
692 version: None,
693 repo: None,
694 commit: None,
695 },
696 run: Default::default(),
697 verdict: Default::default(),
698 findings: vec![
699 Finding {
700 severity: Severity::Error,
701 check_id: Some("ERR001".to_string()),
702 code: None,
703 message: Some("error1".to_string()),
704 location: None,
705 fingerprint: None,
706 data: None,
707 ..Default::default()
708 },
709 Finding {
710 severity: Severity::Error,
711 check_id: Some("ERR002".to_string()),
712 code: None,
713 message: Some("error2".to_string()),
714 location: None,
715 fingerprint: None,
716 data: None,
717 ..Default::default()
718 },
719 Finding {
720 severity: Severity::Warn,
721 check_id: Some("WARN001".to_string()),
722 code: None,
723 message: Some("warning".to_string()),
724 location: None,
725 fingerprint: None,
726 data: None,
727 ..Default::default()
728 },
729 ],
730 capabilities: None,
731 data: None,
732 };
733
734 let result = harness.assert_finding_count(&receipt, 3, None);
735 assert!(result.is_ok());
736
737 let result = harness.assert_finding_count(&receipt, 2, Some(Severity::Error));
738 assert!(result.is_ok());
739
740 let result = harness.assert_finding_count(&receipt, 1, Some(Severity::Warn));
741 assert!(result.is_ok());
742
743 let result = harness.assert_finding_count(&receipt, 5, None);
744 assert!(result.is_err());
745 }
746
747 #[test]
750 fn test_validate_check_id_format_valid() {
751 let harness = AdapterTestHarness::new(DummyAdapter);
752 let receipt = ReceiptEnvelope {
753 schema: "sensor.report.v1".to_string(),
754 tool: buildfix_types::receipt::ToolInfo {
755 name: "test".to_string(),
756 version: None,
757 repo: None,
758 commit: None,
759 },
760 run: Default::default(),
761 verdict: Default::default(),
762 findings: vec![
763 Finding {
764 severity: Severity::Error,
765 check_id: Some("cargo-deny.ban.multiple-versions".to_string()),
766 code: None,
767 message: Some("error".to_string()),
768 location: None,
769 fingerprint: None,
770 data: None,
771 ..Default::default()
772 },
773 Finding {
774 severity: Severity::Warn,
775 check_id: Some("machete.unused_dependency.main".to_string()),
776 code: None,
777 message: Some("warning".to_string()),
778 location: None,
779 fingerprint: None,
780 data: None,
781 ..Default::default()
782 },
783 ],
784 capabilities: None,
785 data: None,
786 };
787
788 assert!(harness.validate_check_id_format(&receipt).is_ok());
789 }
790
791 #[test]
792 fn test_validate_check_id_format_no_dots() {
793 let harness = AdapterTestHarness::new(DummyAdapter);
794 let receipt = ReceiptEnvelope {
795 schema: "sensor.report.v1".to_string(),
796 tool: buildfix_types::receipt::ToolInfo {
797 name: "test".to_string(),
798 version: None,
799 repo: None,
800 commit: None,
801 },
802 run: Default::default(),
803 verdict: Default::default(),
804 findings: vec![Finding {
805 severity: Severity::Error,
806 check_id: Some("simplecheck".to_string()),
807 code: None,
808 message: Some("error".to_string()),
809 location: None,
810 fingerprint: None,
811 data: None,
812 ..Default::default()
813 }],
814 capabilities: None,
815 data: None,
816 };
817
818 let result = harness.validate_check_id_format(&receipt);
819 assert!(result.is_err());
820 let errors = result.unwrap_err();
821 assert_eq!(errors.len(), 1);
822 assert_eq!(errors[0].value, "simplecheck");
823 }
824
825 #[test]
826 fn test_validate_check_id_format_one_dot() {
827 let harness = AdapterTestHarness::new(DummyAdapter);
828 let receipt = ReceiptEnvelope {
829 schema: "sensor.report.v1".to_string(),
830 tool: buildfix_types::receipt::ToolInfo {
831 name: "test".to_string(),
832 version: None,
833 repo: None,
834 commit: None,
835 },
836 run: Default::default(),
837 verdict: Default::default(),
838 findings: vec![Finding {
839 severity: Severity::Error,
840 check_id: Some("sensor.check".to_string()),
841 code: None,
842 message: Some("error".to_string()),
843 location: None,
844 fingerprint: None,
845 data: None,
846 ..Default::default()
847 }],
848 capabilities: None,
849 data: None,
850 };
851
852 let result = harness.validate_check_id_format(&receipt);
853 assert!(result.is_err());
854 let errors = result.unwrap_err();
855 assert_eq!(errors.len(), 1);
856 assert_eq!(errors[0].value, "sensor.check");
857 }
858
859 #[test]
860 fn test_validate_check_id_format_uppercase() {
861 let harness = AdapterTestHarness::new(DummyAdapter);
862 let receipt = ReceiptEnvelope {
863 schema: "sensor.report.v1".to_string(),
864 tool: buildfix_types::receipt::ToolInfo {
865 name: "test".to_string(),
866 version: None,
867 repo: None,
868 commit: None,
869 },
870 run: Default::default(),
871 verdict: Default::default(),
872 findings: vec![Finding {
873 severity: Severity::Error,
874 check_id: Some("Cargo-Deny.Ban.Multiple".to_string()),
875 code: None,
876 message: Some("error".to_string()),
877 location: None,
878 fingerprint: None,
879 data: None,
880 ..Default::default()
881 }],
882 capabilities: None,
883 data: None,
884 };
885
886 let result = harness.validate_check_id_format(&receipt);
887 assert!(result.is_err());
888 let errors = result.unwrap_err();
889 assert_eq!(errors.len(), 1);
890 assert_eq!(errors[0].value, "Cargo-Deny.Ban.Multiple");
891 }
892
893 #[test]
894 fn test_validate_check_id_format_none() {
895 let harness = AdapterTestHarness::new(DummyAdapter);
896 let receipt = ReceiptEnvelope {
897 schema: "sensor.report.v1".to_string(),
898 tool: buildfix_types::receipt::ToolInfo {
899 name: "test".to_string(),
900 version: None,
901 repo: None,
902 commit: None,
903 },
904 run: Default::default(),
905 verdict: Default::default(),
906 findings: vec![Finding {
907 severity: Severity::Info,
908 check_id: None,
909 code: None,
910 message: Some("info".to_string()),
911 location: None,
912 fingerprint: None,
913 data: None,
914 ..Default::default()
915 }],
916 capabilities: None,
917 data: None,
918 };
919
920 assert!(harness.validate_check_id_format(&receipt).is_ok());
922 }
923
924 #[test]
927 fn test_validate_location_paths_valid() {
928 let harness = AdapterTestHarness::new(DummyAdapter);
929 let receipt = ReceiptEnvelope {
930 schema: "sensor.report.v1".to_string(),
931 tool: buildfix_types::receipt::ToolInfo {
932 name: "test".to_string(),
933 version: None,
934 repo: None,
935 commit: None,
936 },
937 run: Default::default(),
938 verdict: Default::default(),
939 findings: vec![
940 Finding {
941 severity: Severity::Error,
942 check_id: Some("test.check.id".to_string()),
943 code: None,
944 message: Some("error".to_string()),
945 location: Some(buildfix_types::receipt::Location {
946 path: Utf8PathBuf::from("src/main.rs"),
947 line: Some(1),
948 column: None,
949 }),
950 fingerprint: None,
951 data: None,
952 ..Default::default()
953 },
954 Finding {
955 severity: Severity::Warn,
956 check_id: Some("test.check.two".to_string()),
957 code: None,
958 message: Some("warning".to_string()),
959 location: Some(buildfix_types::receipt::Location {
960 path: Utf8PathBuf::from("crates/domain/src/lib.rs"),
961 line: None,
962 column: None,
963 }),
964 fingerprint: None,
965 data: None,
966 ..Default::default()
967 },
968 ],
969 capabilities: None,
970 data: None,
971 };
972
973 assert!(harness.validate_location_paths(&receipt).is_ok());
974 }
975
976 #[test]
977 fn test_validate_location_paths_empty() {
978 let harness = AdapterTestHarness::new(DummyAdapter);
979 let receipt = ReceiptEnvelope {
980 schema: "sensor.report.v1".to_string(),
981 tool: buildfix_types::receipt::ToolInfo {
982 name: "test".to_string(),
983 version: None,
984 repo: None,
985 commit: None,
986 },
987 run: Default::default(),
988 verdict: Default::default(),
989 findings: vec![Finding {
990 severity: Severity::Error,
991 check_id: Some("test.check.id".to_string()),
992 code: None,
993 message: Some("error".to_string()),
994 location: Some(buildfix_types::receipt::Location {
995 path: Utf8PathBuf::new(),
996 line: None,
997 column: None,
998 }),
999 fingerprint: None,
1000 data: None,
1001 ..Default::default()
1002 }],
1003 capabilities: None,
1004 data: None,
1005 };
1006
1007 let result = harness.validate_location_paths(&receipt);
1008 assert!(result.is_err());
1009 let errors = result.unwrap_err();
1010 assert_eq!(errors.len(), 1);
1011 assert!(errors[0].message.contains("empty"));
1012 }
1013
1014 #[test]
1015 fn test_validate_location_paths_backslash() {
1016 let harness = AdapterTestHarness::new(DummyAdapter);
1017 let receipt = ReceiptEnvelope {
1018 schema: "sensor.report.v1".to_string(),
1019 tool: buildfix_types::receipt::ToolInfo {
1020 name: "test".to_string(),
1021 version: None,
1022 repo: None,
1023 commit: None,
1024 },
1025 run: Default::default(),
1026 verdict: Default::default(),
1027 findings: vec![Finding {
1028 severity: Severity::Error,
1029 check_id: Some("test.check.id".to_string()),
1030 code: None,
1031 message: Some("error".to_string()),
1032 location: Some(buildfix_types::receipt::Location {
1033 path: Utf8PathBuf::from("src\\main.rs"),
1034 line: None,
1035 column: None,
1036 }),
1037 fingerprint: None,
1038 data: None,
1039 ..Default::default()
1040 }],
1041 capabilities: None,
1042 data: None,
1043 };
1044
1045 let result = harness.validate_location_paths(&receipt);
1046 assert!(result.is_err());
1047 let errors = result.unwrap_err();
1048 assert_eq!(errors.len(), 1);
1049 assert!(errors[0].message.contains("forward slashes"));
1050 }
1051
1052 #[test]
1053 fn test_validate_location_paths_absolute() {
1054 let harness = AdapterTestHarness::new(DummyAdapter);
1055 let receipt = ReceiptEnvelope {
1056 schema: "sensor.report.v1".to_string(),
1057 tool: buildfix_types::receipt::ToolInfo {
1058 name: "test".to_string(),
1059 version: None,
1060 repo: None,
1061 commit: None,
1062 },
1063 run: Default::default(),
1064 verdict: Default::default(),
1065 findings: vec![Finding {
1066 severity: Severity::Error,
1067 check_id: Some("test.check.id".to_string()),
1068 code: None,
1069 message: Some("error".to_string()),
1070 location: Some(buildfix_types::receipt::Location {
1071 path: Utf8PathBuf::from("/src/main.rs"),
1072 line: None,
1073 column: None,
1074 }),
1075 fingerprint: None,
1076 data: None,
1077 ..Default::default()
1078 }],
1079 capabilities: None,
1080 data: None,
1081 };
1082
1083 let result = harness.validate_location_paths(&receipt);
1084 assert!(result.is_err());
1085 let errors = result.unwrap_err();
1086 assert_eq!(errors.len(), 1);
1087 assert!(errors[0].message.contains("relative"));
1088 }
1089
1090 #[test]
1091 fn test_validate_location_paths_parent_traversal() {
1092 let harness = AdapterTestHarness::new(DummyAdapter);
1093 let receipt = ReceiptEnvelope {
1094 schema: "sensor.report.v1".to_string(),
1095 tool: buildfix_types::receipt::ToolInfo {
1096 name: "test".to_string(),
1097 version: None,
1098 repo: None,
1099 commit: None,
1100 },
1101 run: Default::default(),
1102 verdict: Default::default(),
1103 findings: vec![Finding {
1104 severity: Severity::Error,
1105 check_id: Some("test.check.id".to_string()),
1106 code: None,
1107 message: Some("error".to_string()),
1108 location: Some(buildfix_types::receipt::Location {
1109 path: Utf8PathBuf::from("../src/main.rs"),
1110 line: None,
1111 column: None,
1112 }),
1113 fingerprint: None,
1114 data: None,
1115 ..Default::default()
1116 }],
1117 capabilities: None,
1118 data: None,
1119 };
1120
1121 let result = harness.validate_location_paths(&receipt);
1122 assert!(result.is_err());
1123 let errors = result.unwrap_err();
1124 assert_eq!(errors.len(), 1);
1125 assert!(errors[0].message.contains(".."));
1126 }
1127
1128 #[test]
1129 fn test_validate_location_paths_no_location() {
1130 let harness = AdapterTestHarness::new(DummyAdapter);
1131 let receipt = ReceiptEnvelope {
1132 schema: "sensor.report.v1".to_string(),
1133 tool: buildfix_types::receipt::ToolInfo {
1134 name: "test".to_string(),
1135 version: None,
1136 repo: None,
1137 commit: None,
1138 },
1139 run: Default::default(),
1140 verdict: Default::default(),
1141 findings: vec![Finding {
1142 severity: Severity::Info,
1143 check_id: Some("test.check.id".to_string()),
1144 code: None,
1145 message: Some("info".to_string()),
1146 location: None,
1147 fingerprint: None,
1148 data: None,
1149 ..Default::default()
1150 }],
1151 capabilities: None,
1152 data: None,
1153 };
1154
1155 assert!(harness.validate_location_paths(&receipt).is_ok());
1157 }
1158
1159 #[test]
1162 fn test_validate_all_valid() {
1163 let harness = AdapterTestHarness::new(DummyAdapter);
1164 let receipt = ReceiptEnvelope {
1165 schema: "sensor.report.v1".to_string(),
1166 tool: buildfix_types::receipt::ToolInfo {
1167 name: "test".to_string(),
1168 version: Some("1.0.0".to_string()),
1169 repo: None,
1170 commit: None,
1171 },
1172 run: Default::default(),
1173 verdict: Default::default(),
1174 findings: vec![Finding {
1175 severity: Severity::Error,
1176 check_id: Some("cargo-deny.ban.multiple-versions".to_string()),
1177 code: None,
1178 message: Some("error".to_string()),
1179 location: Some(buildfix_types::receipt::Location {
1180 path: Utf8PathBuf::from("Cargo.toml"),
1181 line: None,
1182 column: None,
1183 }),
1184 fingerprint: None,
1185 data: None,
1186 ..Default::default()
1187 }],
1188 capabilities: None,
1189 data: None,
1190 };
1191
1192 assert!(harness.validate_all(&receipt).is_ok());
1193 }
1194
1195 #[test]
1196 fn test_validate_all_multiple_errors() {
1197 let harness = AdapterTestHarness::new(DummyAdapter);
1198 let receipt = ReceiptEnvelope {
1199 schema: String::new(), tool: buildfix_types::receipt::ToolInfo {
1201 name: String::new(), version: None,
1203 repo: None,
1204 commit: None,
1205 },
1206 run: Default::default(),
1207 verdict: Default::default(),
1208 findings: vec![Finding {
1209 severity: Severity::Error,
1210 check_id: Some("INVALID".to_string()), code: None,
1212 message: Some("error".to_string()),
1213 location: Some(buildfix_types::receipt::Location {
1214 path: Utf8PathBuf::from("/absolute/path.rs"), line: None,
1216 column: None,
1217 }),
1218 fingerprint: None,
1219 data: None,
1220 ..Default::default()
1221 }],
1222 capabilities: None,
1223 data: None,
1224 };
1225
1226 let result = harness.validate_all(&receipt);
1227 assert!(result.is_err());
1228 let errors = result.unwrap_err();
1229 assert!(errors.len() >= 3);
1231 }
1232
1233 #[test]
1236 fn test_validation_error_display() {
1237 let error = ValidationError {
1238 field: "test.field".to_string(),
1239 value: "invalid_value".to_string(),
1240 message: "Field is invalid".to_string(),
1241 };
1242
1243 let display = format!("{}", error);
1244 assert!(display.contains("test.field"));
1245 assert!(display.contains("invalid_value"));
1246 assert!(display.contains("Field is invalid"));
1247 }
1248}