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/MacLayer → display name mapping; padded_mac(), short_mac()
└── snapshots/ # insta snapshot test fixturesmain.rs — Entry Point
Execution flow
- Parse CLI args via
Cli::parse()(clap) - Configure color output (
--no-colorflag +NO_COLORenv var) - Dispatch to check mode or caps mode
- Map results to exit codes:
0(allowed),1(denied),2(error)
Check mode pipeline
run_check() orchestrates the full flow:
validate_flags()— ensures--jsonand--explainaren't both setextract_args()— pulls subject, operation, path from positional argsparse_subject()→resolve_subject()— string →SubjectInput→ResolvedSubjectresolve_operation()— routessetxattrthrough namespace parsing, otherwise delegates toparse_operation()build_metadata_params()— constructsMetadataParamsfrom--new-mode/--new-uid/--new-gidapply_cap_overrides()— injects--with-capcapability bits into the resolved subject'scapabilitiesfield for hypothetical querieswhyno_gather::gather_state()— OS state gatheringwarn_mac_systems()— suggests--features selinux/apparmorwhen a MAC system is detected and the corresponding feature is absentwhyno_core::checks::run_checks(&state, ¶ms)— check pipeline (receivesMetadataParams)whyno_core::fix::generate_fixes(&report, &state, ¶ms)— fix plan (receivesMetadataParams)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-modeoctal string tou32. Accepts with or without leading0(e.g."644"→0o644,"0755"→0o755). ReturnsCliError::InvalidModeon failure.build_metadata_params(cli)— constructsMetadataParams { new_mode, new_uid, new_gid }from CLI flags.new_modeis parsed viaparse_new_mode();new_uidandnew_gidare 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 parsedInvalidOperation(String)— unknown operation name (valid: read, write, execute, delete, create, stat, chmod, chown-uid, chown-gid, setxattr)ConflictingFlags—--jsonand--explainboth specifiedInvalidCap(String)— unrecognized capability name passed to--with-capInvalidXattrKey(String)—--xattr-keyhas an unrecognized namespace prefix (valid:user.,trusted.,security.,system.posix_acl_)MissingXattrKey—--xattr-keyis required when operation issetxattrbut was not providedInvalidMode(String)—--new-modevalue could not be parsed as an octal integer
OutputError
WriteFailed(io::Error)— stdout/stderr write failureSerializationFailed(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:
- Subject — uid, gid, groups, capabilities (if known), operation
- Path Walk — per-component stat, ACL entries, filesystem flags, mount info (rendered by
explain_walk.rs) - Layer Results — per-layer PASS/FAIL/DEGRADED with detail strings, core then MAC
- 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) 0x00000000VFS_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.