use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictRule {
pub license_a_pattern: String,
pub license_b_pattern: String,
pub conflict_type: ConflictType,
pub severity: ConflictSeverity,
pub description: String,
pub remediation: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConflictType {
BinaryIncompatible,
ProjectIncompatible,
NetworkCopyleft,
PatentConflict,
CopyleftMismatch,
}
impl std::fmt::Display for ConflictType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BinaryIncompatible => write!(f, "Binary Incompatible"),
Self::ProjectIncompatible => write!(f, "Project Incompatible"),
Self::NetworkCopyleft => write!(f, "Network Copyleft"),
Self::PatentConflict => write!(f, "Patent Conflict"),
Self::CopyleftMismatch => write!(f, "Copyleft Mismatch"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum ConflictSeverity {
Info,
Warning,
Error,
}
impl std::fmt::Display for ConflictSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "Info"),
Self::Warning => write!(f, "Warning"),
Self::Error => write!(f, "Error"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedConflict {
pub rule: ConflictRule,
pub license_a: String,
pub license_b: String,
pub affected_components: Vec<String>,
pub context: ConflictContext,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConflictContext {
SameBinary,
SameProject,
TransitiveDependency,
}
impl std::fmt::Display for ConflictContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SameBinary => write!(f, "Same Binary"),
Self::SameProject => write!(f, "Same Project"),
Self::TransitiveDependency => write!(f, "Transitive Dependency"),
}
}
}
pub struct ConflictDetector {
rules: Vec<ConflictRule>,
}
impl ConflictDetector {
pub(crate) fn new() -> Self {
Self {
rules: Self::default_rules(),
}
}
pub(crate) fn default_rules() -> Vec<ConflictRule> {
vec![
ConflictRule {
license_a_pattern: "GPL".to_string(),
license_b_pattern: "Proprietary".to_string(),
conflict_type: ConflictType::BinaryIncompatible,
severity: ConflictSeverity::Error,
description: "GPL licenses require derivative works to be GPL-licensed. Proprietary code cannot be combined with GPL in the same binary.".to_string(),
remediation: "Remove proprietary components or replace GPL dependencies with permissively-licensed alternatives.".to_string(),
},
ConflictRule {
license_a_pattern: "AGPL".to_string(),
license_b_pattern: "Proprietary".to_string(),
conflict_type: ConflictType::BinaryIncompatible,
severity: ConflictSeverity::Error,
description: "AGPL is even stricter than GPL and extends to network use. Cannot combine with proprietary code.".to_string(),
remediation: "Remove proprietary components or replace AGPL dependencies with permissively-licensed alternatives.".to_string(),
},
ConflictRule {
license_a_pattern: "Apache-2.0".to_string(),
license_b_pattern: "GPL-2.0".to_string(),
conflict_type: ConflictType::PatentConflict,
severity: ConflictSeverity::Error,
description: "Apache 2.0's patent termination clause conflicts with GPL 2.0. GPL 2.0 does not have compatible patent provisions.".to_string(),
remediation: "Upgrade to GPL-3.0 which is compatible with Apache 2.0, or use different dependencies.".to_string(),
},
ConflictRule {
license_a_pattern: "GPL-2.0-only".to_string(),
license_b_pattern: "GPL-3.0".to_string(),
conflict_type: ConflictType::CopyleftMismatch,
severity: ConflictSeverity::Error,
description: "GPL-2.0-only is not compatible with GPL-3.0. The 'only' designation prevents upgrading to later versions.".to_string(),
remediation: "Replace GPL-2.0-only components with GPL-2.0-or-later or GPL-3.0 compatible versions.".to_string(),
},
ConflictRule {
license_a_pattern: "LGPL".to_string(),
license_b_pattern: "Proprietary".to_string(),
conflict_type: ConflictType::BinaryIncompatible,
severity: ConflictSeverity::Warning,
description: "LGPL allows proprietary use only through dynamic linking. Static linking requires LGPL compliance.".to_string(),
remediation: "Ensure LGPL dependencies are dynamically linked, or comply with LGPL requirements for static linking.".to_string(),
},
ConflictRule {
license_a_pattern: "AGPL".to_string(),
license_b_pattern: "*".to_string(), conflict_type: ConflictType::NetworkCopyleft,
severity: ConflictSeverity::Warning,
description: "AGPL requires source disclosure for network services. Any service using AGPL code must provide source access.".to_string(),
remediation: "Ensure compliance with AGPL source distribution requirements, or replace with non-AGPL alternatives.".to_string(),
},
ConflictRule {
license_a_pattern: "BSD-4-Clause".to_string(),
license_b_pattern: "GPL".to_string(),
conflict_type: ConflictType::ProjectIncompatible,
severity: ConflictSeverity::Error,
description: "BSD-4-Clause's advertising clause is incompatible with GPL. The FSF considers this a non-free addition.".to_string(),
remediation: "Replace BSD-4-Clause components with BSD-3-Clause or other GPL-compatible licenses.".to_string(),
},
ConflictRule {
license_a_pattern: "CDDL".to_string(),
license_b_pattern: "GPL".to_string(),
conflict_type: ConflictType::BinaryIncompatible,
severity: ConflictSeverity::Error,
description: "CDDL and GPL have incompatible copyleft requirements. Both require derivative works under their respective licenses.".to_string(),
remediation: "Use separate binaries for CDDL and GPL code, or replace one set of dependencies.".to_string(),
},
ConflictRule {
license_a_pattern: "MPL-1.1".to_string(),
license_b_pattern: "GPL".to_string(),
conflict_type: ConflictType::CopyleftMismatch,
severity: ConflictSeverity::Warning,
description: "MPL 1.1 has file-level copyleft which may conflict with GPL's broader copyleft.".to_string(),
remediation: "Use MPL 2.0 which has explicit GPL compatibility, or keep MPL code in separate files.".to_string(),
},
ConflictRule {
license_a_pattern: "EPL".to_string(),
license_b_pattern: "GPL".to_string(),
conflict_type: ConflictType::BinaryIncompatible,
severity: ConflictSeverity::Error,
description: "Eclipse Public License and GPL have incompatible copyleft terms.".to_string(),
remediation: "Use EPL-2.0 with Secondary License option for GPL compatibility, or use separate binaries.".to_string(),
},
]
}
fn matches_pattern(&self, license: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
let license_upper = license.to_uppercase();
let pattern_upper = pattern.to_uppercase();
if license_upper == pattern_upper {
return true;
}
if license_upper.contains(&pattern_upper) {
return true;
}
let license_normalized = license_upper
.replace("-ONLY", "")
.replace("-OR-LATER", "")
.replace('+', "");
license_normalized.contains(&pattern_upper)
}
pub(crate) fn detect_conflicts(
&self,
license_map: &HashMap<String, Vec<String>>, ) -> Vec<DetectedConflict> {
let mut conflicts = Vec::new();
let licenses: Vec<&String> = license_map.keys().collect();
for i in 0..licenses.len() {
for j in (i + 1)..licenses.len() {
let license_a = licenses[i];
let license_b = licenses[j];
for rule in &self.rules {
let a_matches_a = self.matches_pattern(license_a, &rule.license_a_pattern);
let b_matches_b = self.matches_pattern(license_b, &rule.license_b_pattern);
let a_matches_b = self.matches_pattern(license_a, &rule.license_b_pattern);
let b_matches_a = self.matches_pattern(license_b, &rule.license_a_pattern);
if (a_matches_a && b_matches_b) || (a_matches_b && b_matches_a) {
if rule.license_b_pattern == "*" && license_a == license_b {
continue;
}
let mut affected = Vec::new();
if let Some(comps) = license_map.get(license_a) {
affected.extend(comps.clone());
}
if let Some(comps) = license_map.get(license_b) {
affected.extend(comps.clone());
}
affected.sort();
affected.dedup();
conflicts.push(DetectedConflict {
rule: rule.clone(),
license_a: license_a.clone(),
license_b: license_b.clone(),
affected_components: affected,
context: ConflictContext::SameProject,
});
}
}
}
}
conflicts.sort_by(|a, b| b.rule.severity.cmp(&a.rule.severity));
conflicts
}
}
impl Default for ConflictDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pattern_matching() {
let detector = ConflictDetector::new();
assert!(detector.matches_pattern("GPL-3.0", "GPL"));
assert!(detector.matches_pattern("AGPL-3.0-or-later", "AGPL"));
assert!(detector.matches_pattern("Apache-2.0", "Apache-2.0"));
assert!(detector.matches_pattern("MIT", "*"));
assert!(!detector.matches_pattern("MIT", "GPL"));
}
#[test]
fn test_gpl_proprietary_conflict() {
let detector = ConflictDetector::new();
let mut license_map = HashMap::new();
license_map.insert("GPL-3.0".to_string(), vec!["dep1".to_string()]);
license_map.insert("Proprietary".to_string(), vec!["main".to_string()]);
let conflicts = detector.detect_conflicts(&license_map);
assert!(!conflicts.is_empty());
assert_eq!(
conflicts[0].rule.conflict_type,
ConflictType::BinaryIncompatible
);
assert_eq!(conflicts[0].rule.severity, ConflictSeverity::Error);
}
#[test]
fn test_apache_gpl2_conflict() {
let detector = ConflictDetector::new();
let mut license_map = HashMap::new();
license_map.insert("Apache-2.0".to_string(), vec!["lib1".to_string()]);
license_map.insert("GPL-2.0".to_string(), vec!["lib2".to_string()]);
let conflicts = detector.detect_conflicts(&license_map);
assert!(!conflicts.is_empty());
let patent_conflict = conflicts
.iter()
.find(|c| c.rule.conflict_type == ConflictType::PatentConflict);
assert!(patent_conflict.is_some());
}
#[test]
fn test_no_conflicts() {
let detector = ConflictDetector::new();
let mut license_map = HashMap::new();
license_map.insert("MIT".to_string(), vec!["lib1".to_string()]);
license_map.insert("Apache-2.0".to_string(), vec!["lib2".to_string()]);
license_map.insert("BSD-3-Clause".to_string(), vec!["lib3".to_string()]);
let conflicts = detector.detect_conflicts(&license_map);
assert!(conflicts.is_empty());
}
}