//! Strict, secret-free configuration shared by every frontend. use std::{ collections::BTreeSet, env, error::Error, fmt, fs, path::{Component, Path, PathBuf}, }; use serde::Deserialize; use url::Url; const APPLICATION_DIRECTORY: &str = "ironstorage"; const CONFIG_FILE: &str = "config.toml"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; /// Validated application configuration. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { source: PathBuf, vault: PathBuf, default_key: KeyIdentity, key_material: PathBuf, editor: Option, git_remotes: Vec, } impl Config { /// Load an explicit configuration file, or the native per-user default. pub fn load(explicit: Option<&Path>) -> Result { ConfigLoader::system()?.load(explicit) } pub fn source(&self) -> &Path { &self.source } pub fn vault(&self) -> &Path { &self.vault } pub fn default_key(&self) -> &KeyIdentity { &self.default_key } pub fn key_material(&self) -> &Path { &self.key_material } pub fn configured_editor(&self) -> Option<&EditorCommand> { self.editor.as_ref() } pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } /// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`. pub fn resolve_editor(&self) -> Result { self.resolve_editor_from( env::var_os("VISUAL").as_deref(), env::var_os("EDITOR").as_deref(), ) } /// Deterministic editor resolution used by adapters and tests. pub fn resolve_editor_from( &self, visual: Option<&std::ffi::OsStr>, editor: Option<&std::ffi::OsStr>, ) -> Result { if let Some(command) = &self.editor { return Ok(ResolvedEditor { command: command.clone(), source: EditorSource::Configuration, }); } if let Some(command) = parse_environment_editor("VISUAL", visual)? { return Ok(ResolvedEditor { command, source: EditorSource::VisualEnvironment, }); } if let Some(command) = parse_environment_editor("EDITOR", editor)? { return Ok(ResolvedEditor { command, source: EditorSource::EditorEnvironment, }); } Ok(ResolvedEditor { command: EditorCommand { program: "vim".to_owned(), arguments: Vec::new(), }, source: EditorSource::Fallback, }) } } /// Deterministic path context for configuration loading. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigLoader { current_directory: PathBuf, native_config_directory: PathBuf, } impl ConfigLoader { pub fn system() -> Result { let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?; let native_config_directory = native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?; Ok(Self::new(current_directory, native_config_directory)) } pub fn new(current_directory: PathBuf, native_config_directory: PathBuf) -> Self { Self { current_directory: normalize_absolute(current_directory), native_config_directory: normalize_absolute(native_config_directory), } } pub fn default_path(&self) -> PathBuf { self.native_config_directory .join(APPLICATION_DIRECTORY) .join(CONFIG_FILE) } pub fn load(&self, explicit: Option<&Path>) -> Result { let requested = explicit .map(|path| resolve_path(&self.current_directory, path)) .unwrap_or_else(|| self.default_path()); let metadata = fs::metadata(&requested).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { ConfigError::NotFound { path: requested.clone(), } } else { ConfigError::Read { path: requested.clone(), } } })?; if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES { return Err(ConfigError::InvalidFile { path: requested.clone(), }); } let source = fs::canonicalize(&requested).map_err(|_| ConfigError::Read { path: requested.clone(), })?; let contents = fs::read_to_string(&source).map_err(|_| ConfigError::Read { path: source.clone(), })?; let value = toml::from_str::(&contents).map_err(|_| ConfigError::Malformed { path: source.clone(), })?; reject_insecure_fields(&value, "")?; validate_known_fields(&value, &source)?; let raw = value .try_into::() .map_err(|_| ConfigError::Malformed { path: source.clone(), })?; validate_config(source, raw) } } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct KeyIdentity(String); impl KeyIdentity { pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for KeyIdentity { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } macro_rules! identifier_type { ($name:ident) => { #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct $name(String); impl $name { pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for $name { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } }; } identifier_type!(RemoteName); identifier_type!(ServerId); identifier_type!(ApplicationId); #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitRemote { name: RemoteName, url: Url, server_id: ServerId, application_id: ApplicationId, } impl GitRemote { pub fn name(&self) -> &RemoteName { &self.name } pub fn url(&self) -> &Url { &self.url } pub fn server_id(&self) -> &ServerId { &self.server_id } pub fn application_id(&self) -> &ApplicationId { &self.application_id } } /// An executable and arguments. It is never interpreted by a shell. #[derive(Clone, Debug, Eq, PartialEq)] pub struct EditorCommand { program: String, arguments: Vec, } impl EditorCommand { pub fn program(&self) -> &str { &self.program } pub fn arguments(&self) -> &[String] { &self.arguments } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EditorSource { Configuration, VisualEnvironment, EditorEnvironment, Fallback, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ResolvedEditor { command: EditorCommand, source: EditorSource, } impl ResolvedEditor { pub fn command(&self) -> &EditorCommand { &self.command } pub fn source(&self) -> EditorSource { self.source } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum ConfigError { CurrentDirectory, NativeConfigDirectory, NotFound { path: PathBuf }, Read { path: PathBuf }, InvalidFile { path: PathBuf }, Malformed { path: PathBuf }, UnknownField { field: String }, InsecureField { field: String }, MissingField { field: &'static str }, InvalidField { field: &'static str }, VaultIsNotDirectory { path: PathBuf }, KeyMaterialNotFound { path: PathBuf }, InvalidKeyMaterial { path: PathBuf }, DuplicateRemote { name: String }, DuplicateCredentialReference, InvalidRemoteUrl { name: String }, } impl fmt::Display for ConfigError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::CurrentDirectory => formatter.write_str("cannot determine the current directory"), Self::NativeConfigDirectory => { formatter.write_str("cannot determine the native user configuration directory") } Self::NotFound { path } => { write!( formatter, "configuration file not found: {}", path.display() ) } Self::Read { path } => { write!( formatter, "cannot read configuration file: {}", path.display() ) } Self::InvalidFile { path } => write!( formatter, "configuration path is not a small regular file: {}", path.display() ), Self::Malformed { path } => write!( formatter, "configuration is malformed; its contents were redacted: {}", path.display() ), Self::UnknownField { field } => { write!(formatter, "unknown configuration field: {field}") } Self::InsecureField { field } => write!( formatter, "secret values are forbidden in configuration field: {field}" ), Self::MissingField { field } => { write!(formatter, "missing required configuration field: {field}") } Self::InvalidField { field } => { write!(formatter, "invalid configuration field: {field}") } Self::VaultIsNotDirectory { path } => { write!( formatter, "vault path is not a directory: {}", path.display() ) } Self::KeyMaterialNotFound { path } => write!( formatter, "exported key material does not exist: {}", path.display() ), Self::InvalidKeyMaterial { path } => write!( formatter, "exported key material is not a file or directory: {}", path.display() ), Self::DuplicateRemote { name } => { write!(formatter, "duplicate Git remote name: {name}") } Self::DuplicateCredentialReference => { formatter.write_str("duplicate Git server/application credential reference") } Self::InvalidRemoteUrl { name } => write!( formatter, "Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment" ), } } } impl Error for ConfigError {} #[derive(Clone, Debug, Eq, PartialEq)] pub enum EditorError { NonUnicode { variable: &'static str }, InvalidCommand { source: &'static str }, } impl fmt::Display for EditorError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NonUnicode { variable } => { write!(formatter, "{variable} is not valid Unicode") } Self::InvalidCommand { source } => { write!( formatter, "{source} does not contain a valid editor command" ) } } } } impl Error for EditorError {} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawConfig { vault: Option, default_key: Option, key_material: Option, editor: Option, #[serde(default)] git: RawGit, } #[derive(Deserialize)] #[serde(untagged)] enum RawEditor { CommandLine(String), Arguments(Vec), } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawGit { #[serde(default)] remotes: Vec, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawGitRemote { name: String, url: String, server_id: String, application_id: String, } fn validate_config(source: PathBuf, raw: RawConfig) -> Result { let base = source .parent() .ok_or(ConfigError::InvalidField { field: "source" })?; let vault = resolve_required_path(base, raw.vault, "vault")?; if vault.exists() && !vault.is_dir() { return Err(ConfigError::VaultIsNotDirectory { path: vault }); } let key_material = resolve_required_path(base, raw.key_material, "key_material")?; if !key_material.exists() { return Err(ConfigError::KeyMaterialNotFound { path: key_material }); } let key_metadata = fs::metadata(&key_material).map_err(|_| ConfigError::KeyMaterialNotFound { path: key_material.clone(), })?; if !key_metadata.is_file() && !key_metadata.is_dir() { return Err(ConfigError::InvalidKeyMaterial { path: key_material }); } let key_material = fs::canonicalize(&key_material) .map_err(|_| ConfigError::InvalidKeyMaterial { path: key_material })?; let default_key = raw .default_key .ok_or(ConfigError::MissingField { field: "default_key", }) .and_then(validate_key_identity)?; let editor = raw.editor.map(validate_editor).transpose()?; let git_remotes = validate_remotes(raw.git.remotes)?; Ok(Config { source, vault, default_key, key_material, editor, git_remotes, }) } fn resolve_required_path( base: &Path, value: Option, field: &'static str, ) -> Result { let value = value.ok_or(ConfigError::MissingField { field })?; if value.as_os_str().is_empty() { return Err(ConfigError::InvalidField { field }); } Ok(resolve_path(base, &value)) } fn validate_key_identity(value: String) -> Result { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.len() > 512 || trimmed.chars().any(char::is_control) || trimmed != value { return Err(ConfigError::InvalidField { field: "default_key", }); } Ok(KeyIdentity(value)) } fn validate_editor(raw: RawEditor) -> Result { let words = match raw { RawEditor::CommandLine(command) => { shlex::split(&command).ok_or(ConfigError::InvalidField { field: "editor" })? } RawEditor::Arguments(arguments) => arguments, }; editor_from_words(words).map_err(|_| ConfigError::InvalidField { field: "editor" }) } fn editor_from_words(mut words: Vec) -> Result { if words.is_empty() || words.iter().any(|word| word.contains('\0')) { return Err(()); } let program = words.remove(0); if program.is_empty() { return Err(()); } Ok(EditorCommand { program, arguments: words, }) } fn parse_environment_editor( variable: &'static str, value: Option<&std::ffi::OsStr>, ) -> Result, EditorError> { let Some(value) = value else { return Ok(None); }; if value.is_empty() { return Ok(None); } let value = value.to_str().ok_or(EditorError::NonUnicode { variable })?; let words = shlex::split(value).ok_or(EditorError::InvalidCommand { source: variable })?; editor_from_words(words) .map(Some) .map_err(|()| EditorError::InvalidCommand { source: variable }) } fn validate_remotes(raw: Vec) -> Result, ConfigError> { let mut names = BTreeSet::new(); let mut references = BTreeSet::new(); let mut remotes = Vec::with_capacity(raw.len()); for remote in raw { let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?); let server_id = ServerId(validate_identifier( "git.remotes.server_id", remote.server_id, )?); let application_id = ApplicationId(validate_identifier( "git.remotes.application_id", remote.application_id, )?); if !names.insert(name.clone()) { return Err(ConfigError::DuplicateRemote { name: name.0.clone(), }); } if !references.insert((server_id.clone(), application_id.clone())) { return Err(ConfigError::DuplicateCredentialReference); } let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl { name: name.0.clone(), })?; if url.scheme() != "https" || url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { return Err(ConfigError::InvalidRemoteUrl { name: name.0.clone(), }); } remotes.push(GitRemote { name, url, server_id, application_id, }); } Ok(remotes) } fn validate_identifier(field: &'static str, value: String) -> Result { if value.is_empty() || value.len() > 128 || !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) { return Err(ConfigError::InvalidField { field }); } Ok(value) } fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), ConfigError> { let root = value.as_table().ok_or_else(|| ConfigError::UnknownField { field: "".to_owned(), })?; validate_table( root, "", &["vault", "default_key", "key_material", "editor", "git"], )?; let Some(git) = root.get("git") else { return Ok(()); }; let git = git.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; validate_table(git, "git", &["remotes"])?; if let Some(remotes) = git.get("remotes") { let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; for (index, remote) in remotes.iter().enumerate() { let remote = remote.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; validate_table( remote, &format!("git.remotes[{index}]"), &["name", "url", "server_id", "application_id"], )?; } } Ok(()) } fn validate_table(table: &toml::Table, prefix: &str, allowed: &[&str]) -> Result<(), ConfigError> { for key in table.keys() { if !allowed.contains(&key.as_str()) { let field = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; return Err(ConfigError::UnknownField { field }); } } Ok(()) } fn reject_insecure_fields(value: &toml::Value, prefix: &str) -> Result<(), ConfigError> { match value { toml::Value::Table(table) => { for (key, nested) in table { let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; let normalized = key.to_ascii_lowercase().replace('-', "_"); if [ "password", "passphrase", "token", "secret", "credential", "credentials", "private_key", ] .contains(&normalized.as_str()) { return Err(ConfigError::InsecureField { field: path }); } reject_insecure_fields(nested, &path)?; } } toml::Value::Array(values) => { for (index, nested) in values.iter().enumerate() { reject_insecure_fields(nested, &format!("{prefix}[{index}]"))?; } } _ => {} } Ok(()) } fn resolve_path(base: &Path, path: &Path) -> PathBuf { if path.is_absolute() { normalize_absolute(path.to_owned()) } else { normalize_absolute(base.join(path)) } } fn normalize_absolute(path: PathBuf) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { if !normalized.pop() { normalized.push(component.as_os_str()); } } _ => normalized.push(component.as_os_str()), } } normalized } #[cfg(target_os = "windows")] fn native_config_directory() -> Option { env::var_os("APPDATA").map(PathBuf::from) } #[cfg(target_os = "macos")] fn native_config_directory() -> Option { env::var_os("HOME") .map(PathBuf::from) .map(|home| home.join("Library/Application Support")) } #[cfg(all(unix, not(target_os = "macos")))] fn native_config_directory() -> Option { match env::var_os("XDG_CONFIG_HOME") { Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => { Some(PathBuf::from(path)) } _ => env::var_os("HOME") .filter(|home| !home.is_empty()) .map(PathBuf::from) .map(|home| home.join(".config")), } } #[cfg(not(any(unix, target_os = "windows")))] fn native_config_directory() -> Option { None }