//! Strict, secret-free configuration shared by every frontend. use std::{ collections::BTreeSet, env, error::Error, fmt, fs, fs::OpenOptions, io::{self, Write}, path::{Component, Path, PathBuf}, time::Duration, }; use cap_std::{ambient_authority, fs::Dir}; use cap_tempfile::TempFile; #[cfg(not(any(target_os = "ios", target_os = "watchos")))] use gix::bstr::ByteSlice as _; use serde::Deserialize; #[cfg(not(any(target_os = "ios", target_os = "watchos")))] use sha2::{Digest as _, Sha256}; 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) => { git.insert( "remotes".to_owned(), toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]), ); } 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 git = toml::Table::new(); git.insert( "remotes".to_owned(), toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]), ); 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> { self.persist_with_directory_sync(sync_config_directory) } fn persist_with_directory_sync( &self, sync_parent: impl FnOnce(&Dir) -> io::Result<()>, ) -> 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(), })?; sync_parent(&directory).map_err(|_| ConfigError::DurabilityUncertain { path: self.source.clone(), }) } fn persist_new(&self) -> Result<(), ConfigError> { self.persist_new_with_directory_sync(sync_config_directory) } fn persist_new_with_directory_sync( &self, sync_parent: impl FnOnce(&Dir) -> io::Result<()>, ) -> 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 directory = Dir::open_ambient_dir(parent, ambient_authority()).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(()) => sync_parent(&directory).map_err(|_| ConfigError::DurabilityUncertain { path: self.source.clone(), }), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { Err(ConfigError::AlreadyConfigured { path: self.source.clone(), }) } Err(_) => Err(ConfigError::Write { path: self.source.clone(), }), } } } fn sync_config_directory(directory: &Dir) -> io::Result<()> { directory.open(".").and_then(|file| file.sync_all()) } fn git_remote_document(remote: &GitRemote) -> Result { 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_owned()), ); if let Some((server_id, application_id)) = remote.https_credentials() { configured.insert( "server_id".to_owned(), toml::Value::String(server_id.as_str().to_owned()), ); configured.insert( "application_id".to_owned(), toml::Value::String(application_id.as_str().to_owned()), ); } if let Some(authentication) = remote.ssh_authentication() { match authentication.identity() { SshIdentitySource::KeyFile(path) => { configured.insert( "ssh_identity_file".to_owned(), toml::Value::String(path_text(path, "git.remotes.ssh_identity_file")?), ); } SshIdentitySource::Agent { fingerprint, socket, } => { configured.insert( "ssh_agent_fingerprint".to_owned(), toml::Value::String(fingerprint.to_string()), ); if let Some(socket) = socket { configured.insert( "ssh_agent_socket".to_owned(), toml::Value::String(path_text(socket, "git.remotes.ssh_agent_socket")?), ); } } } configured.insert( "ssh_known_hosts_file".to_owned(), toml::Value::String(path_text( authentication.known_hosts_file(), "git.remotes.ssh_known_hosts_file", )?), ); } Ok(configured) } /// 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, Copy, Debug, Eq, PartialEq)] pub enum RemoteTransport { Https, Ssh, } impl fmt::Display for RemoteTransport { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Https => "HTTPS", Self::Ssh => "SSH", }) } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum RemoteEndpoint { Https(Url), Ssh(SshEndpoint), } impl RemoteEndpoint { pub fn parse(value: &str) -> Result { if value.is_empty() || value.chars().any(char::is_control) { return Err(InvalidRemoteEndpoint); } if value.contains("://") { let parsed = Url::parse(value).map_err(|_| InvalidRemoteEndpoint)?; return match parsed.scheme() { "https" if parsed.host_str().is_some() && parsed.username().is_empty() && parsed.password().is_none() && parsed.query().is_none() && parsed.fragment().is_none() => { Ok(Self::Https(parsed)) } "ssh" => parse_ssh_url(parsed).map(Self::Ssh), _ => Err(InvalidRemoteEndpoint), }; } parse_scp_remote(value).map(Self::Ssh) } pub const fn transport(&self) -> RemoteTransport { match self { Self::Https(_) => RemoteTransport::Https, Self::Ssh(_) => RemoteTransport::Ssh, } } pub const fn as_https(&self) -> Option<&Url> { match self { Self::Https(url) => Some(url), Self::Ssh(_) => None, } } pub const fn as_ssh(&self) -> Option<&SshEndpoint> { match self { Self::Https(_) => None, Self::Ssh(endpoint) => Some(endpoint), } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SshEndpoint { user: Option, host: String, port: u16, path: SshRepositoryPath, } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct SshFingerprint(String); impl SshFingerprint { pub fn parse(value: impl Into) -> Result { let value = value.into(); let encoded = value.strip_prefix("SHA256:").ok_or(InvalidRemoteEndpoint)?; if encoded.len() != 43 { return Err(InvalidRemoteEndpoint); } let digest = data_encoding::BASE64_NOPAD .decode(encoded.as_bytes()) .map_err(|_| InvalidRemoteEndpoint)?; if digest.len() != 32 { return Err(InvalidRemoteEndpoint); } Ok(Self(value)) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for SshFingerprint { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum SshIdentitySource { KeyFile(PathBuf), Agent { fingerprint: SshFingerprint, socket: Option, }, } impl SshIdentitySource { pub fn key_file(&self) -> Option<&Path> { match self { Self::KeyFile(path) => Some(path), Self::Agent { .. } => None, } } pub fn agent_fingerprint(&self) -> Option<&SshFingerprint> { match self { Self::KeyFile(_) => None, Self::Agent { fingerprint, .. } => Some(fingerprint), } } pub fn agent_socket(&self) -> Option<&Path> { match self { Self::Agent { socket, .. } => socket.as_deref(), Self::KeyFile(_) => None, } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SshRemoteAuthentication { identity: SshIdentitySource, known_hosts_file: PathBuf, } impl SshRemoteAuthentication { pub fn key_file( identity_file: PathBuf, known_hosts_file: PathBuf, ) -> Result { validate_standalone_ssh_path(&identity_file, "git.remotes.ssh_identity_file")?; validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?; Ok(Self { identity: SshIdentitySource::KeyFile(identity_file), known_hosts_file, }) } pub fn agent( fingerprint: SshFingerprint, socket: Option, known_hosts_file: PathBuf, ) -> Result { if let Some(socket) = &socket { validate_standalone_ssh_path(socket, "git.remotes.ssh_agent_socket")?; } validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?; Ok(Self { identity: SshIdentitySource::Agent { fingerprint, socket, }, known_hosts_file, }) } pub const fn identity(&self) -> &SshIdentitySource { &self.identity } pub fn known_hosts_file(&self) -> &Path { &self.known_hosts_file } } impl SshEndpoint { pub fn user(&self) -> Option<&str> { self.user.as_deref() } pub fn host(&self) -> &str { &self.host } pub const fn port(&self) -> u16 { self.port } pub const fn path(&self) -> &SshRepositoryPath { &self.path } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum SshRepositoryPath { Absolute(String), Relative(String), Tilde(String), } impl SshRepositoryPath { pub fn as_str(&self) -> &str { match self { Self::Absolute(path) | Self::Relative(path) | Self::Tilde(path) => path, } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct InvalidRemoteEndpoint; impl fmt::Display for InvalidRemoteEndpoint { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("invalid or forbidden Git remote endpoint") } } impl Error for InvalidRemoteEndpoint {} fn parse_ssh_url(url: Url) -> Result { if url.password().is_some() || url.query().is_some() || url.fragment().is_some() { return Err(InvalidRemoteEndpoint); } let user = parse_ssh_user(url.username())?; let host = url.host_str().ok_or(InvalidRemoteEndpoint)?.to_owned(); let port = url.port().unwrap_or(22); if port == 0 { return Err(InvalidRemoteEndpoint); } let path = percent_encoding::percent_decode_str(url.path()) .decode_utf8() .map_err(|_| InvalidRemoteEndpoint)?; let path = if let Some(tilde) = path.strip_prefix("/~") { parse_ssh_path(format!("~{tilde}"), true)? } else { parse_ssh_path(path.into_owned(), false)? }; Ok(SshEndpoint { user, host, port, path, }) } fn parse_scp_remote(value: &str) -> Result { if value.contains(['?', '#']) || value.to_ascii_lowercase().starts_with("ext::") { return Err(InvalidRemoteEndpoint); } let separator = if let Some(bracket) = value.find('[') { let close = value[bracket + 1..] .find(']') .map(|index| bracket + index + 1) .ok_or(InvalidRemoteEndpoint)?; if value.as_bytes().get(close + 1) != Some(&b':') { return Err(InvalidRemoteEndpoint); } close + 1 } else { value.find(':').ok_or(InvalidRemoteEndpoint)? }; let (authority, path) = value.split_at(separator); let path = path.strip_prefix(':').ok_or(InvalidRemoteEndpoint)?; if path.contains(':') { return Err(InvalidRemoteEndpoint); } let (user, host) = match authority.split_once('@') { Some((user, host)) if !host.contains('@') => (parse_ssh_user(user)?, host), Some(_) => return Err(InvalidRemoteEndpoint), None => (None, authority), }; if host.is_empty() || host.starts_with('-') || (host.len() == 1 && host.as_bytes()[0].is_ascii_alphabetic() && path.starts_with(['/', '\\'])) { return Err(InvalidRemoteEndpoint); } let bracketed = host.starts_with('[') && host.ends_with(']'); if host.contains(['[', ']']) && !bracketed || host.contains(':') && !bracketed { return Err(InvalidRemoteEndpoint); } let host = host .strip_prefix('[') .and_then(|host| host.strip_suffix(']')) .unwrap_or(host); let host = url::Host::parse(host) .map_err(|_| InvalidRemoteEndpoint)? .to_string(); Ok(SshEndpoint { user, host, port: 22, path: parse_ssh_path(path.to_owned(), true)?, }) } fn parse_ssh_user(value: &str) -> Result, InvalidRemoteEndpoint> { if value.is_empty() { return Ok(None); } if value.starts_with('-') || !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) { return Err(InvalidRemoteEndpoint); } Ok(Some(value.to_owned())) } fn parse_ssh_path( path: String, allow_relative: bool, ) -> Result { if path.is_empty() || path == "/" || path.contains(['?', '#']) || path.chars().any(char::is_control) { return Err(InvalidRemoteEndpoint); } if path.starts_with('/') { return Ok(SshRepositoryPath::Absolute(path)); } if let Some(tilde_path) = path.strip_prefix('~') { let user = tilde_path.split('/').next().unwrap_or_default(); if !user.is_empty() && !user .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) { return Err(InvalidRemoteEndpoint); } return Ok(SshRepositoryPath::Tilde(path)); } if !allow_relative || path.starts_with('-') { return Err(InvalidRemoteEndpoint); } Ok(SshRepositoryPath::Relative(path)) } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitRemote { name: RemoteName, url: String, endpoint: RemoteEndpoint, credentials: GitRemoteCredentials, } #[derive(Clone, Debug, Eq, PartialEq)] enum GitRemoteCredentials { Https { server_id: ServerId, application_id: ApplicationId, }, Ssh(Option), } 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: Some(server_id.into()), application_id: Some(application_id.into()), ssh_identity_file: None, ssh_agent_fingerprint: None, ssh_agent_socket: None, ssh_known_hosts_file: None, }], None, )?; Ok(remotes.remove(0)) } pub fn ssh(name: impl Into, url: impl Into) -> Result { let mut remotes = validate_remotes( vec![RawGitRemote { name: name.into(), url: url.into(), server_id: None, application_id: None, ssh_identity_file: None, ssh_agent_fingerprint: None, ssh_agent_socket: None, ssh_known_hosts_file: None, }], None, )?; Ok(remotes.remove(0)) } pub fn ssh_with_authentication( name: impl Into, url: impl Into, authentication: SshRemoteAuthentication, ) -> Result { let mut remote = Self::ssh(name, url)?; remote.credentials = GitRemoteCredentials::Ssh(Some(authentication)); Ok(remote) } pub fn name(&self) -> &RemoteName { &self.name } pub fn url(&self) -> &str { &self.url } pub const fn endpoint(&self) -> &RemoteEndpoint { &self.endpoint } pub const fn https_credentials(&self) -> Option<(&ServerId, &ApplicationId)> { match &self.credentials { GitRemoteCredentials::Https { server_id, application_id, } => Some((server_id, application_id)), GitRemoteCredentials::Ssh(_) => None, } } pub const fn ssh_authentication(&self) -> Option<&SshRemoteAuthentication> { match &self.credentials { GitRemoteCredentials::Ssh(authentication) => authentication.as_ref(), GitRemoteCredentials::Https { .. } => None, } } #[cfg(all(test, feature = "ssh"))] pub(crate) fn set_ssh_test_port(&mut self, port: u16) { if let RemoteEndpoint::Ssh(endpoint) = &mut self.endpoint { endpoint.port = port; } } } /// 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 }, DurabilityUncertain { 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::DurabilityUncertain { path } => write!( formatter, "configuration replacement completed but its directory sync failed: {}", 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 allowed HTTPS or SSH endpoint 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(Clone, Deserialize)] #[serde(deny_unknown_fields)] struct RawGitRemote { name: String, url: String, server_id: Option, application_id: Option, ssh_identity_file: Option, ssh_agent_fingerprint: Option, ssh_agent_socket: Option, ssh_known_hosts_file: Option, } 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 configured_git_remotes = raw.git.remotes; #[cfg(not(any(target_os = "ios", target_os = "watchos")))] let git_remotes = match repository_git_remotes(&vault, &configured_git_remotes, base)? { Some(remotes) => remotes, None => validate_remotes(configured_git_remotes, Some(base))?, }; #[cfg(any(target_os = "ios", target_os = "watchos"))] let git_remotes = validate_remotes(configured_git_remotes, Some(base))?; 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, }) } #[cfg(not(any(target_os = "ios", target_os = "watchos")))] fn repository_git_remotes( vault: &Path, configured_remotes: &[RawGitRemote], config_base: &Path, ) -> Result>, ConfigError> { let path = vault.join(".git/config"); if !path.is_file() { return Ok(None); } let config = gix_config::File::from_path_no_includes(path, gix_config::Source::Local).map_err(|_| { ConfigError::InvalidField { field: "git.repository_remotes", } })?; let mut remotes = Vec::new(); if let Some(sections) = config.sections_by_name("remote") { for section in sections { let name = section .header() .subsection_name() .and_then(|name| name.to_str().ok()) .ok_or(ConfigError::InvalidField { field: "git.repository_remotes", })?; let url = config .raw_value_by("remote", Some(name.into()), "url") .ok() .and_then(|url| url.to_str().ok().map(str::to_owned)) .ok_or(ConfigError::InvalidRemoteUrl { name: name.to_owned(), })?; let endpoint = RemoteEndpoint::parse(&url).map_err(|_| ConfigError::InvalidRemoteUrl { name: name.to_owned(), })?; let remote = match endpoint { RemoteEndpoint::Https(ref https_endpoint) => { let credential_ids = configured_remotes .iter() .find(|configured| { RemoteEndpoint::parse(&configured.url) .is_ok_and(|configured| configured == endpoint) }) .map(|configured| { let mut validated = validate_remotes(vec![configured.clone()], Some(config_base))?; let configured = validated.remove(0); let (server, application) = configured.https_credentials().ok_or( ConfigError::InvalidField { field: "git.remotes.https_credentials", }, )?; Ok::<_, ConfigError>(( server.as_str().to_owned(), application.as_str().to_owned(), )) }) .transpose()? .unwrap_or_else(|| { ( stable_identifier( "server", format!( "{}://{}:{}", https_endpoint.scheme(), https_endpoint.host_str().unwrap_or_default(), https_endpoint.port_or_known_default().unwrap_or(443) ) .as_bytes(), ), stable_identifier("repository", https_endpoint.as_str().as_bytes()), ) }); GitRemote::https(name, &url, credential_ids.0, credential_ids.1)? } RemoteEndpoint::Ssh(_) => GitRemote::ssh(name, &url)?, }; remotes.push(remote); } } Ok((!remotes.is_empty()).then_some(remotes)) } #[cfg(not(any(target_os = "ios", target_os = "watchos")))] fn stable_identifier(prefix: &str, value: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let digest = Sha256::digest(value); let suffix: String = digest[..8] .iter() .flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]]) .map(char::from) .collect(); format!("{prefix}-{suffix}") } 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, config_base: Option<&Path>, ) -> 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)?); if !names.insert(name.clone()) { return Err(ConfigError::DuplicateRemote { name: name.0.clone(), }); } let endpoint = RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl { name: name.0.clone(), })?; let has_ssh_fields = remote.ssh_identity_file.is_some() || remote.ssh_agent_fingerprint.is_some() || remote.ssh_agent_socket.is_some() || remote.ssh_known_hosts_file.is_some(); if matches!(&endpoint, RemoteEndpoint::Https(_)) && has_ssh_fields { return Err(ConfigError::InvalidField { field: "git.remotes.ssh_authentication", }); } let credentials = match (&endpoint, remote.server_id, remote.application_id) { (RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => { let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?); let application_id = ApplicationId(validate_identifier( "git.remotes.application_id", application_id, )?); if !references.insert((server_id.clone(), application_id.clone())) { return Err(ConfigError::DuplicateCredentialReference); } GitRemoteCredentials::Https { server_id, application_id, } } (RemoteEndpoint::Https(_), None, _) => { return Err(ConfigError::MissingField { field: "git.remotes.server_id", }); } (RemoteEndpoint::Https(_), _, None) => { return Err(ConfigError::MissingField { field: "git.remotes.application_id", }); } (RemoteEndpoint::Ssh(_), None, None) => { let Some(base) = config_base else { if has_ssh_fields { return Err(ConfigError::InvalidField { field: "git.remotes.ssh_authentication", }); } remotes.push(GitRemote { name, url: remote.url, endpoint, credentials: GitRemoteCredentials::Ssh(None), }); continue; }; let known_hosts_file = match remote.ssh_known_hosts_file { Some(path) => resolve_ssh_path(base, path, "git.remotes.ssh_known_hosts_file")?, None => default_known_hosts_path().ok_or(ConfigError::InvalidField { field: "git.remotes.ssh_known_hosts_file", })?, }; let identity = match ( remote.ssh_identity_file, remote.ssh_agent_fingerprint, remote.ssh_agent_socket, ) { (Some(path), None, None) => SshIdentitySource::KeyFile(resolve_ssh_path( base, path, "git.remotes.ssh_identity_file", )?), (None, Some(fingerprint), socket) => SshIdentitySource::Agent { fingerprint: SshFingerprint::parse(fingerprint).map_err(|_| { ConfigError::InvalidField { field: "git.remotes.ssh_agent_fingerprint", } })?, socket: socket .map(|path| { resolve_ssh_path(base, path, "git.remotes.ssh_agent_socket") }) .transpose()?, }, _ => { return Err(ConfigError::InvalidField { field: "git.remotes.ssh_authentication", }); } }; GitRemoteCredentials::Ssh(Some(SshRemoteAuthentication { identity, known_hosts_file, })) } (RemoteEndpoint::Ssh(_), _, _) => { return Err(ConfigError::InvalidField { field: "git.remotes.https_credentials", }); } }; remotes.push(GitRemote { name, url: remote.url, endpoint, credentials, }); } Ok(remotes) } fn resolve_ssh_path( base: &Path, value: PathBuf, field: &'static str, ) -> Result { if value.as_os_str().is_empty() || path_text(&value, field)?.chars().any(char::is_control) { return Err(ConfigError::InvalidField { field }); } Ok(resolve_path(base, &value)) } fn validate_standalone_ssh_path(path: &Path, field: &'static str) -> Result<(), ConfigError> { if !path.is_absolute() || path.as_os_str().is_empty() || path_text(path, field)?.chars().any(char::is_control) { return Err(ConfigError::InvalidField { field }); } Ok(()) } #[cfg(target_os = "windows")] fn default_known_hosts_path() -> Option { env::var_os("USERPROFILE") .filter(|home| !home.is_empty() && Path::new(home).is_absolute()) .map(PathBuf::from) .map(|home| home.join(".ssh").join("known_hosts")) } #[cfg(not(target_os = "windows"))] fn default_known_hosts_path() -> Option { env::var_os("HOME") .filter(|home| !home.is_empty() && Path::new(home).is_absolute()) .map(PathBuf::from) .map(|home| home.join(".ssh").join("known_hosts")) } 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", "ssh_identity_file", "ssh_agent_fingerprint", "ssh_agent_socket", "ssh_known_hosts_file", ], )?; } } 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, io}; 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")); } #[test] fn post_install_directory_sync_failure_is_distinct_and_keeps_complete_config() { 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"); 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("initial config"); let error = config .persist_with_directory_sync(|_| Err(io::Error::other("simulated sync failure"))) .expect_err("replacement directory sync must fail"); assert_eq!( error, ConfigError::DurabilityUncertain { path: source.clone() } ); Config::load(Some(&source)).expect("replacement is complete"); let created = temporary.path().join("created.toml"); let mut new_config = config; new_config.source = created.clone(); let error = new_config .persist_new_with_directory_sync(|_| Err(io::Error::other("simulated sync failure"))) .expect_err("creation directory sync must fail"); assert_eq!( error, ConfigError::DurabilityUncertain { path: created.clone() } ); Config::load(Some(&created)).expect("creation is complete"); } }