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 rejection

Execution 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 2

All 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

  1. Positional argument (input: Some(json)) — returned directly
  2. File flag (-f / --file) — read via fs::read_to_string
  3. 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 stdin

This 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

  1. Reads input via read_input
  2. Calls canaad_core::canonicalize(&json)Vec<u8>
  3. 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
  4. Writes to --to-file path or stdout

cmd_validate

  1. Reads input via read_input
  2. Calls canaad_core::validate(&json) (semantic alias for parse)
  3. If --quiet is not set, prints "valid" to stdout
  4. Returns Ok(()) — exit code 0 signals validity

cmd_hash

  1. Reads input via read_input
  2. Calls canaad_core::canonicalize(&json)Vec<u8>
  3. Computes Sha256::digest(&canonical)
  4. Formats per HashOutputFormat:
    • Hex: hex::encode(hash)
    • Base64: BASE64_STANDARD.encode(hash)
  5. 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.