//! Strict, secret-free configuration shared by every frontend. use std::{ collections::BTreeSet, env, error::Error, fmt, fs, fs::OpenOptions, io::Write, path::{Component, Path, PathBuf}, time::Duration, }; use cap_std::{ambient_authority, fs::Dir}; use cap_tempfile::TempFile; use serde::Deserialize; use url::Url; use crate::{ authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, git::GitIdentity, mobile::MobileTab, presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}, repository::EntryPath, }; const APPLICATION_DIRECTORY: &str = "ironstorage"; const CONFIG_FILE: &str = "config.toml"; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; /// Validated application configuration. #[derive(Clone, Debug, PartialEq)] pub struct Config { source: PathBuf, document: toml::Value, vault: PathBuf, default_key: KeyIdentity, key_material: PathBuf, editor: Option, clipboard_timeout: ClipboardTimeout, authentication_timeout: AuthenticationTimeout, biometric_unlock_enabled: bool, mobile_appearance: MobileAppearance, mobile_tab: MobileTab, mobile_home_refreshed_at: Option, watch_shared_totp_entries: BTreeSet, git_identity: GitIdentity, git_remotes: Vec, } // Accepted fields contain no floating-point TOML values, so validated documents // preserve the equivalence relation required by `Eq`. impl Eq for Config {} /// Editable, secret-free values shared by every frontend. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigSettings { vault: PathBuf, default_key: String, editor: Option>, authentication_timeout: Duration, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum MobileAppearance { #[default] System, Light, Dark, } impl MobileAppearance { fn from_config(value: &str) -> Result { match value { "system" => Ok(Self::System), "light" => Ok(Self::Light), "dark" => Ok(Self::Dark), _ => Err(ConfigError::InvalidField { field: "ui.mobile_appearance", }), } } const fn config_value(self) -> &'static str { match self { Self::System => "system", Self::Light => "light", Self::Dark => "dark", } } } impl ConfigSettings { pub fn vault(&self) -> &Path { &self.vault } pub fn set_vault(&mut self, vault: PathBuf) { self.vault = vault; } pub fn default_key(&self) -> &str { &self.default_key } pub fn set_default_key(&mut self, default_key: String) { self.default_key = default_key; } pub fn editor(&self) -> Option<&[String]> { self.editor.as_deref() } pub fn set_editor(&mut self, editor: Option>) { self.editor = editor; } pub fn authentication_timeout(&self) -> Duration { self.authentication_timeout } pub fn set_authentication_timeout(&mut self, timeout: Duration) { self.authentication_timeout = timeout; } } 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 clipboard_timeout(&self) -> ClipboardTimeout { self.clipboard_timeout } /// Shared inactivity lease used by every interactive frontend. pub fn authentication_timeout(&self) -> AuthenticationTimeout { self.authentication_timeout } pub fn biometric_unlock_enabled(&self) -> bool { self.biometric_unlock_enabled } pub fn mobile_appearance(&self) -> MobileAppearance { self.mobile_appearance } pub fn mobile_tab(&self) -> MobileTab { self.mobile_tab } pub fn mobile_home_refreshed_at(&self) -> Option { self.mobile_home_refreshed_at } pub fn watch_shared_totp_entries(&self) -> &BTreeSet { &self.watch_shared_totp_entries } pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } pub fn git_identity(&self) -> &GitIdentity { &self.git_identity } pub fn settings(&self) -> ConfigSettings { let editor = self.editor.as_ref().map(|editor| { std::iter::once(editor.program.clone()) .chain(editor.arguments.iter().cloned()) .collect() }); ConfigSettings { vault: self.vault.clone(), default_key: self.default_key.0.clone(), editor, authentication_timeout: self.authentication_timeout.duration(), } } pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let ui = root .entry("ui") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "ui" })?; ui.insert( "selected_mobile_tab".to_owned(), toml::Value::String(tab.config_value().to_owned()), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub fn update_watch_shared_totp_entries( &self, entries: &BTreeSet, ) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let ui = root .entry("ui") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "ui" })?; ui.insert( "watch_shared_totp_entries".to_owned(), toml::Value::Array( entries .iter() .map(|entry| toml::Value::String(entry.to_string())) .collect(), ), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> { if unix_seconds <= 0 { return Err(ConfigError::InvalidField { field: "ui.home_remote_refreshed_at_unix_seconds", }); } let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let ui = root .entry("ui") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "ui" })?; ui.insert( "home_remote_refreshed_at_unix_seconds".to_owned(), toml::Value::Integer(unix_seconds), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let security = root .entry("security") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "security" })?; security.insert( "biometric_unlock_enabled".to_owned(), toml::Value::Boolean(enabled), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub fn update_authentication_timeout( &self, timeout: AuthenticationTimeout, ) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let security = root .entry("security") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "security" })?; let seconds = i64::try_from(timeout.duration().as_secs()).map_err(|_| ConfigError::InvalidField { field: "security.inactivity_timeout_seconds", })?; security.insert( "inactivity_timeout_seconds".to_owned(), toml::Value::Integer(seconds), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub fn update_mobile_appearance( &self, appearance: MobileAppearance, ) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let ui = root .entry("ui") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "ui" })?; ui.insert( "mobile_appearance".to_owned(), toml::Value::String(appearance.config_value().to_owned()), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let git = root .entry("git") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "git" })?; git.insert( "user_name".to_owned(), toml::Value::String(identity.name().to_owned()), ); git.insert( "user_email".to_owned(), toml::Value::String(identity.email().to_owned()), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } pub(crate) fn update_mobile_remote( &self, remote: Option<&GitRemote>, ) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; let git = root .entry("git") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "git" })?; match remote { Some(remote) => { let mut configured = toml::Table::new(); configured.insert( "name".to_owned(), toml::Value::String(remote.name().as_str().to_owned()), ); configured.insert( "url".to_owned(), toml::Value::String(remote.url().to_string()), ); configured.insert( "server_id".to_owned(), toml::Value::String(remote.server_id().as_str().to_owned()), ); configured.insert( "application_id".to_owned(), toml::Value::String(remote.application_id().as_str().to_owned()), ); git.insert( "remotes".to_owned(), toml::Value::Array(vec![toml::Value::Table(configured)]), ); } None => { git.remove("remotes"); } } let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw)?.persist() } fn current_document(&self) -> Result { Self::load(Some(&self.source)).map(|config| config.document) } pub(crate) fn create_mobile_clone( source: PathBuf, vault: &Path, key_material: &Path, default_key: &str, remote: &GitRemote, ) -> Result { Self::create_mobile(source, vault, key_material, default_key, Some(remote)) } pub(crate) fn create_mobile_local( source: PathBuf, vault: &Path, key_material: &Path, default_key: &str, ) -> Result { Self::create_mobile(source, vault, key_material, default_key, None) } fn create_mobile( source: PathBuf, vault: &Path, key_material: &Path, default_key: &str, remote: Option<&GitRemote>, ) -> Result { let base = source .parent() .ok_or(ConfigError::InvalidField { field: "source" })?; let vault = vault .strip_prefix(base) .map_err(|_| ConfigError::InvalidField { field: "vault" })?; let key_material = key_material .strip_prefix(base) .map_err(|_| ConfigError::InvalidField { field: "key_material", })?; let mut root = toml::Table::new(); root.insert( "vault".to_owned(), toml::Value::String(path_text(vault, "vault")?), ); root.insert( "default_key".to_owned(), toml::Value::String(default_key.to_owned()), ); root.insert( "key_material".to_owned(), toml::Value::String(path_text(key_material, "key_material")?), ); if let Some(remote) = remote { let mut configured = toml::Table::new(); configured.insert( "name".to_owned(), toml::Value::String(remote.name().as_str().to_owned()), ); configured.insert( "url".to_owned(), toml::Value::String(remote.url().to_string()), ); configured.insert( "server_id".to_owned(), toml::Value::String(remote.server_id().as_str().to_owned()), ); configured.insert( "application_id".to_owned(), toml::Value::String(remote.application_id().as_str().to_owned()), ); let mut git = toml::Table::new(); git.insert( "remotes".to_owned(), toml::Value::Array(vec![toml::Value::Table(configured)]), ); root.insert("git".to_owned(), toml::Value::Table(git)); } let document = toml::Value::Table(root); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: source.clone(), })?; let config = validate_config(source, document, raw)?; config.persist_new()?; Ok(config) } /// Select a configured remote by name, or the configured default (first /// remote) when no name was requested. pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> { match requested { Some(name) => self .git_remotes .iter() .find(|remote| remote.name().as_str() == name), None => self.git_remotes.first(), } } /// 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, }) } pub(crate) fn with_settings(&self, settings: ConfigSettings) -> Result { let vault = fs::canonicalize(&settings.vault).map_err(|_| ConfigError::VaultUnavailable { path: settings.vault.clone(), })?; if !vault.is_dir() { return Err(ConfigError::VaultIsNotDirectory { path: vault }); } let mut document = self.document.clone(); let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: self.source.clone(), })?; if vault != self.vault { let base = self .source .parent() .ok_or(ConfigError::InvalidField { field: "source" })?; let stored = vault.strip_prefix(base).unwrap_or(&vault); root.insert( "vault".to_owned(), toml::Value::String(path_text(stored, "vault")?), ); } root.insert( "default_key".to_owned(), toml::Value::String(settings.default_key), ); match settings.editor { Some(editor) => { root.insert( "editor".to_owned(), toml::Value::Array(editor.into_iter().map(toml::Value::String).collect()), ); } None => { root.remove("editor"); } } if settings.authentication_timeout.subsec_nanos() != 0 { return Err(ConfigError::InvalidField { field: "security.inactivity_timeout_seconds", }); } let authentication_timeout = i64::try_from(settings.authentication_timeout.as_secs()) .map_err(|_| ConfigError::InvalidField { field: "security.inactivity_timeout_seconds", })?; let security = root .entry("security") .or_insert_with(|| toml::Value::Table(toml::Table::new())) .as_table_mut() .ok_or(ConfigError::InvalidField { field: "security" })?; security.insert( "inactivity_timeout_seconds".to_owned(), toml::Value::Integer(authentication_timeout), ); let raw = document .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: self.source.clone(), })?; validate_config(self.source.clone(), document, raw) } pub(crate) fn persist(&self) -> Result<(), ConfigError> { let parent = self.source.parent().ok_or_else(|| ConfigError::Write { path: self.source.clone(), })?; let name = self.source.file_name().ok_or_else(|| ConfigError::Write { path: self.source.clone(), })?; let directory = Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; let permissions = directory .metadata(name) .map_err(|_| ConfigError::Write { path: self.source.clone(), })? .permissions(); let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; let mut temporary = TempFile::new(&directory).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; temporary .as_file() .set_permissions(permissions) .and_then(|()| temporary.write_all(contents.as_bytes())) .and_then(|()| temporary.as_file().sync_all()) .and_then(|()| temporary.replace(name)) .map_err(|_| ConfigError::Write { path: self.source.clone(), }) } fn persist_new(&self) -> Result<(), ConfigError> { let parent = self.source.parent().ok_or_else(|| ConfigError::Write { path: self.source.clone(), })?; let name = self.source.file_name().ok_or_else(|| ConfigError::Write { path: self.source.clone(), })?; if self.source.exists() { return Err(ConfigError::AlreadyConfigured { path: self.source.clone(), }); } fs::create_dir_all(parent).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; set_private_directory(parent).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write { path: self.source.clone(), })?; let temporary = (0..128_u8) .find_map(|attempt| { let path = parent.join(format!( ".ironstorage-config-{}-{attempt}", rand::random::() )); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } match options.open(&path) { Ok(file) => Some(Ok((path, file))), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None, Err(_) => Some(Err(ConfigError::Write { path: self.source.clone(), })), } }) .transpose()? .ok_or_else(|| ConfigError::Write { path: self.source.clone(), })?; let (temporary_path, mut temporary_file) = temporary; let installed = temporary_file .write_all(contents.as_bytes()) .and_then(|()| temporary_file.sync_all()) .and_then(|()| fs::hard_link(&temporary_path, parent.join(name))); drop(temporary_file); let _ = fs::remove_file(&temporary_path); match installed { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { Err(ConfigError::AlreadyConfigured { path: self.source.clone(), }) } Err(_) => Err(ConfigError::Write { path: self.source.clone(), }), } } } /// 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 .clone() .try_into::() .map_err(|_| ConfigError::Malformed { path: source.clone(), })?; validate_config(source, value, 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 https( name: impl Into, url: impl Into, server_id: impl Into, application_id: impl Into, ) -> Result { let mut remotes = validate_remotes(vec![RawGitRemote { name: name.into(), url: url.into(), server_id: server_id.into(), application_id: application_id.into(), }])?; Ok(remotes.remove(0)) } 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 }, VaultUnavailable { path: PathBuf }, VaultIsNotDirectory { path: PathBuf }, Write { path: PathBuf }, AlreadyConfigured { 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::VaultUnavailable { path } => write!( formatter, "vault folder is missing or inaccessible: {}", path.display() ), Self::VaultIsNotDirectory { path } => { write!( formatter, "vault path is not a directory: {}", path.display() ) } Self::Write { path } => { write!( formatter, "cannot update configuration file: {}", path.display() ) } Self::AlreadyConfigured { path } => write!( formatter, "configuration already exists and was not replaced: {}", 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, clipboard_timeout_seconds: Option, #[serde(default)] security: RawSecurity, #[serde(default)] ui: RawUi, #[serde(default)] git: RawGit, } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawSecurity { inactivity_timeout_seconds: Option, biometric_unlock_enabled: Option, } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawUi { selected_mobile_tab: Option, mobile_appearance: Option, home_remote_refreshed_at_unix_seconds: Option, #[serde(default)] watch_shared_totp_entries: Vec, } #[derive(Deserialize)] #[serde(untagged)] enum RawEditor { CommandLine(String), Arguments(Vec), } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawGit { user_name: Option, user_email: Option, #[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, mut document: toml::Value, 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 }); } make_local_path_relative(&mut document, base, &vault, "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 })?; make_local_path_relative(&mut document, base, &key_material, "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 clipboard_timeout = ClipboardTimeout::new(Duration::from_secs( raw.clipboard_timeout_seconds .unwrap_or(DEFAULT_CLIPBOARD_TIMEOUT.as_secs()), )) .map_err(|_| ConfigError::InvalidField { field: "clipboard_timeout_seconds", })?; let authentication_timeout = AuthenticationTimeout::new(Duration::from_secs( raw.security .inactivity_timeout_seconds .unwrap_or(DEFAULT_AUTHENTICATION_TIMEOUT.as_secs()), )) .map_err(|_| ConfigError::InvalidField { field: "security.inactivity_timeout_seconds", })?; let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false); let mobile_appearance = raw .ui .mobile_appearance .as_deref() .map(MobileAppearance::from_config) .transpose()? .unwrap_or_default(); let mobile_tab = raw .ui .selected_mobile_tab .as_deref() .map(MobileTab::from_config) .transpose()? .unwrap_or_default(); let mobile_home_refreshed_at = match raw.ui.home_remote_refreshed_at_unix_seconds { Some(value) if value > 0 => Some(value), Some(_) => { return Err(ConfigError::InvalidField { field: "ui.home_remote_refreshed_at_unix_seconds", }); } None => None, }; let watch_shared_totp_entries = raw .ui .watch_shared_totp_entries .into_iter() .map(|entry| { EntryPath::parse(&entry).map_err(|_| ConfigError::InvalidField { field: "ui.watch_shared_totp_entries", }) }) .collect::, _>>()?; let git_identity = match (raw.git.user_name, raw.git.user_email) { (None, None) => GitIdentity::ironstorage(), (Some(name), Some(email)) => { GitIdentity::new(name, email).map_err(|_| ConfigError::InvalidField { field: "git.user_identity", })? } _ => { return Err(ConfigError::InvalidField { field: "git.user_identity", }); } }; let git_remotes = validate_remotes(raw.git.remotes)?; Ok(Config { source, document, vault, default_key, key_material, editor, clipboard_timeout, authentication_timeout, biometric_unlock_enabled, mobile_appearance, mobile_tab, mobile_home_refreshed_at, watch_shared_totp_entries, git_identity, 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 }); } let resolved = resolve_path(base, &value); if resolved.exists() || !value.is_absolute() { return Ok(resolved); } Ok(relocated_mobile_path(base, &resolved, field).unwrap_or(resolved)) } fn relocated_mobile_path(base: &Path, path: &Path, field: &'static str) -> Option { let leaf = match field { "vault" => "vault", "key_material" => "keys", _ => return None, }; let suffix = Path::new("Library/Application Support/ironstorage").join(leaf); let is_stale_mobile_path = [ Path::new("/private/var/mobile/Containers/Data/Application"), Path::new("/var/mobile/Containers/Data/Application"), ] .iter() .any(|prefix| { path.strip_prefix(prefix).is_ok_and(|relative| { let mut components = relative.components(); components.next().is_some() && components.as_path() == suffix }) }); let relocated = base.join(leaf); (is_stale_mobile_path && relocated.exists()).then_some(relocated) } fn make_local_path_relative( document: &mut toml::Value, base: &Path, path: &Path, field: &'static str, ) -> Result<(), ConfigError> { let Ok(relative) = path.strip_prefix(base) else { return Ok(()); }; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { path: base.join(CONFIG_FILE), })?; root.insert( field.to_owned(), toml::Value::String(path_text(relative, field)?), ); Ok(()) } fn path_text(path: &Path, field: &'static str) -> Result { path.to_str() .map(str::to_owned) .ok_or(ConfigError::InvalidField { field }) } fn set_private_directory(path: &Path) -> std::io::Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; } #[cfg(not(unix))] let _ = path; Ok(()) } 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", "clipboard_timeout_seconds", "security", "ui", "git", ], )?; if let Some(security) = root.get("security") { let security = security.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; validate_table( security, "security", &["inactivity_timeout_seconds", "biometric_unlock_enabled"], )?; } if let Some(ui) = root.get("ui") { let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; validate_table( ui, "ui", &[ "selected_mobile_tab", "mobile_appearance", "home_remote_refreshed_at_unix_seconds", "watch_shared_totp_entries", ], )?; } 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", &["user_name", "user_email", "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(target_os = "ios")] fn native_config_directory() -> Option { env::var_os("HOME") .map(PathBuf::from) .map(|home| home.join("Library/Application Support")) } #[cfg(all(unix, not(any(target_os = "ios", 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 } #[cfg(test)] mod tests { use std::fs; use crate::mobile::MobileTab; use super::{Config, ConfigError, GitRemote}; #[test] fn mobile_clone_configuration_is_secret_free_and_never_replaced() { let temporary = tempfile::tempdir().expect("temporary directory"); let original = temporary.path().join("original"); let relocated = temporary.path().join("relocated"); let vault = original.join("vault"); let keys = original.join("keys"); let source = original.join("config.toml"); fs::create_dir_all(&vault).expect("vault"); fs::create_dir(&keys).expect("keys"); let remote = GitRemote::https( "origin", "https://example.test/team/passwords.git", "server-example", "repository-example", ) .expect("remote"); let config = Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote) .expect("create config"); assert_eq!(config.mobile_home_refreshed_at(), None); config .update_mobile_home_refresh(1_789_000_000) .expect("persist refresh time"); Config::load(Some(&source)) .expect("reload timestamped config") .update_mobile_tab(MobileTab::Preferences) .expect("persist tab after refresh time"); let contents = fs::read_to_string(&source).expect("read config"); assert!(!contents.contains("token =")); assert!(!contents.contains("password =")); assert!(!contents.contains(&original.to_string_lossy().into_owned())); assert!(contents.contains("vault = \"vault\"")); assert!(contents.contains("key_material = \"keys\"")); assert!(contents.contains("https://example.test/team/passwords.git")); assert_eq!( Config::create_mobile_clone(source.clone(), &vault, &keys, "BOB", &remote) .expect_err("must not replace existing config"), ConfigError::AlreadyConfigured { path: source.clone() } ); fs::rename(&original, &relocated).expect("relocate app container"); let relocated_config = Config::load(Some(&relocated.join("config.toml"))).expect("reload config"); assert_eq!( ( relocated_config.default_key().as_str(), relocated_config.mobile_home_refreshed_at(), relocated_config.mobile_tab(), ), ("ALICE", Some(1_789_000_000), MobileTab::Preferences) ); } #[test] fn stale_ios_container_path_follows_the_current_container() { let temporary = tempfile::tempdir().expect("temporary directory"); let vault = temporary.path().join("vault"); let keys = temporary.path().join("keys"); let source = temporary.path().join("config.toml"); fs::create_dir(&vault).expect("vault"); fs::create_dir(&keys).expect("keys"); fs::write( &source, r#"vault = "/private/var/mobile/Containers/Data/Application/OLD-CONTAINER/Library/Application Support/ironstorage/vault" default_key = "ALICE" key_material = "keys" "#, ) .expect("config"); let config = Config::load(Some(&source)).expect("recover moved container"); assert_eq!(config.vault(), fs::canonicalize(vault).expect("vault path")); config .update_mobile_tab(MobileTab::Preferences) .expect("persist recovered config"); let contents = fs::read_to_string(source).expect("read config"); assert!(contents.contains("vault = \"vault\"")); assert!(!contents.contains("OLD-CONTAINER")); } }