Some checks failed
Dependency security audit / rustsec (push) Failing after 3s
2482 lines
80 KiB
Rust
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");
|
|
}
|
|
}
|