From 0a905e5e14810105209c2d31937935b4f98636dc Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 01:51:47 +0000 Subject: [PATCH] Complete the end-to-end CLI parity audit --- Cargo.lock | 10 + Cargo.toml | 1 + DEPENDENCIES.md | 2 +- README.md | 3 + apps/cli/src/editor.rs | 46 +- apps/cli/src/main.rs | 1153 +++++++++++++++-- crates/storage/Cargo.toml | 1 + crates/storage/src/command.rs | 85 +- crates/storage/src/git.rs | 65 +- crates/storage/src/write.rs | 37 +- crates/storage/tests/command_contract.rs | 40 +- .../storage/tests/compatibility_fixtures.rs | 33 + crates/storage/tests/git_embedded.rs | 5 + docs/cli-parity.md | 90 ++ 14 files changed, 1476 insertions(+), 95 deletions(-) create mode 100644 docs/cli-parity.md diff --git a/Cargo.lock b/Cargo.lock index 51a7cae..d00be26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1013,6 +1013,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -3972,6 +3981,7 @@ dependencies = [ "cap-std", "cap-tempfile", "clap", + "clap_complete", "data-encoding", "flate2", "gix", diff --git a/Cargo.toml b/Cargo.toml index 8e80cf4..cedb8ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ arboard = { version = "3.6", default-features = false, features = ["wayland-data cap-std = "4.0" cap-tempfile = "4.0" clap = { version = "4.6", features = ["derive"] } +clap_complete = "4.6" crossterm = "0.29" ctrlc = "3.5" data-encoding = "2.9" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index a842edc..03b4042 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -14,7 +14,7 @@ The current direct dependencies are: | Crate | Purpose | License | | --- | --- | --- | | [cap-std 4.0](https://crates.io/crates/cap-std/4.0.2), [cap-tempfile 4.0](https://crates.io/crates/cap-tempfile/4.0.2) | Capability-scoped filesystem access and atomic temporary files | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | -| [clap 4.6](https://crates.io/crates/clap/4.6.4) | CLI parsing | MIT OR Apache-2.0 | +| [clap 4.6](https://crates.io/crates/clap/4.6.4), [clap_complete 4.6](https://crates.io/crates/clap_complete/4.6.9) | CLI parsing and in-process shell completion generation | MIT OR Apache-2.0 | | [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT | | [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | diff --git a/README.md b/README.md index d2be64e..f07e5d3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Clipboard cleanup/race behavior and platform-neutral QR rendering are documented in [`docs/presentation.md`](docs/presentation.md). Pass-OTP URI compatibility, RFC code generation, and atomic HOTP counters are 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). 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/apps/cli/src/editor.rs b/apps/cli/src/editor.rs index cd6f247..76af2ba 100644 --- a/apps/cli/src/editor.rs +++ b/apps/cli/src/editor.rs @@ -1,9 +1,15 @@ +#![allow( + clippy::disallowed_types, + reason = "this module is the documented CLI-only configured-editor process boundary" +)] + use std::{ error::Error, ffi::OsString, fmt, fs, io::{self, Read as _, Seek as _, SeekFrom, Write as _}, path::{Path, PathBuf}, + process::Command, }; use ironstorage::{config::ResolvedEditor, repository::SecretBytes}; @@ -44,12 +50,32 @@ pub(crate) trait EditorHost { fn edit(&mut self, invocation: &EditorInvocation) -> Result; } +pub(crate) struct NativeEditorHost; + +impl EditorHost for NativeEditorHost { + fn edit(&mut self, invocation: &EditorInvocation) -> Result { + let status = Command::new(invocation.program()) + .args(invocation.arguments()) + .status() + .map_err(|_| EditorHostError(invocation.program().to_owned()))?; + if status.success() { + Ok(EditorStatus::Saved) + } else { + Ok(EditorStatus::Failed(status.code().unwrap_or(1))) + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct EditorHostError; +pub(crate) struct EditorHostError(String); impl fmt::Display for EditorHostError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("editor host failed") + write!( + formatter, + "editor executable could not be launched: {}", + self.0 + ) } } @@ -305,4 +331,20 @@ mod tests { } Ok(()) } + + #[test] + fn missing_editor_executable_has_a_clear_error() { + let invocation = EditorInvocation { + program: "ironstorage-editor-does-not-exist".to_owned(), + arguments: Vec::new(), + plaintext_path: PathBuf::from("unused"), + }; + let error = NativeEditorHost + .edit(&invocation) + .expect_err("the intentionally absent editor must fail"); + assert_eq!( + error.to_string(), + "editor executable could not be launched: ironstorage-editor-does-not-exist" + ); + } } diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 1b6caf8..3b46cc9 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -5,8 +5,8 @@ use std::{ error::Error, ffi::OsString, fmt, - io::{BufRead as _, IsTerminal as _, Write}, - path::Path, + io::{BufRead as _, IsTerminal as _, Read as _, Write}, + path::{Path, PathBuf}, process::ExitCode, sync::{ Arc, @@ -18,25 +18,33 @@ use std::{ use ironstorage::{ command::{ CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, - GeneratedPresentation, GitRequest, HelpTopic, InputPlan, OtpInputSource, OtpRequest, - OtpUriPresentation, help_text, otp_version_text, parse_from, version_text, + GeneratedPresentation, GitConfigRequest, GitRemoteRequest, GitRequest, HelpTopic, + InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text, + otp_version_text, parse_from, version_text, }, config::Config, crypto::KeyStore, generate::{GeneratorConfig, PasswordGenerator}, - git::{GitIdentity, GitRepository}, + git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome}, + mutation::{ + MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, + }, otp::{OtpError, OtpInput, OtpService}, presentation::{ ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix, }, read::{PresentationChannel, ShowOutput, ShowResult, VaultReader}, - repository::{Repository, SecretBytes}, + recipient::{ + NoGitCommitter, PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager, + }, + repository::{DirectoryPath, Repository, SecretBytes}, secret_store::{ NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend, }, write::{ - EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter, OverwriteDecision, + EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter, + OverwriteDecision, VaultWriter, }, }; @@ -106,6 +114,12 @@ where Ok(()) => Ok(EXIT_SUCCESS), Err(error) => operation_error(&mut stderr, error), }, + CommandRequest::Completion { shell } => { + stdout + .write_all(&completion_script(*shell)) + .map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } request => match Config::load(invocation.config()) { Ok(config) if needs_secret_store(request) => { let mut secrets = match NativeSecretStore::system( @@ -124,14 +138,7 @@ where }; execute_secure(&config, request, &mut secrets, &mut stdout, &mut stderr) } - Ok(_) => { - stderr - .write_all( - b"the command contract is valid, but this storage operation is not available yet\n", - ) - .map_err(|_| ())?; - Ok(EXIT_UNAVAILABLE) - } + Ok(config) => execute_local(&config, request, &mut stdout, &mut stderr), Err(error) => { writeln!(stderr, "{error}").map_err(|_| ())?; Ok(EXIT_CONFIG) @@ -145,17 +152,216 @@ fn needs_secret_store(request: &CommandRequest) -> bool { matches!( request, CommandRequest::Show(_) + | CommandRequest::Init(_) + | CommandRequest::Grep(_) + | CommandRequest::Insert(_) + | CommandRequest::Edit(_) | CommandRequest::Generate(_) + | CommandRequest::Remove(_) + | CommandRequest::Move(_) + | CommandRequest::Copy(_) | CommandRequest::Otp( OtpRequest::Code(_) | OtpRequest::Insert(_) | OtpRequest::Append(_) | OtpRequest::Uri(_), ) - | CommandRequest::Git(GitRequest::Fetch { .. }) + | CommandRequest::Git( + GitRequest::Diff { .. } + | GitRequest::Fetch { .. } + | GitRequest::Pull { .. } + | GitRequest::Push { .. } + | GitRequest::Sync { .. }, + ) ) } +fn execute_local( + config: &Config, + request: &CommandRequest, + stdout: &mut O, + stderr: &mut E, +) -> Result { + let repository = match Repository::open(config.vault()) { + Ok(repository) => repository, + Err(error) => return operation_error(stderr, error), + }; + match request { + CommandRequest::List(request) => { + let directory = match DirectoryPath::parse(request.path.as_deref().unwrap_or_default()) + { + Ok(directory) => directory, + Err(error) => return operation_error(stderr, error), + }; + let keys = match KeyStore::load(config.key_material()) { + Ok(keys) => keys, + Err(error) => return operation_error(stderr, error), + }; + match VaultReader::new(&repository, &keys).list(&directory) { + Ok(tree) => { + stdout + .write_all(tree.render_plain().as_bytes()) + .map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Err(error) => operation_error(stderr, error), + } + } + CommandRequest::Find(request) => { + let keys = match KeyStore::load(config.key_material()) { + Ok(keys) => keys, + Err(error) => return operation_error(stderr, error), + }; + match VaultReader::new(&repository, &keys).find(&request.terms) { + Ok(results) => { + stdout + .write_all(results.render_plain().as_bytes()) + .map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Err(error) => operation_error(stderr, error), + } + } + CommandRequest::Git(request) => execute_local_git(&repository, request, stdout, stderr), + _ => operation_error(stderr, "the command requires authenticated secret access"), + } +} + +fn execute_local_git( + repository: &Repository, + request: &GitRequest, + stdout: &mut O, + stderr: &mut E, +) -> Result { + if matches!(request, GitRequest::Init) { + return match GitRepository::init(repository, git_identity()) { + Ok(_) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + }; + } + let mut git = match GitRepository::open(repository, git_identity()) { + Ok(git) => git, + Err(error) => return operation_error(stderr, error), + }; + let result = match request { + GitRequest::Status => git.status().and_then(|status| { + if status.is_clean() { + writeln!(stdout, "clean").map_err(|_| output_git_error())?; + } else { + for change in status.staged() { + writeln!( + stdout, + "staged\t{}\t{}", + git_change_name(change.kind()), + change.path().display() + ) + .map_err(|_| output_git_error())?; + } + for change in status.unstaged() { + writeln!( + stdout, + "unstaged\t{}\t{}", + git_change_name(change.kind()), + change.path().display() + ) + .map_err(|_| output_git_error())?; + } + } + Ok(()) + }), + GitRequest::Log { maximum } => git.log(maximum.map(usize::from)).and_then(|entries| { + for entry in entries { + writeln!(stdout, "commit {}", entry.id()).map_err(|_| output_git_error())?; + writeln!( + stdout, + "Author: {} <{}>", + entry.author_name(), + entry.author_email() + ) + .map_err(|_| output_git_error())?; + writeln!(stdout, "Date: {}", entry.timestamp()).map_err(|_| output_git_error())?; + writeln!(stdout, "\n {}\n", entry.message().trim_end()) + .map_err(|_| output_git_error())?; + } + Ok(()) + }), + GitRequest::Diff { paths } => git + .diff(&paths.iter().map(PathBuf::from).collect::>()) + .and_then(|entries| { + for entry in entries { + let kind = match (entry.old(), entry.current()) { + (None, Some(_)) => "added", + (Some(_), None) => "deleted", + _ => "modified", + }; + writeln!(stdout, "{kind}\t{}", entry.path().display()) + .map_err(|_| output_git_error())?; + } + Ok(()) + }), + GitRequest::Add { paths } => { + git.stage(&paths.iter().map(PathBuf::from).collect::>()) + } + GitRequest::Commit { message } => git.commit(message).and_then(|id| { + writeln!(stdout, "{id}").map_err(|_| output_git_error())?; + Ok(()) + }), + GitRequest::Remote(request) => match request { + GitRemoteRequest::List => { + for remote in git.remotes() { + writeln!(stdout, "{remote}").map_err(|_| ())?; + } + return Ok(EXIT_SUCCESS); + } + GitRemoteRequest::GetUrl { name } => git.remote_url(name).and_then(|url| { + writeln!(stdout, "{url}").map_err(|_| output_git_error())?; + Ok(()) + }), + GitRemoteRequest::Add { name, url } => git.add_remote(name, url), + GitRemoteRequest::SetUrl { name, url } => git.set_remote_url(name, url), + GitRemoteRequest::Remove { name } => git.remove_remote(name), + }, + GitRequest::Config(request) => match request { + GitConfigRequest::Get { key } => git.config_get(key).and_then(|value| { + if let Some(value) = value { + writeln!(stdout, "{value}").map_err(|_| output_git_error())?; + } + Ok(()) + }), + GitConfigRequest::Set { key, value } => git.config_set(key, value), + }, + GitRequest::Init + | GitRequest::Fetch { .. } + | GitRequest::Pull { .. } + | GitRequest::Push { .. } + | GitRequest::Sync { .. } => unreachable!("routed before local Git execution"), + }; + match result { + Ok(()) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + } +} + +fn git_change_name(kind: GitChangeKind) -> &'static str { + match kind { + GitChangeKind::Added => "added", + GitChangeKind::Modified => "modified", + GitChangeKind::Deleted => "deleted", + } +} + +fn output_git_error() -> ironstorage::git::GitError { + ironstorage::git::GitError::Io { + operation: "write command output", + path: PathBuf::from("stdout"), + } +} + +fn git_identity() -> GitIdentity { + GitIdentity::new("IronStorage", "ironstorage@localhost") + .expect("the built-in Git identity is valid") +} + fn execute_secure( config: &Config, request: &CommandRequest, @@ -200,7 +406,7 @@ fn execute_secure_with( @@ -213,16 +419,32 @@ fn execute_secure_with_services< stdout: &mut O, stderr: &mut E, ) -> Result { + let repository = match Repository::open(config.vault()) { + Ok(repository) => repository, + Err(error) => return operation_error(stderr, error), + }; + let keys = match KeyStore::load(config.key_material()) { + Ok(keys) => keys, + Err(error) => return operation_error(stderr, error), + }; match request { + CommandRequest::Init(request) => { + let path = request.path.as_deref().unwrap_or_default(); + let mut committer = match automatic_committer(&repository, path) { + Ok(committer) => committer, + Err(error) => return operation_error(stderr, error), + }; + match RecipientPolicyManager::new(&repository, &keys).apply_init( + request, + None, + secrets, + &mut committer, + ) { + Ok(_) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + } + } CommandRequest::Show(request) => { - let repository = match Repository::open(config.vault()) { - Ok(repository) => repository, - Err(error) => return operation_error(stderr, error), - }; - let keys = match KeyStore::load(config.key_material()) { - Ok(keys) => keys, - Err(error) => return operation_error(stderr, error), - }; match VaultReader::new(&repository, &keys).execute_show(request, secrets) { Ok(ShowOutput::Display(ShowResult::Entry(secret))) => { stdout.write_all(secret.expose()).map_err(|_| ())?; @@ -255,32 +477,108 @@ fn execute_secure_with_services< Err(error) => operation_error(stderr, error), } } + CommandRequest::Grep(request) => { + match VaultReader::new(&repository, &keys).grep(request, secrets) { + Ok(results) => { + stdout + .write_all(results.render_plain().expose()) + .map_err(|_| ())?; + Ok(if results.is_empty() { + EXIT_FAILURE + } else { + EXIT_SUCCESS + }) + } + Err(error) => operation_error(stderr, error), + } + } + CommandRequest::Insert(request) => { + let writer = VaultWriter::new(&repository, &keys); + let exists = match writer.entry_exists(&request.entry) { + Ok(exists) => exists, + Err(error) => return operation_error(stderr, error), + }; + let overwrite = if exists && !request.force { + match interaction.confirm( + &format!( + "An entry already exists for {}. Overwrite it?", + request.entry + ), + stderr, + ) { + Ok(decision) => decision, + Err(error) => return operation_error(stderr, error), + } + } else { + OverwriteDecision::Allow + }; + if overwrite == OverwriteDecision::Decline { + return operation_error(stderr, "entry overwrite was declined"); + } + let contents = match interaction.read_insert( + request.input_plan(interaction.standard_input_is_terminal()), + &request.entry, + stderr, + ) { + Ok(contents) => contents, + Err(error) => return operation_error(stderr, error), + }; + let mut committer = match automatic_committer(&repository, &request.entry) { + Ok(committer) => committer, + Err(error) => return operation_error(stderr, error), + }; + match writer.insert(request, contents, overwrite, None, &mut committer) { + Ok(_) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + } + } + CommandRequest::Edit(request) => { + let writer = VaultWriter::new(&repository, &keys); + let session = match writer.begin_edit(request, secrets) { + Ok(session) => session, + Err(error) => return operation_error(stderr, error), + }; + let (replacement, editor_name) = match interaction.edit(session.plaintext(), config) { + Ok(replacement) => replacement, + Err(error) => return operation_error(stderr, error), + }; + let mut committer = match automatic_committer(&repository, &request.entry) { + Ok(committer) => committer, + Err(error) => return operation_error(stderr, error), + }; + match writer.finish_edit(session, replacement, &editor_name, None, &mut committer) { + Ok(_) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + } + } CommandRequest::Generate(request) => { - let repository = match Repository::open(config.vault()) { - Ok(repository) => repository, - Err(error) => return operation_error(stderr, error), + let overwrite = if !request.force + && !request.in_place + && match VaultWriter::new(&repository, &keys).entry_exists(&request.entry) { + Ok(exists) => exists, + Err(error) => return operation_error(stderr, error), + } { + match interaction.confirm( + &format!( + "An entry already exists for {}. Overwrite it?", + request.entry + ), + stderr, + ) { + Ok(decision) => decision, + Err(error) => return operation_error(stderr, error), + } + } else { + OverwriteDecision::Allow }; - let keys = match KeyStore::load(config.key_material()) { - Ok(keys) => keys, - Err(error) => return operation_error(stderr, error), - }; - let mut committer = match generation_committer(&repository, &request.entry) { + let mut committer = match automatic_committer(&repository, &request.entry) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; let outcome = match PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults()) - .generate( - request, - if request.force || request.in_place { - OverwriteDecision::Allow - } else { - OverwriteDecision::Decline - }, - None, - secrets, - &mut committer, - ) { + .generate(request, overwrite, None, secrets, &mut committer) + { Ok(outcome) => outcome, Err(error) => return operation_error(stderr, error), }; @@ -302,6 +600,40 @@ fn execute_secure_with_services< Err(error) => operation_error(stderr, error), } } + CommandRequest::Remove(request) => { + let decision = if request.force { + OverwriteDecision::Allow + } else { + match interaction.confirm(&format!("Remove {}?", request.entry), stderr) { + Ok(decision) => decision, + Err(error) => return operation_error(stderr, error), + } + }; + let mut committer = match automatic_committer(&repository, &request.entry) { + Ok(committer) => committer, + Err(error) => return operation_error(stderr, error), + }; + match TreeMutator::new(&repository, &keys).remove(request, decision, &mut committer) { + Ok(_) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + } + } + CommandRequest::Move(request) => execute_transfer( + &repository, + &keys, + TransferRequest::Move(request), + secrets, + interaction, + stderr, + ), + CommandRequest::Copy(request) => execute_transfer( + &repository, + &keys, + TransferRequest::Copy(request), + secrets, + interaction, + stderr, + ), CommandRequest::Otp(request) => execute_otp( config, request, @@ -312,8 +644,32 @@ fn execute_secure_with_services< stdout, stderr, ), - CommandRequest::Git(GitRequest::Fetch { remote }) => { - let configured = match select_remote(config, remote.as_deref()) { + CommandRequest::Git(request) => { + if let GitRequest::Diff { paths } = request { + let git = match GitRepository::open(&repository, git_identity()) { + Ok(git) => git, + Err(error) => return operation_error(stderr, error), + }; + return match git.render_diff( + &paths.iter().map(PathBuf::from).collect::>(), + &keys, + secrets, + ) { + Ok(diff) => { + stdout.write_all(diff.expose()).map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Err(error) => operation_error(stderr, error), + }; + } + let requested_remote = match request { + GitRequest::Fetch { remote } + | GitRequest::Pull { remote, .. } + | GitRequest::Push { remote, .. } + | GitRequest::Sync { remote } => remote.as_deref(), + _ => unreachable!("local Git requests do not require secret access"), + }; + let configured = match select_remote(config, requested_remote) { Some(configured) => configured, None => { stderr @@ -322,22 +678,131 @@ fn execute_secure_with_services< return Ok(EXIT_CONFIG); } }; - let repository = match Repository::open(config.vault()) { - Ok(repository) => repository, - Err(error) => return operation_error(stderr, error), - }; - let identity = GitIdentity::new("IronStorage", "ironstorage@localhost") - .expect("the built-in Git identity is valid"); - let git = match GitRepository::open(&repository, identity) { + let git = match GitRepository::open(&repository, git_identity()) { Ok(git) => git, Err(error) => return operation_error(stderr, error), }; - match git.fetch(configured, secrets) { - Ok(_) => Ok(EXIT_SUCCESS), + let result = match request { + GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None), + GitRequest::Pull { branch, .. } => git + .pull(configured, branch.as_deref(), secrets) + .map(|outcome| Some(pull_outcome_name(outcome).to_owned())), + GitRequest::Push { branch, .. } => git + .push(configured, branch.as_deref(), secrets) + .map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))), + GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| { + Some(format!( + "{}; {} {}", + pull_outcome_name(pull), + push.remote(), + push.new_id() + )) + }), + _ => unreachable!("local Git requests do not require secret access"), + }; + match result { + Ok(Some(message)) => { + writeln!(stdout, "{message}").map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Ok(None) => Ok(EXIT_SUCCESS), Err(error) => operation_error(stderr, error), } } - _ => Ok(EXIT_UNAVAILABLE), + CommandRequest::List(_) + | CommandRequest::Find(_) + | CommandRequest::Completion { .. } + | CommandRequest::Help { .. } + | CommandRequest::Version => unreachable!("request is handled before secure execution"), + } +} + +#[derive(Clone, Copy)] +enum TransferRequest<'a> { + Move(&'a ironstorage::command::MoveRequest), + Copy(&'a ironstorage::command::CopyRequest), +} + +impl TransferRequest<'_> { + fn source(&self) -> &str { + match self { + Self::Move(request) => &request.source, + Self::Copy(request) => &request.source, + } + } + + fn destination(&self) -> &str { + match self { + Self::Move(request) => &request.destination, + Self::Copy(request) => &request.destination, + } + } + + fn force(&self) -> bool { + match self { + Self::Move(request) => request.force, + Self::Copy(request) => request.force, + } + } +} + +#[allow(clippy::too_many_arguments)] +fn execute_transfer( + repository: &Repository, + keys: &KeyStore, + request: TransferRequest<'_>, + secrets: &mut SecretStore, + interaction: &mut I, + stderr: &mut E, +) -> Result { + let mut committer = match automatic_committer(repository, request.source()) { + Ok(committer) => committer, + Err(error) => return operation_error(stderr, error), + }; + let mut decision = if request.force() { + OverwriteDecision::Allow + } else { + OverwriteDecision::Decline + }; + loop { + let mutator = TreeMutator::new(repository, keys); + let result = match request { + TransferRequest::Move(request) => { + mutator.move_tree(request, decision, None, secrets, &mut committer) + } + TransferRequest::Copy(request) => { + mutator.copy(request, decision, None, secrets, &mut committer) + } + }; + match result { + Ok(_) => return Ok(EXIT_SUCCESS), + Err(MutationError::Cancelled) + if decision == OverwriteDecision::Decline && !request.force() => + { + decision = match interaction.confirm( + &format!( + "An entry already exists at {}. Overwrite it?", + request.destination() + ), + stderr, + ) { + Ok(decision) => decision, + Err(error) => return operation_error(stderr, error), + }; + if decision == OverwriteDecision::Decline { + return operation_error(stderr, MutationError::Cancelled); + } + } + Err(error) => return operation_error(stderr, error), + } + } +} + +fn pull_outcome_name(outcome: PullOutcome) -> &'static str { + match outcome { + PullOutcome::UpToDate => "up to date", + PullOutcome::FastForward => "fast-forward", + PullOutcome::Merged => "merged", } } @@ -368,7 +833,7 @@ fn execute_otp return operation_error(stderr, error), }; let mut committer = - match generation_committer(&repository, request.entry.trim_end_matches('/')) { + match automatic_committer(&repository, request.entry.trim_end_matches('/')) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; @@ -431,7 +896,7 @@ fn execute_otp committer, Err(error) => return operation_error(stderr, error), }; @@ -472,7 +937,7 @@ fn execute_otp input, Err(error) => return operation_error(stderr, error), }; - let mut committer = match generation_committer(&repository, &path) { + let mut committer = match automatic_committer(&repository, &path) { Ok(committer) => committer, Err(error) => return operation_error(stderr, error), }; @@ -528,6 +993,21 @@ trait OtpInteraction { ) -> Result; } +trait CliInteraction: OtpInteraction { + fn read_insert( + &mut self, + plan: InputPlan, + entry: &str, + stderr: &mut dyn Write, + ) -> Result; + + fn edit( + &mut self, + plaintext: &SecretBytes, + config: &Config, + ) -> Result<(SecretBytes, String), CliInteractionError>; +} + struct NativeOtpInteraction; impl OtpInteraction for NativeOtpInteraction { @@ -593,6 +1073,60 @@ impl OtpInteraction for NativeOtpInteraction { } } +impl CliInteraction for NativeOtpInteraction { + fn read_insert( + &mut self, + plan: InputPlan, + entry: &str, + stderr: &mut dyn Write, + ) -> Result { + match plan { + InputPlan::HiddenConfirmed => { + let first = rpassword::prompt_password(format!("Enter password for {entry}: ")) + .map_err(|_| CliInteractionError::Input)? + .into_bytes(); + let confirmation = + rpassword::prompt_password(format!("Retype password for {entry}: ")) + .map_err(|_| CliInteractionError::Input)? + .into_bytes(); + InsertContent::hidden(first, confirmation).map_err(Into::into) + } + InputPlan::EchoedLine => { + write!(stderr, "Enter password for {entry}: ") + .and_then(|()| stderr.flush()) + .map_err(|_| CliInteractionError::Output)?; + InsertContent::echoed(read_cli_input_line()?).map_err(Into::into) + } + InputPlan::StandardInputLine => { + InsertContent::echoed(read_cli_input_line()?).map_err(Into::into) + } + InputPlan::StandardInputToEnd => { + let mut contents = Vec::new(); + std::io::stdin() + .lock() + .read_to_end(&mut contents) + .map_err(|_| CliInteractionError::Input)?; + Ok(InsertContent::multiline(contents)) + } + } + } + + fn edit( + &mut self, + plaintext: &SecretBytes, + config: &Config, + ) -> Result<(SecretBytes, String), CliInteractionError> { + let resolved = config + .resolve_editor() + .map_err(CliInteractionError::EditorResolution)?; + let name = resolved.command().program().to_owned(); + let replacement = + editor::edit_replacement(plaintext, &resolved, &mut editor::NativeEditorHost) + .map_err(CliInteractionError::Editor)?; + Ok((replacement, name)) + } +} + #[cfg(test)] struct UnavailableOtpInteraction; @@ -621,6 +1155,55 @@ impl OtpInteraction for UnavailableOtpInteraction { } } +#[cfg(test)] +impl CliInteraction for UnavailableOtpInteraction { + fn read_insert( + &mut self, + _plan: InputPlan, + _entry: &str, + _stderr: &mut dyn Write, + ) -> Result { + Err(CliInteractionError::Input) + } + + fn edit( + &mut self, + _plaintext: &SecretBytes, + _config: &Config, + ) -> Result<(SecretBytes, String), CliInteractionError> { + Err(CliInteractionError::Input) + } +} + +#[derive(Debug)] +enum CliInteractionError { + Write(ironstorage::write::WriteError), + EditorResolution(ironstorage::config::EditorError), + Editor(editor::CliEditorError), + Input, + Output, +} + +impl fmt::Display for CliInteractionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Write(error) => error.fmt(formatter), + Self::EditorResolution(error) => error.fmt(formatter), + Self::Editor(error) => error.fmt(formatter), + Self::Input => formatter.write_str("command input could not be read"), + Self::Output => formatter.write_str("command prompt could not be written"), + } + } +} + +impl From for CliInteractionError { + fn from(error: ironstorage::write::WriteError) -> Self { + Self::Write(error) + } +} + +impl Error for CliInteractionError {} + #[derive(Debug)] enum OtpInteractionError { Otp(OtpError), @@ -666,6 +1249,24 @@ fn read_standard_input_line() -> Result, OtpInteractionError> { Ok(value) } +fn read_cli_input_line() -> Result, CliInteractionError> { + let mut value = Vec::new(); + let read = std::io::stdin() + .lock() + .read_until(b'\n', &mut value) + .map_err(|_| CliInteractionError::Input)?; + if read == 0 { + return Err(CliInteractionError::Input); + } + if value.ends_with(b"\n") { + value.pop(); + } + if value.ends_with(b"\r") { + value.pop(); + } + Ok(value) +} + fn current_unix_seconds() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -673,31 +1274,53 @@ fn current_unix_seconds() -> Result { .map_err(|_| OtpInteractionError::Clock) } -enum GenerationCommitter { +enum AutomaticCommitter { Git(Box), - None(NoGitEntryCommitter), + None { + entry: NoGitEntryCommitter, + policy: NoGitCommitter, + tree: NoGitTreeCommitter, + }, } -impl EntryCommitter for GenerationCommitter { +impl EntryCommitter for AutomaticCommitter { 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), + Self::None { entry, .. } => entry.commit(change), } } } -fn generation_committer( +impl PolicyCommitter for AutomaticCommitter { + fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { + match self { + Self::Git(git) => PolicyCommitter::commit(git.as_mut(), change), + Self::None { policy, .. } => policy.commit(change), + } + } +} + +impl TreeCommitter for AutomaticCommitter { + fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> { + match self { + Self::Git(git) => TreeCommitter::commit(git.as_mut(), change), + Self::None { tree, .. } => tree.commit(change), + } + } +} + +fn automatic_committer( repository: &Repository, entry: &str, -) -> Result { - let identity = GitIdentity::new("IronStorage", "ironstorage@localhost") - .expect("the built-in Git identity is valid"); - match GitRepository::open_innermost(repository, Path::new(entry), identity) { - Ok(git) => Ok(GenerationCommitter::Git(Box::new(git))), - Err(ironstorage::git::GitError::NotRepository) => { - Ok(GenerationCommitter::None(NoGitEntryCommitter)) - } +) -> Result { + match GitRepository::open_innermost(repository, Path::new(entry), git_identity()) { + Ok(git) => Ok(AutomaticCommitter::Git(Box::new(git))), + Err(ironstorage::git::GitError::NotRepository) => Ok(AutomaticCommitter::None { + entry: NoGitEntryCommitter, + policy: NoGitCommitter, + tree: NoGitTreeCommitter, + }), Err(error) => Err(error), } } @@ -846,10 +1469,12 @@ mod tests { use ironstorage::{ command::{ - CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, - GenerateRequest, GeneratedPresentation, InputPlan, OtpAppendRequest, OtpCodeRequest, - OtpInputSource, OtpInsertRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, - Presentation, ShowRequest, + CommandRequest, CopyRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, + EXIT_USAGE, EditRequest, FindRequest, GenerateRequest, GeneratedPresentation, + GitRequest, GrepRequest, InitRequest, InputPlan, InsertInput, InsertRequest, + ListRequest, MoveRequest, OtpAppendRequest, OtpCodeRequest, OtpInputSource, + OtpInsertRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, Presentation, + RemoveRequest, ShowRequest, }, config::Config, git::GitCredentialProvider as _, @@ -860,12 +1485,13 @@ mod tests { SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, }, - write::OverwriteDecision, + write::{InsertContent, OverwriteDecision}, }; use super::{ - CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_secure, - execute_secure_with, execute_secure_with_services, run_with, wait_for_clipboard, + CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local, + execute_secure, execute_secure_with, execute_secure_with_services, run_with, + wait_for_clipboard, }; type TestResult = Result<(), Box>; @@ -883,6 +1509,8 @@ mod tests { struct MemoryOtpInteraction { terminal: bool, inputs: VecDeque, + insert_inputs: VecDeque, + edit_replacements: VecDeque, decisions: VecDeque, plans: Vec, prompts: Vec, @@ -915,6 +1543,30 @@ mod tests { } } + impl super::CliInteraction for MemoryOtpInteraction { + fn read_insert( + &mut self, + _plan: InputPlan, + _entry: &str, + _stderr: &mut dyn std::io::Write, + ) -> Result { + self.insert_inputs + .pop_front() + .ok_or(super::CliInteractionError::Input) + } + + fn edit( + &mut self, + _plaintext: &SecretBytes, + _config: &Config, + ) -> Result<(SecretBytes, String), super::CliInteractionError> { + self.edit_replacements + .pop_front() + .map(|replacement| (replacement, "fixture-editor".to_owned())) + .ok_or(super::CliInteractionError::Input) + } + } + impl CliPresentation for MemoryPresentation { fn clipboard( &mut self, @@ -1557,6 +2209,349 @@ mod tests { Ok(()) } + #[test] + fn complete_cli_dispatch_runs_storage_workflows_and_automatic_commits() -> TestResult { + const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; + let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let temporary = tempfile::tempdir()?; + let vault = temporary.path().join("vault"); + fs::create_dir_all(vault.join("email"))?; + for path in [".gpg-id", ".gpg-id.sig", "email/personal.gpg"] { + fs::copy(fixtures.join("stores/basic").join(path), vault.join(path))?; + } + let config_path = temporary.path().join("config.toml"); + fs::write( + &config_path, + format!( + "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", + vault, + FINGERPRINT, + fixtures.join("keys"), + ), + )?; + let config = Config::load(Some(&config_path))?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + assert_eq!( + execute_local( + &config, + &CommandRequest::Git(GitRequest::Init), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + stdout.clear(); + assert_eq!( + execute_local( + &config, + &CommandRequest::List(ListRequest { path: None }), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert!(stdout.windows(5).any(|part| part == b"email")); + stdout.clear(); + assert_eq!( + execute_local( + &config, + &CommandRequest::Find(FindRequest { + terms: vec!["PERSONAL".to_owned()], + }), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert!(stdout.windows(8).any(|part| part == b"personal")); + + let mut secrets = fixture_secrets(FINGERPRINT)?; + let mut presentation = MemoryPresentation::default(); + let mut interaction = MemoryOtpInteraction::default(); + stdout.clear(); + assert_eq!( + execute_secure_with_services( + &config, + &CommandRequest::Grep(GrepRequest { + pattern: "alice@example\\.test".to_owned(), + ignore_case: false, + invert_match: false, + line_number: false, + fixed_strings: false, + }), + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert!(stdout.windows(14).any(|part| part == b"email/personal")); + + assert_eq!( + execute_secure_with_services( + &config, + &CommandRequest::Init(InitRequest { + path: Some("nested".to_owned()), + key_identities: vec![FINGERPRINT.to_owned()], + }), + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + interaction + .insert_inputs + .push_back(InsertContent::echoed(b"first secret".to_vec())?); + assert_eq!( + execute_secure_with_services( + &config, + &CommandRequest::Insert(InsertRequest { + entry: "nested/new".to_owned(), + input: InsertInput::EchoedLine, + force: false, + }), + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + interaction + .edit_replacements + .push_back(SecretBytes::new(b"edited secret\nmetadata".to_vec())); + assert_eq!( + execute_secure_with_services( + &config, + &CommandRequest::Edit(EditRequest { + entry: "nested/new".to_owned(), + }), + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + for request in [ + CommandRequest::Copy(CopyRequest { + source: "nested/new".to_owned(), + destination: "nested/copied".to_owned(), + force: false, + }), + CommandRequest::Move(MoveRequest { + source: "nested/copied".to_owned(), + destination: "nested/moved".to_owned(), + force: false, + }), + ] { + assert_eq!( + execute_secure_with_services( + &config, + &request, + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + } + interaction.decisions.push_back(OverwriteDecision::Allow); + assert_eq!( + execute_secure_with_services( + &config, + &CommandRequest::Remove(RemoveRequest { + entry: "nested/moved".to_owned(), + recursive: false, + force: false, + }), + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + + let repository = Repository::open(&vault)?; + let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?; + let edited = keys.decrypt( + &repository.read_entry(&EntryPath::parse("nested/new")?)?, + &mut secrets, + )?; + assert_eq!(edited.expose(), b"edited secret\nmetadata"); + assert!(!vault.join("nested/moved.gpg").exists()); + + stdout.clear(); + assert_eq!( + execute_local( + &config, + &CommandRequest::Git(GitRequest::Status), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert_eq!(stdout, b"clean\n"); + stdout.clear(); + assert_eq!( + execute_local( + &config, + &CommandRequest::Git(GitRequest::Log { maximum: None }), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + let log = String::from_utf8(stdout.clone())?; + for message in [ + "Set GPG id", + "Add given password", + "Edit password", + "Copy nested/new", + "Rename nested/copied", + "Remove nested/moved", + ] { + assert!(log.contains(message), "missing automatic commit {message}"); + } + let entry = EntryPath::parse("nested/new")?; + let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys) + .resolve_for_entry(&entry, None)?; + let replacement = keys.encrypt( + SecretBytes::new(b"changed secret\nmetadata".to_vec()), + recipients.recipients(), + )?; + repository.write_entry(&entry, &replacement)?; + stdout.clear(); + assert_eq!( + execute_secure_with_services( + &config, + &CommandRequest::Git(GitRequest::Diff { + paths: vec!["nested/new.gpg".to_owned()], + }), + &mut secrets, + &mut presentation, + &mut interaction, + || Ok(0), + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert!( + stdout + .windows(b"-edited secret\n".len()) + .any(|part| part == b"-edited secret\n") + ); + assert!( + stdout + .windows(b"+changed secret\n".len()) + .any(|part| part == b"+changed secret\n") + ); + assert!(!stdout.windows(5).any(|part| part == b"-----")); + assert!(stderr.is_empty()); + Ok(()) + } + + #[test] + fn completion_output_and_source_process_boundary_are_audited() -> TestResult { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + run_with( + ["ironstorage", "completion", "bash"], + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert!(stdout.windows(11).any(|part| part == b"ironstorage")); + assert!(stderr.is_empty()); + + let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut rust_sources = Vec::new(); + collect_rust_sources(&workspace.join("apps"), &mut rust_sources)?; + collect_rust_sources(&workspace.join("crates"), &mut rust_sources)?; + let unsafe_block = ["unsafe", " {"].concat(); + let unsafe_function = ["unsafe", " fn"].concat(); + let unsafe_implementation = ["unsafe", " impl"].concat(); + let command_constructor = ["Command", "::new("].concat(); + let process_command = ["process", "::Command"].concat(); + for source in rust_sources { + let contents = fs::read_to_string(&source)?; + assert!( + !contents.contains(&unsafe_block), + "forbidden block in {}", + source.display() + ); + assert!( + !contents.contains(&unsafe_function), + "forbidden function in {}", + source.display() + ); + assert!( + !contents.contains(&unsafe_implementation), + "forbidden implementation in {}", + source.display() + ); + if contents.contains(&command_constructor) || contents.contains(&process_command) { + assert!( + source.ends_with("apps/cli/src/editor.rs"), + "process launch outside editor adapter: {}", + source.display() + ); + } + } + Ok(()) + } + + fn collect_rust_sources( + directory: &std::path::Path, + output: &mut Vec, + ) -> std::io::Result<()> { + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + if path.is_dir() { + collect_rust_sources(&path, output)?; + } else if path.extension().and_then(std::ffi::OsStr::to_str) == Some("rs") { + output.push(path); + } + } + Ok(()) + } + fn fixture_secrets(fingerprint: &str) -> Result, SecretStoreError> { let secrets = SecretStore::new( MemoryBackend::default(), diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 0fc741f..895e231 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -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 diff --git a/crates/storage/src/command.rs b/crates/storage/src/command.rs index 122b12b..64d3d93 100644 --- a/crates/storage/src/command.rs +++ b/crates/storage/src/command.rs @@ -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 }, 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, @@ -417,6 +440,13 @@ pub fn otp_version_text() -> &'static str { "1.1.1\n" } +pub fn completion_script(shell: CompletionShell) -> Vec { + 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 { 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) -> Vec { }; 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, 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" { diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 32f3a4f..a297cd6 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -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 { + 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>, 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 { + 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, 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()) diff --git a/crates/storage/src/write.rs b/crates/storage/src/write.rs index a97b8b1..3576bfd 100644 --- a/crates/storage/src/write.rs +++ b/crates/storage/src/write.rs @@ -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, confirmation: Vec) -> Result { - validate_single_line(&first)?; - validate_single_line(&confirmation)?; + pub fn hidden(mut first: Vec, mut confirmation: Vec) -> Result { + 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) -> Result { - validate_single_line(&line)?; + pub fn echoed(mut line: Vec) -> Result { + 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 { + 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, diff --git a/crates/storage/tests/command_contract.rs b/crates/storage/tests/command_contract.rs index ff50760..058da07 100644 --- a/crates/storage/tests/command_contract.rs +++ b/crates/storage/tests/command_contract.rs @@ -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>; @@ -19,6 +20,39 @@ fn request(arguments: &[&str]) -> Result> { } } +#[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!( diff --git a/crates/storage/tests/compatibility_fixtures.rs b/crates/storage/tests/compatibility_fixtures.rs index 867841e..634f894 100644 --- a/crates/storage/tests/compatibility_fixtures.rs +++ b/crates/storage/tests/compatibility_fixtures.rs @@ -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()?; diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index d92a38b..42725be 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -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); diff --git a/docs/cli-parity.md b/docs/cli-parity.md new file mode 100644 index 0000000..d3f93d8 --- /dev/null +++ b/docs/cli-parity.md @@ -0,0 +1,90 @@ +# CLI parity and executable audit + +The `ironstorage` binary is a presentation and interaction adapter over +`crates/storage`. The compatibility target is password-store 1.7.4 at +`1078f2514d579178d5df7042c6a790e9c9b731ad` and pass-otp 1.2.0 at +`1e9d10ca75ae1a8672a7f192809713463657778e`. The checked-in `behavior.toml` +catalog contains 108 original, data-only cases. A contract test submits every +case to the Rust parser, while domain and CLI workflow tests exercise the +corresponding storage effects. + +## Command matrix + +| Upstream surface | IronStorage command | Rust owner and evidence | +| --- | --- | --- | +| default/list, `show`, `ls`, `list` | same, including implicit entry dispatch, line selection, clipboard and QR | `read`, `presentation`; command-contract, read-domain, presentation and CLI tests | +| `find`, `search`, `grep` | same aliases; supported grep flags are explicit | `read`; compatibility catalog, read-domain and complete CLI workflow tests | +| `init -p/--path` | same, including removal through an empty identity | `recipient`; recipient-policy and complete CLI workflow tests | +| `insert`, `add` | hidden-confirmed, echoed and multiline input, force and confirmation | `write`; write-domain and complete CLI workflow tests | +| `edit` | same logical edit session through the configured/default editor | `write`; editor-adapter, write-domain and complete CLI workflow tests | +| `generate` | length, no-symbols, force, in-place, clipboard and QR | `generate`; generation and CLI presentation tests | +| `rm`, `remove`, `delete` | force and recursive behavior | `mutation`; tree-mutation and complete CLI workflow tests | +| `mv`, `rename`, `cp`, `copy` | same aliases, destination and overwrite behavior | `mutation`; tree-mutation and complete CLI workflow tests | +| `git` | `init`, `status`, `log`, helper-free decrypted `diff`, `add`, `commit`, `remote`, safe local `config`, `fetch`, `pull`, `push`, `sync` | `git`; embedded Git, smart-HTTP and complete CLI workflow tests | +| pass-otp default/code/show | `otp ENTRY`, `otp code`, `otp show`, clipboard | `otp`; RFC, fixture and CLI tests | +| pass-otp insert/add/append | URI or secret input and issuer/account derivation | `otp`; fixture, transaction and CLI tests | +| pass-otp uri/validate/help/version | terminal, clipboard, QR and the pinned upstream version string | `otp`, `presentation`, `command`; fixture and CLI tests | +| help/version | command, `-h`/`--help`, `-V`/`--version` forms | `command`; contract and stream/exit-code tests | + +`ironstorage completion SHELL` generates Bash, Elvish, Fish, PowerShell or Zsh +completion source in process with `clap_complete`. It writes the script to +standard output and neither searches for nor invokes a shell or completion +helper. + +## Deliberate boundaries + +Upstream extension discovery executes files named `pass-*`. IronStorage does +not execute extensions; the first-party OTP surface is built in and typed. +Upstream `pass git` forwards arbitrary arguments to the Git executable. +IronStorage instead exposes the documented embedded workflows in the matrix. +Arbitrary passthrough such as `rebase`, `reflog`, hooks, filters, credential +helpers and helper transports is rejected. This is the only compatibility gap +in the first-party command surface and follows directly from the no-process +rule. + +The sole runtime process boundary is `apps/cli/src/editor.rs`. It launches the +resolved editor program directly with parsed arguments and the private edit +file path; it never invokes a shell. A missing configured/default editor is a +typed configuration error printed on standard error, the entry is unchanged, +and the command fails. Editor failure, cancellation, oversized output and +cleanup failure are likewise typed and leave no committed mutation. + +All prompts and errors use standard error. Trees, plaintext explicitly +requested for terminal display, Git reports, help, versions and completion +scripts use standard output. Clipboard and QR paths do not copy their secret +payload back to standard output. Parse and ordinary operation errors return 1, +configuration errors 78, unavailable operating-system services 69, and +success returns 0. + +## Compatibility and security evidence + +The compatibility fixtures contain GnuPG-produced armored and binary keys, +GPG-encrypted entries, root/nested/multiple/signed `.gpg-id` policies, +pass-otp URIs, and valid loose-object Git repositories. Tests independently +decrypt and authenticate every entry, verify recipient signatures and Git +objects, round-trip OTP URIs, and exercise automatic commits. No compatibility +test requires an upstream executable at runtime. + +The executable audit checks project Rust sources for process construction and +permits it only in the editor adapter. Every project crate forbids unsafe Rust. +Git repository configuration rejects executable helpers and all non-HTTPS, +credential-bearing or rewritten remote forms before transport. Error and debug +models redact secret bytes; CLI presentation tests assert clipboard, QR, OTP +and generated values do not appear on unintended streams. + +The activated dependency graph was reviewed with `cargo tree -e features` and +`cargo metadata --locked`. Gix default features are disabled and only the +embedded index, merge, revision, tree editing and Rustls smart-HTTP features +are selected. Platform secret-store implementations may compile operating- +system IPC/runtime support, but IronStorage never calls dependency APIs that +spawn a helper. Direct dependency licenses and the remaining project-license +release decision are recorded in `DEPENDENCIES.md`. + +Run the executable gate from the workspace root: + +```sh +cargo fmt --all -- --check +RUSTFLAGS="-D warnings" cargo check --workspace --all-targets +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +```