whyno-cli

Binary crate and entry point. Handles CLI argument parsing, output formatting, and VFS capability management. Depends on both whyno-core and whyno-gather.

Dependencies: whyno-core, whyno-gather, clap, owo-colors, serde, serde_json, schemars, thiserror, nix, libc

Dev-dependencies: whyno-core (with test-helpers feature), insta, tempfile

Module Map

whyno-cli/src/
├── main.rs             # Entry point, dispatch, exit codes
├── cli.rs              # clap definitions, subject/operation parsing
├── error.rs            # WhynoError, CliError, OutputError
├── caps.rs             # VFS capability management (install/uninstall/check)
├── caps_xattr.rs       # Raw setxattr/getxattr/removexattr syscall helpers
├── crosscheck.rs       # --self-test runtime crosscheck (faccessat2 vs CheckReport)
└── output/             # Output formatting
    ├── mod.rs          # OutputMode enum, render() dispatch
    ├── checklist.rs    # Default [PASS]/[FAIL]/[SKIP] output
    ├── json.rs         # --json structured output
    ├── explain.rs      # --explain verbose resolution chain
    ├── explain_walk.rs # Path walk detail rendering for explain mode
    ├── layer_name.rs   # CoreLayer/MacLayerdisplay name mapping; padded_mac(), short_mac()
    └── snapshots/      # insta snapshot test fixtures

main.rs — Entry Point

Execution flow

  1. Parse CLI args via Cli::parse() (clap)
  2. Configure color output (--no-color flag + NO_COLOR env var)
  3. Dispatch to check mode or caps mode
  4. Map results to exit codes: 0 (allowed), 1 (denied), 2 (error)

Check mode pipeline

run_check() orchestrates the full flow:

  1. validate_flags() — ensures --json and --explain aren't both set
  2. extract_args() — pulls subject, operation, path from positional args
  3. parse_subject()resolve_subject() — string → SubjectInputResolvedSubject
  4. resolve_operation() — routes setxattr through namespace parsing, otherwise delegates to parse_operation()
  5. build_metadata_params() — constructs MetadataParams from --new-mode / --new-uid / --new-gid
  6. apply_cap_overrides() — injects --with-cap capability bits into the resolved subject's capabilities field for hypothetical queries
  7. whyno_gather::gather_state() — OS state gathering
  8. warn_mac_systems() — suggests --features selinux/apparmor when a MAC system is detected and the corresponding feature is absent
  9. whyno_core::checks::run_checks(&state, &params) — check pipeline (receives MetadataParams)
  10. whyno_core::fix::generate_fixes(&report, &state, &params) — fix plan (receives MetadataParams)
  11. output::render() — format and write to stdout

cli.rs — Argument Parsing

Cli struct (clap derive)

struct Cli {
    subject: Option<String>,     // Positional: username, uid:N, pid:N, svc:name
    operation: Option<String>,   // Positional: read, write, execute, delete, create, stat, chmod, chown-uid, chown-gid, setxattr
    path: Option<PathBuf>,       // Positional: target filesystem path
    json: bool,                  // --json
    explain: bool,               // --explain
    no_color: bool,              // --no-color (clap derives this from the field name; also aliased explicitly as "no-color" in the #[arg] attribute)
    self_test: bool,             // --self-test
    with_cap: Vec<String>,       // --with-cap <CAP> (repeatable); parsed by parse_cap_name()
    new_mode: Option<String>,    // --new-mode <MODE> (octal, e.g. "644" or "0644") for chmod
    new_uid: Option<u32>,        // --new-uid <UID> for chown-uid
    new_gid: Option<u32>,        // --new-gid <GID> for chown-gid
    xattr_key: Option<String>,   // --xattr-key <KEY> for setxattr (e.g. "user.custom", "security.selinux")
    command: Option<Commands>,   // Subcommand (caps, schema)
}

Subject parsing: parse_subject()

Converts a subject string into SubjectInput:

Input Result
"user:nginx" Username("nginx")
"uid:33" Uid(33)
"pid:1234" Pid(1234)
"svc:nginx" Service("nginx")
"nginx" (bare string) Username("nginx")
"33" (bare number) Uid(33)

Bare values auto-detect: numeric → UID, non-numeric → username. Prefixes always win.

Operation parsing: parse_operation()

Case-insensitive. Accepts: read, write, execute, delete, create, stat, chmod, chown-uid, chown-gid. Returns CliError::InvalidOperation for anything else.

setxattr is not handled by parse_operation() — it requires namespace resolution from --xattr-key and is routed through resolve_operation() instead.

Operation resolution: resolve_operation()

Top-level entry point for operation parsing in the check pipeline (replaces direct parse_operation() calls in main.rs). If the operation string is "setxattr" (case-insensitive), requires --xattr-key and delegates to parse_setxattr_operation(). Otherwise falls through to parse_operation(). Returns CliError::MissingXattrKey if setxattr is requested without --xattr-key.

Xattr namespace parsing: parse_xattr_namespace()

Derives XattrNamespace from the --xattr-key prefix. Returns CliError::InvalidXattrKey for unrecognized prefixes.

Key prefix Namespace
user. User
trusted. Trusted
security. Security
system.posix_acl_ SystemPosixAcl

Metadata parameter helpers

  • parse_new_mode(s) — parses --new-mode octal string to u32. Accepts with or without leading 0 (e.g. "644"0o644, "0755"0o755). Returns CliError::InvalidMode on failure.
  • build_metadata_params(cli) — constructs MetadataParams { new_mode, new_uid, new_gid } from CLI flags. new_mode is parsed via parse_new_mode(); new_uid and new_gid are taken directly from --new-uid / --new-gid.

Capability name parsing: parse_cap_name()

Maps --with-cap capability name strings to bitmasks. Case-insensitive. Returns CliError::InvalidCap for unrecognized names.

Input Bitmask Bit
CAP_CHOWN 1 << 0 0
CAP_DAC_OVERRIDE 1 << 1 1
CAP_DAC_READ_SEARCH 1 << 2 2
CAP_FOWNER 1 << 3 3
CAP_LINUX_IMMUTABLE 1 << 9 9

error.rs — Error Hierarchy

WhynoError (top-level)

enum WhynoError {
    Cli(CliError),           // Argument parsing/validation
    Output(OutputError),     // Formatting/writing
    Gather(GatherError),     // Subject resolution, stat, mountinfo
    Caps(String),            // Capability management
}

All variants map to exit code 2 (internal error).

CliError

  • InvalidSubject(String) — subject string couldn't be parsed
  • InvalidOperation(String) — unknown operation name (valid: read, write, execute, delete, create, stat, chmod, chown-uid, chown-gid, setxattr)
  • ConflictingFlags--json and --explain both specified
  • InvalidCap(String) — unrecognized capability name passed to --with-cap
  • InvalidXattrKey(String)--xattr-key has an unrecognized namespace prefix (valid: user., trusted., security., system.posix_acl_)
  • MissingXattrKey--xattr-key is required when operation is setxattr but was not provided
  • InvalidMode(String)--new-mode value could not be parsed as an octal integer

OutputError

  • WriteFailed(io::Error) — stdout/stderr write failure
  • SerializationFailed(serde_json::Error) — JSON serialization failure

output/ — Output Formatting

OutputMode dispatch

enum OutputMode { Checklist, Json, Explain }

render(report, plan, state, mode, writer) dispatches to the appropriate renderer. All renderers write to a dyn Write for testability (not hardcoded to stdout).

Checklist mode (default)

ASCII [PASS] / [FAIL] / [SKIP] with ANSI color via owo-colors. One line per core layer, then MAC layers (SELinux, AppArmor) if present. Fixes indented below failed core layers with impact score. Plan-level warnings rendered at the bottom.

JSON mode (--json)

Versioned schema ("version": 1), append-only. Top-level keys: subject, operation, target, result, layers, fixes, warnings, degraded. Same exit code scheme as checklist.

Struct doc comments are lowercased per schemars convention (e.g. /// top-level JSON output (schema v1).).

Overall result logic (overall_result()): returns "allowed" if no layer fails, "denied" if any layer fails, or "degraded" if no failure but at least one core layer has Degraded status.

Layer array: core layers followed by MAC layers (SELinux, AppArmor). Each layer entry includes a warnings field (omitted when empty via skip_serializing_if).

Explain mode (--explain)

Full verbose resolution chain: subject resolution source, stat() output per ancestor, ACL entries, mount options, flag bits, plus MAC layer results (SELinux, AppArmor). This is debug mode — verbosity is the point.

Sections rendered in order:

  1. Subject — uid, gid, groups, capabilities (if known), operation
  2. Path Walk — per-component stat, ACL entries, filesystem flags, mount info (rendered by explain_walk.rs)
  3. Layer Results — per-layer PASS/FAIL/DEGRADED with detail strings, core then MAC
  4. Fix Plan — scored fixes with layer, impact, command, and description; plan-level warnings

layer_name.rs — Layer Display Names

Maps CoreLayer and MacLayer enum variants to display strings for output renderers.

Function Layer type Use
padded(CoreLayer) Core Fixed-width names for checklist alignment
short(CoreLayer) Core Lowercase names for JSON keys and explain mode
padded_mac(MacLayer) MAC Fixed-width names for checklist alignment
short_mac(MacLayer) MAC Lowercase names for JSON keys and explain mode

Core layers: Mount → "mount", FsFlags → "fsflags", Traversal → "traversal", Dac → "dac", Acl → "acl", Metadata → "metadata"

MAC layers: SeLinux → "selinux", AppArmor → "apparmor"

caps.rs / caps_xattr.rs — Capability Management

Commands

Subcommand Function What it does
whyno caps install caps_install() Locates binary via /proc/self/exe, verifies xattr support, writes VFS cap v2 xattr, reads back to verify
whyno caps uninstall caps_uninstall() Removes security.capability xattr
whyno caps check caps_check() Reads xattr and reports status

VFS cap v2 format

20 bytes, little-endian. Hardcoded constant VFS_CAP_DATA:

Offset  Field               Value
0-3     magic_etc           0x02000002  (VFS_CAP_REVISION_2 | VFS_CAP_FLAGS_EFFECTIVE)
4-7     data[0].permitted   0x00000004  (1 << 2 = CAP_DAC_READ_SEARCH)
8-19    (remaining)         0x00000000

VFS_CAP_FLAGS_EFFECTIVE (bit 0 of magic_etc) is critical — without it, the capability is permitted but not effective, and the binary would need to explicitly raise it at runtime.

Raw syscall helpers (caps_xattr.rs)

Extracted to keep both modules under the size limit.

Function Syscall Purpose
check_xattr_support() getxattr (size=0) Verify filesystem supports xattrs
write_cap_xattr() setxattr Write 20-byte VFS cap v2 data
read_cap_xattr() getxattr Read back for verification
remove_cap_xattr() removexattr Remove capability xattr

All use libc raw syscalls directly — no libcap dependency. Paths are converted to CString via path_to_cstring().

whyno schema — JSON Schema Output

Commands::Schema variant. Prints the JSON Schema for the --json output format to stdout and exits. Uses schemars to derive the schema from the JsonReport struct at compile time. Useful for validating --json output in CI pipelines or generating client types.

crosscheck.rs — Self-Test Crosscheck

--self-test runs a runtime crosscheck of whyno's own result against the kernel. When the subject is the calling user's effective UID and the kernel is >= 5.8, calls faccessat2(AT_EACCESS) on the target path with the appropriate access flags and compares the kernel's boolean answer to whyno's own CheckReport::is_allowed() result. Any mismatch is printed to stderr. No-ops when the subject is a different user or the kernel is older than 5.8.