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,
})
}