whyno-core
Pure-logic library crate. Zero I/O, zero syscalls. All filesystem state is passed in via SystemState.
Dependencies: serde, serde_json, thiserror
Module Map
whyno-core/src/
├── lib.rs # Crate root, module declarations
├── operation.rs # Operation, XattrNamespace, MetadataParams
├── state/ # State representation types
│ ├── mod.rs # SystemState, Probe<T>
│ ├── subject.rs # ResolvedSubject { uid, gid, groups }
│ ├── path.rs # PathComponent, StatResult, FileType
│ ├── mount.rs # MountEntry, MountTable, MountOptions
│ ├── acl.rs # AclEntry, AclTag, AclPerms, PosixAcl
│ ├── fsflags.rs # FsFlags { immutable, append_only }
│ └── mac.rs # MacState, SeLinuxState, SeLinuxMode, AppArmorState
├── checks/ # One module per permission layer
│ ├── mod.rs # CheckReport, LayerResult, CoreLayer, MacLayer, run_checks()
│ ├── mount.rs # check_mount() — ro, noexec, nosuid
│ ├── fsflags.rs # check_fs_flags() — immutable, append-only
│ ├── traversal.rs # check_traversal() — +x on ancestors
│ ├── dac.rs # check_dac() — owner/group/other rwx
│ ├── acl.rs # check_acl() — POSIX ACL evaluation with mask
│ ├── caps.rs # Capability constants (+ CAP_FSETID, CAP_SYS_ADMIN in v0.4) and has_cap()
│ ├── caps_modify.rs # capability_modify() — CAP_DAC_OVERRIDE post-DAC modifier
│ ├── metadata.rs # check_metadata() — chmod/chown/setxattr checks (v0.4)
│ ├── selinux.rs # check_selinux() — pure function over &SystemState (--features selinux)
│ └── apparmor.rs # check_apparmor() — pure function over &SystemState (--features apparmor)
├── fix/ # Fix engine
│ ├── mod.rs # FixPlan, Fix, FixAction, generate_fixes()
│ ├── generators.rs # Per-layer fix generation functions
│ ├── scoring.rs # Impact scoring (1–6 scale)
│ ├── cascade.rs # Cascade simulation — simulate_cascade(), prune redundant fixes
│ ├── cascade/
│ │ └── apply.rs # apply_fix() — semantic state mutation per FixAction
│ └── commands.rs # FixAction → shell command rendering
└── test_helpers/ # Synthetic state builders (gated behind test-helpers feature)
├── mod.rs
├── internal.rs
└── tests.rsstate — State Representation
Probe<T>
Tri-state wrapper for gathered data. Every OS-sourced value is wrapped in Probe to distinguish "successfully read," "couldn't read," and "permission denied during read."
enum Probe<T> {
Known(T), // Successfully gathered
Unknown, // General failure (syscall error, missing /proc)
Inaccessible, // Explicit EACCES/EPERM on the probe
}Key methods:
known() -> Option<&T>— ReturnsSomeonly forKnownis_known() -> boolmap(f) -> Probe<U>— Transforms inner value, preserves Unknown/Inaccessibleas_ref() -> Probe<&T>
Check functions treat Unknown and Inaccessible as degraded — never false-green.
SystemState
The complete gathered state for a single permission query.
struct SystemState {
subject: ResolvedSubject, // Resolved identity (uid, gid, groups)
walk: Vec<PathComponent>, // Path components from / to target
mounts: MountTable, // Parsed /proc/self/mountinfo
operation: Operation, // The operation being checked
mac_state: MacState, // SELinux + AppArmor pre-gathered state (v0.3)
}ResolvedSubject
Normalized identity — all 4 input formats (username, UID, PID, service) collapse to this before checks run.
struct ResolvedSubject {
uid: u32, // Effective user ID
gid: u32, // Primary group ID
groups: Vec<u32>, // Supplementary groups (excludes primary)
capabilities: Probe<u64>, // CapEff bitmask; Known for pid:/svc:, Unknown for username/UID
}Key method: in_group(gid) -> bool — checks both primary and supplementary groups.
PathComponent
A single component in the path walk from / to the target. Each ancestor independently carries its permissions state.
struct PathComponent {
path: PathBuf,
stat: Probe<StatResult>, // mode, uid, gid, dev, nlink, file_type
acl: Probe<PosixAcl>, // POSIX ACL entries
flags: Probe<FsFlags>, // Filesystem inode flags
mount: Option<usize>, // Index into SystemState::mounts
}StatResult
struct StatResult {
mode: u32, // Permission mode bits (e.g., 0o755)
uid: u32, // Owner UID
gid: u32, // Owner GID
dev: u64, // Device ID (st_dev) — joins with mount table
nlink: u64, // Hard link count
file_type: FileType, // Regular, Directory, Symlink, Other
}MountTable / MountEntry
struct MountTable(pub Vec<MountEntry>);
struct MountEntry {
mount_id: u32,
device: u64, // Matches StatResult::dev
mountpoint: PathBuf,
fs_type: String, // "ext4", "tmpfs", "xfs", etc.
options: MountOptions, // { read_only, noexec, nosuid }
}find_by_device(dev) -> Option<&MountEntry> returns the entry with the longest mountpoint for correct bind-mount resolution.
PosixAcl
Models system.posix_acl_access extended attribute content per POSIX.1e.
struct PosixAcl(pub Vec<AclEntry>);
struct AclEntry {
tag: AclTag, // UserObj, User, GroupObj, Group, Mask, Other
qualifier: Option<u32>, // UID/GID for named entries
perms: AclPerms, // { read, write, execute }
}Key methods:
mask() -> Option<&AclPerms>— The mask limits effective perms for named user, owning group, and named group entriesnamed_user(uid) -> Option<&AclEntry>named_group(gid) -> Option<&AclEntry>has_extended_entries() -> bool— True if named User or Group entries exist (triggers POSIX.1e evaluation)effective_perms(entry) -> AclPerms— Applies mask to entry per POSIX.1e rules.UserObjandOtherare unaffected by mask.
FsFlags
struct FsFlags {
immutable: bool, // FS_IMMUTABLE_FL — no modifications at all
append_only: bool, // FS_APPEND_FL — write only in append mode
}These flags are not overridden by root or capabilities — they apply unconditionally.
MacState / SeLinuxState / AppArmorState
Added in v0.3. Pre-gathered MAC (mandatory access control) layer state. Both probes default to Probe::Unknown when the corresponding subsystem is absent or unreadable.
struct MacState {
selinux: Probe<SeLinuxState>, // SELinux probe result
apparmor: Probe<AppArmorState>, // AppArmor probe result
}SeLinuxMode
enum SeLinuxMode {
Enforcing, // Policy actively enforced; denials block access
Permissive, // Policy evaluated but denials logged, not enforced
Disabled, // SELinux compiled out or disabled at boot
}SeLinuxState
struct SeLinuxState {
mode: SeLinuxMode, // Kernel enforcement mode at gather time
subject_ctx: String, // Subject security context, e.g. "unconfined_u:unconfined_r:unconfined_t:s0"
target_ctx: String, // Target file security context
access_allowed: bool, // AVC access decision pre-computed during gathering
}AppArmorState
struct AppArmorState {
profile_label: String, // Profile label from procfs, e.g. "nginx (enforce)" or "unconfined"
}operation — Operation Types
enum Operation {
Read, // Requires r
Write, // Requires w
Execute, // Requires x
Delete, // Checks w+x on parent
Create, // Checks w+x on parent
Stat, // Traverse-only (no file perm needed)
Chmod, // Ownership or CAP_FOWNER (v0.4)
ChownUid, // Requires CAP_CHOWN (v0.4)
ChownGid, // Owner-in-group or CAP_CHOWN (v0.4)
SetXattr { namespace: XattrNamespace }, // Gated by namespace (v0.4)
}Key methods:
checks_parent() -> bool—truefor Delete and Createtarget_component(walk_len) -> Option<usize>— Returns the index of the component to check. Last element for most ops, second-to-last for parent-directed ops.is_metadata() -> bool— v0.4.truefor Chmod, ChownUid, ChownGid, SetXattr. Metadata ops bypass DAC/ACL mode-bit checks; the Metadata layer evaluates ownership + capability rules directly.
XattrNamespace
Added in v0.4. Namespace for setxattr(2) operations. Determines which capability is required.
enum XattrNamespace {
User, // user.* — owner or CAP_FOWNER
Trusted, // trusted.* — requires CAP_SYS_ADMIN
Security, // security.* — requires CAP_SYS_ADMIN
SystemPosixAcl, // system.posix_acl_access — owner or CAP_FOWNER
}Derives Copy.
MetadataParams
Added in v0.4. Caller-supplied intent for metadata-change operations. Separate from SystemState (OS-gathered state). Missing fields cause the Metadata check layer to return Degraded.
struct MetadataParams {
new_mode: Option<u32>, // Target mode bits for Chmod; None → Degraded (not Fail)
new_uid: Option<u32>, // Target UID for ChownUid; None → Degraded (not Fail)
new_gid: Option<u32>, // Target GID for ChownGid; None → Degraded (not Fail)
}Implements Default (all None). Passed to run_checks() and generate_fixes(). When a field is None for its corresponding operation, the metadata check layer returns Degraded rather than a hard Fail, signaling that the caller did not supply the required intent.
checks — Check Pipeline
Entry point: run_checks(state, &MetadataParams) -> CheckReport
Runs all core layers in order: mount → fs_flags → traversal → DAC → ACL → Metadata, then MAC layers. v0.4 breaking change: now takes &MetadataParams for metadata-op sub-checks. params carries caller-supplied intent for metadata operations (Chmod, ChownUid, ChownGid, SetXattr). Only the metadata layer reads it; all other layers ignore it. Pass MetadataParams::default() for non-metadata operations. capability_modify() (in caps_modify.rs) is applied to the DAC result as a post-check modifier. When capabilities is Probe::Unknown, capability_modify() falls back to uid==0 as a heuristic. DAC and ACL layers return Pass early for metadata operations (is_metadata() == true); the Metadata layer handles these via ownership + capability rules. MAC layers read pre-gathered state from SystemState.mac_state — no I/O at check time. Both MAC entries are always present: live checks when feature flags are enabled, Degraded stubs when absent.
CoreLayer enum
enum CoreLayer { Mount, FsFlags, Traversal, Dac, Acl, Metadata }MacLayer enum
enum MacLayer { SeLinux, AppArmor }LayerResult
enum LayerResult {
Pass { detail: String, warnings: Vec<String> },
Fail { detail: String, component_index: Option<usize> },
Degraded { reason: String },
}Warnings in Pass are advisory — they do not affect the pass/fail outcome. Used when a layer passes but with caveats, e.g., nosuid stripping suid/sgid bits on execute.
CheckReport
Contains core_results: EnumMap<CoreLayer, LayerResult> and mac_results: Vec<(MacLayer, LayerResult)>. Key methods:
is_allowed() -> bool— True if no layer isFailfailed_layers() -> Vec<CoreLayer>— List of core layers that producedFailresults
Individual check functions
Each is a pure function: fn check_*(state: &SystemState) -> LayerResult (except check_metadata which also takes &MetadataParams)
| Function | Layer | What it checks |
|---|---|---|
check_mount() |
Mount | read_only, noexec, nosuid on the target's filesystem (flags sourced from statvfs()) |
check_fs_flags() |
FsFlags | immutable and append_only inode flags; for Delete operations, also checks append_only on the parent directory (mirrors the kernel's may_delete() behavior) |
check_traversal() |
Traversal | +x permission on every ancestor directory |
check_dac() |
DAC | Owner/group/other mode bits against subject identity. Returns Pass early for metadata operations (v0.4). |
check_acl() |
ACL | POSIX.1e ACL evaluation with mask application. Returns Pass early for metadata operations (v0.4). |
check_metadata() |
Metadata | v0.4. Implements setattr_prepare from fs/attr.c. Dispatches by operation: Chmod → owner or CAP_FOWNER; ChownUid → CAP_CHOWN; ChownGid → CAP_CHOWN or owner-in-target-group; SetXattr → namespace-dependent (user/posix_acl → owner/CAP_FOWNER, trusted/security → CAP_SYS_ADMIN). Returns Pass for non-metadata ops. Chmod warns when CAP_FOWNER bypasses ownership but caller lacks CAP_FSETID and is not in the file's group (kernel strips setgid bit per inode_init_owner). Capability resolution in the metadata layer treats Unknown/Inaccessible as zero (conservative) — unlike capability_modify()'s uid==0 root heuristic. |
capability_modify() |
(post-DAC) | In caps_modify.rs (split from dac.rs in v0.3). Three-way dispatch: Known(bitmask) checks CAP_DAC_OVERRIDE bit, Unknown with uid 0 uses root heuristic, Inaccessible leaves result unchanged. Root execute override requires at least one x bit set on any class. |
check_selinux() |
SELinux (MAC) | Reads state.mac_state.selinux. Disabled → Degraded; Permissive → Pass with warning; Enforcing → Pass/Fail based on pre-gathered access_allowed. Feature-gated (--features selinux); stub reads probe state when absent. v0.4: operation_to_selinux_perm() now pub and re-exported; metadata ops map to ("file", "setattr"). |
check_apparmor() |
AppArmor (MAC) | Reads state.mac_state.apparmor. Unconfined → Pass; Complain → Pass with warnings (deny rules still enforced); Enforce → Degraded without libapparmor. v0.4: metadata ops in enforce mode return Degraded with a capability-rules hint (chown/fowner/sys_admin) rather than the generic libapparmor rebuild hint. Feature-gated (--features apparmor); stub reads probe state when absent. |
fix — Fix Engine
Entry point: generate_fixes(report, state, &MetadataParams) -> FixPlan
Collects fixes for each failed layer, orders by layer position then impact ascending, runs cascade simulation, returns final plan. v0.4 breaking change: now takes &MetadataParams. params carries caller-supplied intent for metadata operations; only the metadata layer generator reads it to tailor fix targets. Pass MetadataParams::default() for non-metadata operations. Metadata layer failures produce GrantCap and Chown fix suggestions.
Fix
struct Fix {
layer: CoreLayer, // Which check layer this addresses
action: FixAction, // Structured action (rendered to shell command at output time)
impact: u8, // 1 (least privilege) to 6 (broadest)
description: String, // Human-readable
}FixAction enum
enum FixAction {
Chmod { path, mode_change }, // e.g., "o+x", "g+r"
Chown { path, owner, group }, // Change ownership
SetAcl { path, entry }, // e.g., "u:33:r"
Remount { mountpoint, options }, // e.g., "rw", "exec"
Chattr { path, flags }, // e.g., "-i", "-a"
GrantCap { path, capability }, // e.g., "cap_fowner", "cap_sys_admin" (v0.4)
}Rendered to shell commands only at output time. GrantCap with path: None renders as a descriptive recommendation rather than a concrete setcap command. Cascade simulator applies fixes semantically by mutating state struct copies.
FixPlan
struct FixPlan {
fixes: Vec<Fix>, // Ordered: layer position, then impact ascending
warnings: Vec<String>, // For high-impact fixes (score ≥ 5)
}Submodules
| Module | Role |
|---|---|
generators.rs |
Per-layer fix generation: mount_fixes(), fsflags_fixes(), traversal_fixes(), dac_fixes(), acl_fixes(), metadata_fixes() (v0.4). Private module (mod generators, not pub mod); not part of the public API surface — accessed only through generate_fixes(). |
scoring.rs |
Impact scoring (1–6 scale), needs_warning(impact) -> bool |
cascade.rs |
simulate_cascade() — apply fix → re-run checks → prune redundant fixes. Max 5 iterations. |
cascade/apply.rs |
apply_fix() — semantic state mutation per FixAction. Handles Chmod (mode bit manipulation), SetAcl (ACL entry merge), Chattr (flag removal), Remount (option flip), Chown (uid/gid mutation with ATTR_KILL_SUID/SGID semantics), and GrantCap (capability bitmask merge). v0.4: apply_chown(), apply_grant_cap(). |
commands.rs |
FixAction → shell command string rendering |
Metadata Fix Strategies
metadata_fixes() produces least-privilege suggestions per operation type, ordered by ascending impact score:
| Operation | Fix Suggestions (impact ascending) |
|---|---|
Chmod |
Chown file to subject UID (4), GrantCap cap_fowner (5) |
ChownUid / ChownGid |
GrantCap cap_chown (5) |
SetXattr user.* |
Chown file to subject UID (4) |
SetXattr system.posix_acl_access |
Chown file to subject UID (4), GrantCap cap_fowner (5) |
SetXattr trusted.* / security.* |
GrantCap cap_sys_admin (6) — triggers high-impact warning |
GrantCap fixes are emitted with path: None (process binary unknown); they render as descriptive recommendations rather than concrete setcap commands. Pass --executable to make them actionable.
Cascade Apply Semantics
apply_chown() mirrors kernel notify_change(): clears S_ISUID on owner change, clears S_ISGID on group change only when S_IXGRP is set (ATTR_KILL_SUID/ATTR_KILL_SGID). apply_grant_cap() maps capability name strings (cap_fowner, cap_chown, cap_sys_admin, cap_fsetid) to bitmask bits and merges into state.subject.capabilities — promotes Unknown/Inaccessible to Known(bit).
test_helpers — Synthetic State Builders
Gated behind #[cfg(any(test, feature = "test-helpers"))]. Provides fluent builder API for constructing SystemState structs in tests without OS interaction.
Used by both whyno-core internal tests and whyno-cli dev-dependencies (via whyno-core = { features = ["test-helpers"] } in dev-deps).