whyno v0.3.0 — mac layer test infrastructure
Version: 0.3
semver
v0.3.0 ships the full scope. pre-1.0, minor bumps cover breaking changes — and adding
mac_state to SystemState (a public struct) is technically breaking for anyone
constructing it directly. patches (v0.3.1, v0.3.2) are bugs encountered during or after
implementation only. no new features in the 0.3.x line. v0.4.0 is the next feature
(metadata change operation).
context
MAC layers (SELinux, AppArmor) shipped in v0.2.0 but break the project's core architectural contract: pure-function check pipeline over a state struct — no I/O in the core crate.
check_selinux() and check_apparmor() bypass SystemState entirely. they make live
kernel and filesystem calls at check time:
selinux::kernel_support()— live kernel querySecurityContext::current()— live process context readSecurityContext::of_path()— live file context readPath::new("/sys/kernel/security/apparmor").exists()— live filesystem checkfs::read_to_string("/proc/self/attr/...")— live procfs read
SystemState is passed in but ignored. the check functions have hidden inputs — the
running kernel and filesystem state at call time — making them untestable without specific
kernel configurations (SELinux enforcing on Fedora, AppArmor on Ubuntu).
every other check layer (check_dac, check_acl, check_mount, check_traversal,
check_fs_flags) is a pure function over state. MAC is the exception. this must be
corrected before additional layers are added.
the consequence: check_selinux(), check_apparmor(), get_file_context(),
read_current_profile(), and evaluate_access() have zero unit test coverage. the 6
SELinux and 5 AppArmor tests that exist only cover pure string-parsing helpers
(operation_to_selinux_perm, parse_profile_label) — they do not touch the check entry
points at all.
decision
restore the pure-function contract for MAC check layers by moving all kernel and filesystem interaction into the gather layer, where it belongs. check functions become pure logic over pre-gathered state, matching every other layer in the pipeline.
three deliverables, in dependency order:
- restore the pure-function contract — state types, gather functions, check refactors,
StateBuilderextensions. - write the missing unit tests — synthetic MAC state across all modes and outcomes.
- live kernel integration environments — Docker build matrix + Proxmox VM templates for kernel-dependent test paths.
architectural change
before:
gather_state() → SystemState → run_checks()
├── check_dac() reads state ✓
├── check_acl() reads state ✓
├── check_mount() reads state ✓
├── check_selinux() ignores state, calls kernel ✗
└── check_apparmor() ignores state, calls kernel ✗after:
gather_state() → SystemState → run_checks()
├── gather path/stat/acl/flags (existing)
└── gather_mac_state() (new)
├── SeLinuxState { mode, subject_ctx, target_ctx, access_allowed }
└── AppArmorState { profile_label }
├── check_selinux() reads state.mac_state ✓
└── check_apparmor() reads state.mac_state ✓crate dependency change:
selinux and apparmor crate dependencies move from whyno-core to whyno-gather.
whyno-core ends up with zero MAC crate dependencies — only plain Rust state types.
the boundary constraint holds: whyno-core has no I/O, no external crate deps for MAC.
new state types (in whyno-core/src/state/mac.rs, no external deps):
pub enum SeLinuxMode { Enforcing, Permissive, Disabled }
pub struct SeLinuxState {
pub mode: SeLinuxMode,
pub subject_ctx: String,
pub target_ctx: String,
pub access_allowed: bool, // AVC decision, pre-computed at gather time
}
pub struct AppArmorState {
pub profile_label: String, // e.g. "nginx (enforce)", "unconfined"
}
pub struct MacState {
pub selinux: Probe<SeLinuxState>,
pub apparmor: Probe<AppArmorState>,
}SystemState gains mac_state: MacState. MacState::default() sets both probes to
Probe::Unknown.
the AVC query (check_access()) moves to the gather layer because the operation is known
at gather time — gather_state() already receives operation as a parameter. the check
function uses the pre-computed access_allowed boolean; it does not call the kernel.
implementation phases
phase 1 — clean baseline
fix: remove unused imports in dac_tests, apply_tests, generators_testszero warnings before any new work starts.
phase 2 — state types
feat(core/state): add MacState, SeLinuxState, SeLinuxMode, AppArmorState typesnew file whyno-core/src/state/mac.rs. plain Rust types, no external crate deps.
SeLinuxMode is an internal enum — not the selinux crate's type. MacState defaults both
probes to Unknown.
feat(core/state): add mac_state field to SystemState, update gather_state defaultadds mac_state: MacState to SystemState. updates gather_state() to set
mac_state: MacState::default() so it compiles immediately. all existing tests still pass —
no behaviour change yet.
phase 3 — statebuilder extensions
feat(core/test-helpers): add with_selinux_state and with_apparmor_state to StateBuildertwo new builder methods, gated behind test-helpers feature. set mac_state fields
directly on the in-progress state. tests can now compile against synthetic MAC state.
phase 4 — tests (red)
test(core/checks): add unit tests for check_selinux across all modes and outcomescases: enforcing+allowed, enforcing+denied, permissive+would-deny, disabled (Degraded),
no target path (Degraded), unreadable context (Degraded). all use StateBuilder.
tests compile but fail — check functions still make live kernel calls at this point.
test(core/checks): add unit tests for check_apparmor across all modescases: enforce mode (Degraded — no libapparmor), complain mode (Pass with warnings),
unconfined (Pass), AppArmor absent (Degraded), unknown mode (Degraded). all use
StateBuilder. tests compile but fail at this point.
phase 5 — gather layer
feat(gather): move selinux/apparmor feature deps from whyno-core to whyno-gatherCargo.toml changes only. whyno-core drops selinux/apparmor crate deps entirely.
whyno-gather picks them up, feature-gated.
feat(gather): add gather_mac_state() for SELinux and AppArmornew whyno-gather/src/mac.rs. feature-gated. gathers subject ctx, target ctx, mode, calls
AVC, populates SeLinuxState. reads procfs profile label, populates AppArmorState.
returns MacState.
feat(gather): wire gather_mac_state into gather_state()gather_state() calls gather_mac_state() and sets state.mac_state. the
MacState::default() placeholder from phase 2 is replaced with real data.
phase 6 — refactor check functions (green)
refactor(core/checks): make check_selinux pure over state.mac_stateremoves all live kernel calls from check_selinux(). reads state.mac_state.selinux.
phase 4 SELinux tests now pass.
refactor(core/checks): make check_apparmor pure over state.mac_statesame for check_apparmor(). removes procfs reads and securityfs presence check from the
check function. phase 4 AppArmor tests now pass.
phase 7 — filesystem probe assertions
test(gather): tighten filesystem probe assertions from permissive to distro-awarereplace permissive Known or Unknown are both fine accepts with real assertions. tests that
must run on ext4 get #[cfg_attr(not(feature = "ext4-tests"), ignore)].
phase 8 — cross-compilation release build
chore(ci): add cross-compilation Makefile for musl static release artifactswhyno ships as a single static musl binary. no glibc build is produced or needed —
musl is strictly preferable for a debugging tool (no NSS, no runtime deps, runs on any
kernel ≥ 3.x including broken systems being investigated). subject resolution reads
/etc/passwd and /etc/group directly, bypassing libc NSS entirely, so there is no
glibc-only code path.
tool: cross — Docker-backed cross-compilation, invoked identically to cargo.
no hand-written Dockerfiles; cross manages toolchain containers internally.
targets:
x86_64-unknown-linux-muslaarch64-unknown-linux-muslarmv7-unknown-linux-musleabihfriscv64gc-unknown-linux-musl
deliverables:
justfilewith per-arch targets and aggregatereleaseandtest-crosstargetscross test --target <arch>per target — validates test suite on each arch via QEMU- artifacts written to
dist/<target>/whyno just releasebuilds all four targets sequentiallyjust test-crossrunscross testfor all four targets.cargo/config.toml— target-specific linker and runner config;justfileis a thin wrapper only, all build logic stays incargo/cross
phase 9 — proxmox vm templates
chore(ci): add Proxmox VM templates and SSH test runner for live kernel testsFedora (SELinux enforcing) and Ubuntu (AppArmor enforcing) VM setup scripts. Makefile
targets: make test-selinux, make test-apparmor. feature-gated:
--features selinux-integration / --features apparmor-integration.
phases 8 and 9 are independent and can run in parallel with phases 4–7. phases 1–7 are sequential — each depends on the previous.
consequences
positive:
- MAC check functions match every other layer: pure functions over state, testable anywhere.
whyno-corehas no MAC crate dependencies — the boundary constraint holds.- unit tests for all MAC modes and outcomes become possible without kernel access.
StateBuildercan inject any MAC scenario for future layers.
negative / trade-offs:
gather_state()now calls the AVC at gather time, not check time. if the AVC state changes between gather and display (unlikely in practice), the result reflects gather-time state. consistent with how every other layer works.SystemStategrows amac_statefield. pre-1.0 breaking change, covered by the minor bump to v0.3.0.- the selinux/apparmor feature flags move from
whyno-coretowhyno-gather. any downstream consumer buildingwhyno-core --features selinuxdirectly must update their dependency. pre-1.0 breaking change.
deferred:
- live kernel integration tests for SELinux (Fedora VM) and AppArmor (Ubuntu with enforced profiles) are phase 9 and may slip to v0.3.1 if VM setup complexity balloons. the synthetic unit tests (phase 6) are the primary deliverable — they cover all logic paths. live tests validate the gather layer's kernel interactions only.