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