Revamp desktop usability and multiline fields
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user