Complete the end-to-end CLI parity audit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user