Files
IronStorage/crates/storage/src/config.rs
Chili Palmer c2dd826920
Some checks failed
Dependency security audit / rustsec (push) Failing after 3s
Implement audited findings
2026-08-27 15:34:31 +02:00

2482 lines
80 KiB
Rust

//! 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<EditorCommand>,
clipboard_timeout: ClipboardTimeout,
authentication_timeout: AuthenticationTimeout,
biometric_unlock_enabled: bool,
mobile_appearance: MobileAppearance,
mobile_tab: MobileTab,
mobile_home_refreshed_at: Option<i64>,
watch_shared_totp_entries: BTreeSet<EntryPath>,
git_identity: GitIdentity,
git_remotes: Vec<GitRemote>,
}
// 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<Vec<String>>,
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<Self, ConfigError> {
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<Vec<String>>) {
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<Self, ConfigError> {
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<i64> {
self.mobile_home_refreshed_at
}
pub fn watch_shared_totp_entries(&self) -> &BTreeSet<EntryPath> {
&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::<RawConfig>()
.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<EntryPath>,
) -> 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::<RawConfig>()
.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::<RawConfig>()
.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::<RawConfig>()
.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::<RawConfig>()
.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::<RawConfig>()
.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::<RawConfig>()
.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::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: self.source.clone(),
})?;
validate_config(self.source.clone(), document, raw)?.persist()
}
fn current_document(&self) -> Result<toml::Value, ConfigError> {
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, ConfigError> {
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, ConfigError> {
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<Self, ConfigError> {
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::<RawConfig>()
.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<ResolvedEditor, EditorError> {
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<ResolvedEditor, EditorError> {
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<Self, ConfigError> {
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::<RawConfig>()
.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::<u64>()
));
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<toml::Table, ConfigError> {
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<Self, ConfigError> {
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<Config, ConfigError> {
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::<toml::Value>(&contents).map_err(|_| ConfigError::Malformed {
path: source.clone(),
})?;
reject_insecure_fields(&value, "")?;
validate_known_fields(&value, &source)?;
let raw = value
.clone()
.try_into::<RawConfig>()
.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<Self, InvalidRemoteEndpoint> {
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<String>,
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<String>) -> Result<Self, InvalidRemoteEndpoint> {
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<PathBuf>,
},
}
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<Self, ConfigError> {
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<PathBuf>,
known_hosts_file: PathBuf,
) -> Result<Self, ConfigError> {
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<SshEndpoint, InvalidRemoteEndpoint> {
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<SshEndpoint, InvalidRemoteEndpoint> {
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<Option<String>, 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<SshRepositoryPath, InvalidRemoteEndpoint> {
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<SshRemoteAuthentication>),
}
impl GitRemote {
pub fn https(
name: impl Into<String>,
url: impl Into<String>,
server_id: impl Into<String>,
application_id: impl Into<String>,
) -> Result<Self, ConfigError> {
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<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
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<String>,
url: impl Into<String>,
authentication: SshRemoteAuthentication,
) -> Result<Self, ConfigError> {
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<String>,
}
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<PathBuf>,
default_key: Option<String>,
key_material: Option<PathBuf>,
editor: Option<RawEditor>,
clipboard_timeout_seconds: Option<u64>,
#[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<u64>,
biometric_unlock_enabled: Option<bool>,
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawUi {
selected_mobile_tab: Option<String>,
mobile_appearance: Option<String>,
home_remote_refreshed_at_unix_seconds: Option<i64>,
#[serde(default)]
watch_shared_totp_entries: Vec<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawEditor {
CommandLine(String),
Arguments(Vec<String>),
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGit {
user_name: Option<String>,
user_email: Option<String>,
#[serde(default)]
remotes: Vec<RawGitRemote>,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGitRemote {
name: String,
url: String,
server_id: Option<String>,
application_id: Option<String>,
ssh_identity_file: Option<PathBuf>,
ssh_agent_fingerprint: Option<String>,
ssh_agent_socket: Option<PathBuf>,
ssh_known_hosts_file: Option<PathBuf>,
}
fn validate_config(
source: PathBuf,
mut document: toml::Value,
raw: RawConfig,
) -> Result<Config, ConfigError> {
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::<Result<BTreeSet<_>, _>>()?;
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<Option<Vec<GitRemote>>, 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<PathBuf>,
field: &'static str,
) -> Result<PathBuf, ConfigError> {
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<PathBuf> {
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<String, ConfigError> {
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<KeyIdentity, ConfigError> {
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<EditorCommand, ConfigError> {
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<String>) -> Result<EditorCommand, ()> {
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<Option<EditorCommand>, 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<RawGitRemote>,
config_base: Option<&Path>,
) -> Result<Vec<GitRemote>, 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<PathBuf, ConfigError> {
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<PathBuf> {
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<PathBuf> {
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<String, ConfigError> {
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: "<root>".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<PathBuf> {
env::var_os("APPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
fn native_config_directory() -> Option<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join("Library/Application Support"))
}
#[cfg(target_os = "ios")]
fn native_config_directory() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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");
}
}