Skip to main content

pi/
extension_license.rs

1//! License detection and policy screening for Pi extension candidates.
2//!
3//! Screens extensions for:
4//! - License type (SPDX identifier)
5//! - Redistributability (can we include in our corpus?)
6//! - Security red flags (suspicious patterns in code)
7//!
8//! License detection uses filename-based heuristics and content matching
9//! against common license texts. No external API calls required.
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14// ────────────────────────────────────────────────────────────────────────────
15// Types
16// ────────────────────────────────────────────────────────────────────────────
17
18/// SPDX license identifiers we recognize.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum License {
21    /// MIT License.
22    Mit,
23    /// Apache License 2.0.
24    Apache2,
25    /// ISC License.
26    Isc,
27    /// BSD 2-Clause.
28    Bsd2,
29    /// BSD 3-Clause.
30    Bsd3,
31    /// Mozilla Public License 2.0.
32    Mpl2,
33    /// GNU General Public License v2.
34    Gpl2,
35    /// GNU General Public License v3.
36    Gpl3,
37    /// GNU Affero General Public License v3.
38    Agpl3,
39    /// GNU Lesser General Public License v2.1.
40    Lgpl21,
41    /// The Unlicense.
42    Unlicense,
43    /// Creative Commons Zero.
44    Cc0,
45    /// No license detected.
46    Unknown,
47    /// Custom/proprietary license detected.
48    Custom(String),
49}
50
51impl License {
52    /// Return the SPDX identifier string.
53    #[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/// Redistributability verdict.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum Redistributable {
84    /// Can freely redistribute (MIT, Apache-2.0, ISC, BSD, Unlicense, CC0).
85    Yes,
86    /// Copyleft — can redistribute but must maintain license (GPL, LGPL, MPL, AGPL).
87    Copyleft,
88    /// Cannot determine redistributability.
89    Unknown,
90    /// Explicitly restricted.
91    No,
92}
93
94/// Security red flag severity.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum SecuritySeverity {
98    /// Informational — not necessarily malicious.
99    Info,
100    /// Warning — should be reviewed.
101    Warning,
102    /// Critical — likely malicious.
103    Critical,
104}
105
106/// A security finding.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SecurityFinding {
109    pub severity: SecuritySeverity,
110    pub pattern: String,
111    pub description: String,
112}
113
114/// Policy verdict for a single extension.
115#[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/// Overall verdict.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum VerdictStatus {
130    /// Extension passes all checks.
131    Pass,
132    /// Extension passes but has warnings.
133    PassWithWarnings,
134    /// Extension is excluded due to policy violation.
135    Excluded,
136    /// Insufficient information to determine.
137    NeedsReview,
138}
139
140/// Full screening report.
141#[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/// Aggregate statistics.
150#[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// ────────────────────────────────────────────────────────────────────────────
161// License detection
162// ────────────────────────────────────────────────────────────────────────────
163
164/// Detect license from a license file's content.
165#[must_use]
166pub fn detect_license_from_content(content: &str) -> License {
167    let lower = content.to_lowercase();
168
169    // MIT — most common for Pi extensions.
170    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    // Apache 2.0
180    if lower.contains("apache license") && lower.contains("version 2.0") {
181        return License::Apache2;
182    }
183
184    // ISC
185    if lower.contains("isc license")
186        || (lower.contains("permission to use, copy, modify") && lower.contains("isc"))
187    {
188        return License::Isc;
189    }
190
191    // BSD 3-Clause
192    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    // BSD 2-Clause
199    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    // GPL-3.0
206    if lower.contains("gnu general public license") && lower.contains("version 3") {
207        return License::Gpl3;
208    }
209
210    // GPL-2.0
211    if lower.contains("gnu general public license") && lower.contains("version 2") {
212        return License::Gpl2;
213    }
214
215    // AGPL-3.0
216    if lower.contains("gnu affero general public license") {
217        return License::Agpl3;
218    }
219
220    // LGPL-2.1
221    if lower.contains("gnu lesser general public license") {
222        return License::Lgpl21;
223    }
224
225    // MPL-2.0
226    if lower.contains("mozilla public license") && lower.contains("2.0") {
227        return License::Mpl2;
228    }
229
230    // Unlicense
231    if lower.contains("this is free and unencumbered software") {
232        return License::Unlicense;
233    }
234
235    // CC0
236    if lower.contains("cc0") || lower.contains("creative commons zero") {
237        return License::Cc0;
238    }
239
240    License::Unknown
241}
242
243/// Detect license from a `package.json` license field.
244#[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/// Determine redistributability from a license.
265#[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
282// ────────────────────────────────────────────────────────────────────────────
283// Security screening
284// ────────────────────────────────────────────────────────────────────────────
285
286/// Known suspicious patterns in extension source code.
287const 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/// Scan source content for security red flags.
342#[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// ────────────────────────────────────────────────────────────────────────────
358// Policy screening pipeline
359// ────────────────────────────────────────────────────────────────────────────
360
361/// Input for policy screening: an extension with its known license info.
362#[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/// Screen a batch of extensions and produce a report.
370#[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    // Sort for stable output.
418    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// ────────────────────────────────────────────────────────────────────────────
453// Tests
454// ────────────────────────────────────────────────────────────────────────────
455
456#[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    // -----------------------------------------------------------------------
631    // detect_license_from_content — all license types
632    // -----------------------------------------------------------------------
633
634    #[test]
635    fn detect_mit_alt_path() {
636        // The second MIT detection path: "mit license" + "permission is hereby granted"
637        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    // -----------------------------------------------------------------------
709    // detect_license_from_spdx — all variants + case insensitivity
710    // -----------------------------------------------------------------------
711
712    #[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    // -----------------------------------------------------------------------
795    // License::spdx() — all variants
796    // -----------------------------------------------------------------------
797
798    #[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    // -----------------------------------------------------------------------
817    // redistributable — custom variant
818    // -----------------------------------------------------------------------
819
820    #[test]
821    fn redistributable_custom_is_unknown() {
822        assert_eq!(
823            redistributable(&License::Custom("proprietary".to_string())),
824            Redistributable::Unknown
825        );
826    }
827
828    // -----------------------------------------------------------------------
829    // scan_security — comprehensive pattern coverage
830    // -----------------------------------------------------------------------
831
832    #[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        // Matches both ".env" and "process.env.API_KEY"
865        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    // -----------------------------------------------------------------------
915    // screen_extensions — edge cases
916    // -----------------------------------------------------------------------
917
918    #[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    // -----------------------------------------------------------------------
1011    // Serde round-trips for enums
1012    // -----------------------------------------------------------------------
1013
1014    #[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        /// All known licenses (excludes Custom).
1119        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            /// `detect_license_from_content` never panics on arbitrary input.
1139            #[test]
1140            fn detect_content_never_panics(s in "(?s).{0,500}") {
1141                let _ = detect_license_from_content(&s);
1142            }
1143
1144            /// `detect_license_from_spdx` never panics on arbitrary input.
1145            #[test]
1146            fn detect_spdx_never_panics(s in ".*") {
1147                let _ = detect_license_from_spdx(&s);
1148            }
1149
1150            /// SPDX identifiers of known licenses roundtrip through `detect_license_from_spdx`.
1151            #[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            /// `detect_license_from_spdx` is case-insensitive.
1160            #[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            /// All SPDX strings are non-empty and have no leading/trailing whitespace.
1170            #[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            /// `License::to_string()` equals `License::spdx()`.
1179            #[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            /// `redistributable` never panics and covers all known licenses.
1186            #[test]
1187            fn redistributable_never_panics(idx in 0..13usize) {
1188                let license = &all_known_licenses()[idx];
1189                let _ = redistributable(license);
1190            }
1191
1192            /// `Custom` licenses always map to `Redistributable::Unknown`.
1193            #[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            /// Permissive licenses all map to `Redistributable::Yes`.
1200            #[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            /// `scan_security` never panics on arbitrary input.
1207            #[test]
1208            fn scan_security_never_panics(s in "(?s).{0,500}") {
1209                let _ = scan_security(&s);
1210            }
1211
1212            /// When a known security pattern is present, `scan_security` finds it.
1213            #[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            /// `screen_extensions` verdict count invariant: sum of categories = total.
1229            #[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            /// `screen_extensions` verdicts are sorted by `canonical_id`.
1251            #[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            /// `screen_extensions` preserves task ID.
1267            #[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            /// License serde roundtrip for all known variants.
1274            #[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            /// `Redistributable` serde roundtrip.
1283            #[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}