use crate::audit_patterns;
use rustix::fs::{self as rfs, Mode, OFlags};
use rustix::io::Errno;
use serde::Deserialize;
use std::fs::File;
use std::io::Read;
use std::path::Path;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub fetch_remote: bool,
#[serde(default)]
pub severity: SeverityFilter,
#[serde(default)]
pub ignore: IgnoreConfig,
#[serde(default)]
pub extra_data_formats: Vec<String>,
#[serde(default)]
pub trusted_hosts: Vec<String>,
#[serde(skip)]
pub source: ConfigSource,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ConfigSource {
#[default]
Default,
Global,
RepoLocal,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeverityFilter {
#[default]
Low,
Medium,
High,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IgnoreConfig {
#[serde(default)]
pub actions: Vec<String>,
#[serde(default)]
pub patterns: Vec<String>,
}
impl Config {
pub fn load(repo_root: &Path, use_repo_config: bool) -> Self {
if use_repo_config {
match load_local(repo_root) {
ConfigLoad::Loaded(mut local) => {
local.source = ConfigSource::RepoLocal;
return local;
}
ConfigLoad::Malformed => return Config::default(),
ConfigLoad::Absent => {}
}
}
match load_global() {
ConfigLoad::Loaded(mut global) => {
global.source = ConfigSource::Global;
global
}
ConfigLoad::Malformed | ConfigLoad::Absent => Config::default(),
}
}
pub fn is_repo_local(&self) -> bool {
self.source == ConfigSource::RepoLocal
}
pub fn severity_threshold(&self) -> u8 {
match self.severity {
SeverityFilter::High => 2,
SeverityFilter::Medium => 1,
SeverityFilter::Low => 0,
}
}
pub fn meets_severity(&self, severity: &str) -> bool {
let level = match severity {
"high" => 2,
"medium" => 1,
_ => 0,
};
level >= self.severity_threshold()
}
pub fn is_action_ignored(&self, action_name: &str) -> bool {
self.ignore
.actions
.iter()
.any(|pattern| ignore_pattern_matches(pattern, action_name))
}
pub fn is_pattern_ignored(&self, description: &str) -> bool {
self.ignore
.patterns
.iter()
.any(|p| !p.is_empty() && description.contains(p.as_str()))
}
pub fn is_data_format_exempt(&self, url: &str) -> bool {
if audit_patterns::url_is_data_format(url) {
return true;
}
self.is_extra_data_format_exempt(url)
}
pub fn is_extra_data_format_exempt(&self, url: &str) -> bool {
let Some(ext) = audit_patterns::url_extension(url) else {
return false;
};
self.extra_data_formats
.iter()
.any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext))
}
pub fn is_host_trusted(&self, url: &str) -> bool {
let Some(host) = audit_patterns::url_host(url) else {
return false;
};
self.trusted_hosts
.iter()
.any(|h| h.eq_ignore_ascii_case(host))
}
}
fn ignore_pattern_matches(pattern: &str, action_name: &str) -> bool {
let pattern = pattern.trim_end_matches('/');
if pattern.is_empty() {
return false;
}
let pattern = pattern.to_ascii_lowercase();
let action_name = action_name.to_ascii_lowercase();
action_name == pattern || action_name.starts_with(&format!("{pattern}/"))
}
enum ConfigLoad {
Loaded(Config),
Malformed,
Absent,
}
fn load_global() -> ConfigLoad {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(dir) if dir.starts_with('/') => std::path::PathBuf::from(dir),
_ => {
let Ok(home) = std::env::var("HOME") else {
return ConfigLoad::Absent;
};
Path::new(&home).join(".config")
}
};
load_file(
&base.join("pinprick").join("config.toml"),
ParseWarning::Detailed,
)
}
fn load_local(repo_root: &Path) -> ConfigLoad {
load_repo_file(repo_root, ".pinprick.toml")
}
fn load_file(path: &Path, warning: ParseWarning) -> ConfigLoad {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(_) => return ConfigLoad::Absent,
};
parse_file(path, &content, warning)
}
fn load_repo_file(repo_root: &Path, name: &str) -> ConfigLoad {
let root = match openat_file(
rfs::CWD,
repo_root,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
) {
Ok(root) => root,
Err(_) => return ConfigLoad::Absent,
};
let path = repo_root.join(name);
let mut file = match openat_file(
&root,
name,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
) {
Ok(file) => file,
Err(_) => return ConfigLoad::Absent,
};
let mut content = String::new();
if file.read_to_string(&mut content).is_err() {
return ConfigLoad::Absent;
}
parse_file(&path, &content, ParseWarning::Generic)
}
fn parse_file(path: &Path, content: &str, warning: ParseWarning) -> ConfigLoad {
match toml::from_str(content) {
Ok(config) => ConfigLoad::Loaded(config),
Err(e) => {
match warning {
ParseWarning::Detailed => {
eprintln!(
"warning: failed to parse {}, using defaults:\n{e}",
path.display()
);
}
ParseWarning::Generic => {
eprintln!(
"warning: failed to parse {}, using defaults",
path.display()
);
}
}
ConfigLoad::Malformed
}
}
}
#[derive(Clone, Copy)]
enum ParseWarning {
Detailed,
Generic,
}
fn openat_file<Fd: rustix::fd::AsFd, P: rustix::path::Arg>(
dirfd: Fd,
path: P,
flags: OFlags,
) -> std::result::Result<File, Errno> {
rfs::openat(dirfd, path, flags, Mode::empty()).map(File::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_without_repo_config_ignores_local_file() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(".pinprick.toml"),
"trusted-hosts = [\"x.example\"]\n",
)
.unwrap();
let with = Config::load(dir.path(), true);
assert!(with.is_repo_local());
assert!(with.is_host_trusted("https://x.example/tool"));
let without = Config::load(dir.path(), false);
assert!(!without.is_repo_local());
assert!(!without.is_host_trusted("https://x.example/tool"));
}
#[test]
fn load_repo_config_ignores_symlinked_file() {
let dir = tempfile::TempDir::new().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
std::fs::write(outside.path(), "trusted-hosts = [\"x.example\"]\n").unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join(".pinprick.toml")).unwrap();
let cfg = Config::load(dir.path(), true);
assert!(!cfg.is_repo_local());
assert!(!cfg.is_host_trusted("https://x.example/tool"));
}
#[test]
fn is_data_format_exempt_built_in() {
let cfg = Config::default();
assert!(cfg.is_data_format_exempt("https://example.com/data.json"));
assert!(cfg.is_data_format_exempt("https://example.com/config.yaml"));
}
#[test]
fn is_data_format_exempt_rejects_non_data_default() {
let cfg = Config::default();
assert!(!cfg.is_data_format_exempt("https://example.com/tool.tar.gz"));
assert!(!cfg.is_data_format_exempt("https://example.com/install.sh"));
}
#[test]
fn is_data_format_exempt_with_extra_format() {
let cfg = Config {
extra_data_formats: vec!["proto".to_string(), "graphql".to_string()],
..Config::default()
};
assert!(cfg.is_data_format_exempt("https://example.com/api.proto"));
assert!(cfg.is_data_format_exempt("https://example.com/schema.graphql"));
assert!(!cfg.is_data_format_exempt("https://example.com/install.sh"));
}
#[test]
fn is_data_format_exempt_extra_format_case_insensitive() {
let cfg = Config {
extra_data_formats: vec!["proto".to_string()],
..Config::default()
};
assert!(cfg.is_data_format_exempt("https://example.com/API.PROTO"));
}
#[test]
fn is_data_format_exempt_strips_leading_dot_in_config() {
let cfg = Config {
extra_data_formats: vec![".proto".to_string()],
..Config::default()
};
assert!(cfg.is_data_format_exempt("https://example.com/api.proto"));
}
#[test]
fn is_data_format_exempt_does_not_match_similar_extension() {
let cfg = Config {
extra_data_formats: vec!["proto".to_string()],
..Config::default()
};
assert!(!cfg.is_data_format_exempt("https://example.com/api.protobuf"));
}
#[test]
fn deserializes_extra_data_formats_from_toml() {
let toml_content = r#"
extra-data-formats = ["proto", "graphql"]
"#;
let cfg: Config = toml::from_str(toml_content).unwrap();
assert_eq!(cfg.extra_data_formats, vec!["proto", "graphql"]);
}
#[test]
fn missing_extra_data_formats_defaults_to_empty() {
let toml_content = "";
let cfg: Config = toml::from_str(toml_content).unwrap();
assert!(cfg.extra_data_formats.is_empty());
}
#[test]
fn is_host_trusted_exact_match() {
let cfg = Config {
trusted_hosts: vec!["artifacts.example.com".to_string()],
..Config::default()
};
assert!(cfg.is_host_trusted("https://artifacts.example.com/foo/bar"));
}
#[test]
fn is_host_trusted_case_insensitive() {
let cfg = Config {
trusted_hosts: vec!["artifacts.example.com".to_string()],
..Config::default()
};
assert!(cfg.is_host_trusted("https://ARTIFACTS.EXAMPLE.COM/foo"));
}
#[test]
fn is_host_trusted_strips_port() {
let cfg = Config {
trusted_hosts: vec!["artifacts.example.com".to_string()],
..Config::default()
};
assert!(cfg.is_host_trusted("https://artifacts.example.com:8443/foo"));
}
#[test]
fn is_host_trusted_no_subdomain_match() {
let cfg = Config {
trusted_hosts: vec!["example.com".to_string()],
..Config::default()
};
assert!(!cfg.is_host_trusted("https://api.example.com/foo"));
}
#[test]
fn is_host_trusted_empty_list_rejects_all() {
let cfg = Config::default();
assert!(!cfg.is_host_trusted("https://example.com/foo"));
}
#[test]
fn is_host_trusted_non_url_returns_false() {
let cfg = Config {
trusted_hosts: vec!["example.com".to_string()],
..Config::default()
};
assert!(!cfg.is_host_trusted("example.com"));
}
#[test]
fn deserializes_trusted_hosts_from_toml() {
let toml_content = r#"
trusted-hosts = ["artifacts.example.com", "releases.example.org"]
"#;
let cfg: Config = toml::from_str(toml_content).unwrap();
assert_eq!(
cfg.trusted_hosts,
vec!["artifacts.example.com", "releases.example.org"]
);
}
#[test]
fn missing_trusted_hosts_defaults_to_empty() {
let toml_content = "";
let cfg: Config = toml::from_str(toml_content).unwrap();
assert!(cfg.trusted_hosts.is_empty());
}
#[test]
fn severity_valid_values_parse() {
for (value, expected) in [
("low", SeverityFilter::Low),
("medium", SeverityFilter::Medium),
("high", SeverityFilter::High),
] {
let cfg: Config = toml::from_str(&format!("severity = \"{value}\"")).unwrap();
assert_eq!(cfg.severity, expected);
}
}
#[test]
fn severity_invalid_value_is_error() {
assert!(toml::from_str::<Config>("severity = \"higq\"").is_err());
assert!(toml::from_str::<Config>("severity = \"critical\"").is_err());
}
#[test]
fn default_severity_is_low() {
let cfg = Config::default();
assert_eq!(cfg.severity, SeverityFilter::Low);
assert_eq!(cfg.severity_threshold(), 0);
}
#[test]
fn severity_threshold_and_meets() {
let high = Config {
severity: SeverityFilter::High,
..Config::default()
};
assert_eq!(high.severity_threshold(), 2);
assert!(high.meets_severity("high"));
assert!(!high.meets_severity("medium"));
assert!(!high.meets_severity("low"));
let medium = Config {
severity: SeverityFilter::Medium,
..Config::default()
};
assert_eq!(medium.severity_threshold(), 1);
assert!(medium.meets_severity("medium"));
assert!(!medium.meets_severity("low"));
let low = Config::default();
assert!(low.meets_severity("low"));
assert!(low.meets_severity("high"));
}
#[test]
fn unknown_top_level_key_is_error() {
assert!(toml::from_str::<Config>("trusted-onwers = [\"acme\"]").is_err());
assert!(toml::from_str::<Config>("definitely-not-a-key = true").is_err());
}
#[test]
fn unknown_nested_ignore_key_is_error() {
assert!(toml::from_str::<Config>("[ignore]\nbogus = 1").is_err());
}
#[test]
fn load_file_missing_is_absent() {
let dir = tempfile::TempDir::new().unwrap();
assert!(matches!(
load_file(&dir.path().join("config.toml"), ParseWarning::Detailed),
ConfigLoad::Absent
));
}
#[test]
fn load_file_malformed_is_distinct_from_absent() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "severity = \"nope\"\n").unwrap();
assert!(matches!(
load_file(&path, ParseWarning::Detailed),
ConfigLoad::Malformed
));
}
#[test]
fn load_file_valid_is_parsed() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "severity = \"high\"\nfetch-remote = true\n").unwrap();
let ConfigLoad::Loaded(cfg) = load_file(&path, ParseWarning::Detailed) else {
panic!("valid config should parse");
};
assert_eq!(cfg.severity, SeverityFilter::High);
assert!(cfg.fetch_remote);
}
#[test]
fn load_repo_file_treats_missing_root_and_unreadable_file_as_absent() {
let dir = tempfile::TempDir::new().unwrap();
assert!(matches!(
load_repo_file(&dir.path().join("missing"), ".pinprick.toml"),
ConfigLoad::Absent
));
std::fs::create_dir(dir.path().join(".pinprick.toml")).unwrap();
assert!(matches!(
load_repo_file(dir.path(), ".pinprick.toml"),
ConfigLoad::Absent
));
}
#[test]
fn ignore_pattern_is_case_insensitive() {
assert!(ignore_pattern_matches(
"actions/checkout",
"Actions/Checkout"
));
assert!(ignore_pattern_matches("Actions", "actions/setup-node"));
}
#[test]
fn ignore_pattern_exact_and_org_prefix() {
assert!(ignore_pattern_matches(
"actions/checkout",
"actions/checkout"
));
assert!(ignore_pattern_matches("actions", "actions/checkout"));
assert!(ignore_pattern_matches("actions/", "actions/setup-node"));
}
#[test]
fn ignore_pattern_respects_path_boundary() {
assert!(!ignore_pattern_matches("actions/check", "actions/checkout"));
assert!(!ignore_pattern_matches(
"aws",
"aws-actions/configure-aws-credentials"
));
assert!(!ignore_pattern_matches(
"actions/checkout",
"actions/checkout-action"
));
}
#[test]
fn ignore_pattern_empty_matches_nothing() {
assert!(!ignore_pattern_matches("", "actions/checkout"));
assert!(!ignore_pattern_matches("/", "actions/checkout"));
}
#[test]
fn is_action_ignored_through_config() {
let cfg = Config {
ignore: IgnoreConfig {
actions: vec!["actions/checkout".to_string(), "aws-actions".to_string()],
patterns: vec![],
},
..Config::default()
};
assert!(cfg.is_action_ignored("actions/checkout"));
assert!(cfg.is_action_ignored("aws-actions/configure-aws-credentials"));
assert!(!cfg.is_action_ignored("actions/setup-node"));
assert!(!cfg.is_action_ignored("actions/checkout-action"));
}
#[test]
fn empty_finding_pattern_matches_nothing() {
let cfg = Config {
ignore: IgnoreConfig {
actions: vec![],
patterns: vec![String::new()],
},
..Config::default()
};
assert!(!cfg.is_pattern_ignored("curl fetching unversioned URL"));
}
}