use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::verify_error::VerifyError;
const TRUSTED_KEYS_FILE: &str = "extensions/trusted_keys.toml";
fn default_schema() -> String {
"1.0".to_string()
}
fn default_oidc_issuer() -> String {
"https://token.actions.githubusercontent.com".to_string()
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TrustMode {
Ignore,
#[default]
Warn,
Require,
}
impl TrustMode {
pub fn as_str(&self) -> &'static str {
match self {
TrustMode::Ignore => "ignore",
TrustMode::Warn => "warn",
TrustMode::Require => "require",
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthorPolicy {
pub owner: String,
pub identity_regexp: String,
#[serde(default = "default_oidc_issuer")]
pub oidc_issuer: String,
#[serde(default)]
pub mode: Option<TrustMode>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TrustedKeysConfig {
#[serde(default = "default_schema")]
pub schema_version: String,
#[serde(default)]
pub default: TrustMode,
#[serde(default)]
pub cosign_binary: Option<PathBuf>,
#[serde(default)]
pub authors: Vec<AuthorPolicy>,
}
impl TrustedKeysConfig {
pub fn load(config_dir: &Path) -> Result<Self, VerifyError> {
let path = config_dir.join(TRUSTED_KEYS_FILE);
if !path.exists() {
return Ok(Self::default());
}
let body = std::fs::read_to_string(&path).map_err(|e| VerifyError::TrustedKeysParse {
path: path.clone(),
reason: format!("read failed: {e}"),
})?;
let cfg: Self = toml::from_str(&body).map_err(|e| VerifyError::TrustedKeysParse {
path: path.clone(),
reason: e.to_string(),
})?;
for author in &cfg.authors {
regex::Regex::new(&author.identity_regexp).map_err(|e| {
VerifyError::IdentityRegexpInvalid {
got: author.identity_regexp.clone(),
reason: e.to_string(),
}
})?;
}
Ok(cfg)
}
pub fn resolve(&self, owner: &str) -> (TrustMode, Option<&AuthorPolicy>) {
for author in &self.authors {
if author.owner == owner {
return (author.mode.unwrap_or(self.default), Some(author));
}
}
(self.default, None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_config(dir: &Path, body: &str) -> PathBuf {
let ext_dir = dir.join("extensions");
fs::create_dir_all(&ext_dir).unwrap();
let path = ext_dir.join("trusted_keys.toml");
fs::write(&path, body).unwrap();
path
}
#[test]
fn parses_minimal_default() {
let tmp = TempDir::new().unwrap();
write_config(tmp.path(), r#"schema_version = "1.0""#);
let cfg = TrustedKeysConfig::load(tmp.path()).unwrap();
assert_eq!(cfg.schema_version, "1.0");
assert_eq!(cfg.default, TrustMode::Warn);
assert!(cfg.authors.is_empty());
}
#[test]
fn parses_global_default_and_authors() {
let tmp = TempDir::new().unwrap();
write_config(
tmp.path(),
r#"
schema_version = "1.0"
default = "require"
[[authors]]
owner = "lordmacu"
identity_regexp = "^https://github.com/lordmacu/.*$"
[[authors]]
owner = "other"
identity_regexp = "^https://github.com/other/.*$"
oidc_issuer = "https://example.com"
mode = "ignore"
"#,
);
let cfg = TrustedKeysConfig::load(tmp.path()).unwrap();
assert_eq!(cfg.default, TrustMode::Require);
assert_eq!(cfg.authors.len(), 2);
assert_eq!(cfg.authors[0].owner, "lordmacu");
assert_eq!(cfg.authors[0].oidc_issuer, default_oidc_issuer());
assert!(cfg.authors[0].mode.is_none());
assert_eq!(cfg.authors[1].mode, Some(TrustMode::Ignore));
assert_eq!(cfg.authors[1].oidc_issuer, "https://example.com");
}
#[test]
fn rejects_unknown_fields() {
let tmp = TempDir::new().unwrap();
write_config(
tmp.path(),
r#"
schema_version = "1.0"
unknown_field = "x"
"#,
);
let err = TrustedKeysConfig::load(tmp.path()).unwrap_err();
assert!(matches!(err, VerifyError::TrustedKeysParse { .. }));
}
#[test]
fn resolve_returns_global_default_when_no_match() {
let cfg = TrustedKeysConfig {
default: TrustMode::Require,
..Default::default()
};
let (mode, hit) = cfg.resolve("nobody");
assert_eq!(mode, TrustMode::Require);
assert!(hit.is_none());
}
#[test]
fn resolve_returns_author_override_when_present() {
let cfg = TrustedKeysConfig {
default: TrustMode::Warn,
authors: vec![AuthorPolicy {
owner: "alice".into(),
identity_regexp: "^.*$".into(),
oidc_issuer: default_oidc_issuer(),
mode: Some(TrustMode::Require),
}],
..Default::default()
};
let (mode, hit) = cfg.resolve("alice");
assert_eq!(mode, TrustMode::Require);
assert!(hit.is_some());
}
#[test]
fn resolve_inherits_global_when_author_mode_unset() {
let cfg = TrustedKeysConfig {
default: TrustMode::Require,
authors: vec![AuthorPolicy {
owner: "alice".into(),
identity_regexp: "^.*$".into(),
oidc_issuer: default_oidc_issuer(),
mode: None,
}],
..Default::default()
};
let (mode, hit) = cfg.resolve("alice");
assert_eq!(mode, TrustMode::Require);
assert!(hit.is_some());
}
#[test]
fn load_returns_default_when_file_missing() {
let tmp = TempDir::new().unwrap();
let cfg = TrustedKeysConfig::load(tmp.path()).unwrap();
assert_eq!(cfg.default, TrustMode::Warn);
assert!(cfg.authors.is_empty());
}
#[test]
fn rejects_invalid_identity_regexp() {
let tmp = TempDir::new().unwrap();
write_config(
tmp.path(),
r#"
[[authors]]
owner = "alice"
identity_regexp = "[unterminated"
"#,
);
let err = TrustedKeysConfig::load(tmp.path()).unwrap_err();
assert!(matches!(err, VerifyError::IdentityRegexpInvalid { .. }));
}
#[test]
fn rejects_invalid_trust_mode() {
let tmp = TempDir::new().unwrap();
write_config(
tmp.path(),
r#"
default = "yolo"
"#,
);
let err = TrustedKeysConfig::load(tmp.path()).unwrap_err();
assert!(matches!(err, VerifyError::TrustedKeysParse { .. }));
}
#[test]
fn trust_mode_as_str_returns_canonical_lowercase() {
assert_eq!(TrustMode::Ignore.as_str(), "ignore");
assert_eq!(TrustMode::Warn.as_str(), "warn");
assert_eq!(TrustMode::Require.as_str(), "require");
}
}