Implement the in-process structured entry editor

Closes #21
This commit is contained in:
Hermes Agent
2026-08-10 07:22:55 +00:00
parent cf7a6216ac
commit b503de8b2e
14 changed files with 1547 additions and 37 deletions

View File

@@ -262,6 +262,33 @@ impl EntryDocument {
.ok_or(DocumentError::UnknownField { id })
}
/// Replace only a structured field's value while preserving its storage-
/// supplied name and syntax.
pub fn replace_field_value(
&mut self,
id: EntryFieldId,
value: Vec<u8>,
) -> Result<(), DocumentError> {
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::Username
| EntryFieldKind::Email
| EntryFieldKind::Url
| EntryFieldKind::Field => EntryFieldDraft::field(
field
.metadata()
.name()
.ok_or(DocumentError::InvalidFieldName)?,
value,
)?,
};
self.update(id, draft)
}
pub fn conflict_token(&self) -> DocumentConflictToken {
self.conflict_token
}
@@ -424,6 +451,26 @@ impl<'a> EntryDocumentService<'a> {
committer,
)?)
}
/// Save without consuming the document, allowing an in-process editor to
/// present a conflict or encryption failure and retain the user's draft.
pub fn save_recoverable(
&self,
document: &EntryDocument,
signing: Option<&SigningPolicy>,
committer: &mut impl EntryCommitter,
) -> Result<WriteOutcome, DocumentError> {
let replacement = document.serialize();
Ok(
VaultWriter::new(self.repository, self.keys).finish_edit_recoverable(
&document.session,
replacement,
"IronStorage structured editor",
signing,
committer,
)?,
)
}
}
#[derive(Debug)]

View File

@@ -46,6 +46,31 @@ impl GeneratorConfig {
pub fn character_set(&self) -> &[char] {
&self.character_set
}
/// Generate a zeroizing password for an in-process structured editor
/// without mutating a repository.
pub fn generate_secret(
&self,
length: Option<NonZeroUsize>,
no_symbols: bool,
) -> Result<SecretBytes, GenerateError> {
self.generate_secret_with_rng(length, no_symbols, &mut OsRng)
}
fn generate_secret_with_rng<R: RngCore + CryptoRng>(
&self,
length: Option<NonZeroUsize>,
no_symbols: bool,
rng: &mut R,
) -> Result<SecretBytes, GenerateError> {
let length = validate_length(length.unwrap_or(self.default_length).get())?;
let characters = if no_symbols {
&self.alphanumeric_set
} else {
&self.character_set
};
generate_password(rng, length, characters)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -126,14 +151,9 @@ impl<'a> PasswordGenerator<'a> {
if request.force && request.in_place {
return Err(GenerateError::IncompatibleFlags);
}
let length = request.length.unwrap_or(self.config.default_length);
let length = validate_length(length.get())?;
let characters = if request.no_symbols {
&self.config.alphanumeric_set
} else {
&self.config.character_set
};
let password = generate_password(rng, length, characters)?;
let password =
self.config
.generate_secret_with_rng(request.length, request.no_symbols, rng)?;
let path = EntryPath::parse(&request.entry)?;
let contents = if request.in_place {
let ciphertext = self.repository.read_entry(&path)?;

View File

@@ -25,7 +25,7 @@ use crate::{
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
repository::{EncryptedEntry, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter},
write::{EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter},
};
const DEFAULT_BRANCH: &str = "main";
@@ -38,6 +38,11 @@ pub struct GitIdentity {
}
impl GitIdentity {
pub fn ironstorage() -> Self {
Self::new("IronStorage", "ironstorage@localhost")
.expect("the built-in Git identity is valid")
}
pub fn new(name: impl Into<String>, email: impl Into<String>) -> Result<Self, GitError> {
let name = name.into();
let email = email.into();
@@ -257,6 +262,35 @@ pub struct GitRepository {
identity: GitIdentity,
}
/// Storage-owned selection of pass-compatible automatic Git commits.
pub enum AutomaticEntryCommitter {
Git(Box<GitRepository>),
None(NoGitEntryCommitter),
}
impl AutomaticEntryCommitter {
pub fn for_entry(
repository: &Repository,
entry: &str,
identity: GitIdentity,
) -> Result<Self, GitError> {
match GitRepository::open_innermost(repository, Path::new(entry), identity) {
Ok(git) => Ok(Self::Git(Box::new(git))),
Err(GitError::NotRepository) => Ok(Self::None(NoGitEntryCommitter)),
Err(error) => Err(error),
}
}
}
impl EntryCommitter for AutomaticEntryCommitter {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
match self {
Self::Git(git) => EntryCommitter::commit(git.as_mut(), change),
Self::None(committer) => committer.commit(change),
}
}
}
pub struct GitCredential {
username: String,
password: crate::repository::SecretBytes,

View File

@@ -337,6 +337,20 @@ impl<'a> VaultWriter<'a> {
editor_name: &str,
signing: Option<&SigningPolicy>,
committer: &mut impl EntryCommitter,
) -> Result<WriteOutcome, WriteError> {
self.finish_edit_recoverable(&session, replacement, editor_name, signing, committer)
}
/// Finish an edit while retaining the session when validation, encryption,
/// conflict detection, or commit orchestration fails.
#[allow(clippy::too_many_arguments)]
pub fn finish_edit_recoverable(
&self,
session: &EditSession,
replacement: SecretBytes,
editor_name: &str,
signing: Option<&SigningPolicy>,
committer: &mut impl EntryCommitter,
) -> Result<WriteOutcome, WriteError> {
if replacement.expose() == session.plaintext.expose() {
return Err(WriteError::Unchanged);
@@ -375,7 +389,7 @@ impl<'a> VaultWriter<'a> {
return Err(WriteError::Commit(error));
}
Ok(WriteOutcome {
path: session.path,
path: session.path.clone(),
action: EntryAction::Edit,
})
}

View File

@@ -238,11 +238,12 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult {
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),
service.save_recoverable(&stale, None, &mut committer),
Err(DocumentError::Write(
WriteError::ConcurrentModification { .. }
))
));
assert_eq!(stale.serialize().expose(), b"stale\nusername: alice");
assert_eq!(
repository.read_entry(&EntryPath::parse("documents/new")?)?,
winner
@@ -253,9 +254,31 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult {
rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?;
committer.fail = true;
assert!(matches!(
service.save(rollback, None, &mut committer),
service.save_recoverable(&rollback, None, &mut committer),
Err(DocumentError::Write(WriteError::Commit(_)))
));
assert!(
rollback
.serialize()
.expose()
.starts_with(b"must roll back\n")
);
assert_eq!(
repository.read_entry(&EntryPath::parse("documents/new")?)?,
winner
);
committer.fail = false;
std::fs::write(
store.path().join(".gpg-id"),
b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n",
)?;
let retained = rollback.serialize();
assert!(
service
.save_recoverable(&rollback, None, &mut committer)
.is_err()
);
assert_eq!(rollback.serialize().expose(), retained.expose());
assert_eq!(
repository.read_entry(&EntryPath::parse("documents/new")?)?,
winner

View File

@@ -131,6 +131,21 @@ fn deterministic_generation_uses_only_requested_characters_and_length() -> TestR
Ok(())
}
#[test]
fn in_process_generation_returns_a_zeroizing_unstored_secret() -> TestResult {
let config = GeneratorConfig::new(12, "abc123")?;
let generated = config.generate_secret(NonZeroUsize::new(48), false)?;
assert_eq!(generated.expose().len(), 48);
assert!(
generated
.expose()
.iter()
.all(|byte| b"abc123".contains(byte))
);
assert!(!format!("{generated:?}").contains(std::str::from_utf8(generated.expose())?));
Ok(())
}
#[test]
fn default_no_symbols_and_presentation_actions_are_typed() -> TestResult {
let fixture = FixtureSet::load()?;