Complete the end-to-end CLI parity audit

This commit is contained in:
Hermes Agent
2026-08-10 01:51:47 +00:00
parent 4bd39b1ef2
commit 0a905e5e14
14 changed files with 1476 additions and 95 deletions

10
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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 |

View File

@@ -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

View File

@@ -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<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)]
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"
);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,12 +3,13 @@
use std::{error::Error, ffi::OsString, fmt, num::NonZeroUsize, path::PathBuf};
use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
use clap_complete::{Shell, generate};
use crate::PRODUCT_NAME;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_FAILURE: u8 = 1;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_USAGE: u8 = EXIT_FAILURE;
pub const EXIT_UNAVAILABLE: u8 = 69;
pub const EXIT_CONFIG: u8 = 78;
@@ -53,10 +54,32 @@ pub enum CommandRequest {
Copy(CopyRequest),
Git(GitRequest),
Otp(OtpRequest),
Completion { shell: CompletionShell },
Help { topic: Option<HelpTopic> },
Version,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CompletionShell {
Bash,
Elvish,
Fish,
PowerShell,
Zsh,
}
impl CompletionShell {
fn generator(self) -> Shell {
match self {
Self::Bash => Shell::Bash,
Self::Elvish => Shell::Elvish,
Self::Fish => Shell::Fish,
Self::PowerShell => Shell::PowerShell,
Self::Zsh => Shell::Zsh,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InitRequest {
pub path: Option<String>,
@@ -417,6 +440,13 @@ pub fn otp_version_text() -> &'static str {
"1.1.1\n"
}
pub fn completion_script(shell: CompletionShell) -> Vec<u8> {
let mut command = CliArguments::command();
let mut output = Vec::new();
generate(shell.generator(), &mut command, "ironstorage", &mut output);
output
}
#[derive(Parser)]
#[command(
name = "ironstorage",
@@ -452,10 +482,26 @@ enum CommandArguments {
Copy(CopyArguments),
Git(GitArguments),
Otp(OtpArguments),
Completion(CompletionArguments),
Help(HelpArguments),
Version,
}
#[derive(Args)]
struct CompletionArguments {
#[arg(value_enum)]
shell: CompletionShellArgument,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum CompletionShellArgument {
Bash,
Elvish,
Fish,
Powershell,
Zsh,
}
#[derive(Args)]
struct InitArguments {
#[arg(short = 'p', long = "path")]
@@ -784,6 +830,15 @@ fn convert_arguments(arguments: CliArguments) -> Result<CliInvocation, CliParseE
Some(CommandArguments::Otp(arguments)) => {
CommandRequest::Otp(convert_otp(arguments.command)?)
}
Some(CommandArguments::Completion(arguments)) => CommandRequest::Completion {
shell: match arguments.shell {
CompletionShellArgument::Bash => CompletionShell::Bash,
CompletionShellArgument::Elvish => CompletionShell::Elvish,
CompletionShellArgument::Fish => CompletionShell::Fish,
CompletionShellArgument::Powershell => CompletionShell::PowerShell,
CompletionShellArgument::Zsh => CompletionShell::Zsh,
},
},
Some(CommandArguments::Help(arguments)) => {
let topic = arguments
.topic
@@ -914,9 +969,29 @@ fn normalize_dispatch(mut arguments: Vec<OsString>) -> Vec<OsString> {
};
let command = arguments[command_index].to_string_lossy();
const ROOT_COMMANDS: &[&str] = &[
"init", "ls", "list", "show", "find", "search", "grep", "insert", "add", "edit",
"generate", "rm", "remove", "delete", "mv", "rename", "cp", "copy", "git", "otp", "help",
"init",
"ls",
"list",
"show",
"find",
"search",
"grep",
"insert",
"add",
"edit",
"generate",
"rm",
"remove",
"delete",
"mv",
"rename",
"cp",
"copy",
"git",
"otp",
"help",
"version",
"completion",
];
if !ROOT_COMMANDS.contains(&command.as_ref()) && !command.starts_with('-') {
arguments.insert(command_index, OsString::from("show"));
@@ -998,7 +1073,9 @@ fn normalize_otp_dispatch(arguments: &mut Vec<OsString>, mut index: usize) {
const OTP_COMMANDS: &[&str] = &[
"code", "show", "insert", "add", "append", "uri", "validate", "help", "version",
];
if argument == "--version" {
if argument == "help" {
arguments[index] = OsString::from("--help");
} else if argument == "--version" {
arguments[index] = OsString::from("version");
} else if !OTP_COMMANDS.contains(&argument.as_ref()) && argument != "--help" && argument != "-h"
{

View File

@@ -24,7 +24,7 @@ use crate::{
crypto::{KeyHandle, KeyStore, SecretProvider},
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
repository::Repository,
repository::{EncryptedEntry, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter},
};
@@ -1587,6 +1587,41 @@ impl GitRepository {
Ok(output)
}
/// Render a helper-free working-tree diff. Password entries are decrypted
/// in storage before rendering, so ciphertext is never used as display
/// state and no Git textconv or GPG process is required.
pub fn render_diff(
&self,
paths: &[PathBuf],
keys: &KeyStore,
secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, GitError> {
let mut rendered = Vec::new();
for entry in self.diff(paths)? {
let display = entry.path().to_string_lossy();
rendered.extend_from_slice(
format!("diff --ironstorage a/{display} b/{display}\n").as_bytes(),
);
match entry.old() {
Some(_) => rendered.extend_from_slice(format!("--- a/{display}\n").as_bytes()),
None => rendered.extend_from_slice(b"--- /dev/null\n"),
}
match entry.current() {
Some(_) => rendered.extend_from_slice(format!("+++ b/{display}\n").as_bytes()),
None => rendered.extend_from_slice(b"+++ /dev/null\n"),
}
if let Some(old) = entry.old() {
let plaintext = diff_contents(entry.path(), old, keys, secrets)?;
append_diff_lines(&mut rendered, b'-', plaintext.expose());
}
if let Some(current) = entry.current() {
let plaintext = diff_contents(entry.path(), current, keys, secrets)?;
append_diff_lines(&mut rendered, b'+', plaintext.expose());
}
}
Ok(SecretBytes::new(rendered))
}
fn head_tree_map(&self) -> Result<Option<BTreeMap<String, gix::hash::ObjectId>>, GitError> {
let Some(id) = self.repository.head_id().ok() else {
return Ok(None);
@@ -1639,6 +1674,34 @@ impl GitRepository {
}
}
fn diff_contents(
path: &Path,
contents: &[u8],
keys: &KeyStore,
secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, GitError> {
if path.extension().is_some_and(|extension| extension == "gpg") {
keys.decrypt(&EncryptedEntry::new(contents.to_vec()), secrets)
.map_err(invalid)
} else {
Ok(SecretBytes::new(contents.to_vec()))
}
}
fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
if contents.is_empty() {
return;
}
for line in contents.split_inclusive(|byte| *byte == b'\n') {
output.push(prefix);
output.extend_from_slice(line);
if !line.ends_with(b"\n") {
output.push(b'\n');
output.extend_from_slice(b"\\ No newline at end of file\n");
}
}
}
impl EntryCommitter for GitRepository {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())

View File

@@ -8,6 +8,7 @@ use crate::{
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
};
use zeroize::Zeroize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OverwriteDecision {
@@ -21,23 +22,38 @@ pub struct InsertContent {
}
impl InsertContent {
pub fn hidden(first: Vec<u8>, confirmation: Vec<u8>) -> Result<Self, WriteError> {
validate_single_line(&first)?;
validate_single_line(&confirmation)?;
pub fn hidden(mut first: Vec<u8>, mut confirmation: Vec<u8>) -> Result<Self, WriteError> {
if let Err(error) = validate_single_line(&first) {
first.zeroize();
confirmation.zeroize();
return Err(error);
}
if let Err(error) = validate_single_line(&confirmation) {
first.zeroize();
confirmation.zeroize();
return Err(error);
}
if first != confirmation {
first.zeroize();
confirmation.zeroize();
return Err(WriteError::ConfirmationMismatch);
}
if first.is_empty() {
confirmation.zeroize();
return Err(WriteError::EmptySingleLine);
}
confirmation.zeroize();
Ok(Self {
mode: InsertInput::HiddenConfirmed,
secret: SecretBytes::new(first),
})
}
pub fn echoed(line: Vec<u8>) -> Result<Self, WriteError> {
validate_single_line(&line)?;
pub fn echoed(mut line: Vec<u8>) -> Result<Self, WriteError> {
if let Err(error) = validate_single_line(&line) {
line.zeroize();
return Err(error);
}
if line.is_empty() {
return Err(WriteError::EmptySingleLine);
}
@@ -186,6 +202,17 @@ impl<'a> VaultWriter<'a> {
Self { repository, keys }
}
/// Report whether a logical entry already exists so a frontend can ask for
/// confirmation without inspecting the password-store filesystem itself.
pub fn entry_exists(&self, entry: &str) -> Result<bool, WriteError> {
let path = EntryPath::parse(entry)?;
match self.repository.read_entry(&path) {
Ok(_) => Ok(true),
Err(RepositoryError::NotFound { .. }) => Ok(false),
Err(error) => Err(error.into()),
}
}
#[allow(clippy::too_many_arguments)]
pub fn insert(
&self,

View File

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

View File

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

View File

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

90
docs/cli-parity.md Normal file
View 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
```