Open configured vault folders

This commit is contained in:
2026-08-10 17:21:35 +02:00
parent c301d9a3df
commit 491d7b1557
11 changed files with 712 additions and 19 deletions

View File

@@ -5,10 +5,13 @@ use std::{
env,
error::Error,
fmt, fs,
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;
@@ -20,9 +23,10 @@ const CONFIG_FILE: &str = "config.toml";
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
/// Validated application configuration.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Config {
source: PathBuf,
document: toml::Value,
vault: PathBuf,
default_key: KeyIdentity,
key_material: PathBuf,
@@ -32,6 +36,53 @@ pub struct Config {
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,
}
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> {
@@ -71,6 +122,20 @@ impl Config {
&self.git_remotes
}
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(),
}
}
/// 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> {
@@ -123,6 +188,100 @@ impl Config {
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 vault = vault
.to_str()
.ok_or(ConfigError::InvalidField { field: "vault" })?;
let mut document = self.document.clone();
let root = document
.as_table_mut()
.ok_or_else(|| ConfigError::Malformed {
path: self.source.clone(),
})?;
root.insert("vault".to_owned(), toml::Value::String(vault.to_owned()));
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(),
})
}
}
/// Deterministic path context for configuration loading.
@@ -186,11 +345,12 @@ impl ConfigLoader {
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, raw)
validate_config(source, value, raw)
}
}
@@ -311,7 +471,9 @@ pub enum ConfigError {
InsecureField { field: String },
MissingField { field: &'static str },
InvalidField { field: &'static str },
VaultUnavailable { path: PathBuf },
VaultIsNotDirectory { path: PathBuf },
Write { path: PathBuf },
KeyMaterialNotFound { path: PathBuf },
InvalidKeyMaterial { path: PathBuf },
DuplicateRemote { name: String },
@@ -363,6 +525,11 @@ impl fmt::Display for ConfigError {
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,
@@ -370,6 +537,13 @@ impl fmt::Display for ConfigError {
path.display()
)
}
Self::Write { path } => {
write!(
formatter,
"cannot update configuration file: {}",
path.display()
)
}
Self::KeyMaterialNotFound { path } => write!(
formatter,
"exported key material does not exist: {}",
@@ -463,7 +637,11 @@ struct RawGitRemote {
application_id: String,
}
fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigError> {
fn validate_config(
source: PathBuf,
document: toml::Value,
raw: RawConfig,
) -> Result<Config, ConfigError> {
let base = source
.parent()
.ok_or(ConfigError::InvalidField { field: "source" })?;
@@ -512,6 +690,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
Ok(Config {
source,
document,
vault,
default_key,
key_material,