From 2aabd589de624cecf86225abd1e2a1542fc7ff03 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 02:04:00 +0000 Subject: [PATCH] Expose structured entry documents --- README.md | 3 + crates/storage/src/document.rs | 565 ++++++++++++++++++++++++ crates/storage/src/lib.rs | 1 + crates/storage/src/write.rs | 4 + crates/storage/tests/entry_documents.rs | 319 +++++++++++++ docs/entry-documents.md | 43 ++ 6 files changed, 935 insertions(+) create mode 100644 crates/storage/src/document.rs create mode 100644 crates/storage/tests/entry_documents.rs create mode 100644 docs/entry-documents.md diff --git a/README.md b/README.md index f07e5d3..6552468 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ documented in [`docs/otp.md`](docs/otp.md). The complete command matrix, shell completion interface, deliberate no-process differences, and executable security audit are documented in [`docs/cli-parity.md`](docs/cli-parity.md). +Lossless structured entry fields, semantic/sensitivity metadata, conflict +tokens, and atomic frontend saves are documented in +[`docs/entry-documents.md`](docs/entry-documents.md). The capability-scoped password-store layout and atomic mutation guarantees are documented in [`docs/repository-core.md`](docs/repository-core.md). The embedded OpenPGP backend, exported-key model, secret-provider boundary, and diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs new file mode 100644 index 0000000..0916188 --- /dev/null +++ b/crates/storage/src/document.rs @@ -0,0 +1,565 @@ +//! Lossless, presentation-neutral documents for arbitrary pass entries. + +use std::{error::Error, fmt, ops::Range}; + +use sha2::{Digest as _, Sha256}; + +use crate::{ + command::EditRequest, + crypto::{KeyStore, SecretProvider}, + otp::OtpUri, + recipient::SigningPolicy, + repository::{EntryPath, Repository, SecretBytes}, + write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome}, +}; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EntryFieldId(u64); + +impl EntryFieldId { + pub fn value(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EntryFieldKind { + Password, + Username, + Email, + Url, + OtpUri, + Field, + Note, + Blank, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EntrySensitivity { + Sensitive, + Ordinary, + Empty, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryFieldMetadata { + kind: EntryFieldKind, + sensitivity: EntrySensitivity, + name: Option, + value: Range, +} + +impl EntryFieldMetadata { + pub fn kind(&self) -> EntryFieldKind { + self.kind + } + + pub fn sensitivity(&self) -> EntrySensitivity { + self.sensitivity + } + + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } +} + +pub struct EntryField { + id: EntryFieldId, + contents: SecretBytes, + ending: Vec, + metadata: EntryFieldMetadata, +} + +impl EntryField { + pub fn id(&self) -> EntryFieldId { + self.id + } + + pub fn contents(&self) -> &SecretBytes { + &self.contents + } + + pub fn value(&self) -> &[u8] { + &self.contents.expose()[self.metadata.value.clone()] + } + + pub fn metadata(&self) -> &EntryFieldMetadata { + &self.metadata + } +} + +impl fmt::Debug for EntryField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EntryField") + .field("id", &self.id) + .field("metadata", &self.metadata) + .field("contents", &"[REDACTED]") + .finish() + } +} + +pub struct EntryFieldDraft { + contents: SecretBytes, +} + +impl EntryFieldDraft { + pub fn line(contents: Vec) -> Result { + validate_line(&contents)?; + Ok(Self { + contents: SecretBytes::new(contents), + }) + } + + pub fn field(name: impl Into, value: Vec) -> Result { + 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()); + contents.extend_from_slice(b": "); + contents.extend_from_slice(value.expose()); + Ok(Self { + contents: SecretBytes::new(contents), + }) + } + + pub fn otp_uri(uri: Vec) -> Result { + validate_line(&uri)?; + let encoded = SecretBytes::new(uri); + OtpUri::parse(SecretBytes::new(encoded.expose().to_vec())) + .map_err(|_| DocumentError::InvalidOtpUri)?; + Ok(Self { contents: encoded }) + } + + pub fn blank() -> Self { + Self { + contents: SecretBytes::new(Vec::new()), + } + } + + fn render(self) -> SecretBytes { + self.contents + } +} + +impl fmt::Debug for EntryFieldDraft { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EntryFieldDraft([REDACTED])") + } +} + +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct DocumentConflictToken([u8; 32]); + +impl DocumentConflictToken { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for DocumentConflictToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DocumentConflictToken([REDACTED])") + } +} + +pub struct EntryDocument { + path: EntryPath, + fields: Vec, + next_id: u64, + conflict_token: DocumentConflictToken, + session: EditSession, +} + +impl EntryDocument { + fn from_session(session: EditSession) -> Self { + let path = session.path().clone(); + let conflict_token = conflict_token(&session); + let mut fields = parse_lines(session.plaintext().expose()); + classify_all(&mut fields); + let next_id = fields.len() as u64; + Self { + path, + fields, + next_id, + conflict_token, + session, + } + } + + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn fields(&self) -> &[EntryField] { + &self.fields + } + + pub fn field(&self, id: EntryFieldId) -> Option<&EntryField> { + self.fields.iter().find(|field| field.id == id) + } + + pub fn password(&self) -> Option<&EntryField> { + self.fields.first() + } + + pub fn conflict_token(&self) -> DocumentConflictToken { + self.conflict_token + } + + pub fn add( + &mut self, + index: usize, + draft: EntryFieldDraft, + ) -> Result { + if index > self.fields.len() { + return Err(DocumentError::InvalidIndex { index }); + } + let final_newline = self.has_final_newline(); + let default_ending = self.default_ending(); + let id = EntryFieldId(self.next_id); + self.next_id = self + .next_id + .checked_add(1) + .ok_or(DocumentError::FieldIdExhausted)?; + self.fields.insert( + index, + EntryField { + id, + contents: draft.render(), + ending: default_ending.clone(), + metadata: blank_metadata(), + }, + ); + normalize_endings(&mut self.fields, &default_ending, final_newline); + classify_all(&mut self.fields); + Ok(id) + } + + pub fn update( + &mut self, + id: EntryFieldId, + draft: EntryFieldDraft, + ) -> Result<(), DocumentError> { + let field = self + .fields + .iter_mut() + .find(|field| field.id == id) + .ok_or(DocumentError::UnknownField { id })?; + field.contents = draft.render(); + classify_all(&mut self.fields); + Ok(()) + } + + pub fn remove(&mut self, id: EntryFieldId) -> Result { + let index = self + .fields + .iter() + .position(|field| field.id == id) + .ok_or(DocumentError::UnknownField { id })?; + let final_newline = self.has_final_newline(); + let default_ending = self.default_ending(); + let removed = self.fields.remove(index); + normalize_endings(&mut self.fields, &default_ending, final_newline); + classify_all(&mut self.fields); + Ok(removed) + } + + pub fn reorder(&mut self, id: EntryFieldId, index: usize) -> Result<(), DocumentError> { + if index >= self.fields.len() { + return Err(DocumentError::InvalidIndex { index }); + } + let current = self + .fields + .iter() + .position(|field| field.id == id) + .ok_or(DocumentError::UnknownField { id })?; + if current == index { + return Ok(()); + } + let final_newline = self.has_final_newline(); + let default_ending = self.default_ending(); + let field = self.fields.remove(current); + self.fields.insert(index, field); + normalize_endings(&mut self.fields, &default_ending, final_newline); + classify_all(&mut self.fields); + Ok(()) + } + + pub fn serialize(&self) -> SecretBytes { + let capacity = self + .fields + .iter() + .map(|field| field.contents.expose().len() + field.ending.len()) + .sum(); + let mut output = Vec::with_capacity(capacity); + for field in &self.fields { + output.extend_from_slice(field.contents.expose()); + output.extend_from_slice(&field.ending); + } + SecretBytes::new(output) + } + + fn has_final_newline(&self) -> bool { + self.fields + .last() + .is_some_and(|field| !field.ending.is_empty()) + } + + fn default_ending(&self) -> Vec { + self.fields + .iter() + .find(|field| !field.ending.is_empty()) + .map(|field| field.ending.clone()) + .unwrap_or_else(|| b"\n".to_vec()) + } +} + +impl fmt::Debug for EntryDocument { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EntryDocument") + .field("path", &self.path) + .field("field_count", &self.fields.len()) + .field("conflict_token", &self.conflict_token) + .finish_non_exhaustive() + } +} + +pub struct EntryDocumentService<'a> { + repository: &'a Repository, + keys: &'a KeyStore, +} + +impl<'a> EntryDocumentService<'a> { + pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { + Self { repository, keys } + } + + pub fn open( + &self, + entry: &str, + secrets: &mut impl SecretProvider, + ) -> Result { + let session = VaultWriter::new(self.repository, self.keys).begin_edit( + &EditRequest { + entry: entry.to_owned(), + }, + secrets, + )?; + Ok(EntryDocument::from_session(session)) + } + + pub fn save( + &self, + document: EntryDocument, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result { + let replacement = document.serialize(); + Ok(VaultWriter::new(self.repository, self.keys).finish_edit( + document.session, + replacement, + "IronStorage structured editor", + signing, + committer, + )?) + } +} + +#[derive(Debug)] +pub enum DocumentError { + Write(WriteError), + InvalidIndex { index: usize }, + UnknownField { id: EntryFieldId }, + LineBreak, + InvalidFieldName, + InvalidOtpUri, + FieldIdExhausted, +} + +impl fmt::Display for DocumentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Write(error) => error.fmt(formatter), + Self::InvalidIndex { index } => write!(formatter, "invalid entry field index: {index}"), + Self::UnknownField { id } => write!(formatter, "unknown entry field: {}", id.value()), + Self::LineBreak => formatter.write_str("an entry field may not contain a line break"), + Self::InvalidFieldName => formatter.write_str("entry field name is invalid"), + Self::InvalidOtpUri => formatter.write_str("entry OTP URI is invalid"), + Self::FieldIdExhausted => formatter.write_str("entry field identifiers are exhausted"), + } + } +} + +impl Error for DocumentError {} + +impl From for DocumentError { + fn from(error: WriteError) -> Self { + Self::Write(error) + } +} + +fn parse_lines(contents: &[u8]) -> Vec { + let mut fields = Vec::new(); + let mut start = 0; + while start < contents.len() { + let newline = contents[start..] + .iter() + .position(|byte| *byte == b'\n') + .map(|offset| start + offset); + let (content_end, end, ending) = match newline { + Some(newline) if newline > start && contents[newline - 1] == b'\r' => { + (newline - 1, newline + 1, b"\r\n".to_vec()) + } + Some(newline) => (newline, newline + 1, b"\n".to_vec()), + None => (contents.len(), contents.len(), Vec::new()), + }; + fields.push(EntryField { + id: EntryFieldId(fields.len() as u64), + contents: SecretBytes::new(contents[start..content_end].to_vec()), + ending, + metadata: blank_metadata(), + }); + start = end; + } + fields +} + +fn classify_all(fields: &mut [EntryField]) { + for (index, field) in fields.iter_mut().enumerate() { + field.metadata = classify(index, field.contents.expose()); + } +} + +fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { + if index == 0 { + return EntryFieldMetadata { + kind: EntryFieldKind::Password, + sensitivity: EntrySensitivity::Sensitive, + name: Some("password".to_owned()), + value: 0..line.len(), + }; + } + if line.is_empty() { + return blank_metadata(); + } + if line.starts_with(b"otpauth://") && OtpUri::parse(SecretBytes::new(line.to_vec())).is_ok() { + return EntryFieldMetadata { + kind: EntryFieldKind::OtpUri, + sensitivity: EntrySensitivity::Sensitive, + name: Some("otp".to_owned()), + value: 0..line.len(), + }; + } + if let Some(colon) = line.iter().position(|byte| *byte == b':') { + let raw_name = trim_ascii(&line[..colon]); + if !raw_name.is_empty() + && let Ok(name) = std::str::from_utf8(raw_name) + { + let value_start = colon + 1 + usize::from(line.get(colon + 1) == Some(&b' ')); + let kind = semantic_field_kind(name); + return EntryFieldMetadata { + kind, + sensitivity: field_sensitivity(name, kind), + name: Some(name.to_owned()), + value: value_start..line.len(), + }; + } + } + EntryFieldMetadata { + kind: EntryFieldKind::Note, + sensitivity: EntrySensitivity::Sensitive, + name: None, + value: 0..line.len(), + } +} + +fn semantic_field_kind(name: &str) -> EntryFieldKind { + match name.trim().to_ascii_lowercase().as_str() { + "user" | "username" | "login" => EntryFieldKind::Username, + "email" | "e-mail" => EntryFieldKind::Email, + "url" | "uri" | "website" => EntryFieldKind::Url, + _ => EntryFieldKind::Field, + } +} + +fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity { + if matches!( + kind, + EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url + ) { + return EntrySensitivity::Ordinary; + } + match name.trim().to_ascii_lowercase().as_str() { + "title" | "site" | "host" => EntrySensitivity::Ordinary, + _ => EntrySensitivity::Sensitive, + } +} + +fn blank_metadata() -> EntryFieldMetadata { + EntryFieldMetadata { + kind: EntryFieldKind::Blank, + sensitivity: EntrySensitivity::Empty, + name: None, + value: 0..0, + } +} + +fn trim_ascii(mut value: &[u8]) -> &[u8] { + while value.first().is_some_and(u8::is_ascii_whitespace) { + value = &value[1..]; + } + while value.last().is_some_and(u8::is_ascii_whitespace) { + value = &value[..value.len() - 1]; + } + value +} + +fn validate_line(value: &[u8]) -> Result<(), DocumentError> { + if value.iter().any(|byte| matches!(byte, b'\r' | b'\n')) { + Err(DocumentError::LineBreak) + } else { + Ok(()) + } +} + +fn validate_name(name: &str) -> Result<(), DocumentError> { + if name.trim().is_empty() || name.contains([':', '\r', '\n']) || name.trim() != name { + Err(DocumentError::InvalidFieldName) + } else { + Ok(()) + } +} + +fn normalize_endings(fields: &mut [EntryField], default: &[u8], final_newline: bool) { + let last = fields.len().saturating_sub(1); + for (index, field) in fields.iter_mut().enumerate() { + if index < last && field.ending.is_empty() { + field.ending = default.to_vec(); + } else if index == last { + if final_newline && field.ending.is_empty() { + field.ending = default.to_vec(); + } else if !final_newline { + field.ending.clear(); + } + } + } +} + +fn conflict_token(session: &EditSession) -> DocumentConflictToken { + let mut digest = Sha256::new(); + digest.update(b"ironstorage-entry-document-v1\0"); + digest.update(session.path().to_string().as_bytes()); + digest.update(b"\0"); + match session.original_ciphertext() { + Some(ciphertext) => { + digest.update(b"present\0"); + digest.update(ciphertext.as_bytes()); + } + None => digest.update(b"missing\0"), + } + DocumentConflictToken(digest.finalize().into()) +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b3d142a..f37bc8f 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,6 +8,7 @@ pub mod command; pub mod config; pub mod crypto; +pub mod document; pub mod generate; pub mod git; pub mod mutation; diff --git a/crates/storage/src/write.rs b/crates/storage/src/write.rs index 3576bfd..c366a89 100644 --- a/crates/storage/src/write.rs +++ b/crates/storage/src/write.rs @@ -180,6 +180,10 @@ impl EditSession { pub fn plaintext(&self) -> &SecretBytes { &self.plaintext } + + pub(crate) fn original_ciphertext(&self) -> Option<&EncryptedEntry> { + self.original_ciphertext.as_ref() + } } impl fmt::Debug for EditSession { diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs new file mode 100644 index 0000000..f863a58 --- /dev/null +++ b/crates/storage/tests/entry_documents.rs @@ -0,0 +1,319 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::collections::{BTreeMap, BTreeSet}; + +use ironstorage::{ + crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + document::{ + DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity, + }, + recipient::RecipientPolicyManager, + repository::{EntryPath, Repository, SecretBytes}, + write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError}, +}; +use support::compatibility::{FixtureSet, TestResult}; + +struct FixtureSecrets(BTreeMap>); + +impl FixtureSecrets { + fn all(fixture: &FixtureSet) -> Self { + Self( + fixture + .generated + .keys + .iter() + .map(|key| { + ( + key.primary_fingerprint.clone(), + key.passphrase.as_bytes().to_vec(), + ) + }) + .collect(), + ) + } +} + +impl SecretProvider for FixtureSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.0 + .get(key.fingerprint().as_str()) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretProviderError::Unavailable) + } +} + +#[derive(Default)] +struct Committer { + changes: Vec, + fail: bool, +} + +impl EntryCommitter for Committer { + fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { + self.changes.push(change.clone()); + if self.fail { + Err(EntryCommitError::new("simulated commit failure")) + } else { + Ok(()) + } + } +} + +#[test] +fn complex_documents_round_trip_with_storage_owned_metadata() -> 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"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line"; + write_plaintext(&repository, &keys, "documents/complex", plaintext)?; + + let service = EntryDocumentService::new(&repository, &keys); + let document = service.open("documents/complex", &mut secrets)?; + assert_eq!(document.serialize().expose(), plaintext); + assert_eq!( + document.password().expect("password").value(), + "pässwörd".as_bytes() + ); + assert_eq!(document.fields().len(), 10); + + let kinds = document + .fields() + .iter() + .map(|field| field.metadata().kind()) + .collect::>(); + assert_eq!( + kinds, + [ + EntryFieldKind::Password, + EntryFieldKind::Username, + EntryFieldKind::Field, + EntryFieldKind::Field, + EntryFieldKind::OtpUri, + EntryFieldKind::Blank, + EntryFieldKind::Note, + EntryFieldKind::Note, + EntryFieldKind::Field, + EntryFieldKind::Note, + ] + ); + assert_eq!(document.fields()[1].metadata().name(), Some("username")); + assert_eq!( + document.fields()[1].metadata().sensitivity(), + EntrySensitivity::Ordinary + ); + assert_eq!(document.fields()[2].metadata().name(), Some("custom")); + assert_eq!(document.fields()[2].value(), b"one"); + assert_eq!(document.fields()[3].metadata().name(), Some("custom")); + assert!(document.fields()[3].value().is_empty()); + assert_ne!(document.fields()[2].id(), document.fields()[3].id()); + assert_eq!(document.fields()[8].metadata().name(), Some("鍵")); + assert_eq!(document.fields()[8].value(), "值".as_bytes()); + assert_eq!( + document.fields()[4].metadata().sensitivity(), + EntrySensitivity::Sensitive + ); + assert!(!format!("{document:?}").contains("pässwörd")); + assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP")); + Ok(()) +} + +#[test] +fn field_ids_survive_updates_removal_and_reordering() -> 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); + write_plaintext( + &repository, + &keys, + "documents/editable", + b"password\nusername: alice\nnote one\nnote two\n", + )?; + let service = EntryDocumentService::new(&repository, &keys); + let mut document = service.open("documents/editable", &mut secrets)?; + let password_id = document.fields()[0].id(); + let username_id = document.fields()[1].id(); + let note_one_id = document.fields()[2].id(); + let note_two_id = document.fields()[3].id(); + let token = document.conflict_token(); + + document.update( + username_id, + EntryFieldDraft::field("username", b"bob".to_vec())?, + )?; + let email_id = document.add( + 2, + EntryFieldDraft::field("email", "bob@例.test".as_bytes().to_vec())?, + )?; + document.reorder(note_two_id, 2)?; + let removed = document.remove(note_one_id)?; + assert_eq!(removed.id(), note_one_id); + assert_eq!(document.conflict_token(), token); + assert_eq!(document.fields()[0].id(), password_id); + assert_eq!( + document.field(username_id).expect("username").value(), + b"bob" + ); + assert_eq!( + document.field(email_id).expect("email").metadata().kind(), + EntryFieldKind::Email + ); + 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() + ); + + let mut empty = service.open("documents/missing", &mut secrets)?; + assert!(empty.fields().is_empty()); + assert!(empty.password().is_none()); + empty.add(0, EntryFieldDraft::line(b"password".to_vec())?)?; + empty.add(1, EntryFieldDraft::blank())?; + assert_eq!(empty.fields().len(), 2); + assert_eq!(empty.fields()[1].metadata().kind(), EntryFieldKind::Blank); + assert_eq!(empty.serialize().expose(), b"password\n"); + + assert!(matches!( + EntryFieldDraft::line(b"two\nlines".to_vec()), + Err(DocumentError::LineBreak) + )); + assert!(matches!( + EntryFieldDraft::field(" invalid ", Vec::new()), + Err(DocumentError::InvalidFieldName) + )); + assert!(matches!( + EntryFieldDraft::otp_uri(b"otpauth://totp/missing-secret".to_vec()), + Err(DocumentError::InvalidOtpUri) + )); + Ok(()) +} + +#[test] +fn atomic_save_creates_updates_and_rejects_stale_documents() -> 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 service = EntryDocumentService::new(&repository, &keys); + let mut committer = Committer::default(); + + let mut created = service.open("documents/new", &mut secrets)?; + created.add(0, EntryFieldDraft::line(b"new password".to_vec())?)?; + created.add(1, EntryFieldDraft::field("username", b"alice".to_vec())?)?; + service.save(created, None, &mut committer)?; + assert_eq!(committer.changes.len(), 1); + assert_eq!( + decrypt(&repository, &keys, "documents/new", &mut secrets)?.expose(), + b"new password\nusername: alice" + ); + + let mut first = service.open("documents/new", &mut secrets)?; + let mut stale = service.open("documents/new", &mut secrets)?; + assert_eq!(first.conflict_token(), stale.conflict_token()); + let first_password = first.password().expect("password").id(); + first.update(first_password, EntryFieldDraft::line(b"winner".to_vec())?)?; + service.save(first, None, &mut committer)?; + let winner = repository.read_entry(&EntryPath::parse("documents/new")?)?; + + let stale_password = stale.password().expect("password").id(); + stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?; + assert!(matches!( + service.save(stale, None, &mut committer), + Err(DocumentError::Write( + WriteError::ConcurrentModification { .. } + )) + )); + assert_eq!( + repository.read_entry(&EntryPath::parse("documents/new")?)?, + winner + ); + + let mut rollback = service.open("documents/new", &mut secrets)?; + let password = rollback.password().expect("password").id(); + rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?; + committer.fail = true; + assert!(matches!( + service.save(rollback, None, &mut committer), + Err(DocumentError::Write(WriteError::Commit(_))) + )); + assert_eq!( + repository.read_entry(&EntryPath::parse("documents/new")?)?, + winner + ); + Ok(()) +} + +#[test] +fn every_compatibility_entry_round_trips_as_a_document() -> TestResult { + let fixture = FixtureSet::load()?; + let stores = fixture + .generated + .entries + .iter() + .map(|entry| entry.store.as_str()) + .collect::>(); + for store_id in stores { + let store = fixture.materialize_store(store_id)?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + let service = EntryDocumentService::new(&repository, &keys); + for entry in fixture + .generated + .entries + .iter() + .filter(|entry| entry.store == store_id) + { + let logical = entry + .path + .strip_suffix(".gpg") + .expect("fixture entry suffix"); + let document = service.open(logical, &mut secrets)?; + assert_eq!( + document.serialize().expose(), + fixture.read( + std::path::Path::new("expected") + .join(store_id) + .join(&entry.plaintext), + )?, + "document round trip for {}/{}", + store_id, + entry.path + ); + } + } + Ok(()) +} + +fn write_plaintext( + repository: &Repository, + keys: &KeyStore, + path: &str, + plaintext: &[u8], +) -> TestResult { + let path = EntryPath::parse(path)?; + let recipients = + RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?; + let encrypted = keys.encrypt( + SecretBytes::new(plaintext.to_vec()), + recipients.recipients(), + )?; + repository.write_entry(&path, &encrypted)?; + Ok(()) +} + +fn decrypt( + repository: &Repository, + keys: &KeyStore, + path: &str, + secrets: &mut impl SecretProvider, +) -> Result> { + Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, secrets)?) +} diff --git a/docs/entry-documents.md b/docs/entry-documents.md new file mode 100644 index 0000000..292d4cf --- /dev/null +++ b/docs/entry-documents.md @@ -0,0 +1,43 @@ +# Structured entry documents + +`crates/storage::document` exposes arbitrary password-store entries as ordered, +presentation-neutral documents. Frontends supply a logical entry path and a +secret provider to `EntryDocumentService::open`; storage resolves and reads the +entry, decrypts it, parses it, and returns fields with stable in-document IDs. +Frontends never inspect `.gpg` files, select recipients, parse entry text, or +write the repository. + +The first physical line is always the conventional password field. Every later +physical line remains independently addressable and is classified as a dynamic +`name: value` field, valid `otpauth://` URI, blank line, or free-form note. +Recognized username, email and URL names receive semantic kinds. Passwords, +OTP URIs, notes and unknown/custom values are sensitive by default; ordinary +identity/navigation fields and blank lines receive separate sensitivity +metadata. Duplicate names are retained as distinct fields in their original +order. Unicode names and values are supported, while non-UTF-8 or otherwise +unrecognized lines remain lossless notes. + +Line contents and their LF, CRLF or absent final endings are retained exactly. +Parsing and serializing an untouched document therefore returns byte-identical +plaintext, including mixed endings, empty values, blank lines and a missing +first line. Updates replace only the selected line. Structural operations keep +untouched line bytes and preserve the document's final-newline convention; +new dynamic fields serialize as the upstream-compatible `name: value` form. +No IDs or metadata are written to the password entry, so this does not create a +new on-disk format. IDs are stable for the lifetime of the opened document and +remain attached to fields through update and reorder operations. + +Documents support indexed add, ID-based update and removal, ID-based reorder, +lossless serialization and a redacted conflict token derived from the original +ciphertext. `EntryDocumentService::save` consumes the edited document and uses +the existing `VaultWriter` edit session. It rechecks the original ciphertext, +resolves the current recipient policy in storage, encrypts, atomically replaces +the `.gpg` entry, and performs the storage mutation commit. Concurrent changes +are rejected before writing. A commit failure restores the original encrypted +entry, so clients never receive a successful save for an uncommitted or partial +mutation. + +Compatibility tests round-trip every checked-in upstream-compatible fixture +and separately cover duplicate keys, Unicode, CRLF, multiline notes, empty +values, OTP URIs, blank and missing first lines, stable IDs, reorder, creation, +stale documents and commit rollback.