1use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum License {
21 Mit,
23 Apache2,
25 Isc,
27 Bsd2,
29 Bsd3,
31 Mpl2,
33 Gpl2,
35 Gpl3,
37 Agpl3,
39 Lgpl21,
41 Unlicense,
43 Cc0,
45 Unknown,
47 Custom(String),
49}
50
51impl License {
52 #[must_use]
54 pub fn spdx(&self) -> &str {
55 match self {
56 Self::Mit => "MIT",
57 Self::Apache2 => "Apache-2.0",
58 Self::Isc => "ISC",
59 Self::Bsd2 => "BSD-2-Clause",
60 Self::Bsd3 => "BSD-3-Clause",
61 Self::Mpl2 => "MPL-2.0",
62 Self::Gpl2 => "GPL-2.0",
63 Self::Gpl3 => "GPL-3.0",
64 Self::Agpl3 => "AGPL-3.0",
65 Self::Lgpl21 => "LGPL-2.1",
66 Self::Unlicense => "Unlicense",
67 Self::Cc0 => "CC0-1.0",
68 Self::Unknown => "UNKNOWN",
69 Self::Custom(s) => s.as_str(),
70 }
71 }
72}
73
74impl std::fmt::Display for License {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(f, "{}", self.spdx())
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum Redistributable {
84 Yes,
86 Copyleft,
88 Unknown,
90 No,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum SecuritySeverity {
98 Info,
100 Warning,
102 Critical,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SecurityFinding {
109 pub severity: SecuritySeverity,
110 pub pattern: String,
111 pub description: String,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct PolicyVerdict {
117 pub canonical_id: String,
118 pub license: String,
119 pub license_source: String,
120 pub redistributable: Redistributable,
121 pub security_findings: Vec<SecurityFinding>,
122 pub verdict: VerdictStatus,
123 pub notes: String,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum VerdictStatus {
130 Pass,
132 PassWithWarnings,
134 Excluded,
136 NeedsReview,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ScreeningReport {
143 pub generated_at: String,
144 pub task: String,
145 pub stats: ScreeningStats,
146 pub verdicts: Vec<PolicyVerdict>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ScreeningStats {
152 pub total_screened: usize,
153 pub pass: usize,
154 pub pass_with_warnings: usize,
155 pub excluded: usize,
156 pub needs_review: usize,
157 pub license_distribution: HashMap<String, usize>,
158}
159
160#[must_use]
166pub fn detect_license_from_content(content: &str) -> License {
167 let lower = content.to_lowercase();
168
169 if lower.contains("permission is hereby granted, free of charge")
171 && lower.contains("the software is provided \"as is\"")
172 {
173 return License::Mit;
174 }
175 if lower.contains("mit license") && lower.contains("permission is hereby granted") {
176 return License::Mit;
177 }
178
179 if lower.contains("apache license") && lower.contains("version 2.0") {
181 return License::Apache2;
182 }
183
184 if lower.contains("isc license")
186 || (lower.contains("permission to use, copy, modify") && lower.contains("isc"))
187 {
188 return License::Isc;
189 }
190
191 if lower.contains("redistribution and use in source and binary forms")
193 && lower.contains("neither the name")
194 {
195 return License::Bsd3;
196 }
197
198 if lower.contains("redistribution and use in source and binary forms")
200 && !lower.contains("neither the name")
201 {
202 return License::Bsd2;
203 }
204
205 if lower.contains("gnu general public license") && lower.contains("version 3") {
207 return License::Gpl3;
208 }
209
210 if lower.contains("gnu general public license") && lower.contains("version 2") {
212 return License::Gpl2;
213 }
214
215 if lower.contains("gnu affero general public license") {
217 return License::Agpl3;
218 }
219
220 if lower.contains("gnu lesser general public license") {
222 return License::Lgpl21;
223 }
224
225 if lower.contains("mozilla public license") && lower.contains("2.0") {
227 return License::Mpl2;
228 }
229
230 if lower.contains("this is free and unencumbered software") {
232 return License::Unlicense;
233 }
234
235 if lower.contains("cc0") || lower.contains("creative commons zero") {
237 return License::Cc0;
238 }
239
240 License::Unknown
241}
242
243#[must_use]
245pub fn detect_license_from_spdx(spdx: &str) -> License {
246 match spdx.trim().to_uppercase().as_str() {
247 "MIT" => License::Mit,
248 "APACHE-2.0" | "APACHE 2.0" => License::Apache2,
249 "ISC" => License::Isc,
250 "BSD-2-CLAUSE" => License::Bsd2,
251 "BSD-3-CLAUSE" => License::Bsd3,
252 "MPL-2.0" => License::Mpl2,
253 "GPL-2.0" | "GPL-2.0-ONLY" | "GPL-2.0-OR-LATER" => License::Gpl2,
254 "GPL-3.0" | "GPL-3.0-ONLY" | "GPL-3.0-OR-LATER" => License::Gpl3,
255 "AGPL-3.0" | "AGPL-3.0-ONLY" | "AGPL-3.0-OR-LATER" => License::Agpl3,
256 "LGPL-2.1" | "LGPL-2.1-ONLY" | "LGPL-2.1-OR-LATER" => License::Lgpl21,
257 "UNLICENSE" => License::Unlicense,
258 "CC0-1.0" | "CC0" => License::Cc0,
259 "UNKNOWN" | "" => License::Unknown,
260 other => License::Custom(other.to_string()),
261 }
262}
263
264#[must_use]
266pub const fn redistributable(license: &License) -> Redistributable {
267 match license {
268 License::Mit
269 | License::Apache2
270 | License::Isc
271 | License::Bsd2
272 | License::Bsd3
273 | License::Unlicense
274 | License::Cc0 => Redistributable::Yes,
275 License::Gpl2 | License::Gpl3 | License::Agpl3 | License::Lgpl21 | License::Mpl2 => {
276 Redistributable::Copyleft
277 }
278 License::Unknown | License::Custom(_) => Redistributable::Unknown,
279 }
280}
281
282const SECURITY_PATTERNS: &[(&str, SecuritySeverity, &str)] = &[
288 (
289 "eval(",
290 SecuritySeverity::Warning,
291 "Dynamic code evaluation via eval()",
292 ),
293 (
294 "new Function(",
295 SecuritySeverity::Warning,
296 "Dynamic function construction",
297 ),
298 (
299 "child_process",
300 SecuritySeverity::Info,
301 "Uses child_process module (common in extensions)",
302 ),
303 (
304 "crypto.createHash",
305 SecuritySeverity::Info,
306 "Uses cryptographic hashing",
307 ),
308 (".env", SecuritySeverity::Info, "References .env files"),
309 (
310 "process.env.API_KEY",
311 SecuritySeverity::Warning,
312 "Accesses API key from environment",
313 ),
314 (
315 "fetch(\"http://",
316 SecuritySeverity::Warning,
317 "HTTP (non-HTTPS) fetch",
318 ),
319 (
320 "XMLHttpRequest",
321 SecuritySeverity::Info,
322 "Uses XMLHttpRequest",
323 ),
324 (
325 "document.cookie",
326 SecuritySeverity::Critical,
327 "Accesses browser cookies",
328 ),
329 (
330 "localStorage",
331 SecuritySeverity::Warning,
332 "Accesses localStorage",
333 ),
334 (
335 "Buffer.from(",
336 SecuritySeverity::Info,
337 "Binary buffer operations",
338 ),
339];
340
341#[must_use]
343pub fn scan_security(content: &str) -> Vec<SecurityFinding> {
344 let mut findings = Vec::new();
345 for (pattern, severity, description) in SECURITY_PATTERNS {
346 if content.contains(pattern) {
347 findings.push(SecurityFinding {
348 severity: severity.clone(),
349 pattern: (*pattern).to_string(),
350 description: (*description).to_string(),
351 });
352 }
353 }
354 findings
355}
356
357#[derive(Debug, Clone)]
363pub struct ScreeningInput {
364 pub canonical_id: String,
365 pub known_license: Option<String>,
366 pub source_tier: Option<String>,
367}
368
369#[must_use]
371pub fn screen_extensions(inputs: &[ScreeningInput], task_id: &str) -> ScreeningReport {
372 let mut verdicts = Vec::new();
373 let mut license_dist: HashMap<String, usize> = HashMap::new();
374
375 for input in inputs {
376 let license = input
377 .known_license
378 .as_deref()
379 .map_or(License::Unknown, detect_license_from_spdx);
380
381 let redist = redistributable(&license);
382 let spdx = license.spdx().to_string();
383
384 *license_dist.entry(spdx.clone()).or_insert(0) += 1;
385
386 let verdict = match redist {
387 Redistributable::Yes => VerdictStatus::Pass,
388 Redistributable::Copyleft => VerdictStatus::PassWithWarnings,
389 Redistributable::Unknown => VerdictStatus::NeedsReview,
390 Redistributable::No => VerdictStatus::Excluded,
391 };
392
393 let notes = match redist {
394 Redistributable::Yes => format!("{spdx}: permissive, freely redistributable"),
395 Redistributable::Copyleft => {
396 format!("{spdx}: copyleft, must preserve license in redistribution")
397 }
398 Redistributable::Unknown => "License unknown; manual review required".to_string(),
399 Redistributable::No => "Restricted license; excluded from corpus".to_string(),
400 };
401
402 verdicts.push(PolicyVerdict {
403 canonical_id: input.canonical_id.clone(),
404 license: spdx,
405 license_source: input
406 .known_license
407 .as_deref()
408 .map_or("none", |_| "candidate_pool")
409 .to_string(),
410 redistributable: redist,
411 security_findings: Vec::new(),
412 verdict,
413 notes,
414 });
415 }
416
417 verdicts.sort_by(|a, b| a.canonical_id.cmp(&b.canonical_id));
419
420 let pass = verdicts
421 .iter()
422 .filter(|v| v.verdict == VerdictStatus::Pass)
423 .count();
424 let pass_warn = verdicts
425 .iter()
426 .filter(|v| v.verdict == VerdictStatus::PassWithWarnings)
427 .count();
428 let excluded = verdicts
429 .iter()
430 .filter(|v| v.verdict == VerdictStatus::Excluded)
431 .count();
432 let needs_review = verdicts
433 .iter()
434 .filter(|v| v.verdict == VerdictStatus::NeedsReview)
435 .count();
436
437 ScreeningReport {
438 generated_at: crate::extension_validation::chrono_now_iso(),
439 task: task_id.to_string(),
440 stats: ScreeningStats {
441 total_screened: verdicts.len(),
442 pass,
443 pass_with_warnings: pass_warn,
444 excluded,
445 needs_review,
446 license_distribution: license_dist,
447 },
448 verdicts,
449 }
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn detect_mit_license() {
462 let content = "MIT License\n\nPermission is hereby granted, free of charge...\nTHE SOFTWARE IS PROVIDED \"AS IS\"";
463 assert_eq!(detect_license_from_content(content), License::Mit);
464 }
465
466 #[test]
467 fn detect_apache2_license() {
468 let content = "Apache License\nVersion 2.0, January 2004";
469 assert_eq!(detect_license_from_content(content), License::Apache2);
470 }
471
472 #[test]
473 fn detect_gpl3_license() {
474 let content = "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007";
475 assert_eq!(detect_license_from_content(content), License::Gpl3);
476 }
477
478 #[test]
479 fn detect_unknown_license() {
480 let content = "Some random text that doesn't match any license";
481 assert_eq!(detect_license_from_content(content), License::Unknown);
482 }
483
484 #[test]
485 fn spdx_mit() {
486 assert_eq!(detect_license_from_spdx("MIT"), License::Mit);
487 }
488
489 #[test]
490 fn spdx_apache() {
491 assert_eq!(detect_license_from_spdx("Apache-2.0"), License::Apache2);
492 }
493
494 #[test]
495 fn spdx_unknown() {
496 assert_eq!(detect_license_from_spdx(""), License::Unknown);
497 }
498
499 #[test]
500 fn spdx_custom() {
501 assert_eq!(
502 detect_license_from_spdx("WTFPL"),
503 License::Custom("WTFPL".to_string())
504 );
505 }
506
507 #[test]
508 fn redistributable_permissive() {
509 assert_eq!(redistributable(&License::Mit), Redistributable::Yes);
510 assert_eq!(redistributable(&License::Apache2), Redistributable::Yes);
511 assert_eq!(redistributable(&License::Isc), Redistributable::Yes);
512 assert_eq!(redistributable(&License::Bsd2), Redistributable::Yes);
513 assert_eq!(redistributable(&License::Bsd3), Redistributable::Yes);
514 assert_eq!(redistributable(&License::Unlicense), Redistributable::Yes);
515 assert_eq!(redistributable(&License::Cc0), Redistributable::Yes);
516 }
517
518 #[test]
519 fn redistributable_copyleft() {
520 assert_eq!(redistributable(&License::Gpl2), Redistributable::Copyleft);
521 assert_eq!(redistributable(&License::Gpl3), Redistributable::Copyleft);
522 assert_eq!(redistributable(&License::Agpl3), Redistributable::Copyleft);
523 assert_eq!(redistributable(&License::Lgpl21), Redistributable::Copyleft);
524 assert_eq!(redistributable(&License::Mpl2), Redistributable::Copyleft);
525 }
526
527 #[test]
528 fn redistributable_unknown() {
529 assert_eq!(redistributable(&License::Unknown), Redistributable::Unknown);
530 }
531
532 #[test]
533 fn security_scan_clean() {
534 let content = "function hello() { console.log('world'); }";
535 assert!(scan_security(content).is_empty());
536 }
537
538 #[test]
539 fn security_scan_eval() {
540 let content = "eval(userInput)";
541 let findings = scan_security(content);
542 assert_eq!(findings.len(), 1);
543 assert_eq!(findings[0].severity, SecuritySeverity::Warning);
544 }
545
546 #[test]
547 fn security_scan_cookie() {
548 let content = "const token = document.cookie;";
549 let findings = scan_security(content);
550 assert!(
551 findings
552 .iter()
553 .any(|f| f.severity == SecuritySeverity::Critical)
554 );
555 }
556
557 #[test]
558 fn screen_extensions_basic() {
559 let inputs = vec![
560 ScreeningInput {
561 canonical_id: "alice/ext-a".to_string(),
562 known_license: Some("MIT".to_string()),
563 source_tier: Some("community".to_string()),
564 },
565 ScreeningInput {
566 canonical_id: "bob/ext-b".to_string(),
567 known_license: None,
568 source_tier: Some("third-party-github".to_string()),
569 },
570 ScreeningInput {
571 canonical_id: "carol/ext-c".to_string(),
572 known_license: Some("GPL-3.0".to_string()),
573 source_tier: Some("community".to_string()),
574 },
575 ];
576
577 let report = screen_extensions(&inputs, "test");
578
579 assert_eq!(report.stats.total_screened, 3);
580 assert_eq!(report.stats.pass, 1);
581 assert_eq!(report.stats.pass_with_warnings, 1);
582 assert_eq!(report.stats.needs_review, 1);
583
584 let alice = report
585 .verdicts
586 .iter()
587 .find(|v| v.canonical_id == "alice/ext-a")
588 .unwrap();
589 assert_eq!(alice.verdict, VerdictStatus::Pass);
590
591 let bob = report
592 .verdicts
593 .iter()
594 .find(|v| v.canonical_id == "bob/ext-b")
595 .unwrap();
596 assert_eq!(bob.verdict, VerdictStatus::NeedsReview);
597
598 let carol = report
599 .verdicts
600 .iter()
601 .find(|v| v.canonical_id == "carol/ext-c")
602 .unwrap();
603 assert_eq!(carol.verdict, VerdictStatus::PassWithWarnings);
604 }
605
606 #[test]
607 fn verdict_serde_round_trip() {
608 let v = PolicyVerdict {
609 canonical_id: "test/ext".to_string(),
610 license: "MIT".to_string(),
611 license_source: "candidate_pool".to_string(),
612 redistributable: Redistributable::Yes,
613 security_findings: vec![],
614 verdict: VerdictStatus::Pass,
615 notes: "MIT: permissive".to_string(),
616 };
617 let json = serde_json::to_string(&v).unwrap();
618 let back: PolicyVerdict = serde_json::from_str(&json).unwrap();
619 assert_eq!(back.canonical_id, "test/ext");
620 assert_eq!(back.verdict, VerdictStatus::Pass);
621 }
622
623 #[test]
624 fn license_display() {
625 assert_eq!(License::Mit.to_string(), "MIT");
626 assert_eq!(License::Apache2.to_string(), "Apache-2.0");
627 assert_eq!(License::Custom("WTFPL".to_string()).to_string(), "WTFPL");
628 }
629
630 #[test]
635 fn detect_mit_alt_path() {
636 let content = "MIT License\n\nCopyright (c) 2025\n\nPermission is hereby granted...";
638 assert_eq!(detect_license_from_content(content), License::Mit);
639 }
640
641 #[test]
642 fn detect_isc_license_content() {
643 let content = "ISC License\n\nCopyright (c) 2025 Author\n\nPermission to use...";
644 assert_eq!(detect_license_from_content(content), License::Isc);
645 }
646
647 #[test]
648 fn detect_isc_alt_path() {
649 let content = "Permission to use, copy, modify, and distribute... ISC";
650 assert_eq!(detect_license_from_content(content), License::Isc);
651 }
652
653 #[test]
654 fn detect_bsd3_content() {
655 let content = "Redistribution and use in source and binary forms, with or without modification...\nNeither the name of the copyright holder...";
656 assert_eq!(detect_license_from_content(content), License::Bsd3);
657 }
658
659 #[test]
660 fn detect_bsd2_content() {
661 let content =
662 "Redistribution and use in source and binary forms, with or without modification...";
663 assert_eq!(detect_license_from_content(content), License::Bsd2);
664 }
665
666 #[test]
667 fn detect_gpl2_content() {
668 let content = "GNU General Public License\nVersion 2, June 1991";
669 assert_eq!(detect_license_from_content(content), License::Gpl2);
670 }
671
672 #[test]
673 fn detect_agpl3_content() {
674 let content = "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007";
675 assert_eq!(detect_license_from_content(content), License::Agpl3);
676 }
677
678 #[test]
679 fn detect_lgpl21_content() {
680 let content = "GNU Lesser General Public License v2.1";
681 assert_eq!(detect_license_from_content(content), License::Lgpl21);
682 }
683
684 #[test]
685 fn detect_mpl2_content() {
686 let content = "Mozilla Public License Version 2.0";
687 assert_eq!(detect_license_from_content(content), License::Mpl2);
688 }
689
690 #[test]
691 fn detect_unlicense_content() {
692 let content = "This is free and unencumbered software released into the public domain.";
693 assert_eq!(detect_license_from_content(content), License::Unlicense);
694 }
695
696 #[test]
697 fn detect_cc0_content() {
698 let content = "Creative Commons Zero v1.0 Universal";
699 assert_eq!(detect_license_from_content(content), License::Cc0);
700 }
701
702 #[test]
703 fn detect_cc0_short() {
704 let content = "Licensed under CC0";
705 assert_eq!(detect_license_from_content(content), License::Cc0);
706 }
707
708 #[test]
713 fn spdx_isc() {
714 assert_eq!(detect_license_from_spdx("ISC"), License::Isc);
715 }
716
717 #[test]
718 fn spdx_bsd2() {
719 assert_eq!(detect_license_from_spdx("BSD-2-Clause"), License::Bsd2);
720 }
721
722 #[test]
723 fn spdx_bsd3() {
724 assert_eq!(detect_license_from_spdx("BSD-3-Clause"), License::Bsd3);
725 }
726
727 #[test]
728 fn spdx_mpl2() {
729 assert_eq!(detect_license_from_spdx("MPL-2.0"), License::Mpl2);
730 }
731
732 #[test]
733 fn spdx_gpl2_variants() {
734 assert_eq!(detect_license_from_spdx("GPL-2.0"), License::Gpl2);
735 assert_eq!(detect_license_from_spdx("GPL-2.0-only"), License::Gpl2);
736 assert_eq!(detect_license_from_spdx("GPL-2.0-or-later"), License::Gpl2);
737 }
738
739 #[test]
740 fn spdx_gpl3_variants() {
741 assert_eq!(detect_license_from_spdx("GPL-3.0"), License::Gpl3);
742 assert_eq!(detect_license_from_spdx("GPL-3.0-only"), License::Gpl3);
743 assert_eq!(detect_license_from_spdx("GPL-3.0-or-later"), License::Gpl3);
744 }
745
746 #[test]
747 fn spdx_agpl3_variants() {
748 assert_eq!(detect_license_from_spdx("AGPL-3.0"), License::Agpl3);
749 assert_eq!(detect_license_from_spdx("AGPL-3.0-only"), License::Agpl3);
750 assert_eq!(
751 detect_license_from_spdx("AGPL-3.0-or-later"),
752 License::Agpl3
753 );
754 }
755
756 #[test]
757 fn spdx_lgpl21_variants() {
758 assert_eq!(detect_license_from_spdx("LGPL-2.1"), License::Lgpl21);
759 assert_eq!(detect_license_from_spdx("LGPL-2.1-only"), License::Lgpl21);
760 assert_eq!(
761 detect_license_from_spdx("LGPL-2.1-or-later"),
762 License::Lgpl21
763 );
764 }
765
766 #[test]
767 fn spdx_unlicense() {
768 assert_eq!(detect_license_from_spdx("Unlicense"), License::Unlicense);
769 }
770
771 #[test]
772 fn spdx_cc0_variants() {
773 assert_eq!(detect_license_from_spdx("CC0-1.0"), License::Cc0);
774 assert_eq!(detect_license_from_spdx("CC0"), License::Cc0);
775 }
776
777 #[test]
778 fn spdx_case_insensitive() {
779 assert_eq!(detect_license_from_spdx("mit"), License::Mit);
780 assert_eq!(detect_license_from_spdx("apache-2.0"), License::Apache2);
781 assert_eq!(detect_license_from_spdx(" MIT "), License::Mit);
782 }
783
784 #[test]
785 fn spdx_apache_space_variant() {
786 assert_eq!(detect_license_from_spdx("Apache 2.0"), License::Apache2);
787 }
788
789 #[test]
790 fn spdx_unknown_explicit() {
791 assert_eq!(detect_license_from_spdx("UNKNOWN"), License::Unknown);
792 }
793
794 #[test]
799 fn spdx_identifiers_all_variants() {
800 assert_eq!(License::Mit.spdx(), "MIT");
801 assert_eq!(License::Apache2.spdx(), "Apache-2.0");
802 assert_eq!(License::Isc.spdx(), "ISC");
803 assert_eq!(License::Bsd2.spdx(), "BSD-2-Clause");
804 assert_eq!(License::Bsd3.spdx(), "BSD-3-Clause");
805 assert_eq!(License::Mpl2.spdx(), "MPL-2.0");
806 assert_eq!(License::Gpl2.spdx(), "GPL-2.0");
807 assert_eq!(License::Gpl3.spdx(), "GPL-3.0");
808 assert_eq!(License::Agpl3.spdx(), "AGPL-3.0");
809 assert_eq!(License::Lgpl21.spdx(), "LGPL-2.1");
810 assert_eq!(License::Unlicense.spdx(), "Unlicense");
811 assert_eq!(License::Cc0.spdx(), "CC0-1.0");
812 assert_eq!(License::Unknown.spdx(), "UNKNOWN");
813 assert_eq!(License::Custom("WTFPL".to_string()).spdx(), "WTFPL");
814 }
815
816 #[test]
821 fn redistributable_custom_is_unknown() {
822 assert_eq!(
823 redistributable(&License::Custom("proprietary".to_string())),
824 Redistributable::Unknown
825 );
826 }
827
828 #[test]
833 fn security_scan_new_function() {
834 let findings = scan_security("const fn = new Function('return 1')");
835 assert_eq!(findings.len(), 1);
836 assert_eq!(findings[0].severity, SecuritySeverity::Warning);
837 assert!(findings[0].pattern.contains("new Function("));
838 }
839
840 #[test]
841 fn security_scan_child_process() {
842 let findings = scan_security("const cp = require('child_process')");
843 assert_eq!(findings.len(), 1);
844 assert_eq!(findings[0].severity, SecuritySeverity::Info);
845 }
846
847 #[test]
848 fn security_scan_crypto_hash() {
849 let findings = scan_security("crypto.createHash('sha256')");
850 assert_eq!(findings.len(), 1);
851 assert_eq!(findings[0].severity, SecuritySeverity::Info);
852 }
853
854 #[test]
855 fn security_scan_env_file() {
856 let findings = scan_security("fs.readFileSync('.env')");
857 assert_eq!(findings.len(), 1);
858 assert_eq!(findings[0].severity, SecuritySeverity::Info);
859 }
860
861 #[test]
862 fn security_scan_api_key_env() {
863 let findings = scan_security("const key = process.env.API_KEY;");
864 assert!(!findings.is_empty());
866 assert!(findings.iter().any(|f| f.pattern == "process.env.API_KEY"));
867 }
868
869 #[test]
870 fn security_scan_http_fetch() {
871 let findings = scan_security(r#"fetch("http://evil.com")"#);
872 assert_eq!(findings.len(), 1);
873 assert_eq!(findings[0].severity, SecuritySeverity::Warning);
874 }
875
876 #[test]
877 fn security_scan_localstorage() {
878 let findings = scan_security("localStorage.setItem('key', 'value')");
879 assert_eq!(findings.len(), 1);
880 assert_eq!(findings[0].severity, SecuritySeverity::Warning);
881 }
882
883 #[test]
884 fn security_scan_buffer_from() {
885 let findings = scan_security("const b = Buffer.from('hello')");
886 assert_eq!(findings.len(), 1);
887 assert_eq!(findings[0].severity, SecuritySeverity::Info);
888 }
889
890 #[test]
891 fn security_scan_xmlhttprequest() {
892 let findings = scan_security("new XMLHttpRequest()");
893 assert_eq!(findings.len(), 1);
894 assert_eq!(findings[0].severity, SecuritySeverity::Info);
895 }
896
897 #[test]
898 fn security_scan_multiple_findings() {
899 let content = "eval(x); document.cookie; localStorage.getItem('k')";
900 let findings = scan_security(content);
901 assert!(findings.len() >= 3);
902 assert!(
903 findings
904 .iter()
905 .any(|f| f.severity == SecuritySeverity::Critical)
906 );
907 assert!(
908 findings
909 .iter()
910 .any(|f| f.severity == SecuritySeverity::Warning)
911 );
912 }
913
914 #[test]
919 fn screen_extensions_empty_input() {
920 let report = screen_extensions(&[], "empty-test");
921 assert_eq!(report.stats.total_screened, 0);
922 assert_eq!(report.stats.pass, 0);
923 assert!(report.verdicts.is_empty());
924 assert_eq!(report.task, "empty-test");
925 }
926
927 #[test]
928 fn screen_extensions_sorted_output() {
929 let inputs = vec![
930 ScreeningInput {
931 canonical_id: "zzz/ext".to_string(),
932 known_license: Some("MIT".to_string()),
933 source_tier: None,
934 },
935 ScreeningInput {
936 canonical_id: "aaa/ext".to_string(),
937 known_license: Some("MIT".to_string()),
938 source_tier: None,
939 },
940 ];
941 let report = screen_extensions(&inputs, "sort-test");
942 assert_eq!(report.verdicts[0].canonical_id, "aaa/ext");
943 assert_eq!(report.verdicts[1].canonical_id, "zzz/ext");
944 }
945
946 #[test]
947 fn screen_extensions_license_distribution() {
948 let inputs = vec![
949 ScreeningInput {
950 canonical_id: "a".to_string(),
951 known_license: Some("MIT".to_string()),
952 source_tier: None,
953 },
954 ScreeningInput {
955 canonical_id: "b".to_string(),
956 known_license: Some("MIT".to_string()),
957 source_tier: None,
958 },
959 ScreeningInput {
960 canonical_id: "c".to_string(),
961 known_license: Some("Apache-2.0".to_string()),
962 source_tier: None,
963 },
964 ];
965 let report = screen_extensions(&inputs, "dist-test");
966 assert_eq!(report.stats.license_distribution["MIT"], 2);
967 assert_eq!(report.stats.license_distribution["Apache-2.0"], 1);
968 }
969
970 #[test]
971 fn screen_extensions_notes_content() {
972 let inputs = vec![
973 ScreeningInput {
974 canonical_id: "a".to_string(),
975 known_license: Some("MIT".to_string()),
976 source_tier: None,
977 },
978 ScreeningInput {
979 canonical_id: "b".to_string(),
980 known_license: Some("GPL-3.0".to_string()),
981 source_tier: None,
982 },
983 ScreeningInput {
984 canonical_id: "c".to_string(),
985 known_license: None,
986 source_tier: None,
987 },
988 ];
989 let report = screen_extensions(&inputs, "notes-test");
990 let a = report
991 .verdicts
992 .iter()
993 .find(|v| v.canonical_id == "a")
994 .unwrap();
995 assert!(a.notes.contains("permissive"));
996 let b = report
997 .verdicts
998 .iter()
999 .find(|v| v.canonical_id == "b")
1000 .unwrap();
1001 assert!(b.notes.contains("copyleft"));
1002 let c = report
1003 .verdicts
1004 .iter()
1005 .find(|v| v.canonical_id == "c")
1006 .unwrap();
1007 assert!(c.notes.contains("manual review"));
1008 }
1009
1010 #[test]
1015 fn redistributable_serde_roundtrip() {
1016 for variant in &[
1017 Redistributable::Yes,
1018 Redistributable::Copyleft,
1019 Redistributable::Unknown,
1020 Redistributable::No,
1021 ] {
1022 let json = serde_json::to_string(variant).unwrap();
1023 let back: Redistributable = serde_json::from_str(&json).unwrap();
1024 assert_eq!(&back, variant);
1025 }
1026 }
1027
1028 #[test]
1029 fn verdict_status_serde_roundtrip() {
1030 for variant in &[
1031 VerdictStatus::Pass,
1032 VerdictStatus::PassWithWarnings,
1033 VerdictStatus::Excluded,
1034 VerdictStatus::NeedsReview,
1035 ] {
1036 let json = serde_json::to_string(variant).unwrap();
1037 let back: VerdictStatus = serde_json::from_str(&json).unwrap();
1038 assert_eq!(&back, variant);
1039 }
1040 }
1041
1042 #[test]
1043 fn security_severity_serde_roundtrip() {
1044 for variant in &[
1045 SecuritySeverity::Info,
1046 SecuritySeverity::Warning,
1047 SecuritySeverity::Critical,
1048 ] {
1049 let json = serde_json::to_string(variant).unwrap();
1050 let back: SecuritySeverity = serde_json::from_str(&json).unwrap();
1051 assert_eq!(&back, variant);
1052 }
1053 }
1054
1055 #[test]
1056 fn license_serde_roundtrip() {
1057 let licenses = vec![
1058 License::Mit,
1059 License::Apache2,
1060 License::Isc,
1061 License::Bsd2,
1062 License::Bsd3,
1063 License::Mpl2,
1064 License::Gpl2,
1065 License::Gpl3,
1066 License::Agpl3,
1067 License::Lgpl21,
1068 License::Unlicense,
1069 License::Cc0,
1070 License::Unknown,
1071 License::Custom("WTFPL".to_string()),
1072 ];
1073 for lic in &licenses {
1074 let json = serde_json::to_string(lic).unwrap();
1075 let back: License = serde_json::from_str(&json).unwrap();
1076 assert_eq!(&back, lic);
1077 }
1078 }
1079
1080 #[test]
1081 fn screening_report_serde_roundtrip() {
1082 let report = ScreeningReport {
1083 generated_at: "2026-01-01T00:00:00Z".to_string(),
1084 task: "test".to_string(),
1085 stats: ScreeningStats {
1086 total_screened: 1,
1087 pass: 1,
1088 pass_with_warnings: 0,
1089 excluded: 0,
1090 needs_review: 0,
1091 license_distribution: std::iter::once(("MIT".to_string(), 1)).collect(),
1092 },
1093 verdicts: vec![PolicyVerdict {
1094 canonical_id: "test/ext".to_string(),
1095 license: "MIT".to_string(),
1096 license_source: "candidate_pool".to_string(),
1097 redistributable: Redistributable::Yes,
1098 security_findings: vec![SecurityFinding {
1099 severity: SecuritySeverity::Info,
1100 pattern: "child_process".to_string(),
1101 description: "test".to_string(),
1102 }],
1103 verdict: VerdictStatus::Pass,
1104 notes: "ok".to_string(),
1105 }],
1106 };
1107 let json = serde_json::to_string(&report).unwrap();
1108 let back: ScreeningReport = serde_json::from_str(&json).unwrap();
1109 assert_eq!(back.stats.total_screened, 1);
1110 assert_eq!(back.verdicts.len(), 1);
1111 assert_eq!(back.verdicts[0].security_findings.len(), 1);
1112 }
1113
1114 mod proptest_extension_license {
1115 use super::*;
1116 use proptest::prelude::*;
1117
1118 fn all_known_licenses() -> Vec<License> {
1120 vec![
1121 License::Mit,
1122 License::Apache2,
1123 License::Isc,
1124 License::Bsd2,
1125 License::Bsd3,
1126 License::Mpl2,
1127 License::Gpl2,
1128 License::Gpl3,
1129 License::Agpl3,
1130 License::Lgpl21,
1131 License::Unlicense,
1132 License::Cc0,
1133 License::Unknown,
1134 ]
1135 }
1136
1137 proptest! {
1138 #[test]
1140 fn detect_content_never_panics(s in "(?s).{0,500}") {
1141 let _ = detect_license_from_content(&s);
1142 }
1143
1144 #[test]
1146 fn detect_spdx_never_panics(s in ".*") {
1147 let _ = detect_license_from_spdx(&s);
1148 }
1149
1150 #[test]
1152 fn known_license_spdx_roundtrip(idx in 0..13usize) {
1153 let license = &all_known_licenses()[idx];
1154 let spdx = license.spdx();
1155 let back = detect_license_from_spdx(spdx);
1156 assert_eq!(*license, back, "roundtrip failed for {spdx}");
1157 }
1158
1159 #[test]
1161 fn spdx_case_insensitive(idx in 0..13usize) {
1162 let license = &all_known_licenses()[idx];
1163 let spdx = license.spdx();
1164 let upper = detect_license_from_spdx(&spdx.to_uppercase());
1165 let lower = detect_license_from_spdx(&spdx.to_lowercase());
1166 assert_eq!(upper, lower, "case mismatch for {spdx}");
1167 }
1168
1169 #[test]
1171 fn spdx_strings_are_clean(idx in 0..13usize) {
1172 let licenses = all_known_licenses();
1173 let spdx = licenses[idx].spdx();
1174 assert!(!spdx.is_empty());
1175 assert_eq!(spdx, spdx.trim());
1176 }
1177
1178 #[test]
1180 fn display_equals_spdx(idx in 0..13usize) {
1181 let license = &all_known_licenses()[idx];
1182 assert_eq!(license.to_string(), license.spdx());
1183 }
1184
1185 #[test]
1187 fn redistributable_never_panics(idx in 0..13usize) {
1188 let license = &all_known_licenses()[idx];
1189 let _ = redistributable(license);
1190 }
1191
1192 #[test]
1194 fn custom_always_unknown(s in "[a-zA-Z0-9 -]{1,50}") {
1195 let license = License::Custom(s);
1196 assert_eq!(redistributable(&license), Redistributable::Unknown);
1197 }
1198
1199 #[test]
1201 fn permissive_licenses_redistributable(idx in prop::sample::select(vec![0usize, 2, 3, 4, 10, 11])) {
1202 let license = &all_known_licenses()[idx];
1203 assert_eq!(redistributable(license), Redistributable::Yes);
1204 }
1205
1206 #[test]
1208 fn scan_security_never_panics(s in "(?s).{0,500}") {
1209 let _ = scan_security(&s);
1210 }
1211
1212 #[test]
1214 fn scan_security_finds_known_patterns(
1215 idx in 0..SECURITY_PATTERNS.len(),
1216 prefix in "[a-zA-Z ]{0,20}",
1217 suffix in "[a-zA-Z ]{0,20}"
1218 ) {
1219 let (pattern, _, _) = SECURITY_PATTERNS[idx];
1220 let content = format!("{prefix}{pattern}{suffix}");
1221 let findings = scan_security(&content);
1222 assert!(
1223 findings.iter().any(|f| f.pattern == pattern),
1224 "pattern '{pattern}' not found in findings"
1225 );
1226 }
1227
1228 #[test]
1230 fn screen_report_count_invariant(
1231 n in 0..20usize,
1232 task in "[a-z]{5,10}"
1233 ) {
1234 let inputs: Vec<ScreeningInput> = (0..n)
1235 .map(|i| ScreeningInput {
1236 canonical_id: format!("ext-{i}"),
1237 known_license: if i % 3 == 0 { Some("MIT".to_string()) } else { None },
1238 source_tier: None,
1239 })
1240 .collect();
1241 let report = screen_extensions(&inputs, &task);
1242 assert_eq!(report.stats.total_screened, n);
1243 assert_eq!(
1244 report.stats.pass + report.stats.pass_with_warnings
1245 + report.stats.excluded + report.stats.needs_review,
1246 n
1247 );
1248 }
1249
1250 #[test]
1252 fn screen_report_sorted(n in 0..20usize) {
1253 let inputs: Vec<ScreeningInput> = (0..n)
1254 .map(|i| ScreeningInput {
1255 canonical_id: format!("ext-{}", n - i),
1256 known_license: Some("MIT".to_string()),
1257 source_tier: None,
1258 })
1259 .collect();
1260 let report = screen_extensions(&inputs, "test");
1261 for w in report.verdicts.windows(2) {
1262 assert!(w[0].canonical_id <= w[1].canonical_id);
1263 }
1264 }
1265
1266 #[test]
1268 fn screen_report_task_preserved(task in "[a-z0-9]{1,20}") {
1269 let report = screen_extensions(&[], &task);
1270 assert_eq!(report.task, task);
1271 }
1272
1273 #[test]
1275 fn license_serde_roundtrip(idx in 0..13usize) {
1276 let license = all_known_licenses()[idx].clone();
1277 let json = serde_json::to_string(&license).unwrap();
1278 let back: License = serde_json::from_str(&json).unwrap();
1279 assert_eq!(license, back);
1280 }
1281
1282 #[test]
1284 fn redistributable_serde_roundtrip(idx in 0..4usize) {
1285 let variants = [
1286 Redistributable::Yes,
1287 Redistributable::Copyleft,
1288 Redistributable::Unknown,
1289 Redistributable::No,
1290 ];
1291 let v = variants[idx];
1292 let json = serde_json::to_string(&v).unwrap();
1293 let back: Redistributable = serde_json::from_str(&json).unwrap();
1294 assert_eq!(v, back);
1295 }
1296 }
1297 }
1298}