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

@@ -10,6 +10,7 @@ publish = false
cap-std.workspace = true
cap-tempfile.workspace = true
clap.workspace = true
clap_complete.workspace = true
data-encoding.workspace = true
flate2.workspace = true
gix.workspace = true

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,

View File

@@ -3,9 +3,10 @@
use std::{error::Error, num::NonZeroUsize, path::Path};
use ironstorage::command::{
CliAction, CommandRequest, EXIT_USAGE, GeneratedPresentation, GitConfigRequest,
GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput, OtpInputSource, OtpRequest,
OtpUriPresentation, Presentation, help_text, otp_version_text, parse_from, version_text,
CliAction, CommandRequest, CompletionShell, EXIT_USAGE, GeneratedPresentation,
GitConfigRequest, GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput,
OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text,
otp_version_text, parse_from, version_text,
};
type TestResult = Result<(), Box<dyn Error>>;
@@ -19,6 +20,39 @@ fn request(arguments: &[&str]) -> Result<CommandRequest, Box<dyn Error>> {
}
}
#[test]
fn completion_scripts_are_generated_in_process_for_supported_shells() -> TestResult {
for (name, shell, marker) in [
("bash", CompletionShell::Bash, "complete"),
("elvish", CompletionShell::Elvish, "edit:completion"),
("fish", CompletionShell::Fish, "complete"),
(
"powershell",
CompletionShell::PowerShell,
"Register-ArgumentCompleter",
),
("zsh", CompletionShell::Zsh, "compdef"),
] {
assert_eq!(
request(&["completion", name])?,
CommandRequest::Completion { shell }
);
let script = String::from_utf8(completion_script(shell))?;
assert!(script.contains("ironstorage"));
assert!(
script.contains(marker),
"missing {marker} in {name} completion"
);
}
assert_eq!(
parse_from(["ironstorage", "completion", "unsupported"])
.unwrap_err()
.exit_code(),
EXIT_USAGE
);
Ok(())
}
#[test]
fn implicit_show_and_list_aliases_are_canonical_requests() -> TestResult {
assert!(matches!(

View File

@@ -4,6 +4,8 @@ mod support;
use std::collections::{BTreeMap, BTreeSet};
use ironstorage::command::parse_from;
use support::compatibility::{
FixtureSet, TestResult, decrypt_entry_with_passphrase, validate_entry_record,
validate_key_record, validate_recipient_signature, validate_repository,
@@ -154,6 +156,37 @@ fn behavior_catalog_covers_the_milestone_contract() -> TestResult {
Ok(())
}
#[test]
fn every_compatibility_case_reaches_the_typed_command_contract() -> TestResult {
let fixture = FixtureSet::load()?;
for case in &fixture.behavior.cases {
let arguments = std::iter::once("ironstorage").chain(case.argv.iter().map(String::as_str));
let parsed = parse_from(arguments);
let parser_rejection = case.outcome.contains("usage-error")
|| case.outcome.contains("unsupported-option")
|| matches!(
case.id.as_str(),
"generate-zero" | "git-unsupported-passthrough"
);
assert_eq!(
parsed.is_err(),
parser_rejection,
"unexpected command-contract result for {}: {:?}",
case.id,
parsed
);
if let Err(error) = parsed {
assert_eq!(
error.exit_code(),
case.status as u8,
"status for {}",
case.id
);
}
}
Ok(())
}
#[test]
fn generated_openpgp_fixtures_are_self_consistent() -> TestResult {
let fixture = FixtureSet::load()?;

View File

@@ -65,6 +65,11 @@ fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult {
let diff = git.diff(&[])?;
assert_eq!(diff[0].old(), Some(b"ALICE\n".as_slice()));
assert_eq!(diff[0].current(), Some(b"BOB\n".as_slice()));
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let rendered = git.render_diff(&[], &keys, &mut SigningSecret(Vec::new()))?;
assert!(rendered.expose().windows(7).any(|part| part == b"-ALICE\n"));
assert!(rendered.expose().windows(5).any(|part| part == b"+BOB\n"));
git.stage(&[".gpg-id".into()])?;
assert_eq!(git.status()?.staged()[0].kind(), GitChangeKind::Modified);