Complete the end-to-end CLI parity audit

This commit is contained in:
Hermes Agent
2026-08-10 01:51:47 +00:00
parent 4bd39b1ef2
commit 0a905e5e14
14 changed files with 1476 additions and 95 deletions

View File

@@ -3,12 +3,13 @@
use std::{error::Error, ffi::OsString, fmt, num::NonZeroUsize, path::PathBuf};
use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
use clap_complete::{Shell, generate};
use crate::PRODUCT_NAME;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_FAILURE: u8 = 1;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_USAGE: u8 = EXIT_FAILURE;
pub const EXIT_UNAVAILABLE: u8 = 69;
pub const EXIT_CONFIG: u8 = 78;
@@ -53,10 +54,32 @@ pub enum CommandRequest {
Copy(CopyRequest),
Git(GitRequest),
Otp(OtpRequest),
Completion { shell: CompletionShell },
Help { topic: Option<HelpTopic> },
Version,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CompletionShell {
Bash,
Elvish,
Fish,
PowerShell,
Zsh,
}
impl CompletionShell {
fn generator(self) -> Shell {
match self {
Self::Bash => Shell::Bash,
Self::Elvish => Shell::Elvish,
Self::Fish => Shell::Fish,
Self::PowerShell => Shell::PowerShell,
Self::Zsh => Shell::Zsh,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InitRequest {
pub path: Option<String>,
@@ -417,6 +440,13 @@ pub fn otp_version_text() -> &'static str {
"1.1.1\n"
}
pub fn completion_script(shell: CompletionShell) -> Vec<u8> {
let mut command = CliArguments::command();
let mut output = Vec::new();
generate(shell.generator(), &mut command, "ironstorage", &mut output);
output
}
#[derive(Parser)]
#[command(
name = "ironstorage",
@@ -452,10 +482,26 @@ enum CommandArguments {
Copy(CopyArguments),
Git(GitArguments),
Otp(OtpArguments),
Completion(CompletionArguments),
Help(HelpArguments),
Version,
}
#[derive(Args)]
struct CompletionArguments {
#[arg(value_enum)]
shell: CompletionShellArgument,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum CompletionShellArgument {
Bash,
Elvish,
Fish,
Powershell,
Zsh,
}
#[derive(Args)]
struct InitArguments {
#[arg(short = 'p', long = "path")]
@@ -784,6 +830,15 @@ fn convert_arguments(arguments: CliArguments) -> Result<CliInvocation, CliParseE
Some(CommandArguments::Otp(arguments)) => {
CommandRequest::Otp(convert_otp(arguments.command)?)
}
Some(CommandArguments::Completion(arguments)) => CommandRequest::Completion {
shell: match arguments.shell {
CompletionShellArgument::Bash => CompletionShell::Bash,
CompletionShellArgument::Elvish => CompletionShell::Elvish,
CompletionShellArgument::Fish => CompletionShell::Fish,
CompletionShellArgument::Powershell => CompletionShell::PowerShell,
CompletionShellArgument::Zsh => CompletionShell::Zsh,
},
},
Some(CommandArguments::Help(arguments)) => {
let topic = arguments
.topic
@@ -914,9 +969,29 @@ fn normalize_dispatch(mut arguments: Vec<OsString>) -> Vec<OsString> {
};
let command = arguments[command_index].to_string_lossy();
const ROOT_COMMANDS: &[&str] = &[
"init", "ls", "list", "show", "find", "search", "grep", "insert", "add", "edit",
"generate", "rm", "remove", "delete", "mv", "rename", "cp", "copy", "git", "otp", "help",
"init",
"ls",
"list",
"show",
"find",
"search",
"grep",
"insert",
"add",
"edit",
"generate",
"rm",
"remove",
"delete",
"mv",
"rename",
"cp",
"copy",
"git",
"otp",
"help",
"version",
"completion",
];
if !ROOT_COMMANDS.contains(&command.as_ref()) && !command.starts_with('-') {
arguments.insert(command_index, OsString::from("show"));
@@ -998,7 +1073,9 @@ fn normalize_otp_dispatch(arguments: &mut Vec<OsString>, mut index: usize) {
const OTP_COMMANDS: &[&str] = &[
"code", "show", "insert", "add", "append", "uri", "validate", "help", "version",
];
if argument == "--version" {
if argument == "help" {
arguments[index] = OsString::from("--help");
} else if argument == "--version" {
arguments[index] = OsString::from("version");
} else if !OTP_COMMANDS.contains(&argument.as_ref()) && argument != "--help" && argument != "-h"
{

View File

@@ -24,7 +24,7 @@ use crate::{
crypto::{KeyHandle, KeyStore, SecretProvider},
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
repository::Repository,
repository::{EncryptedEntry, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter},
};
@@ -1587,6 +1587,41 @@ impl GitRepository {
Ok(output)
}
/// Render a helper-free working-tree diff. Password entries are decrypted
/// in storage before rendering, so ciphertext is never used as display
/// state and no Git textconv or GPG process is required.
pub fn render_diff(
&self,
paths: &[PathBuf],
keys: &KeyStore,
secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, GitError> {
let mut rendered = Vec::new();
for entry in self.diff(paths)? {
let display = entry.path().to_string_lossy();
rendered.extend_from_slice(
format!("diff --ironstorage a/{display} b/{display}\n").as_bytes(),
);
match entry.old() {
Some(_) => rendered.extend_from_slice(format!("--- a/{display}\n").as_bytes()),
None => rendered.extend_from_slice(b"--- /dev/null\n"),
}
match entry.current() {
Some(_) => rendered.extend_from_slice(format!("+++ b/{display}\n").as_bytes()),
None => rendered.extend_from_slice(b"+++ /dev/null\n"),
}
if let Some(old) = entry.old() {
let plaintext = diff_contents(entry.path(), old, keys, secrets)?;
append_diff_lines(&mut rendered, b'-', plaintext.expose());
}
if let Some(current) = entry.current() {
let plaintext = diff_contents(entry.path(), current, keys, secrets)?;
append_diff_lines(&mut rendered, b'+', plaintext.expose());
}
}
Ok(SecretBytes::new(rendered))
}
fn head_tree_map(&self) -> Result<Option<BTreeMap<String, gix::hash::ObjectId>>, GitError> {
let Some(id) = self.repository.head_id().ok() else {
return Ok(None);
@@ -1639,6 +1674,34 @@ impl GitRepository {
}
}
fn diff_contents(
path: &Path,
contents: &[u8],
keys: &KeyStore,
secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, GitError> {
if path.extension().is_some_and(|extension| extension == "gpg") {
keys.decrypt(&EncryptedEntry::new(contents.to_vec()), secrets)
.map_err(invalid)
} else {
Ok(SecretBytes::new(contents.to_vec()))
}
}
fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
if contents.is_empty() {
return;
}
for line in contents.split_inclusive(|byte| *byte == b'\n') {
output.push(prefix);
output.extend_from_slice(line);
if !line.ends_with(b"\n") {
output.push(b'\n');
output.extend_from_slice(b"\\ No newline at end of file\n");
}
}
}
impl EntryCommitter for GitRepository {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())

View File

@@ -8,6 +8,7 @@ use crate::{
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
};
use zeroize::Zeroize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OverwriteDecision {
@@ -21,23 +22,38 @@ pub struct InsertContent {
}
impl InsertContent {
pub fn hidden(first: Vec<u8>, confirmation: Vec<u8>) -> Result<Self, WriteError> {
validate_single_line(&first)?;
validate_single_line(&confirmation)?;
pub fn hidden(mut first: Vec<u8>, mut confirmation: Vec<u8>) -> Result<Self, WriteError> {
if let Err(error) = validate_single_line(&first) {
first.zeroize();
confirmation.zeroize();
return Err(error);
}
if let Err(error) = validate_single_line(&confirmation) {
first.zeroize();
confirmation.zeroize();
return Err(error);
}
if first != confirmation {
first.zeroize();
confirmation.zeroize();
return Err(WriteError::ConfirmationMismatch);
}
if first.is_empty() {
confirmation.zeroize();
return Err(WriteError::EmptySingleLine);
}
confirmation.zeroize();
Ok(Self {
mode: InsertInput::HiddenConfirmed,
secret: SecretBytes::new(first),
})
}
pub fn echoed(line: Vec<u8>) -> Result<Self, WriteError> {
validate_single_line(&line)?;
pub fn echoed(mut line: Vec<u8>) -> Result<Self, WriteError> {
if let Err(error) = validate_single_line(&line) {
line.zeroize();
return Err(error);
}
if line.is_empty() {
return Err(WriteError::EmptySingleLine);
}
@@ -186,6 +202,17 @@ impl<'a> VaultWriter<'a> {
Self { repository, keys }
}
/// Report whether a logical entry already exists so a frontend can ask for
/// confirmation without inspecting the password-store filesystem itself.
pub fn entry_exists(&self, entry: &str) -> Result<bool, WriteError> {
let path = EntryPath::parse(entry)?;
match self.repository.read_entry(&path) {
Ok(_) => Ok(true),
Err(RepositoryError::NotFound { .. }) => Ok(false),
Err(error) => Err(error.into()),
}
}
#[allow(clippy::too_many_arguments)]
pub fn insert(
&self,