Files
IronStorage/crates/storage/src/config.rs

1736 lines
55 KiB
Rust

//! Strict, secret-free configuration shared by every frontend.
use std::{
collections::BTreeSet,
env,
error::Error,
fmt, fs,
fs::OpenOptions,
io::Write,
path::{Component, Path, PathBuf},
time::Duration,
};
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use serde::Deserialize;
use url::Url;
use crate::{
authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT},
git::GitIdentity,
mobile::MobileTab,
presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT},
repository::EntryPath,
};
const APPLICATION_DIRECTORY: &str = "ironstorage";
const CONFIG_FILE: &str = "config.toml";
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
/// Validated application configuration.
#[derive(Clone, Debug, PartialEq)]
pub struct Config {
source: PathBuf,
document: toml::Value,
vault: PathBuf,
default_key: KeyIdentity,
key_material: PathBuf,
editor: Option<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) => {
let mut configured = toml::Table::new();
configured.insert(
"name".to_owned(),
toml::Value::String(remote.name().as_str().to_owned()),
);
configured.insert(
"url".to_owned(),
toml::Value::String(remote.url().to_string()),
);
configured.insert(
"server_id".to_owned(),
toml::Value::String(remote.server_id().as_str().to_owned()),
);
configured.insert(
"application_id".to_owned(),
toml::Value::String(remote.application_id().as_str().to_owned()),
);
git.insert(
"remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(configured)]),
);
}
None => {
git.remove("remotes");
}
}
let raw = document
.clone()
.try_into::<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 configured = toml::Table::new();
configured.insert(
"name".to_owned(),
toml::Value::String(remote.name().as_str().to_owned()),
);
configured.insert(
"url".to_owned(),
toml::Value::String(remote.url().to_string()),
);
configured.insert(
"server_id".to_owned(),
toml::Value::String(remote.server_id().as_str().to_owned()),
);
configured.insert(
"application_id".to_owned(),
toml::Value::String(remote.application_id().as_str().to_owned()),
);
let mut git = toml::Table::new();
git.insert(
"remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(configured)]),
);
root.insert("git".to_owned(), toml::Value::Table(git));
}
let document = toml::Value::Table(root);
let raw = document
.clone()
.try_into::<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> {
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let directory =
Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let permissions = directory
.metadata(name)
.map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?
.permissions();
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let mut temporary = TempFile::new(&directory).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
temporary
.as_file()
.set_permissions(permissions)
.and_then(|()| temporary.write_all(contents.as_bytes()))
.and_then(|()| temporary.as_file().sync_all())
.and_then(|()| temporary.replace(name))
.map_err(|_| ConfigError::Write {
path: self.source.clone(),
})
}
fn persist_new(&self) -> Result<(), ConfigError> {
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
path: self.source.clone(),
})?;
if self.source.exists() {
return Err(ConfigError::AlreadyConfigured {
path: self.source.clone(),
});
}
fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
set_private_directory(parent).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
path: self.source.clone(),
})?;
let temporary = (0..128_u8)
.find_map(|attempt| {
let path = parent.join(format!(
".ironstorage-config-{}-{attempt}",
rand::random::<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(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(ConfigError::AlreadyConfigured {
path: self.source.clone(),
})
}
Err(_) => Err(ConfigError::Write {
path: self.source.clone(),
}),
}
}
}
/// Deterministic path context for configuration loading.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigLoader {
current_directory: PathBuf,
native_config_directory: PathBuf,
}
impl ConfigLoader {
pub fn system() -> Result<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, Debug, Eq, PartialEq)]
pub struct GitRemote {
name: RemoteName,
url: Url,
server_id: ServerId,
application_id: ApplicationId,
}
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: server_id.into(),
application_id: application_id.into(),
}])?;
Ok(remotes.remove(0))
}
pub fn name(&self) -> &RemoteName {
&self.name
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn server_id(&self) -> &ServerId {
&self.server_id
}
pub fn application_id(&self) -> &ApplicationId {
&self.application_id
}
}
/// An executable and arguments. It is never interpreted by a shell.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EditorCommand {
program: String,
arguments: Vec<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 },
AlreadyConfigured { path: PathBuf },
KeyMaterialNotFound { path: PathBuf },
InvalidKeyMaterial { path: PathBuf },
DuplicateRemote { name: String },
DuplicateCredentialReference,
InvalidRemoteUrl { name: String },
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CurrentDirectory => formatter.write_str("cannot determine the current directory"),
Self::NativeConfigDirectory => {
formatter.write_str("cannot determine the native user configuration directory")
}
Self::NotFound { path } => {
write!(
formatter,
"configuration file not found: {}",
path.display()
)
}
Self::Read { path } => {
write!(
formatter,
"cannot read configuration file: {}",
path.display()
)
}
Self::InvalidFile { path } => write!(
formatter,
"configuration path is not a small regular file: {}",
path.display()
),
Self::Malformed { path } => write!(
formatter,
"configuration is malformed; its contents were redacted: {}",
path.display()
),
Self::UnknownField { field } => {
write!(formatter, "unknown configuration field: {field}")
}
Self::InsecureField { field } => write!(
formatter,
"secret values are forbidden in configuration field: {field}"
),
Self::MissingField { field } => {
write!(formatter, "missing required configuration field: {field}")
}
Self::InvalidField { field } => {
write!(formatter, "invalid configuration field: {field}")
}
Self::VaultUnavailable { path } => write!(
formatter,
"vault folder is missing or inaccessible: {}",
path.display()
),
Self::VaultIsNotDirectory { path } => {
write!(
formatter,
"vault path is not a directory: {}",
path.display()
)
}
Self::Write { path } => {
write!(
formatter,
"cannot update configuration file: {}",
path.display()
)
}
Self::AlreadyConfigured { path } => write!(
formatter,
"configuration already exists and was not replaced: {}",
path.display()
),
Self::KeyMaterialNotFound { path } => write!(
formatter,
"exported key material does not exist: {}",
path.display()
),
Self::InvalidKeyMaterial { path } => write!(
formatter,
"exported key material is not a file or directory: {}",
path.display()
),
Self::DuplicateRemote { name } => {
write!(formatter, "duplicate Git remote name: {name}")
}
Self::DuplicateCredentialReference => {
formatter.write_str("duplicate Git server/application credential reference")
}
Self::InvalidRemoteUrl { name } => write!(
formatter,
"Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment"
),
}
}
}
impl Error for ConfigError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EditorError {
NonUnicode { variable: &'static str },
InvalidCommand { source: &'static str },
}
impl fmt::Display for EditorError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NonUnicode { variable } => {
write!(formatter, "{variable} is not valid Unicode")
}
Self::InvalidCommand { source } => {
write!(
formatter,
"{source} does not contain a valid editor command"
)
}
}
}
}
impl Error for EditorError {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
vault: Option<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(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGitRemote {
name: String,
url: String,
server_id: String,
application_id: String,
}
fn validate_config(
source: PathBuf,
mut document: toml::Value,
raw: RawConfig,
) -> Result<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 git_remotes = validate_remotes(raw.git.remotes)?;
Ok(Config {
source,
document,
vault,
default_key,
key_material,
editor,
clipboard_timeout,
authentication_timeout,
biometric_unlock_enabled,
mobile_appearance,
mobile_tab,
mobile_home_refreshed_at,
watch_shared_totp_entries,
git_identity,
git_remotes,
})
}
fn resolve_required_path(
base: &Path,
value: Option<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>) -> 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)?);
let server_id = ServerId(validate_identifier(
"git.remotes.server_id",
remote.server_id,
)?);
let application_id = ApplicationId(validate_identifier(
"git.remotes.application_id",
remote.application_id,
)?);
if !names.insert(name.clone()) {
return Err(ConfigError::DuplicateRemote {
name: name.0.clone(),
});
}
if !references.insert((server_id.clone(), application_id.clone())) {
return Err(ConfigError::DuplicateCredentialReference);
}
let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
name: name.0.clone(),
})?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidRemoteUrl {
name: name.0.clone(),
});
}
remotes.push(GitRemote {
name,
url,
server_id,
application_id,
});
}
Ok(remotes)
}
fn validate_identifier(field: &'static str, value: String) -> Result<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"],
)?;
}
}
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;
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"));
}
}