Revamp desktop usability and multiline fields

This commit is contained in:
2026-08-10 22:06:16 +02:00
parent 40b15a9713
commit aeb18b488f
12 changed files with 1283 additions and 545 deletions

View File

@@ -1,6 +1,11 @@
//! Storage-owned service boundary for the Iced desktop presentation adapter.
use std::{error::Error, fmt, path::Path};
use std::{
error::Error,
fmt,
path::Path,
sync::{Arc, OnceLock},
};
use crate::{
authentication::{
@@ -120,6 +125,7 @@ impl Error for DesktopError {}
#[derive(Clone, Debug)]
pub struct DesktopStorage {
config: Config,
keys: Arc<OnceLock<KeyStore>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -299,9 +305,12 @@ impl DesktopMutationRequest {
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))
let config = Config::load(explicit)
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
Ok(Self {
config,
keys: Arc::new(OnceLock::new()),
})
}
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
@@ -309,8 +318,7 @@ impl DesktopStorage {
}
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
let keys = KeyStore::load(self.config.key_material())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let keys = self.keys()?;
let handle = keys
.resolve(self.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
@@ -386,7 +394,10 @@ impl DesktopStorage {
.config
.with_settings(settings)
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
let storage = Self { config };
let storage = Self {
config,
keys: Arc::clone(&self.keys),
};
let keys = storage.keys()?;
keys.resolve(storage.config.default_key().as_str())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
@@ -411,7 +422,7 @@ impl DesktopStorage {
pub fn tree(&self) -> Result<TreeModel, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, &keys)
VaultReader::new(&repository, keys)
.list(&DirectoryPath::root())
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
@@ -505,7 +516,7 @@ impl DesktopStorage {
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, &keys)
VaultReader::new(&repository, keys)
.find(&request.terms)
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
@@ -529,7 +540,7 @@ impl DesktopStorage {
) -> Result<GrepResults, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
VaultReader::new(&repository, &keys)
VaultReader::new(&repository, keys)
.grep(request, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
@@ -557,7 +568,7 @@ impl DesktopStorage {
) -> Result<DesktopOtpCode, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let service = OtpService::new(&repository, &keys);
let service = OtpService::new(&repository, keys);
let uri = service
.uri(entry, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
@@ -582,7 +593,7 @@ impl DesktopStorage {
let tree = changed.then(|| self.tree()).transpose()?;
let document = changed
.then(|| {
EntryDocumentService::new(&repository, &keys)
EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)
})
@@ -617,7 +628,7 @@ impl DesktopStorage {
) -> Result<DesktopOtpUri, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let uri = OtpService::new(&repository, &keys)
let uri = OtpService::new(&repository, keys)
.uri(entry, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let payload = SecretBytes::new(uri.encoded().expose().to_vec());
@@ -680,11 +691,11 @@ impl DesktopStorage {
let encoded = parsed.encoded().expose().to_vec();
let repository = self.repository()?;
let keys = self.keys()?;
let exists = VaultWriter::new(&repository, &keys)
let exists = VaultWriter::new(&repository, keys)
.entry_exists(entry)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
if exists {
let mut document = EntryDocumentService::new(&repository, &keys)
let mut document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
if let Some(field) = otp_field(&document)? {
@@ -708,7 +719,7 @@ impl DesktopStorage {
.map_err(DesktopError::document)?;
}
self.save_document(&document)?;
let document = EntryDocumentService::new(&repository, &keys)
let document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
return Ok(DesktopOtpMutation {
@@ -725,7 +736,7 @@ impl DesktopStorage {
};
let input = OtpInput::line(encoded)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let service = OtpService::new(&repository, &keys);
let service = OtpService::new(&repository, keys);
let plan = service
.prepare_insert(&request, input)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
@@ -741,7 +752,7 @@ impl DesktopStorage {
&mut committer,
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let document = EntryDocumentService::new(&repository, &keys)
let document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
Ok(DesktopOtpMutation {
@@ -770,7 +781,7 @@ impl DesktopStorage {
) -> Result<DesktopOtpMutation, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let mut document = EntryDocumentService::new(&repository, &keys)
let mut document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
let field = otp_field(&document)?.ok_or_else(|| {
@@ -781,7 +792,7 @@ impl DesktopStorage {
})?;
document.remove(field).map_err(DesktopError::document)?;
self.save_document(&document)?;
let document = EntryDocumentService::new(&repository, &keys)
let document = EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
Ok(DesktopOtpMutation {
@@ -818,7 +829,7 @@ impl DesktopStorage {
GitIdentity::ironstorage(),
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
let mutator = TreeMutator::new(&repository, &keys);
let mutator = TreeMutator::new(&repository, keys);
match request {
DesktopMutationRequest::Remove(request) => {
mutator.remove(request, overwrite, &mut committer)
@@ -892,7 +903,7 @@ impl DesktopStorage {
replacement: &replacement.config,
persisted: false,
};
let outcome = RecipientPolicyManager::new(&repository, &keys)
let outcome = RecipientPolicyManager::new(&repository, keys)
.apply_init(request, None, provider, &mut committer)
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
if !committer.persisted {
@@ -922,7 +933,7 @@ impl DesktopStorage {
) -> Result<EntryDocument, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
if VaultWriter::new(&repository, &keys)
if VaultWriter::new(&repository, keys)
.entry_exists(entry)
.map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))?
{
@@ -931,7 +942,7 @@ impl DesktopStorage {
format!("password-store entry already exists: {entry}"),
));
}
EntryDocumentService::new(&repository, &keys)
EntryDocumentService::new(&repository, keys)
.open(entry, provider)
.map_err(DesktopError::document)
}
@@ -943,7 +954,7 @@ impl DesktopStorage {
) -> Result<EntryDocument, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
EntryDocumentService::new(&repository, &keys)
EntryDocumentService::new(&repository, keys)
.open(entry, secrets)
.map_err(DesktopError::document)
}
@@ -955,7 +966,7 @@ impl DesktopStorage {
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage())
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
EntryDocumentService::new(&repository, &keys)
EntryDocumentService::new(&repository, keys)
.save_recoverable(document, None, &mut committer)
.map_err(DesktopError::document)
}
@@ -976,9 +987,17 @@ impl DesktopStorage {
.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))
fn keys(&self) -> Result<&KeyStore, DesktopError> {
if let Some(keys) = self.keys.get() {
return Ok(keys);
}
let keys = KeyStore::load(self.config.key_material())
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
let _already_initialized = self.keys.set(keys);
Ok(self
.keys
.get()
.expect("the key store was initialized by this or another caller"))
}
}

View File

@@ -172,7 +172,6 @@ impl EntryFieldDraft {
pub fn field(name: impl Into<String>, value: Vec<u8>) -> Result<Self, DocumentError> {
let name = name.into();
validate_name(&name)?;
validate_line(&value)?;
let value = SecretBytes::new(value);
let mut contents = Vec::with_capacity(name.len() + value.expose().len() + 2);
contents.extend_from_slice(name.as_bytes());
@@ -197,6 +196,12 @@ impl EntryFieldDraft {
}
}
pub fn multiline(contents: Vec<u8>) -> Self {
Self {
contents: SecretBytes::new(contents),
}
}
fn render(self) -> SecretBytes {
self.contents
}
@@ -283,9 +288,8 @@ impl EntryDocument {
let field = self.field(id).ok_or(DocumentError::UnknownField { id })?;
let draft = match field.metadata().kind() {
EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?,
EntryFieldKind::Password | EntryFieldKind::Note | EntryFieldKind::Blank => {
EntryFieldDraft::line(value)?
}
EntryFieldKind::Password => EntryFieldDraft::line(value)?,
EntryFieldKind::Note | EntryFieldKind::Blank => EntryFieldDraft::multiline(value),
EntryFieldKind::Username
| EntryFieldKind::Email
| EntryFieldKind::Url
@@ -540,7 +544,32 @@ fn parse_lines(contents: &[u8]) -> Vec<EntryField> {
});
start = end;
}
fields
let mut logical = Vec::<EntryField>::new();
for field in fields {
let continuation = classify(logical.len(), field.contents.expose()).kind
== EntryFieldKind::Note
&& logical.last().is_some_and(|previous| {
let metadata =
classify(logical.len().saturating_sub(1), previous.contents.expose());
metadata.name.is_some() && metadata.kind != EntryFieldKind::OtpUri
});
if continuation {
let previous = logical
.last_mut()
.expect("continuation has a previous field");
let mut contents = previous.contents.expose().to_vec();
contents.extend_from_slice(&previous.ending);
contents.extend_from_slice(field.contents.expose());
previous.contents = SecretBytes::new(contents);
previous.ending = field.ending;
} else {
logical.push(field);
}
}
for (id, field) in logical.iter_mut().enumerate() {
field.id = EntryFieldId(id as u64);
}
logical
}
fn classify_all(fields: &mut [EntryField]) {
@@ -595,8 +624,13 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
value: 0..line.len(),
};
}
if let Some(colon) = line.iter().position(|byte| *byte == b':') {
let raw_name = trim_ascii(&line[..colon]);
let first_line_end = line
.iter()
.position(|byte| *byte == b'\n')
.unwrap_or(line.len());
let first_line = &line[..first_line_end];
if let Some(colon) = first_line.iter().position(|byte| *byte == b':') {
let raw_name = trim_ascii(&first_line[..colon]);
if !raw_name.is_empty()
&& let Ok(name) = std::str::from_utf8(raw_name)
{

View File

@@ -79,7 +79,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
document.password().expect("password").value(),
"pässwörd".as_bytes()
);
assert_eq!(document.fields().len(), 12);
assert_eq!(document.fields().len(), 11);
let kinds = document
.fields()
@@ -100,7 +100,6 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
EntryFieldKind::Note,
EntryFieldKind::Note,
EntryFieldKind::Field,
EntryFieldKind::Note,
]
);
assert_eq!(document.fields()[1].metadata().name(), Some("username"));
@@ -125,7 +124,10 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
assert!(document.fields()[5].value().is_empty());
assert_ne!(document.fields()[4].id(), document.fields()[5].id());
assert_eq!(document.fields()[10].metadata().name(), Some(""));
assert_eq!(document.fields()[10].value(), "".as_bytes());
assert_eq!(
document.fields()[10].value(),
"\r\nunrecognized line".as_bytes()
);
assert_eq!(
document.fields()[6].metadata().sensitivity(),
EntrySensitivity::Sensitive
@@ -148,6 +150,36 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
Ok(())
}
#[test]
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let plaintext = b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n";
write_plaintext(&repository, &keys, "documents/multiline", plaintext)?;
let service = EntryDocumentService::new(&repository, &keys);
let mut document = service.open("documents/multiline", &mut secrets)?;
assert_eq!(document.serialize().expose(), plaintext);
assert_eq!(document.fields().len(), 3);
let comments = &document.fields()[1];
assert_eq!(comments.metadata().name(), Some("comments"));
assert_eq!(comments.value(), b"Recovery codes:\none\ntwo");
assert_eq!(
document.copy_field_value(comments.id())?.expose(),
b"Recovery codes:\none\ntwo"
);
document.replace_field_value(comments.id(), b"New heading\nalpha\nbeta".to_vec())?;
assert_eq!(
document.serialize().expose(),
b"password\ncomments: New heading\nalpha\nbeta\nurl: https://example.test\n"
);
Ok(())
}
#[test]
fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> TestResult {
let fixture = FixtureSet::load()?;
@@ -189,7 +221,7 @@ fn field_ids_survive_updates_removal_and_reordering() -> TestResult {
&repository,
&keys,
"documents/editable",
b"password\nusername: alice\nnote one\nnote two\n",
b"password\nusername: alice\nnote: one\nnote: two\n",
)?;
let service = EntryDocumentService::new(&repository, &keys);
let mut document = service.open("documents/editable", &mut secrets)?;
@@ -223,7 +255,7 @@ fn field_ids_survive_updates_removal_and_reordering() -> TestResult {
assert_eq!(document.fields()[2].id(), note_two_id);
assert_eq!(
document.serialize().expose(),
"password\nusername: bob\nnote two\nemail: bob@例.test\n".as_bytes()
"password\nusername: bob\nnote: two\nemail: bob@例.test\n".as_bytes()
);
let mut empty = service.open("documents/missing", &mut secrets)?;