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

482 lines
16 KiB
Rust

//! Storage-owned service boundary for the Iced desktop presentation adapter.
use std::{error::Error, fmt, path::Path};
use crate::{
authentication::{
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
},
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
config::{Config, ConfigSettings, EditorCommand},
crypto::{KeyInfo, KeyStore, SecretProvider},
document::{DocumentError, EntryDocument, EntryDocumentService},
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity},
mutation::{MutationOutcome, TreeMutator},
presentation::ClipboardTimeout,
read::{FindResults, GrepResults, TreeModel, VaultReader},
recipient::{
PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager,
RecipientPolicyOutcome,
},
repository::{DirectoryPath, Repository},
secret_store::SecretProtectionPolicy,
write::{OverwriteDecision, VaultWriter, WriteError, WriteOutcome},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DesktopErrorKind {
Configuration,
Authentication,
KeyMaterial,
Repository,
Read,
Document,
Git,
Conflict,
Unchanged,
MissingDefaultKey,
EntryExists,
Mutation,
}
#[derive(Debug)]
pub struct DesktopError {
kind: DesktopErrorKind,
message: String,
}
impl DesktopError {
pub fn kind(&self) -> DesktopErrorKind {
self.kind
}
fn new(kind: DesktopErrorKind, error: impl fmt::Display) -> Self {
Self {
kind,
message: error.to_string(),
}
}
fn document(error: DocumentError) -> Self {
let kind = match &error {
DocumentError::Write(WriteError::ConcurrentModification { .. }) => {
DesktopErrorKind::Conflict
}
DocumentError::Write(WriteError::Unchanged) => DesktopErrorKind::Unchanged,
_ => DesktopErrorKind::Document,
};
Self::new(kind, error)
}
}
impl fmt::Display for DesktopError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for DesktopError {}
#[derive(Clone, Debug)]
pub struct DesktopStorage {
config: Config,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DesktopMutationRequest {
Remove(RemoveRequest),
Move(MoveRequest),
Copy(CopyRequest),
}
impl DesktopMutationRequest {
pub fn source(&self) -> &str {
match self {
Self::Remove(request) => &request.entry,
Self::Move(request) => &request.source,
Self::Copy(request) => &request.source,
}
}
}
impl DesktopStorage {
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
Config::load(explicit)
.map(|config| Self { config })
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))
}
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
Self::load(None)?.bootstrap()
}
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
let keys = KeyStore::load(self.config.key_material())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let handle = keys
.resolve(self.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let key = keys
.infos()
.find(|key| key.fingerprint() == handle.fingerprint())
.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::MissingDefaultKey,
"the configured GPG key is unavailable",
)
})?;
let authentication = NativeAuthenticationSession::system(
SecretProtectionPolicy::default(),
self.config.authentication_timeout(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
Ok(DesktopBootstrap {
storage: self,
authentication,
key,
})
}
pub fn clipboard_timeout(&self) -> ClipboardTimeout {
self.config.clipboard_timeout()
}
pub fn authentication_timeout(&self) -> AuthenticationTimeout {
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, settings: ConfigSettings) -> Result<Self, DesktopError> {
let storage = self.validate_settings(settings)?;
storage.persist()?;
Ok(storage)
}
pub fn update_settings_and_bootstrap(
&self,
settings: ConfigSettings,
) -> Result<DesktopBootstrap, DesktopError> {
let bootstrap = self.validate_settings(settings)?.bootstrap()?;
bootstrap.storage.persist()?;
Ok(bootstrap)
}
fn validate_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()?;
Ok(storage)
}
fn persist(&self) -> Result<(), DesktopError> {
self.config
.persist()
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))
}
/// 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()?;
VaultReader::new(&repository, &keys)
.list(&DirectoryPath::root())
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, &keys)
.find(&request.terms)
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn grep_active(
&self,
handle: &NativeAuthenticationHandle,
request: &GrepRequest,
) -> Result<GrepResults, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.grep(request, &mut provider)
}
pub fn grep(
&self,
request: &GrepRequest,
provider: &mut impl SecretProvider,
) -> Result<GrepResults, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, &keys)
.grep(request, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn mutate_active(
&self,
handle: &NativeAuthenticationHandle,
request: &DesktopMutationRequest,
overwrite: OverwriteDecision,
) -> Result<MutationOutcome, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.mutate(request, overwrite, &mut provider)
}
pub fn mutate(
&self,
request: &DesktopMutationRequest,
overwrite: OverwriteDecision,
provider: &mut impl SecretProvider,
) -> Result<MutationOutcome, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let mut committer = AutomaticTreeCommitter::for_source(
&repository,
request.source(),
GitIdentity::ironstorage(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
let mutator = TreeMutator::new(&repository, &keys);
match request {
DesktopMutationRequest::Remove(request) => {
mutator.remove(request, overwrite, &mut committer)
}
DesktopMutationRequest::Move(request) => {
mutator.move_tree(request, overwrite, None, provider, &mut committer)
}
DesktopMutationRequest::Copy(request) => {
mutator.copy(request, overwrite, None, provider, &mut committer)
}
}
.map_err(|error| DesktopError::new(DesktopErrorKind::Mutation, error))
}
/// Enumerate storage-validated encryption keys for recipient selection.
pub fn key_infos(&self) -> Result<Vec<KeyInfo>, DesktopError> {
Ok(self.keys()?.infos().filter(KeyInfo::can_encrypt).collect())
}
/// Apply an upstream-compatible root or nested `.gpg-id` policy while the
/// shared authentication lease is active.
pub fn apply_active_recipient_policy(
&self,
handle: &NativeAuthenticationHandle,
request: &InitRequest,
default_key: &str,
) -> Result<(Self, RecipientPolicyOutcome), DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.apply_recipient_policy(request, default_key, &mut provider)
}
pub fn apply_recipient_policy(
&self,
request: &InitRequest,
default_key: &str,
provider: &mut impl SecretProvider,
) -> Result<(Self, RecipientPolicyOutcome), DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let default = keys
.infos()
.find(|key| key.fingerprint().as_str() == default_key)
.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::MissingDefaultKey,
"the selected default GPG key is unavailable",
)
})?;
if !default.has_secret() {
return Err(DesktopError::new(
DesktopErrorKind::MissingDefaultKey,
"the selected default GPG key has no secret key material",
));
}
let mut settings = self.settings();
settings.set_default_key(default_key.to_owned());
let replacement = self.validate_settings(settings)?;
let directory = request.path.as_deref().unwrap_or_default();
let committer = AutomaticPolicyCommitter::for_directory(
&repository,
directory,
GitIdentity::ironstorage(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
let mut committer = ConfigPolicyCommitter {
git: committer,
previous: &self.config,
replacement: &replacement.config,
persisted: false,
};
let outcome = RecipientPolicyManager::new(&repository, &keys)
.apply_init(request, None, provider, &mut committer)
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
if !committer.persisted {
replacement.persist()?;
}
Ok((replacement, outcome))
}
/// Open a collision-free in-memory draft. No repository bytes are written
/// until the structured document is explicitly saved.
pub fn create_active_document(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
) -> Result<EntryDocument, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.create_document(entry, &mut provider)
}
pub fn create_document(
&self,
entry: &str,
provider: &mut impl SecretProvider,
) -> Result<EntryDocument, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
if VaultWriter::new(&repository, &keys)
.entry_exists(entry)
.map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))?
{
return Err(DesktopError::new(
DesktopErrorKind::EntryExists,
format!("password-store entry already exists: {entry}"),
));
}
EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)
}
pub fn open_document(
&self,
entry: &str,
secrets: &mut impl SecretProvider,
) -> Result<EntryDocument, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
EntryDocumentService::new(&repository, &keys)
.open(entry, secrets)
.map_err(DesktopError::document)
}
pub fn save_document(&self, document: &EntryDocument) -> Result<WriteOutcome, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let entry = document.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage())
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
EntryDocumentService::new(&repository, &keys)
.save_recoverable(document, None, &mut committer)
.map_err(DesktopError::document)
}
pub fn save_active_document(
&self,
handle: &NativeAuthenticationHandle,
document: &EntryDocument,
) -> Result<WriteOutcome, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
self.save_document(document)
}
fn repository(&self) -> Result<Repository, DesktopError> {
Repository::open(self.config.vault())
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))
}
fn keys(&self) -> Result<KeyStore, DesktopError> {
KeyStore::load(self.config.key_material())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))
}
}
struct ConfigPolicyCommitter<'a> {
git: AutomaticPolicyCommitter,
previous: &'a Config,
replacement: &'a Config,
persisted: bool,
}
impl PolicyCommitter for ConfigPolicyCommitter<'_> {
fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> {
self.replacement
.persist()
.map_err(|error| PolicyCommitError::new(error.to_string()))?;
match self.git.commit(change) {
Ok(()) => {
self.persisted = true;
Ok(())
}
Err(operation) => match self.previous.persist() {
Ok(()) => Err(operation),
Err(rollback) => Err(PolicyCommitError::new(format!(
"recipient-policy commit failed ({operation}) and configuration rollback failed ({rollback})"
))),
},
}
}
}
pub struct DesktopBootstrap {
storage: DesktopStorage,
authentication: NativeAuthenticationSession,
key: KeyInfo,
}
impl DesktopBootstrap {
pub fn into_parts(self) -> (DesktopStorage, NativeAuthenticationSession, KeyInfo) {
(self.storage, self.authentication, self.key)
}
}