engineering change order
v0.3
title whyno v0.3.0 — mac layer test infrastructure
status accepted
date
family whyno-mac-test-infra
project whyno
rusttestingselinuxapparmorarchitecture

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 query
  • SecurityContext::current() — live process context read
  • SecurityContext::of_path() — live file context read
  • Path::new("/sys/kernel/security/apparmor").exists() — live filesystem check
  • fs::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:

  1. restore the pure-function contract — state types, gather functions, check refactors, StateBuilder extensions.
  2. write the missing unit tests — synthetic MAC state across all modes and outcomes.
  3. 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_tests

zero warnings before any new work starts.


phase 2 — state types

feat(core/state): add MacState, SeLinuxState, SeLinuxMode, AppArmorState types

new 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 default

adds 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 StateBuilder

two 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 outcomes

cases: 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 modes

cases: 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-gather

Cargo.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 AppArmor

new 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_state

removes 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_state

same 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-aware

replace 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 artifacts

whyno 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-musl
  • aarch64-unknown-linux-musl
  • armv7-unknown-linux-musleabihf
  • riscv64gc-unknown-linux-musl

deliverables:

  • justfile with per-arch targets and aggregate release and test-cross targets
  • cross test --target <arch> per target — validates test suite on each arch via QEMU
  • artifacts written to dist/<target>/whyno
  • just release builds all four targets sequentially
  • just test-cross runs cross test for all four targets
  • .cargo/config.toml — target-specific linker and runner config; justfile is a thin wrapper only, all build logic stays in cargo/cross

phase 9 — proxmox vm templates

chore(ci): add Proxmox VM templates and SSH test runner for live kernel tests

Fedora (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-core has no MAC crate dependencies — the boundary constraint holds.
  • unit tests for all MAC modes and outcomes become possible without kernel access.
  • StateBuilder can 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.
  • SystemState grows a mac_state field. pre-1.0 breaking change, covered by the minor bump to v0.3.0.
  • the selinux/apparmor feature flags move from whyno-core to whyno-gather. any downstream consumer building whyno-core --features selinux directly 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.