Open configured vault folders
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
authentication::{
|
||||
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
|
||||
},
|
||||
config::Config,
|
||||
config::{Config, ConfigSettings, EditorCommand},
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService},
|
||||
git::{AutomaticEntryCommitter, GitIdentity},
|
||||
@@ -117,6 +117,54 @@ impl DesktopStorage {
|
||||
self.config.authentication_timeout()
|
||||
}
|
||||
|
||||
pub fn vault(&self) -> &Path {
|
||||
self.config.vault()
|
||||
}
|
||||
|
||||
pub fn config_source(&self) -> &Path {
|
||||
self.config.source()
|
||||
}
|
||||
|
||||
pub fn default_key(&self) -> &str {
|
||||
self.config.default_key().as_str()
|
||||
}
|
||||
|
||||
pub fn configured_editor(&self) -> Option<&EditorCommand> {
|
||||
self.config.configured_editor()
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> ConfigSettings {
|
||||
self.config.settings()
|
||||
}
|
||||
|
||||
pub fn update_settings(&self, mut settings: ConfigSettings) -> Result<Self, DesktopError> {
|
||||
let repository = Repository::open(settings.vault())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
|
||||
settings.set_vault(repository.root_path().to_owned());
|
||||
let config = self
|
||||
.config
|
||||
.with_settings(settings)
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
|
||||
let storage = Self { config };
|
||||
let keys = storage.keys()?;
|
||||
keys.resolve(storage.config.default_key().as_str())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
||||
storage.tree()?;
|
||||
storage
|
||||
.config
|
||||
.persist()
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
/// Validate a selected folder through the same repository and key path as
|
||||
/// normal desktop reads, then atomically update the shared configuration.
|
||||
pub fn switch_vault(&self, vault: &Path) -> Result<Self, DesktopError> {
|
||||
let mut settings = self.settings();
|
||||
settings.set_vault(vault.to_owned());
|
||||
self.update_settings(settings)
|
||||
}
|
||||
|
||||
pub fn tree(&self) -> Result<TreeModel, DesktopError> {
|
||||
let repository = self.repository()?;
|
||||
let keys = self.keys()?;
|
||||
|
||||
@@ -6,6 +6,7 @@ use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
|
||||
use ironstorage::{
|
||||
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
|
||||
config::{ConfigError, ConfigLoader, EditorSource},
|
||||
desktop::DesktopStorage,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -177,6 +178,97 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
|
||||
let selected = fixture.temporary.path().join("selected-vault");
|
||||
fs::create_dir(&selected)?;
|
||||
let keys = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility/keys");
|
||||
let contents = fixture
|
||||
.valid_contents()
|
||||
.replace(
|
||||
"0123456789ABCDEF0123456789ABCDEF01234567",
|
||||
"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30",
|
||||
)
|
||||
.replace(
|
||||
"key_material = \"keys\"",
|
||||
&format!("key_material = {keys:?}"),
|
||||
)
|
||||
.replace(
|
||||
"editor = [\"code\", \"--wait\"]",
|
||||
"editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300",
|
||||
);
|
||||
fixture.write_explicit(&contents)?;
|
||||
|
||||
let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?;
|
||||
let switched = storage.switch_vault(&selected)?;
|
||||
assert_eq!(switched.vault(), fs::canonicalize(&selected)?);
|
||||
|
||||
let mut settings = switched.settings();
|
||||
settings.set_default_key("B37027B56FC406BD3F6A622B2AC03492B992D06F".to_owned());
|
||||
settings.set_editor(Some(vec!["nano".to_owned(), "-w".to_owned()]));
|
||||
settings.set_authentication_timeout(Duration::from_secs(600));
|
||||
let updated = switched.update_settings(settings)?;
|
||||
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(reloaded.vault(), fs::canonicalize(&selected)?);
|
||||
assert_eq!(
|
||||
reloaded.default_key().as_str(),
|
||||
"B37027B56FC406BD3F6A622B2AC03492B992D06F"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded.authentication_timeout().duration(),
|
||||
Duration::from_secs(600)
|
||||
);
|
||||
let editor = reloaded.configured_editor().expect("configured editor");
|
||||
assert_eq!(editor.program(), "nano");
|
||||
assert_eq!(editor.arguments(), ["-w"]);
|
||||
assert_eq!(reloaded.git_remotes().len(), 1);
|
||||
|
||||
let before_rejection = fs::read(fixture.explicit_path())?;
|
||||
let mut invalid = updated.settings();
|
||||
invalid.set_default_key("missing-key".to_owned());
|
||||
assert!(updated.update_settings(invalid).is_err());
|
||||
assert_eq!(fs::read(fixture.explicit_path())?, before_rejection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_vault_switches_leave_configuration_and_session_unchanged() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
|
||||
fixture.write_explicit(fixture.valid_contents())?;
|
||||
let before = fs::read(fixture.explicit_path())?;
|
||||
let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?;
|
||||
let current = storage.vault().to_owned();
|
||||
|
||||
for invalid in [
|
||||
fixture.temporary.path().join("missing"),
|
||||
fixture.temporary.path().join("ordinary-file"),
|
||||
] {
|
||||
if invalid.file_name() == Some(OsStr::new("ordinary-file")) {
|
||||
fs::write(&invalid, b"not a folder")?;
|
||||
}
|
||||
assert!(storage.switch_vault(&invalid).is_err());
|
||||
assert_eq!(storage.vault(), current);
|
||||
assert_eq!(fs::read(fixture.explicit_path())?, before);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let target = fixture.temporary.path().join("symlink-target");
|
||||
let link = fixture.temporary.path().join("symlink-vault");
|
||||
fs::create_dir(&target)?;
|
||||
symlink(&target, &link)?;
|
||||
assert!(storage.switch_vault(&link).is_err());
|
||||
assert_eq!(storage.vault(), current);
|
||||
assert_eq!(fs::read(fixture.explicit_path())?, before);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
|
||||
Reference in New Issue
Block a user