use anyhow::{bail, Context};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use crate::shared::Result;
pub const CONFIG_FILENAME: &str = "uv-sbom.config.yml";
const CONFIG_TEMPLATE: &str = r#"# uv-sbom configuration file
# Documentation: https://github.com/Taketo-Yoda/uv-sbom#configuration
# Output format: json | markdown
# format: json
# Package exclusion patterns (supports wildcards)
# exclude_packages:
# - "debug-*"
# - "test-*"
# Disable CVE vulnerability checking (enabled by default; set to false to opt out)
# check_cve: true
# Severity threshold: low | medium | high | critical
# severity_threshold: high
# CVSS score threshold (0.0 - 10.0)
# cvss_threshold: 7.0
# CVEs to ignore during vulnerability checks
# ignore_cves:
# - id: CVE-2024-1234
# reason: "False positive: code path not reachable"
# - id: CVE-2024-5678
# Enable license compliance checking
# check_license: false
# License compliance policy
# license_policy:
# allow:
# - "MIT"
# - "Apache-2.0"
# - "BSD-*"
# deny:
# - "AGPL-*"
# - "GPL-*"
# unknown: warn
# Suggest upgrade paths to fix vulnerable transitive dependencies (requires check_cve: true)
# suggest_fix: false
"#;
pub fn generate_config_template(dir: &Path) -> Result<std::path::PathBuf> {
let file_path = dir.join(CONFIG_FILENAME);
if file_path.exists() {
let abs_path = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
bail!(
"{} already exists in {}. Use a different directory or remove the existing file.",
CONFIG_FILENAME,
abs_path.display()
);
}
std::fs::write(&file_path, CONFIG_TEMPLATE).with_context(|| {
format!(
"Failed to write config template to: {}",
file_path.display()
)
})?;
let abs_path = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.clone());
Ok(abs_path)
}
#[derive(Debug, Deserialize, Default)]
pub struct ConfigFile {
pub format: Option<String>,
pub exclude_packages: Option<Vec<String>>,
pub check_cve: Option<bool>,
pub severity_threshold: Option<String>,
pub cvss_threshold: Option<f64>,
pub ignore_cves: Option<Vec<IgnoreCve>>,
pub check_license: Option<bool>,
pub license_policy: Option<LicensePolicyConfig>,
pub suggest_fix: Option<bool>,
#[serde(flatten)]
pub unknown_fields: HashMap<String, serde_yaml_ng::Value>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct LicensePolicyConfig {
pub allow: Option<Vec<String>>,
pub deny: Option<Vec<String>>,
pub unknown: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct IgnoreCve {
pub id: String,
pub reason: Option<String>,
}
impl IgnoreCve {
pub fn reason(&self) -> Option<&str> {
self.reason.as_deref()
}
}
pub fn load_config_from_path(path: &Path) -> Result<ConfigFile> {
let content = std::fs::read_to_string(path).with_context(|| {
format!(
"Failed to read config file: {}\n\n💡 Hint: Check that the file exists and is readable.",
path.display()
)
})?;
let config: ConfigFile = serde_yaml_ng::from_str(&content).with_context(|| {
format!(
"Failed to parse config file: {}\n\n💡 Hint: Ensure the file contains valid YAML syntax.",
path.display()
)
})?;
validate_config(&config)?;
warn_unknown_fields(&config);
Ok(config)
}
pub fn discover_config(dir: &Path) -> Result<Option<ConfigFile>> {
let config_path = dir.join(CONFIG_FILENAME);
if !config_path.exists() {
return Ok(None);
}
let config = load_config_from_path(&config_path)?;
Ok(Some(config))
}
fn validate_config(config: &ConfigFile) -> Result<()> {
if let Some(ref ignore_cves) = config.ignore_cves {
for (i, entry) in ignore_cves.iter().enumerate() {
if entry.id.trim().is_empty() {
bail!(
"Invalid config: ignore_cves[{}].id must not be empty.\n\n\
💡 Hint: Each ignore_cves entry must have a non-empty 'id' field (e.g., \"CVE-2024-1234\").",
i
);
}
}
}
if let Some(ref lp) = config.license_policy {
if let Some(ref unknown) = lp.unknown {
let valid = ["warn", "deny", "allow"];
if !valid.contains(&unknown.to_lowercase().as_str()) {
bail!(
"Invalid config: license_policy.unknown must be one of: warn, deny, allow. Got: \"{}\"",
unknown
);
}
}
}
Ok(())
}
fn warn_unknown_fields(config: &ConfigFile) {
for key in config.unknown_fields.keys() {
eprintln!(
"⚠️ Warning: Unknown config field '{}' will be ignored.",
key
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_load_valid_config() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.yml");
fs::write(
&config_path,
r#"
format: markdown
exclude_packages:
- setuptools
- pip
check_cve: true
severity_threshold: HIGH
cvss_threshold: 7.0
ignore_cves:
- id: CVE-2024-1234
reason: "Not applicable to our usage"
- id: CVE-2024-5678
"#,
)
.unwrap();
let config = load_config_from_path(&config_path).unwrap();
assert_eq!(config.format.as_deref(), Some("markdown"));
assert_eq!(
config.exclude_packages.as_deref(),
Some(&["setuptools".to_string(), "pip".to_string()][..])
);
assert_eq!(config.check_cve, Some(true));
assert_eq!(config.severity_threshold.as_deref(), Some("HIGH"));
assert_eq!(config.cvss_threshold, Some(7.0));
let cves = config.ignore_cves.unwrap();
assert_eq!(cves.len(), 2);
assert_eq!(cves[0].id, "CVE-2024-1234");
assert_eq!(
cves[0].reason.as_deref(),
Some("Not applicable to our usage")
);
assert_eq!(cves[1].id, "CVE-2024-5678");
assert!(cves[1].reason.is_none());
}
#[test]
fn test_discover_config_found() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join(CONFIG_FILENAME);
fs::write(
&config_path,
r#"
format: json
check_cve: false
"#,
)
.unwrap();
let config = discover_config(dir.path()).unwrap();
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.format.as_deref(), Some("json"));
assert_eq!(config.check_cve, Some(false));
}
#[test]
fn test_discover_config_not_found() {
let dir = TempDir::new().unwrap();
let config = discover_config(dir.path()).unwrap();
assert!(config.is_none());
}
#[test]
fn test_load_config_missing_file() {
let result = load_config_from_path(Path::new("/nonexistent/config.yml"));
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("Failed to read config file"));
}
#[test]
fn test_load_config_parse_error() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("bad.yml");
fs::write(&config_path, "invalid: yaml: [[[broken").unwrap();
let result = load_config_from_path(&config_path);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("Failed to parse config file"));
}
#[test]
fn test_empty_cve_id_validation_error() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.yml");
fs::write(
&config_path,
r#"
ignore_cves:
- id: ""
reason: "empty id"
"#,
)
.unwrap();
let result = load_config_from_path(&config_path);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("must not be empty"));
}
#[test]
fn test_whitespace_only_cve_id_validation_error() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.yml");
fs::write(
&config_path,
r#"
ignore_cves:
- id: " "
reason: "whitespace only"
"#,
)
.unwrap();
let result = load_config_from_path(&config_path);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("must not be empty"));
}
#[test]
fn test_unknown_fields_warning() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.yml");
fs::write(
&config_path,
r#"
format: json
unknown_field: true
another_unknown: value
"#,
)
.unwrap();
let config = load_config_from_path(&config_path).unwrap();
assert_eq!(config.unknown_fields.len(), 2);
assert!(config.unknown_fields.contains_key("unknown_field"));
assert!(config.unknown_fields.contains_key("another_unknown"));
}
#[test]
fn test_default_config() {
let config = ConfigFile::default();
assert!(config.format.is_none());
assert!(config.exclude_packages.is_none());
assert!(config.check_cve.is_none());
assert!(config.severity_threshold.is_none());
assert!(config.cvss_threshold.is_none());
assert!(config.ignore_cves.is_none());
assert!(config.unknown_fields.is_empty());
}
#[test]
fn test_template_is_valid_yaml_when_uncommented() {
let uncommented: String = CONFIG_TEMPLATE
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
if let Some(content) = trimmed.strip_prefix("# ") {
if content.contains(':')
|| content.starts_with(" - ")
|| content.starts_with("- ")
{
Some(content.to_string())
} else {
None
}
} else if trimmed == "#" {
None
} else {
Some(line.to_string())
}
})
.collect::<Vec<_>>()
.join("\n");
let result: std::result::Result<ConfigFile, _> = serde_yaml_ng::from_str(&uncommented);
assert!(
result.is_ok(),
"Template should be valid YAML when uncommented: {:?}\nContent:\n{}",
result.err(),
uncommented
);
}
#[test]
fn test_generate_config_template_creates_file() {
let dir = TempDir::new().unwrap();
let result = generate_config_template(dir.path());
assert!(result.is_ok());
let created_path = result.unwrap();
assert!(created_path.exists());
let content = fs::read_to_string(&created_path).unwrap();
assert!(content.contains("uv-sbom configuration file"));
assert!(content.contains("format: json"));
assert!(content.contains("exclude_packages:"));
assert!(content.contains("check_cve:"));
assert!(content.contains("ignore_cves:"));
}
#[test]
fn test_generate_config_template_fails_if_exists() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join(CONFIG_FILENAME);
fs::write(&config_path, "existing content").unwrap();
let result = generate_config_template(dir.path());
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("already exists"));
}
}