use std::{ collections::BTreeMap, env, ffi::OsStr, fmt, fs::{self, OpenOptions}, io::{self, Write}, path::{Component, Path, PathBuf}, }; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; pub const ACTIONS: [&str; 4] = ["reply", "review", "archive", "delete"]; const DEFAULT_POLICY: &str = include_str!("../policies/default.json"); #[derive(Clone, Debug, Eq, PartialEq)] pub enum PolicySource { EmbeddedDefault, File(PathBuf), } impl PolicySource { pub fn resolve(requested: Option<&Path>) -> Result { Self::resolve_in(requested, &configured_policy_directory()?) } pub fn install_default() -> Result<(PathBuf, bool)> { install_default_in(&configured_policy_directory()?) } fn resolve_in(requested: Option<&Path>, configured_directory: &Path) -> Result { let configured_default = configured_directory.join("default.json"); let Some(requested) = requested else { return if path_exists(&configured_default)? { Ok(Self::File(configured_default)) } else { Ok(Self::EmbeddedDefault) }; }; if is_policy_name(requested) { let configured = configured_directory.join(requested).with_extension("json"); if path_exists(&configured)? { return Ok(Self::File(configured)); } if requested == Path::new("default") { return Ok(Self::EmbeddedDefault); } bail!( "configured policy {:?} does not exist at {}", requested.display().to_string(), configured.display() ); } Ok(Self::File(requested.to_owned())) } pub fn load(&self) -> Result { match self { Self::EmbeddedDefault => { ClassificationPolicy::parse(DEFAULT_POLICY, "embedded policies/default.json") } Self::File(path) => { let source = fs::read_to_string(path).with_context(|| { format!("failed to read classification policy {}", path.display()) })?; ClassificationPolicy::parse(&source, &path.display().to_string()) } } } pub fn scope(&self) -> Result { match self { Self::EmbeddedDefault => Ok("embedded:default".to_owned()), Self::File(path) => Ok(format!( "file:{}", path.canonicalize() .with_context(|| { format!("failed to resolve classification policy {}", path.display()) })? .display() )), } } } impl fmt::Display for PolicySource { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmbeddedDefault => formatter.write_str("embedded policies/default.json"), Self::File(path) => write!(formatter, "{}", path.display()), } } } fn configured_policy_directory() -> Result { resolve_configured_policy_directory( env::var_os("XDG_CONFIG_HOME").as_deref(), dirs::config_dir(), ) } fn resolve_configured_policy_directory( xdg_config_home: Option<&OsStr>, fallback_directory: Option, ) -> Result { let root = xdg_config_home .filter(|directory| !directory.is_empty()) .map(PathBuf::from) .filter(|directory| directory.is_absolute()) .or(fallback_directory) .context("could not determine the user configuration directory")?; Ok(root.join("screener").join("policies")) } fn is_policy_name(value: &Path) -> bool { let mut components = value.components(); matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() && value.extension().is_none() } fn path_exists(path: &Path) -> Result { path.try_exists() .with_context(|| format!("failed to inspect configured policy {}", path.display())) } fn install_default_in(configured_directory: &Path) -> Result<(PathBuf, bool)> { fs::create_dir_all(configured_directory).with_context(|| { format!( "failed to create configured policy directory {}", configured_directory.display() ) })?; let path = configured_directory.join("default.json"); let mut options = OpenOptions::new(); options.create_new(true).write(true); #[cfg(unix)] options.mode(0o600); let mut file = match options.open(&path) { Ok(file) => file, Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { return Ok((path, false)); } Err(error) => { return Err(error) .with_context(|| format!("failed to create default policy {}", path.display())); } }; file.write_all(DEFAULT_POLICY.as_bytes()) .with_context(|| format!("failed to write default policy {}", path.display()))?; file.sync_all() .with_context(|| format!("failed to sync default policy {}", path.display()))?; Ok((path, true)) } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ClassificationPolicy { purpose: String, decision_order: Vec, actions: BTreeMap, examples: Vec, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct PolicyAction { description: String, indicators: Vec, exclusions: Vec, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct PolicyExample { message_type: String, action: String, reason: String, } impl ClassificationPolicy { fn parse(source: &str, source_name: &str) -> Result { let policy: Self = serde_json::from_str(source) .with_context(|| format!("failed to parse classification policy {source_name}"))?; policy.validate(source_name)?; Ok(policy) } fn validate(&self, source_name: &str) -> Result<()> { if self.purpose.trim().is_empty() { bail!("classification policy {source_name} has an empty purpose"); } if self.decision_order.is_empty() { bail!("classification policy {source_name} has no decision_order rules"); } for action in ACTIONS { let Some(rule) = self.actions.get(action) else { bail!("classification policy {source_name} is missing action {action}"); }; if rule.description.trim().is_empty() || rule.indicators.is_empty() { bail!( "classification policy {source_name} action {action} needs a description and indicators" ); } } if self.actions.len() != ACTIONS.len() { let unsupported = self .actions .keys() .find(|action| !ACTIONS.contains(&action.as_str())) .expect("action count differs only when an unsupported action exists"); bail!("classification policy {source_name} has unsupported action {unsupported}"); } for example in &self.examples { if !ACTIONS.contains(&example.action.as_str()) { bail!( "classification policy {source_name} example has unsupported action {}", example.action ); } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; fn temporary_policy_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir() .join(format!("screener-{label}-{}-{nonce}", std::process::id())) .join("policies") } #[test] fn embedded_policy_is_valid() { let policy = PolicySource::EmbeddedDefault.load().unwrap(); assert!( policy .examples .iter() .any(|example| example.action == "review") ); } #[test] fn configured_default_precedes_embedded_default() { let directory = temporary_policy_directory("default-policy"); assert_eq!( PolicySource::resolve_in(None, &directory).unwrap(), PolicySource::EmbeddedDefault ); assert_eq!( PolicySource::resolve_in(Some(Path::new("default")), &directory).unwrap(), PolicySource::EmbeddedDefault ); fs::create_dir_all(&directory).unwrap(); let configured_default = directory.join("default.json"); fs::write(&configured_default, DEFAULT_POLICY).unwrap(); let source = PolicySource::resolve_in(None, &directory).unwrap(); assert_eq!(source, PolicySource::File(configured_default.clone())); source.load().unwrap(); assert_eq!( source.scope().unwrap(), format!( "file:{}", configured_default.canonicalize().unwrap().display() ) ); fs::remove_dir_all(directory.parent().unwrap()).unwrap(); } #[test] fn resolves_named_config_policy_and_preserves_explicit_paths() { let directory = temporary_policy_directory("named-policy"); fs::create_dir_all(&directory).unwrap(); let configured = directory.join("work.json"); fs::write(&configured, DEFAULT_POLICY).unwrap(); let named = PolicySource::resolve_in(Some(Path::new("work")), &directory).unwrap(); assert_eq!(named, PolicySource::File(configured)); named.load().unwrap(); let explicit = Path::new("./custom/policy.json"); assert_eq!( PolicySource::resolve_in(Some(explicit), &directory).unwrap(), PolicySource::File(explicit.to_owned()) ); let error = PolicySource::resolve_in(Some(Path::new("missing")), &directory).unwrap_err(); assert!(error.to_string().contains("missing.json")); fs::remove_dir_all(directory.parent().unwrap()).unwrap(); } #[test] fn installs_editable_default_without_overwriting_user_changes() { let directory = temporary_policy_directory("install-default"); let (path, created) = install_default_in(&directory).unwrap(); assert!(created); assert_eq!(path, directory.join("default.json")); PolicySource::File(path.clone()).load().unwrap(); fs::write(&path, "user-owned policy").unwrap(); let (same_path, created) = install_default_in(&directory).unwrap(); assert!(!created); assert_eq!(same_path, path); assert_eq!(fs::read_to_string(&path).unwrap(), "user-owned policy"); fs::remove_dir_all(directory.parent().unwrap()).unwrap(); } #[test] fn xdg_config_home_precedes_platform_config_directory() { let xdg = std::env::temp_dir().join("screener-xdg-policy-config"); let fallback = std::env::temp_dir().join("screener-platform-config"); assert_eq!( resolve_configured_policy_directory(Some(xdg.as_os_str()), Some(fallback.clone())) .unwrap(), xdg.join("screener").join("policies") ); assert_eq!( resolve_configured_policy_directory( Some(OsStr::new("relative/config")), Some(fallback.clone()) ) .unwrap(), fallback.join("screener").join("policies") ); } #[test] fn policy_requires_every_supported_action() { let source = r#"{ "purpose": "Classify mail", "decision_order": ["Choose one"], "actions": { "reply": {"description": "Reply", "indicators": ["Question"], "exclusions": []}, "review": {"description": "Review", "indicators": ["Human attention"], "exclusions": []}, "archive": {"description": "Archive", "indicators": ["Record"], "exclusions": []} }, "examples": [] }"#; let error = ClassificationPolicy::parse(source, "test policy").unwrap_err(); assert!(error.to_string().contains("missing action delete")); } }