canaad-cli
Internal developer documentation for canaad-cli v0.3.1 — the CLI tool for AAD canonicalization per RFC 8785.
Binary name: canaad (declared via [[bin]] in Cargo.toml).
Dependencies
Runtime
| Crate | Version | Role |
|---|---|---|
canaad-core |
0.3.0 (path dep) | Core parsing, validation, canonicalization |
clap |
4 + derive |
Argument parsing via derive macros |
sha2 |
0.10 | SHA-256 digest for hash subcommand |
hex |
0.4 | Hex encoding for canonicalize --output hex and hash --output hex |
base64 |
0.22 | Base64 encoding for both canonicalize and hash |
anyhow |
1.0 | Error chaining, Result ergonomics, context() annotations |
Dev-only
| Crate | Version | Role |
|---|---|---|
assert_cmd |
2 | Integration testing of the compiled binary |
predicates |
3 | Assertion predicates for stdout/stderr/exit-code matching |
tempfile |
3 | Temporary files for -f input tests |
Module map
canaad-cli/src/
├── main.rs # entry point, exit-code mapping, crate-level //! documenting the 0/1/2 contract
├── args.rs # clap derive structs: Cli, Commands, OutputFormat, HashOutputFormat
├── commands.rs # handler per subcommand: cmd_canonicalize, cmd_validate, cmd_hash + is_validation_error
└── io.rs # read_input(): positional → -f → stdin routing, interactive-stdin rejectionExecution flow
main()
│
├─ commands::run()
│ ├─ Cli::parse() ← clap parses argv
│ ├─ match cli.command ← dispatch to handler
│ │ ├─ Canonicalize → cmd_canonicalize()
│ │ ├─ Validate → cmd_validate()
│ │ └─ Hash → cmd_hash()
│ └─ return Result<()>
│
├─ Ok(()) → ExitCode::SUCCESS (0)
└─ Err(e)
├─ is_validation_error(&e) → exit 1
└─ otherwise → exit 2All handlers follow the same pattern: read input → call canaad_core → format output → write.
Input routing (io.rs)
read_input(input: Option<String>, file: Option<PathBuf>) -> Result<String>
Priority order
- Positional argument (
input: Some(json)) — returned directly - File flag (
-f/--file) — read viafs::read_to_string - Stdin — only if stdin is not a terminal (
!stdin().is_terminal())
Conflict handling
Clap enforces conflicts_with = "input" on the -f flag, so positional + file is rejected at the argument parser level before read_input is called.
Interactive stdin rejection
If no positional argument and no -f flag are provided, read_input checks io::stdin().is_terminal(). If true (user is typing interactively with no pipe), it bails with:
no input provided: use argument, -f FILE, or pipe to stdinThis prevents the CLI from hanging on an interactive terminal waiting for input that will never arrive in the expected format.
args.rs — CLI types
Cli struct
#[derive(Parser)]
#[command(name = "canaad", version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}Commands enum
| Subcommand | Flags | Notes |
|---|---|---|
canonicalize |
input?, -f FILE, -o FORMAT (default: utf8), --to-file PATH |
Deterministic AAD output |
validate |
input?, -f FILE, -q / --quiet |
Schema check only |
hash |
input?, -f FILE, -o FORMAT (default: hex) |
SHA-256 of canonical form |
Output format enums
OutputFormat (canonicalize): Utf8, Hex, Base64, Raw
HashOutputFormat (hash): Hex, Base64
Both derive ValueEnum for clap integration.
commands.rs — handlers
cmd_canonicalize
- Reads input via
read_input - Calls
canaad_core::canonicalize(&json)→Vec<u8> - Formats per
OutputFormat:- Utf8:
String::from_utf8+ trailing\n - Hex:
hex::encode+ trailing\n - Base64:
BASE64_STANDARD.encode+ trailing\n - Raw: bare bytes, no trailing newline
- Utf8:
- Writes to
--to-filepath or stdout
cmd_validate
- Reads input via
read_input - Calls
canaad_core::validate(&json)(semantic alias forparse) - If
--quietis not set, prints"valid"to stdout - Returns
Ok(())— exit code 0 signals validity
cmd_hash
- Reads input via
read_input - Calls
canaad_core::canonicalize(&json)→Vec<u8> - Computes
Sha256::digest(&canonical) - Formats per
HashOutputFormat:- Hex:
hex::encode(hash) - Base64:
BASE64_STANDARD.encode(hash)
- Hex:
- Prints with
println!(always has trailing newline)
is_validation_error
Walks the anyhow::Error chain via .downcast_ref::<AadError>() and .chain().any(...). Returns true if any cause is an AadError.
Output formats
canonicalize defaults
| Format | Suffix | Default |
|---|---|---|
utf8 |
\n |
yes |
hex |
\n |
no |
base64 |
\n |
no |
raw |
none | no |
Why utf8 is the default: canonical AAD is valid UTF-8 JSON. Most users pipe or inspect the output in a terminal, so a human-readable string with a trailing newline is the most ergonomic default.
Why raw has no trailing newline: raw mode emits the exact canonical bytes with no transformation. A trailing \n would alter the byte sequence, breaking use cases where the output is fed directly to cryptographic operations (AEAD additional data, HMAC input).
hash defaults
| Format | Default |
|---|---|
hex |
yes |
base64 |
no |
Why hex is the default: SHA-256 digests are conventionally displayed as hex strings. This matches sha256sum, openssl dgst, and similar tools.
Exit code contract
Documented in the crate-level //! doc comment on main.rs.
| Code | Meaning | Trigger |
|---|---|---|
| 0 | Success | Handler returned Ok(()) |
| 1 | Validation error | is_validation_error(&e) returns true — any AadError in the anyhow error chain |
| 2 | I/O / other error | Everything else: unreadable file, stdin failure, write failure, UTF-8 conversion error |
Error chain mapping
anyhow wraps errors with .context() annotations. The is_validation_error function traverses the full chain, so an AadError nested under an I/O context annotation still maps to exit code 1. Only errors with no AadError anywhere in the chain map to exit code 2.
All errors are printed to stderr via eprintln!("error: {e:#}") — the # flag uses anyhow's alternate Display which includes the full chain.