Complete the end-to-end CLI parity audit
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -1013,6 +1013,15 @@ dependencies = [
|
|||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.4"
|
version = "4.6.4"
|
||||||
@@ -3972,6 +3981,7 @@ dependencies = [
|
|||||||
"cap-std",
|
"cap-std",
|
||||||
"cap-tempfile",
|
"cap-tempfile",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"flate2",
|
"flate2",
|
||||||
"gix",
|
"gix",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ arboard = { version = "3.6", default-features = false, features = ["wayland-data
|
|||||||
cap-std = "4.0"
|
cap-std = "4.0"
|
||||||
cap-tempfile = "4.0"
|
cap-tempfile = "4.0"
|
||||||
clap = { version = "4.6", features = ["derive"] }
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
|
clap_complete = "4.6"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
ctrlc = "3.5"
|
ctrlc = "3.5"
|
||||||
data-encoding = "2.9"
|
data-encoding = "2.9"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ The current direct dependencies are:
|
|||||||
| Crate | Purpose | License |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ Clipboard cleanup/race behavior and platform-neutral QR rendering are
|
|||||||
documented in [`docs/presentation.md`](docs/presentation.md).
|
documented in [`docs/presentation.md`](docs/presentation.md).
|
||||||
Pass-OTP URI compatibility, RFC code generation, and atomic HOTP counters are
|
Pass-OTP URI compatibility, RFC code generation, and atomic HOTP counters are
|
||||||
documented in [`docs/otp.md`](docs/otp.md).
|
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
|
The capability-scoped password-store layout and atomic mutation guarantees are
|
||||||
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
||||||
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
#![allow(
|
||||||
|
clippy::disallowed_types,
|
||||||
|
reason = "this module is the documented CLI-only configured-editor process boundary"
|
||||||
|
)]
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
fmt, fs,
|
fmt, fs,
|
||||||
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
|
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
};
|
};
|
||||||
|
|
||||||
use ironstorage::{config::ResolvedEditor, repository::SecretBytes};
|
use ironstorage::{config::ResolvedEditor, repository::SecretBytes};
|
||||||
@@ -44,12 +50,32 @@ pub(crate) trait EditorHost {
|
|||||||
fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError>;
|
fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct NativeEditorHost;
|
||||||
|
|
||||||
|
impl EditorHost for NativeEditorHost {
|
||||||
|
fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError> {
|
||||||
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct EditorHostError;
|
pub(crate) struct EditorHostError(String);
|
||||||
|
|
||||||
impl fmt::Display for EditorHostError {
|
impl fmt::Display for EditorHostError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
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(())
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1137
apps/cli/src/main.rs
1137
apps/cli/src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ publish = false
|
|||||||
cap-std.workspace = true
|
cap-std.workspace = true
|
||||||
cap-tempfile.workspace = true
|
cap-tempfile.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
clap_complete.workspace = true
|
||||||
data-encoding.workspace = true
|
data-encoding.workspace = true
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
gix.workspace = true
|
gix.workspace = true
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
use std::{error::Error, ffi::OsString, fmt, num::NonZeroUsize, path::PathBuf};
|
use std::{error::Error, ffi::OsString, fmt, num::NonZeroUsize, path::PathBuf};
|
||||||
|
|
||||||
use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
|
use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
|
||||||
|
use clap_complete::{Shell, generate};
|
||||||
|
|
||||||
use crate::PRODUCT_NAME;
|
use crate::PRODUCT_NAME;
|
||||||
|
|
||||||
pub const EXIT_SUCCESS: u8 = 0;
|
pub const EXIT_SUCCESS: u8 = 0;
|
||||||
pub const EXIT_FAILURE: u8 = 1;
|
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_UNAVAILABLE: u8 = 69;
|
||||||
pub const EXIT_CONFIG: u8 = 78;
|
pub const EXIT_CONFIG: u8 = 78;
|
||||||
|
|
||||||
@@ -53,10 +54,32 @@ pub enum CommandRequest {
|
|||||||
Copy(CopyRequest),
|
Copy(CopyRequest),
|
||||||
Git(GitRequest),
|
Git(GitRequest),
|
||||||
Otp(OtpRequest),
|
Otp(OtpRequest),
|
||||||
|
Completion { shell: CompletionShell },
|
||||||
Help { topic: Option<HelpTopic> },
|
Help { topic: Option<HelpTopic> },
|
||||||
Version,
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct InitRequest {
|
pub struct InitRequest {
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
@@ -417,6 +440,13 @@ pub fn otp_version_text() -> &'static str {
|
|||||||
"1.1.1\n"
|
"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)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "ironstorage",
|
name = "ironstorage",
|
||||||
@@ -452,10 +482,26 @@ enum CommandArguments {
|
|||||||
Copy(CopyArguments),
|
Copy(CopyArguments),
|
||||||
Git(GitArguments),
|
Git(GitArguments),
|
||||||
Otp(OtpArguments),
|
Otp(OtpArguments),
|
||||||
|
Completion(CompletionArguments),
|
||||||
Help(HelpArguments),
|
Help(HelpArguments),
|
||||||
Version,
|
Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct CompletionArguments {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: CompletionShellArgument,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||||
|
enum CompletionShellArgument {
|
||||||
|
Bash,
|
||||||
|
Elvish,
|
||||||
|
Fish,
|
||||||
|
Powershell,
|
||||||
|
Zsh,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
struct InitArguments {
|
struct InitArguments {
|
||||||
#[arg(short = 'p', long = "path")]
|
#[arg(short = 'p', long = "path")]
|
||||||
@@ -784,6 +830,15 @@ fn convert_arguments(arguments: CliArguments) -> Result<CliInvocation, CliParseE
|
|||||||
Some(CommandArguments::Otp(arguments)) => {
|
Some(CommandArguments::Otp(arguments)) => {
|
||||||
CommandRequest::Otp(convert_otp(arguments.command)?)
|
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)) => {
|
Some(CommandArguments::Help(arguments)) => {
|
||||||
let topic = arguments
|
let topic = arguments
|
||||||
.topic
|
.topic
|
||||||
@@ -914,9 +969,29 @@ fn normalize_dispatch(mut arguments: Vec<OsString>) -> Vec<OsString> {
|
|||||||
};
|
};
|
||||||
let command = arguments[command_index].to_string_lossy();
|
let command = arguments[command_index].to_string_lossy();
|
||||||
const ROOT_COMMANDS: &[&str] = &[
|
const ROOT_COMMANDS: &[&str] = &[
|
||||||
"init", "ls", "list", "show", "find", "search", "grep", "insert", "add", "edit",
|
"init",
|
||||||
"generate", "rm", "remove", "delete", "mv", "rename", "cp", "copy", "git", "otp", "help",
|
"ls",
|
||||||
|
"list",
|
||||||
|
"show",
|
||||||
|
"find",
|
||||||
|
"search",
|
||||||
|
"grep",
|
||||||
|
"insert",
|
||||||
|
"add",
|
||||||
|
"edit",
|
||||||
|
"generate",
|
||||||
|
"rm",
|
||||||
|
"remove",
|
||||||
|
"delete",
|
||||||
|
"mv",
|
||||||
|
"rename",
|
||||||
|
"cp",
|
||||||
|
"copy",
|
||||||
|
"git",
|
||||||
|
"otp",
|
||||||
|
"help",
|
||||||
"version",
|
"version",
|
||||||
|
"completion",
|
||||||
];
|
];
|
||||||
if !ROOT_COMMANDS.contains(&command.as_ref()) && !command.starts_with('-') {
|
if !ROOT_COMMANDS.contains(&command.as_ref()) && !command.starts_with('-') {
|
||||||
arguments.insert(command_index, OsString::from("show"));
|
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] = &[
|
const OTP_COMMANDS: &[&str] = &[
|
||||||
"code", "show", "insert", "add", "append", "uri", "validate", "help", "version",
|
"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");
|
arguments[index] = OsString::from("version");
|
||||||
} else if !OTP_COMMANDS.contains(&argument.as_ref()) && argument != "--help" && argument != "-h"
|
} else if !OTP_COMMANDS.contains(&argument.as_ref()) && argument != "--help" && argument != "-h"
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::{
|
|||||||
crypto::{KeyHandle, KeyStore, SecretProvider},
|
crypto::{KeyHandle, KeyStore, SecretProvider},
|
||||||
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
||||||
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
||||||
repository::Repository,
|
repository::{EncryptedEntry, Repository, SecretBytes},
|
||||||
write::{EntryCommit, EntryCommitError, EntryCommitter},
|
write::{EntryCommit, EntryCommitError, EntryCommitter},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1587,6 +1587,41 @@ impl GitRepository {
|
|||||||
Ok(output)
|
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> {
|
fn head_tree_map(&self) -> Result<Option<BTreeMap<String, gix::hash::ObjectId>>, GitError> {
|
||||||
let Some(id) = self.repository.head_id().ok() else {
|
let Some(id) = self.repository.head_id().ok() else {
|
||||||
return Ok(None);
|
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 {
|
impl EntryCommitter for GitRepository {
|
||||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||||
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())
|
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::{
|
|||||||
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
|
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
|
||||||
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
||||||
};
|
};
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum OverwriteDecision {
|
pub enum OverwriteDecision {
|
||||||
@@ -21,23 +22,38 @@ pub struct InsertContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InsertContent {
|
impl InsertContent {
|
||||||
pub fn hidden(first: Vec<u8>, confirmation: Vec<u8>) -> Result<Self, WriteError> {
|
pub fn hidden(mut first: Vec<u8>, mut confirmation: Vec<u8>) -> Result<Self, WriteError> {
|
||||||
validate_single_line(&first)?;
|
if let Err(error) = validate_single_line(&first) {
|
||||||
validate_single_line(&confirmation)?;
|
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 {
|
if first != confirmation {
|
||||||
|
first.zeroize();
|
||||||
|
confirmation.zeroize();
|
||||||
return Err(WriteError::ConfirmationMismatch);
|
return Err(WriteError::ConfirmationMismatch);
|
||||||
}
|
}
|
||||||
if first.is_empty() {
|
if first.is_empty() {
|
||||||
|
confirmation.zeroize();
|
||||||
return Err(WriteError::EmptySingleLine);
|
return Err(WriteError::EmptySingleLine);
|
||||||
}
|
}
|
||||||
|
confirmation.zeroize();
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
mode: InsertInput::HiddenConfirmed,
|
mode: InsertInput::HiddenConfirmed,
|
||||||
secret: SecretBytes::new(first),
|
secret: SecretBytes::new(first),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn echoed(line: Vec<u8>) -> Result<Self, WriteError> {
|
pub fn echoed(mut line: Vec<u8>) -> Result<Self, WriteError> {
|
||||||
validate_single_line(&line)?;
|
if let Err(error) = validate_single_line(&line) {
|
||||||
|
line.zeroize();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
return Err(WriteError::EmptySingleLine);
|
return Err(WriteError::EmptySingleLine);
|
||||||
}
|
}
|
||||||
@@ -186,6 +202,17 @@ impl<'a> VaultWriter<'a> {
|
|||||||
Self { repository, keys }
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn insert(
|
pub fn insert(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
use std::{error::Error, num::NonZeroUsize, path::Path};
|
use std::{error::Error, num::NonZeroUsize, path::Path};
|
||||||
|
|
||||||
use ironstorage::command::{
|
use ironstorage::command::{
|
||||||
CliAction, CommandRequest, EXIT_USAGE, GeneratedPresentation, GitConfigRequest,
|
CliAction, CommandRequest, CompletionShell, EXIT_USAGE, GeneratedPresentation,
|
||||||
GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput, OtpInputSource, OtpRequest,
|
GitConfigRequest, GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput,
|
||||||
OtpUriPresentation, Presentation, help_text, otp_version_text, parse_from, version_text,
|
OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text,
|
||||||
|
otp_version_text, parse_from, version_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error>>;
|
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]
|
#[test]
|
||||||
fn implicit_show_and_list_aliases_are_canonical_requests() -> TestResult {
|
fn implicit_show_and_list_aliases_are_canonical_requests() -> TestResult {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ mod support;
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use ironstorage::command::parse_from;
|
||||||
|
|
||||||
use support::compatibility::{
|
use support::compatibility::{
|
||||||
FixtureSet, TestResult, decrypt_entry_with_passphrase, validate_entry_record,
|
FixtureSet, TestResult, decrypt_entry_with_passphrase, validate_entry_record,
|
||||||
validate_key_record, validate_recipient_signature, validate_repository,
|
validate_key_record, validate_recipient_signature, validate_repository,
|
||||||
@@ -154,6 +156,37 @@ fn behavior_catalog_covers_the_milestone_contract() -> TestResult {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn generated_openpgp_fixtures_are_self_consistent() -> TestResult {
|
fn generated_openpgp_fixtures_are_self_consistent() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult {
|
|||||||
let diff = git.diff(&[])?;
|
let diff = git.diff(&[])?;
|
||||||
assert_eq!(diff[0].old(), Some(b"ALICE\n".as_slice()));
|
assert_eq!(diff[0].old(), Some(b"ALICE\n".as_slice()));
|
||||||
assert_eq!(diff[0].current(), Some(b"BOB\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()])?;
|
git.stage(&[".gpg-id".into()])?;
|
||||||
assert_eq!(git.status()?.staged()[0].kind(), GitChangeKind::Modified);
|
assert_eq!(git.status()?.staged()[0].kind(), GitChangeKind::Modified);
|
||||||
|
|||||||
90
docs/cli-parity.md
Normal file
90
docs/cli-parity.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user