diff --git a/.copilot-instructions.md b/.copilot-instructions.md new file mode 100644 index 0000000..88f9eeb --- /dev/null +++ b/.copilot-instructions.md @@ -0,0 +1,40 @@ +# Rust Development Guidelines + +You are an expert Rust developer working on the `HaH` project. Follow these project-specific patterns and Rust best practices. + +## Project Architecture +- `hah-core`: The base logic, models, and rule engine. +- `hah-checks`: Implementations of specific system checks (Apt, Boot, Network, etc.). +- `hah`: The CLI entry point. + +## Coding Patterns +- **Error Handling**: Use `anyhow::Result` for application-level logic and `anyhow!` for formatting errors. +- **Rule Values**: Use the `RuleValue` enum from `hah-core` for data passing between checks and the engine. +- **Mocking**: Use `mockall` for traits used in external interactions (like `CommandRunner`). + +## Tool Usage +- Use `cargo fmt` for formatting. +- Use `cargo clippy` with `-D warnings` for linting. +- Use `cargo llvm-cov` for coverage analysis. + +## Memory & Performance +- Prefer passing references (`&str`, `&[T]`) over cloning where possible. +- Use `Iterator` methods (`map`, `filter`, `flat_map`, `fold`) instead of explicit `for` loops or mutable state when processing collections. +- Avoid `unwrap()` and `expect()` in production code; use proper error propagation. `clippy::unwrap_used` and `clippy::expect_used` are denied in this project except in tests. + +## Functional Programming & Testability +- **Pure Functions**: Favor small, pure functions that take inputs and return outputs without side effects. Logic that doesn't require I/O should be extracted from "impure" functions. +- **Immutability**: Prefer `let` bindings over `let mut` where possible. Transformations should return new data structures rather than mutating in-place. +- **Dependency Injection**: Pass dependencies (like `CommandRunner`) as arguments or generic traits to make side-effecting code testable with mocks. +- **Composition**: Use closure-based composition and combinators (like `Option::map`, `Result::and_then`) to handle control flow. + +## Minimalism & Code Health +- **KISS (Keep It Simple, Stupid)**: Favor the simplest solution that satisfies the requirements. Avoid over-engineering or premature abstraction. +- **DRY (Don't Repeat Yourself)**: Extract common logic into reusable functions or traits. If a logic pattern appears three times, it must be abstracted. +- **Minimal Complexity**: Keep functions small and focused on a single responsibility. If a function has deep nesting or many branches, refactor it into smaller, composed pure functions. +- **Dead Code**: Ensure no unused imports, variables, or functions remain. Trust the compiler and Clippy to identify unnecessary complexity. + +## Testing +- Add unit tests in a `mod tests` block at the bottom of the file. +- Add integration tests in `crates/hah/tests/`. +- Ensure new public functions are covered by tests per `AGENTS.md`. diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..bd9a19b --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,42 @@ +name: PR Check + +on: + pull_request: + branches: [main] + push: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + quality: + name: Format / Lint / Test / Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Cargo registry & build artifacts + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy (deny warnings) + run: cargo clippy --all-targets -- -D warnings + + - name: Run tests + run: cargo test --all + + - name: Security audit + uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..595e44d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +on: + push: + tags: + - 'v[0-9]*' + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build — ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + cross: true + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Cargo registry & build artifacts + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install cross (for cross-compilation) + if: matrix.cross + run: cargo install cross --locked + + - name: Build release binary + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + + - name: Rename binary + run: | + src="target/${{ matrix.target }}/release/hah" + dst="hah-${{ matrix.target }}" + cp "$src" "$dst" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: hah-${{ matrix.target }} + path: hah-${{ matrix.target }} + if-no-files-found: error + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + generate_release_notes: true + files: artifacts/**/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38ac6aa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Agent Guidelines + +## Validation Gate + +Every change **must** pass the full quality gate before being considered done: + +```bash +make check +``` + +This runs, in order: + +| Step | Command | Requirement | +| -------------- | ---------------------------------------------------------------- | ----------------------- | +| Format check | `cargo fmt --all -- --check` | No formatting diffs | +| Lint | `cargo clippy --all-targets -- -D warnings` | Zero warnings | +| Tests | `cargo test --all` | All tests green | +| Security audit | `cargo audit` | No unpatched advisories | +| Coverage | `cargo llvm-cov --all-targets --workspace --fail-under-lines 95` | ≥ 95 % line coverage | + +### One-time setup (if tools are missing) + +```bash +make setup +``` + +## Workflow + +1. Make your changes. +2. Run `make fmt` to auto-format before committing. +3. Run `make check` and fix every reported issue. +4. Do **not** submit or push until `make check` exits with code 0. + +## Non-negotiable rules + +- Never suppress a Clippy warning with `#[allow(...)]` unless it is inside a `#[cfg(test)]` block and the suppression is genuinely test-only (e.g. `clippy::unwrap_used`, `clippy::panic`). +- Never lower or remove the `--fail-under-lines 95` threshold. +- New public functions must have tests that exercise their main code paths. + +## Relevant Instructions +- **[.copilot-instructions.md](.copilot-instructions.md)**: Contains detailed Rust coding patterns, project architecture overview, and error handling policies. Always refer to this for implementation details. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4888650 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[workspace] +members = [ + "crates/hah", + "crates/hah-core", + "crates/hah-dsl", + "crates/hah-checks", + "crates/hah-utils", +] +resolver = "2" + +[workspace.dependencies] +mockall = "0.13" + +[workspace.lints.clippy] +# ── Functional iterator / combinator style ────────────────────────────────── +cloned_instead_of_copied = "warn" +filter_map_next = "warn" +flat_map_option = "warn" +map_flatten = "warn" +map_unwrap_or = "warn" +needless_collect = "warn" +option_if_let_else = "warn" +or_fun_call = "warn" +redundant_closure_for_method_calls = "warn" + +# ── Error handling (no silent panics in production code) ───────────────────── +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" + +# ── Immutability / purity ──────────────────────────────────────────────────── +mut_mut = "warn" +needless_pass_by_ref_mut = "warn" + +[workspace.lints.rust] +# `coverage` cfg is set by cargo-llvm-cov during coverage measurement. +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } +warnings = "deny" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000..be949e0 --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,28 @@ +# Dependencies + +Direct dependencies of the HaH workspace crates. +_Generated by `make doc-dependencies` — do not edit by hand._ + +## Runtime Dependencies + +| Crate | Version | License | Purpose | +| ----- | ------- | ------- | ------- | +| [anyhow](https://crates.io/crates/anyhow) | 1 | MIT OR Apache-2.0 | Flexible concrete Error type built on std::error::Error | +| [bytesize](https://crates.io/crates/bytesize) | 1 | Apache-2.0 | an utility for human-readable bytes representations | +| [clap](https://crates.io/crates/clap) | 4 | MIT OR Apache-2.0 | A simple to use, efficient, and full-featured Command Line Argument Parser | +| [colored](https://crates.io/crates/colored) | 2 | MPL-2.0 | The most simple way to add colors in your terminal | +| [dirs](https://crates.io/crates/dirs) | 5 | MIT OR Apache-2.0 | A tiny low-level library that provides platform-specific standard locations of directories for config, cache and other data on Linux, Windows, macOS and Redox by leveraging the mechanisms defined by the XDG base/user directory specifications on Linux, the Known Folder API on Windows, and the Standard Directory guidelines on macOS | +| [regex](https://crates.io/crates/regex) | 1 | MIT OR Apache-2.0 | An implementation of regular expressions for Rust. This implementation uses +finite automata and guarantees linear time matching on all inputs | +| [serde](https://crates.io/crates/serde) | 1 | MIT OR Apache-2.0 | A generic serialization/deserialization framework | +| [serde_json](https://crates.io/crates/serde_json) | 1 | MIT OR Apache-2.0 | A JSON serialization file format | +| [serde_yaml_ng](https://crates.io/crates/serde_yaml_ng) | 0.9 | MIT OR Apache-2.0 | YAML data format for Serde | +| [walkdir](https://crates.io/crates/walkdir) | 2 | Unlicense/MIT | Recursively walk a directory | + +## Development-only Dependencies + +| Crate | Version | License | Purpose | +| ----- | ------- | ------- | ------- | +| [filetime](https://crates.io/crates/filetime) | 0.2 | MIT/Apache-2.0 | Platform-agnostic accessors of timestamps in File metadata | +| [mockall](https://crates.io/crates/mockall) | 0.13 | MIT OR Apache-2.0 | A powerful mock object library for Rust | +| [tempfile](https://crates.io/crates/tempfile) | 3 | MIT OR Apache-2.0 | A library for managing temporary files and directories | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b3d754 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.PHONY: all setup fmt fmt-check lint test audit check doc-dependencies + +all: check + +## Install required Cargo tools (run once) +setup: + cargo install cargo-audit + cargo install cargo-llvm-cov + rustup component add llvm-tools-preview + +## Auto-format all code +fmt: + cargo fmt --all + +## Check formatting without modifying files (used in CI) +fmt-check: + cargo fmt --all -- --check + +## Run Clippy; treat all warnings as errors +lint: + cargo clippy --all-targets -- -D warnings + +## Run all tests +test: + cargo test --all + +## Run security audit against RustSec advisory database +audit: + cargo audit + +## Generate HTML coverage report (opens in target/llvm-cov/html/) +coverage: + cargo llvm-cov --all-targets --workspace --html + +## Fail the build if line coverage drops below 95 % +coverage-ci: + cargo llvm-cov --all-targets --workspace --fail-under-lines 95 + +## Full quality gate: format-check + lint + test + audit + coverage +check: fmt-check lint test audit coverage-ci + +## Regenerate DEPENDENCIES.md from live cargo metadata +doc-dependencies: + python3 tools/gen_deps_doc.py > DEPENDENCIES.md diff --git a/README.md b/README.md index e156f87..0f47594 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,172 @@ # HaH -Hunt and Heal tool to check and cleanup linux systems + +HaH is a "hunt and heal" utility for inspecting and cleaning up Linux systems. The goal is to detect common system maintenance problems, explain why they matter, and offer safe cleanup or repair actions. + +## Usage + +``` +hah +``` + +### Commands + +| Command | Description | +| ----------------- | ------------------------------------------------- | +| `hah scan` | Run all enabled checks and report findings | +| `hah list-checks` | List every registered check with its ID and title | + +### `hah scan` options + +| Option | Default | Description | +| ------------------- | -------------- | --------------------------------------------------------------------------- | +| `--output ` | `terminal` | Output format: `terminal`, `json`, or `yaml` | +| `--check ` | _(all checks)_ | Run only the single check with this ID | +| `--fix` | off | Apply safe remediations automatically | +| `--dry-run` | on | Report findings only, no changes (default behavior, conflicts with `--fix`) | + +### Exit codes + +| Code | Meaning | +| ---- | -------------------------------------------- | +| `0` | No findings, or only Info / Warning findings | +| `1` | At least one Critical finding was detected | + +### Configuration + +HaH loads configuration from the following locations in order, with later files taking precedence: + +1. `/etc/hah/config.yaml` — system-wide defaults +2. `~/.config/hah/config.yaml` — per-user overrides + +Example configuration: + +```yaml +thresholds: + boot_space_mb: 100 # warn when /boot free space drops below this + initramfs_size_mb: 100 # warn on initramfs images larger than this + journal_size_mb: 500 # warn when the systemd journal exceeds this + snap_max_revisions: 2 # warn when a snap retains more revisions than this + crash_dump_max_days: 30 # warn on crash dumps older than this many days + +allowlist: + packages: + - some-package-to-ignore # suppress findings for this package + +denylist: + packages: + - name: flashplugin-installer + reason: "Adobe Flash is end-of-life and a security risk" + +disabled_checks: + - broken-symlinks # skip this check entirely +``` + +--- + +## Scope + +HaH is intended to help with: + +- package and repository hygiene +- boot partition cleanup +- kernel and driver compatibility issues +- leftover files from upgrades or migrations +- duplicate software installs across package managers +- network configuration hygiene (NTP, DHCP, DNS, interface management) +- general system health checks + +## Target Problems + +### Boot and Kernel Maintenance + +- low disk space on `/boot` +- unused kernels that can be removed safely +- oversized or outdated initramfs images +- initramfs compression choices that waste boot partition space +- stale kernel headers and modules +- mismatched running kernel versus installed kernel packages + +### Drivers and DKMS + +- DKMS modules that fail to build on newer kernels +- orphaned driver sources left behind after upgrades +- third-party drivers that block kernel upgrades +- NVIDIA, VirtualBox, ZFS, or similar modules with broken rebuild status +- missing build dependencies required for DKMS recovery + +### APT and Repository Cleanup + +- old or deprecated APT repositories +- duplicate repository definitions across `/etc/apt/sources.list` and `sources.list.d` +- leftover repository keys or keyrings that are no longer used +- old signing keys stored with deprecated trust methods such as `apt-key` +- legacy APT source formats that should be migrated to newer `.sources` entries or modern keyring usage +- outdated APT configuration snippets that override current defaults or reference removed repositories +- packages installed from repositories that no longer exist +- failed or partial package states in `dpkg` or `apt` + +### Package Hygiene + +- packages that should no longer be installed +- obsolete packages left over from distro migrations +- package cleanup rules driven by YAML configuration +- automatically removable packages that were never cleaned up +- residual config packages in the `rc` state + +### Snap and Cross-Package-Manager Conflicts + +- software installed via both APT and Snap +- cases where the Snap package is preferred because it is still maintained +- broken Snap installs, disabled revisions, or excessive retained revisions +- packages duplicated across APT, Snap, Flatpak, or manual installs + +### Network Configuration + +- legacy NTP daemon (`ntp` / ISC ntpd) installed instead of `chrony` or `systemd-timesyncd` +- multiple time-sync services active simultaneously, competing to adjust the clock +- legacy ISC DHCP client (`dhclient`) still installed when NetworkManager or `systemd-networkd` handles DHCP +- non-loopback interface definitions in `/etc/network/interfaces` (ifupdown) alongside Netplan or NetworkManager +- `/etc/resolv.conf` not linked to `systemd-resolved`'s stub resolver after an upgrade +- `resolvconf` package conflicting with `systemd-resolved` +- `ifupdown` installed alongside a modern network manager causing management overlap + +### Leftovers and System Drift + +- residual configuration files from removed software +- old log files, caches, and temporary artifacts +- broken symlinks left by removed packages +- stale systemd units, timers, or service drop-ins +- configuration drift after in-place upgrades +- outdated configuration files or settings carried forward across releases +- legacy defaults that no longer match current distro recommendations +- missing, conflicting, or suspicious `sysctl` parameters +- `sysctl` overrides that degrade security, stability, or network behavior + +## Additional Ideas + +- dry-run mode that reports findings without changing the system +- severity levels such as info, warning, and critical +- clear remediation output with exact commands before execution +- backup or snapshot hooks before destructive actions +- allowlist and denylist support for packages and repositories +- profile-based scans for desktop, server, VM, or container hosts +- distro-specific handlers for Debian, Ubuntu, Mint, and related systems +- machine-readable output such as JSON or YAML +- audit report generation for scheduled maintenance runs +- interactive mode for reviewing each fix before applying it +- non-interactive mode for automation +- plugin or rule system so checks can be added incrementally +- safety checks to avoid removing the currently running kernel +- detection of unsupported end-of-life releases +- checks for held packages that block security updates +- checks for interrupted upgrades or pending reboot requirements +- cleanup of old crash dumps and journal growth +- validation of `sysctl.d` ordering, overrides, and obsolete kernel tunables +- detection of deprecated config formats across package manager and system settings +- detection of conflicting or redundant NTP, DHCP, and DNS resolver configurations +- migration guidance from legacy network tooling to Netplan or NetworkManager +- optional integration with SMART, filesystem, and memory health checks + +## Future Direction + +HaH could evolve into a rule-based maintenance assistant that combines detection, explanation, and safe remediation for long-lived Linux systems. diff --git a/crates/hah-checks/Cargo.toml b/crates/hah-checks/Cargo.toml new file mode 100644 index 0000000..637ab8a --- /dev/null +++ b/crates/hah-checks/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "hah-checks" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +hah-core = { path = "../hah-core" } +hah-utils = { path = "../hah-utils" } +anyhow = "1" + +[dev-dependencies] +mockall = { workspace = true } +tempfile = "3" +filetime = "0.2" diff --git a/crates/hah-checks/src/apt.rs b/crates/hah-checks/src/apt.rs new file mode 100644 index 0000000..89595af --- /dev/null +++ b/crates/hah-checks/src/apt.rs @@ -0,0 +1,668 @@ +use std::{collections::HashSet, fs, path::Path}; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, +}; + +// ── ResidualConfigCheck ────────────────────────────────────────────────────── + +pub struct ResidualConfigCheck; + +impl Check for ResidualConfigCheck { + fn id(&self) -> &str { + "residual-config" + } + + fn title(&self) -> &str { + "Residual package configuration files" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + + let out = match ctx + .runner + .run("dpkg-query", &["-W", "-f=${Status} ${Package}\n"]) + { + Ok(o) => o, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let rc_packages: Vec = stdout + .lines() + .filter(|l| l.starts_with("deinstall ok config-files ")) + .map(|l| { + l.trim_start_matches("deinstall ok config-files ") + .to_string() + }) + .filter(|pkg| !ctx.config.allowlist.packages.contains(pkg)) + .collect(); + + if rc_packages.is_empty() { + return CheckResult::default(); + } + + let list = rc_packages.join(" "); + CheckResult::default().with_finding(Finding { + id: "residual-config".into(), + title: format!( + "{} package(s) with residual configuration", + rc_packages.len() + ), + description: format!( + "These packages were removed but their configuration files remain: {list}." + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Purge residual configurations.".into(), + commands: vec![format!("sudo dpkg --purge {list}")], + safe: false, + }), + }) + } +} + +// ── DpkgStateCheck ─────────────────────────────────────────────────────────── + +pub struct DpkgStateCheck; + +impl Check for DpkgStateCheck { + fn id(&self) -> &str { + "dpkg-state" + } + + fn title(&self) -> &str { + "Broken dpkg package states" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + + let out = match ctx.runner.run("dpkg", &["--audit"]) { + Ok(o) => o, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + if stdout.trim().is_empty() { + return CheckResult::default(); + } + + CheckResult::default().with_finding(Finding { + id: "dpkg-audit".into(), + title: "dpkg audit reports package state problems".into(), + description: stdout.trim().to_string(), + severity: Severity::Critical, + remediation: Some(Remediation { + description: "Attempt to fix broken packages.".into(), + commands: vec![ + "sudo dpkg --configure -a".into(), + "sudo apt-get install -f".into(), + ], + safe: false, + }), + }) + } +} + +// ── AutoremovableCheck ─────────────────────────────────────────────────────── + +pub struct AutoremovableCheck; + +impl Check for AutoremovableCheck { + fn id(&self) -> &str { + "autoremovable" + } + + fn title(&self) -> &str { + "Auto-removable packages" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + + let out = match ctx.runner.run("apt-get", &["--dry-run", "autoremove"]) { + Ok(o) => o, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let count = stdout + .lines() + .filter(|l| l.trim_start().starts_with("Remv ")) + .count(); + + if count == 0 { + return CheckResult::default(); + } + + CheckResult::default().with_finding(Finding { + id: "autoremovable".into(), + title: format!("{count} auto-removable package(s)"), + description: format!("{count} package(s) are no longer needed and can be removed."), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Remove unused auto-installed packages.".into(), + commands: vec!["sudo apt autoremove --purge".into()], + safe: false, + }), + }) + } +} + +// ── AptKeyCheck ────────────────────────────────────────────────────────────── + +pub struct AptKeyCheck; + +/// Return a finding if `path` exists and is non-empty (legacy keyring). +pub(crate) fn apt_key_finding(path: &Path) -> Option { + if path.exists() + && let Ok(meta) = path.metadata() + && meta.len() > 0 + { + Some(Finding { + id: "apt-key-legacy-gpg".into(), + title: "Legacy /etc/apt/trusted.gpg keyring is in use".into(), + description: "The file /etc/apt/trusted.gpg is non-empty. Keys managed here \ + were added with the deprecated `apt-key` command. They should be \ + migrated to named keyring files under /usr/share/keyrings/ and \ + referenced via the signed-by= option in source entries." + .into(), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Export each key to a dedicated keyring file.".into(), + commands: vec![ + "apt-key list".into(), + "# For each key: sudo gpg --no-default-keyring \ + --keyring /usr/share/keyrings/NAME.gpg \ + --import /tmp/key.asc" + .into(), + ], + safe: true, + }), + }) + } else { + None + } +} + +impl Check for AptKeyCheck { + fn id(&self) -> &str { + "apt-key" + } + + fn title(&self) -> &str { + "Deprecated apt-key signing keys" + } + + fn run(&self, _ctx: &Context) -> CheckResult { + apt_key_finding(Path::new("/etc/apt/trusted.gpg")).map_or_else(CheckResult::default, |f| { + CheckResult::default().with_finding(f) + }) + } +} + +// ── LegacySourcesFormatCheck ───────────────────────────────────────────────── + +pub struct LegacySourcesFormatCheck; + +/// Collect legacy one-line `deb` source files from `sources_list` and every +/// `.list` file inside `sources_d`. Extracted so it can be tested with +/// temporary paths. +pub(crate) fn collect_legacy_source_files(sources_list: &Path, sources_d: &Path) -> Vec { + let mut legacy: Vec = Vec::new(); + + if sources_list.exists() + && let Ok(content) = fs::read_to_string(sources_list) + && content + .lines() + .any(|l| l.starts_with("deb ") || l.starts_with("deb-src ")) + { + legacy.push(sources_list.to_string_lossy().into_owned()); + } + + if let Ok(entries) = fs::read_dir(sources_d) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("list") + && let Ok(content) = fs::read_to_string(&path) + && content + .lines() + .any(|l| l.starts_with("deb ") || l.starts_with("deb-src ")) + { + legacy.push(path.to_string_lossy().into_owned()); + } + } + } + + legacy +} + +fn legacy_sources_finding(legacy_files: Vec) -> Option { + if legacy_files.is_empty() { + return None; + } + let list = legacy_files.join(", "); + Some(Finding { + id: "legacy-sources-format".into(), + title: format!( + "{} file(s) using legacy one-line APT source format", + legacy_files.len() + ), + description: format!( + "The following files use the deprecated one-line `deb` format: {list}. \ + The modern DEB822 `.sources` format is preferred." + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Convert to DEB822 format (one .sources file per repository).".into(), + commands: vec!["# See: https://wiki.debian.org/SourcesList#DEB822_format".into()], + safe: true, + }), + }) +} + +impl Check for LegacySourcesFormatCheck { + fn id(&self) -> &str { + "legacy-sources-format" + } + + fn title(&self) -> &str { + "Legacy one-line APT source entries" + } + + fn run(&self, _ctx: &Context) -> CheckResult { + let files = collect_legacy_source_files( + Path::new("/etc/apt/sources.list"), + Path::new("/etc/apt/sources.list.d"), + ); + legacy_sources_finding(files).map_or_else(CheckResult::default, |f| { + CheckResult::default().with_finding(f) + }) + } +} + +// ── UserDefinedPackageCheck ────────────────────────────────────────────────── + +pub struct UserDefinedPackageCheck; + +impl Check for UserDefinedPackageCheck { + fn id(&self) -> &str { + "user-denylist" + } + + fn title(&self) -> &str { + "Packages matching user denylist" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if ctx.config.denylist.packages.is_empty() { + return CheckResult::default(); + } + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + + let out = match ctx.runner.run("dpkg-query", &["-W", "-f=${Package}\n"]) { + Ok(o) => o, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let installed: HashSet = String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect(); + + let mut result = CheckResult::default(); + for entry in &ctx.config.denylist.packages { + if installed.contains(&entry.name) { + result = result.with_finding(Finding { + id: format!("user-denylist-{}", entry.name), + title: format!("Package '{}' should not be installed", entry.name), + description: entry.reason.clone(), + severity: Severity::Warning, + remediation: Some(Remediation { + description: format!("Remove {}", entry.name), + commands: vec![format!("sudo apt remove --purge {}", entry.name)], + safe: false, + }), + }); + } + } + result + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use hah_core::{ + check::Context, + config::{Config, DenylistEntry}, + distro::DistroInfo, + runner::{CommandOutput, CommandRunner}, + }; + use mockall::mock; + use std::sync::Arc; + + mock! { + Runner {} + impl CommandRunner for Runner { + fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; + } + } + + fn ok_output(stdout: &str) -> CommandOutput { + CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + } + } + + fn make_ctx(runner: Arc, config: Config, distro_id: &str) -> Context { + Context { + dry_run: false, + verbose: false, + config, + distro: DistroInfo { + id: distro_id.into(), + ..DistroInfo::default() + }, + runner, + } + } + + fn debian_ctx(runner: Arc) -> Context { + make_ctx(runner, Config::default(), "debian") + } + + fn non_debian_ctx() -> Context { + make_ctx(Arc::new(MockRunner::new()), Config::default(), "arch") + } + + // ── ResidualConfigCheck ────────────────────────────────────────────────── + + #[test] + fn residual_config_skips_non_debian() { + assert!( + ResidualConfigCheck + .run(&non_debian_ctx()) + .findings + .is_empty() + ); + } + + #[test] + fn residual_config_clean() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("install ok installed bash\n"))); + let result = ResidualConfigCheck.run(&debian_ctx(Arc::new(runner))); + assert!(result.findings.is_empty()); + assert!(result.errors.is_empty()); + } + + #[test] + fn residual_config_finds_rc_packages() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "deinstall ok config-files pkg-a\ninstall ok installed bash\n", + )) + }); + let result = ResidualConfigCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert!(result.findings[0].title.contains('1')); + } + + #[test] + fn residual_config_allowlist_filters_package() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("deinstall ok config-files known-pkg\n"))); + let mut config = Config::default(); + config.allowlist.packages = vec!["known-pkg".into()]; + let ctx = make_ctx(Arc::new(runner), config, "debian"); + assert!(ResidualConfigCheck.run(&ctx).findings.is_empty()); + } + + #[test] + fn residual_config_runner_error_returns_error() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )) + }); + let result = ResidualConfigCheck.run(&debian_ctx(Arc::new(runner))); + assert!(result.errors.len() == 1); + } + + // ── DpkgStateCheck ─────────────────────────────────────────────────────── + + #[test] + fn dpkg_state_skips_non_debian() { + assert!(DpkgStateCheck.run(&non_debian_ctx()).findings.is_empty()); + } + + #[test] + fn dpkg_state_clean() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(ok_output(""))); + let result = DpkgStateCheck.run(&debian_ctx(Arc::new(runner))); + assert!(result.findings.is_empty()); + } + + #[test] + fn dpkg_state_broken_packages() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("broken package state info\n"))); + let result = DpkgStateCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Critical); + } + + // ── AutoremovableCheck ─────────────────────────────────────────────────── + + #[test] + fn autoremovable_skips_non_debian() { + assert!( + AutoremovableCheck + .run(&non_debian_ctx()) + .findings + .is_empty() + ); + } + + #[test] + fn autoremovable_none() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("Reading package lists...\n"))); + let result = AutoremovableCheck.run(&debian_ctx(Arc::new(runner))); + assert!(result.findings.is_empty()); + } + + #[test] + fn autoremovable_finds_packages() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "Reading package lists...\n Remv old-lib [1.0]\n Remv unused-tool [2.3]\n", + )) + }); + let result = AutoremovableCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert!(result.findings[0].title.contains('2')); + } + + // ── UserDefinedPackageCheck ────────────────────────────────────────────── + + #[test] + fn user_denylist_skips_empty_denylist() { + assert!( + UserDefinedPackageCheck + .run(&debian_ctx(Arc::new(MockRunner::new()))) + .findings + .is_empty() + ); + } + + #[test] + fn user_denylist_skips_non_debian() { + let mut config = Config::default(); + config.denylist.packages = vec![DenylistEntry { + name: "bad-pkg".into(), + reason: "insecure".into(), + }]; + let ctx = make_ctx(Arc::new(MockRunner::new()), config, "arch"); + assert!(UserDefinedPackageCheck.run(&ctx).findings.is_empty()); + } + + #[test] + fn user_denylist_installed_package_flagged() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("bash\nbad-pkg\nvim\n"))); + let mut config = Config::default(); + config.denylist.packages = vec![DenylistEntry { + name: "bad-pkg".into(), + reason: "this is insecure".into(), + }]; + let ctx = make_ctx(Arc::new(runner), config, "debian"); + let result = UserDefinedPackageCheck.run(&ctx); + assert_eq!(result.findings.len(), 1); + assert!(result.findings[0].id.contains("bad-pkg")); + } + + #[test] + fn user_denylist_not_installed_no_finding() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("bash\nvim\n"))); + let mut config = Config::default(); + config.denylist.packages = vec![DenylistEntry { + name: "bad-pkg".into(), + reason: "insecure".into(), + }]; + let ctx = make_ctx(Arc::new(runner), config, "debian"); + assert!(UserDefinedPackageCheck.run(&ctx).findings.is_empty()); + } + + // ── apt_key_finding ─────────────────────────────────────────────────────── + + #[test] + fn apt_key_finding_returns_none_when_file_absent() { + assert!(apt_key_finding(std::path::Path::new("/nonexistent/trusted.gpg")).is_none()); + } + + #[test] + fn apt_key_finding_returns_none_when_file_empty() { + let path = std::env::temp_dir().join(format!("hah_gpg_empty_{}.gpg", std::process::id())); + std::fs::write(&path, b"").unwrap(); + let result = apt_key_finding(&path); + let _ = std::fs::remove_file(&path); + assert!(result.is_none()); + } + + #[test] + fn apt_key_finding_returns_warning_when_file_nonempty() { + let path = + std::env::temp_dir().join(format!("hah_gpg_nonempty_{}.gpg", std::process::id())); + std::fs::write(&path, b"fake-gpg-data").unwrap(); + let finding = apt_key_finding(&path); + let _ = std::fs::remove_file(&path); + let f = finding.expect("expected a finding for non-empty gpg file"); + assert_eq!(f.severity, Severity::Warning); + assert_eq!(f.id, "apt-key-legacy-gpg"); + } + + #[test] + fn apt_key_check_id_and_title() { + assert_eq!(AptKeyCheck.id(), "apt-key"); + assert!(!AptKeyCheck.title().is_empty()); + } + + #[test] + fn apt_key_check_runs_without_panic_on_real_system() { + let ctx = make_ctx(Arc::new(MockRunner::new()), Config::default(), "debian"); + let result = AptKeyCheck.run(&ctx); + for f in &result.findings { + assert_eq!(f.severity, Severity::Warning); + } + } + + // ── collect_legacy_source_files ─────────────────────────────────────────── + + #[test] + fn collect_legacy_sources_empty_when_no_deb_lines() { + let tmp = std::env::temp_dir(); + let list = tmp.join(format!("hah_src_{}.list", std::process::id())); + std::fs::write(&list, "# just a comment\n").unwrap(); + let d = tmp.join(format!("hah_srcd_{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let result = collect_legacy_source_files(&list, &d); + let _ = std::fs::remove_file(&list); + let _ = std::fs::remove_dir_all(&d); + assert!(result.is_empty()); + } + + #[test] + fn collect_legacy_sources_detects_deb_line_in_sources_list() { + let tmp = std::env::temp_dir(); + let list = tmp.join(format!("hah_srclist_{}.list", std::process::id())); + std::fs::write(&list, "deb http://archive.ubuntu.com/ubuntu focal main\n").unwrap(); + let d = tmp.join(format!("hah_srcd2_{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let result = collect_legacy_source_files(&list, &d); + let _ = std::fs::remove_file(&list); + let _ = std::fs::remove_dir_all(&d); + assert_eq!(result.len(), 1); + } + + #[test] + fn collect_legacy_sources_detects_deb_line_in_sources_d() { + let tmp = std::env::temp_dir(); + let absent_list = tmp.join(format!("hah_absent_{}.list", std::process::id())); + let d = tmp.join(format!("hah_srcd3_{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write( + d.join("extra.list"), + "deb-src http://archive.ubuntu.com/ubuntu focal main\n", + ) + .unwrap(); + let result = collect_legacy_source_files(&absent_list, &d); + let _ = std::fs::remove_dir_all(&d); + assert_eq!(result.len(), 1); + } + + #[test] + fn legacy_sources_format_check_id_and_title() { + assert_eq!(LegacySourcesFormatCheck.id(), "legacy-sources-format"); + assert!(!LegacySourcesFormatCheck.title().is_empty()); + } + + #[test] + fn legacy_sources_format_check_runs_without_panic_on_real_system() { + let ctx = make_ctx(Arc::new(MockRunner::new()), Config::default(), "debian"); + let _ = LegacySourcesFormatCheck.run(&ctx); + } +} diff --git a/crates/hah-checks/src/boot.rs b/crates/hah-checks/src/boot.rs new file mode 100644 index 0000000..5795f99 --- /dev/null +++ b/crates/hah-checks/src/boot.rs @@ -0,0 +1,827 @@ +use std::path::Path; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, + runner::CommandRunner, +}; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn running_kernel(runner: &dyn CommandRunner) -> anyhow::Result { + let out = runner.run("uname", &["-r"])?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn installed_kernel_packages(runner: &dyn CommandRunner) -> anyhow::Result> { + let out = runner.run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-image-*"], + )?; + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect()) +} + +fn installed_header_packages(runner: &dyn CommandRunner) -> anyhow::Result> { + let out = runner.run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-headers-*"], + )?; + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect()) +} + +fn boot_free_bytes(runner: &dyn CommandRunner) -> anyhow::Result { + let out = runner.run("df", &["--block-size=1", "--output=avail", "/boot"])?; + let stdout = String::from_utf8_lossy(&out.stdout); + let avail: u64 = stdout + .lines() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("unexpected df output"))? + .trim() + .parse()?; + Ok(avail) +} + +pub use hah_utils::fs::sanitize_id; + +// ── BootSpaceCheck ─────────────────────────────────────────────────────────── + +pub struct BootSpaceCheck; + +impl Check for BootSpaceCheck { + fn id(&self) -> &str { + "boot-space" + } + + fn title(&self) -> &str { + "Free space on /boot" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let threshold_mb = ctx.config.threshold("boot_space_mb", 100); + let threshold_bytes = threshold_mb * 1024 * 1024; + + let free_bytes = match boot_free_bytes(ctx.runner.as_ref()) { + Ok(n) => n, + Err(e) => return CheckResult::default().with_error(format!("df /boot: {e}")), + }; + + if free_bytes < threshold_bytes { + let free_mb = free_bytes / 1024 / 1024; + CheckResult::default().with_finding(Finding { + id: "boot-space-low".into(), + title: format!("/boot has only {free_mb} MB free"), + description: format!( + "The /boot partition is nearly full ({free_mb} MB free, \ + threshold: {threshold_mb} MB). This can prevent kernel upgrades \ + or initramfs updates from completing." + ), + severity: Severity::Critical, + remediation: Some(Remediation { + description: "Remove unused kernels to free space.".into(), + commands: vec!["sudo apt autoremove --purge".into()], + safe: false, + }), + }) + } else { + CheckResult::default() + } + } +} + +// ── UnusedKernelsCheck ─────────────────────────────────────────────────────── + +pub struct UnusedKernelsCheck; + +impl Check for UnusedKernelsCheck { + fn id(&self) -> &str { + "unused-kernels" + } + + fn title(&self) -> &str { + "Unused installed kernels" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + + let running = match running_kernel(ctx.runner.as_ref()) { + Ok(v) => v, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let installed = match installed_kernel_packages(ctx.runner.as_ref()) { + Ok(v) => v, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let unused: Vec = installed + .into_iter() + .filter(|pkg| !pkg.contains(&running)) + .collect(); + + if unused.is_empty() { + return CheckResult::default(); + } + + let list = unused.join(", "); + CheckResult::default().with_finding(Finding { + id: "unused-kernels".into(), + title: format!("{} unused kernel package(s) installed", unused.len()), + description: format!( + "Running kernel: {running}. Unused: {list}. \ + These consume space in /boot and can safely be removed." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Remove unused kernels with apt.".into(), + commands: vec!["sudo apt autoremove --purge".into()], + safe: false, + }), + }) + } +} + +// ── StaleKernelHeadersCheck ────────────────────────────────────────────────── + +pub struct StaleKernelHeadersCheck; + +impl Check for StaleKernelHeadersCheck { + fn id(&self) -> &str { + "stale-kernel-headers" + } + + fn title(&self) -> &str { + "Stale kernel header packages" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + + let headers = match installed_header_packages(ctx.runner.as_ref()) { + Ok(v) => v, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let kernels = match installed_kernel_packages(ctx.runner.as_ref()) { + Ok(v) => v, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let stale: Vec = headers + .into_iter() + .filter(|hdr| { + let version = hdr.trim_start_matches("linux-headers-"); + // Skip meta-packages like linux-headers-generic that have no version + if !version.chars().next().is_some_and(char::is_numeric) { + return false; + } + !kernels.iter().any(|k| k.contains(version)) + }) + .collect(); + + if stale.is_empty() { + return CheckResult::default(); + } + + let list = stale.join(", "); + CheckResult::default().with_finding(Finding { + id: "stale-kernel-headers".into(), + title: format!("{} stale kernel header package(s)", stale.len()), + description: format!("Header packages with no matching kernel: {list}."), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Remove stale header packages.".into(), + commands: stale + .iter() + .map(|p| format!("sudo apt remove --purge {p}")) + .collect(), + safe: false, + }), + }) + } +} + +// ── InitramfsCheck ─────────────────────────────────────────────────────────── + +pub struct InitramfsCheck; + +impl Check for InitramfsCheck { + fn id(&self) -> &str { + "initramfs-size" + } + + fn title(&self) -> &str { + "Oversized initramfs images" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let threshold_mb = ctx.config.threshold("initramfs_size_mb", 100); + let threshold_bytes = threshold_mb * 1024 * 1024; + + let mut result = CheckResult::default(); + let entries = match std::fs::read_dir("/boot") { + Ok(e) => e, + Err(e) => return result.with_error(format!("read_dir /boot: {e}")), + }; + + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.starts_with("initrd.img-") && !name_str.starts_with("initramfs-") { + continue; + } + if let Ok(meta) = entry.metadata() { + let size = meta.len(); + if size > threshold_bytes { + let size_mb = size / 1024 / 1024; + result = result.with_finding(Finding { + id: format!("initramfs-large-{}", sanitize_id(&name_str)), + title: format!("{name_str} is {size_mb} MB"), + description: format!( + "initramfs image {name_str} exceeds the {threshold_mb} MB threshold. \ + Large images slow boot and consume /boot space." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Regenerate initramfs images.".into(), + commands: vec!["sudo update-initramfs -u -k all".into()], + safe: false, + }), + }); + } + } + } + result + } +} + +// ── DkmsStatusCheck ────────────────────────────────────────────────────────── + +pub struct DkmsStatusCheck; + +impl Check for DkmsStatusCheck { + fn id(&self) -> &str { + "dkms-status" + } + + fn title(&self) -> &str { + "DKMS module build status" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let out = match ctx.runner.run("dkms", &["status"]) { + Ok(o) => o, + Err(_) => return CheckResult::default(), // dkms not installed + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let mut result = CheckResult::default(); + + for line in stdout.lines() { + let lower = line.to_lowercase(); + if lower.contains("broken") || lower.contains("not installed") { + result = result.with_finding(Finding { + id: format!("dkms-broken-{}", sanitize_id(line)), + title: format!("DKMS module problem: {line}"), + description: format!( + "DKMS reports a problem with: {line}. \ + This module may not work with the current kernel." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Attempt DKMS rebuild.".into(), + commands: vec!["sudo dkms autoinstall".into()], + safe: false, + }), + }); + } + } + result + } +} + +// ── InitramfsCompressionCheck ──────────────────────────────────────────────── + +pub struct InitramfsCompressionCheck; + +impl Check for InitramfsCompressionCheck { + fn id(&self) -> &str { + "initramfs-compression" + } + + fn title(&self) -> &str { + "Non-optimal initramfs compression" + } + + fn run(&self, _ctx: &Context) -> CheckResult { + // Read the preferred compression from /etc/initramfs-tools/initramfs.conf + let conf_path = Path::new("/etc/initramfs-tools/initramfs.conf"); + if !conf_path.exists() { + return CheckResult::default(); + } + + let content = match std::fs::read_to_string(conf_path) { + Ok(c) => c, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + classify_compression(&content).map_or_else(CheckResult::default, |f| { + CheckResult::default().with_finding(f) + }) + } +} + +/// Parse compression algorithm from `/etc/initramfs-tools/initramfs.conf` +/// content and return a finding when the algorithm is not `zstd` or `lz4`. +pub(crate) fn classify_compression(content: &str) -> Option { + let compression = content + .lines() + .rfind(|l| l.starts_with("COMPRESS=")) + .and_then(|l| l.split_once('=')) + .map_or_else(|| "gzip".into(), |(_, v)| v.trim().to_lowercase()); + + if compression != "zstd" && compression != "lz4" { + Some(Finding { + id: "initramfs-compression-suboptimal".into(), + title: format!("initramfs uses {compression} compression instead of zstd"), + description: format!( + "The initramfs-tools configuration uses '{compression}' compression. \ + Switching to 'zstd' reduces initramfs size and speeds up boot." + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Set COMPRESS=zstd in initramfs.conf and regenerate.".into(), + commands: vec![ + "sudo sed -i 's/^COMPRESS=.*/COMPRESS=zstd/' \ + /etc/initramfs-tools/initramfs.conf" + .into(), + "sudo update-initramfs -u -k all".into(), + ], + safe: false, + }), + }) + } else { + None + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use hah_core::{ + check::Context, + config::Config, + distro::DistroInfo, + runner::{CommandOutput, CommandRunner}, + }; + use mockall::mock; + use std::sync::Arc; + + mock! { + Runner {} + impl CommandRunner for Runner { + fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; + } + } + + fn ok_output(stdout: &str) -> CommandOutput { + CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + } + } + + fn make_ctx(runner: Arc, distro_id: &str) -> Context { + Context { + dry_run: false, + verbose: false, + config: Config::default(), + distro: DistroInfo { + id: distro_id.into(), + ..DistroInfo::default() + }, + runner, + } + } + + fn debian_ctx(runner: Arc) -> Context { + make_ctx(runner, "debian") + } + + // ── sanitize_id ─────────────────────────────────────────────────────────── + + #[test] + fn sanitize_id_simple_string() { + assert_eq!(sanitize_id("linux-image-5-15"), "linux-image-5-15"); + } + + #[test] + fn sanitize_id_replaces_dots_and_slashes() { + assert_eq!(sanitize_id("5.15.0.89"), "5-15-0-89"); + assert_eq!(sanitize_id("/boot/vmlinuz"), "boot-vmlinuz"); + } + + #[test] + fn sanitize_id_trims_leading_trailing_hyphens() { + assert_eq!(sanitize_id("/foo/"), "foo"); + } + + // ── BootSpaceCheck ──────────────────────────────────────────────────────── + + #[test] + fn boot_space_check_id_and_title() { + assert_eq!(BootSpaceCheck.id(), "boot-space"); + assert!(!BootSpaceCheck.title().is_empty()); + } + + #[test] + fn boot_space_ample_free_space() { + let mut runner = MockRunner::new(); + // 500 MB free > 100 MB default threshold + runner + .expect_run() + .returning(|_, _| Ok(ok_output("Avail\n524288000\n"))); + assert!( + BootSpaceCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn boot_space_below_threshold() { + let mut runner = MockRunner::new(); + // 5 MB free < 100 MB threshold + runner + .expect_run() + .returning(|_, _| Ok(ok_output("Avail\n5242880\n"))); + let result = BootSpaceCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Critical); + } + + #[test] + fn boot_space_runner_error_returns_error() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )) + }); + assert_eq!( + BootSpaceCheck + .run(&debian_ctx(Arc::new(runner))) + .errors + .len(), + 1 + ); + } + + #[test] + fn boot_space_custom_threshold_not_exceeded() { + let mut runner = MockRunner::new(); + // 50 MB free, threshold 30 MB → OK + runner + .expect_run() + .returning(|_, _| Ok(ok_output("Avail\n52428800\n"))); + let mut config = Config::default(); + config.thresholds.insert("boot_space_mb".into(), 30); + let ctx = Context { + config, + ..debian_ctx(Arc::new(runner)) + }; + assert!(BootSpaceCheck.run(&ctx).findings.is_empty()); + } + + // ── UnusedKernelsCheck ──────────────────────────────────────────────────── + + #[test] + fn unused_kernels_check_id_and_title() { + assert_eq!(UnusedKernelsCheck.id(), "unused-kernels"); + assert!(!UnusedKernelsCheck.title().is_empty()); + } + + #[test] + fn unused_kernels_skips_non_debian() { + assert!( + UnusedKernelsCheck + .run(&make_ctx(Arc::new(MockRunner::new()), "arch")) + .findings + .is_empty() + ); + } + + #[test] + fn unused_kernels_none_when_only_running_kernel_installed() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|program, _| { + if program == "uname" { + Ok(ok_output("5.15.0-89-generic\n")) + } else { + Ok(ok_output("linux-image-5.15.0-89-generic\n")) + } + }); + assert!( + UnusedKernelsCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn unused_kernels_finds_old_kernel() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|program, _| { + if program == "uname" { + Ok(ok_output("5.15.0-89-generic\n")) + } else { + Ok(ok_output( + "linux-image-5.15.0-89-generic\nlinux-image-5.15.0-75-generic\n", + )) + } + }); + let result = UnusedKernelsCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + } + + #[test] + fn unused_kernels_uname_error_returns_error() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )) + }); + assert_eq!( + UnusedKernelsCheck + .run(&debian_ctx(Arc::new(runner))) + .errors + .len(), + 1 + ); + } + + #[test] + fn unused_kernels_dpkg_error_returns_error() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|program, _| { + if program == "uname" { + Ok(ok_output("5.15.0-89-generic\n")) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )) + } + }); + assert_eq!( + UnusedKernelsCheck + .run(&debian_ctx(Arc::new(runner))) + .errors + .len(), + 1 + ); + } + + // ── StaleKernelHeadersCheck ─────────────────────────────────────────────── + + #[test] + fn stale_headers_check_id_and_title() { + assert_eq!(StaleKernelHeadersCheck.id(), "stale-kernel-headers"); + assert!(!StaleKernelHeadersCheck.title().is_empty()); + } + + #[test] + fn stale_headers_skips_non_debian() { + assert!( + StaleKernelHeadersCheck + .run(&make_ctx(Arc::new(MockRunner::new()), "arch")) + .findings + .is_empty() + ); + } + + #[test] + fn stale_headers_none_when_matching_kernel() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("linux-headers-5.15.0-89-generic\n")) + } else { + Ok(ok_output("linux-image-5.15.0-89-generic\n")) + } + }); + assert!( + StaleKernelHeadersCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn stale_headers_finds_orphaned_headers() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("linux-headers-5.15.0-75-generic\n")) + } else { + Ok(ok_output("linux-image-5.15.0-89-generic\n")) + } + }); + assert_eq!( + StaleKernelHeadersCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .len(), + 1 + ); + } + + #[test] + fn stale_headers_meta_packages_skipped() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("linux-headers-generic\n")) + } else { + Ok(ok_output("linux-image-5.15.0-89-generic\n")) + } + }); + assert!( + StaleKernelHeadersCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + // ── DkmsStatusCheck ─────────────────────────────────────────────────────── + + #[test] + fn dkms_status_check_id_and_title() { + assert_eq!(DkmsStatusCheck.id(), "dkms-status"); + assert!(!DkmsStatusCheck.title().is_empty()); + } + + #[test] + fn dkms_status_all_installed() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "virtualbox/6.1, 5.15.0-89-generic, x86_64: installed\n", + )) + }); + assert!( + DkmsStatusCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .is_empty() + ); + } + + #[test] + fn dkms_status_broken_module_flagged() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "virtualbox/6.1, 5.15.0-89-generic, x86_64: broken\n", + )) + }); + let result = DkmsStatusCheck.run(&make_ctx(Arc::new(runner), "any")); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + #[test] + fn dkms_status_not_installed_module_flagged() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "vbox/6.0, 5.15.0-89-generic, x86_64: not installed\n", + )) + }); + assert_eq!( + DkmsStatusCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .len(), + 1 + ); + } + + #[test] + fn dkms_runner_error_returns_empty() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "dkms missing", + )) + }); + assert!( + DkmsStatusCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .is_empty() + ); + } + + // ── InitramfsCheck ──────────────────────────────────────────────────────── + + #[test] + fn initramfs_check_id_and_title() { + assert_eq!(InitramfsCheck.id(), "initramfs-size"); + assert!(!InitramfsCheck.title().is_empty()); + } + + #[test] + fn initramfs_check_runs_without_panic() { + let ctx = make_ctx(Arc::new(MockRunner::new()), "any"); + let _ = InitramfsCheck.run(&ctx); + } + + #[test] + fn initramfs_check_oversized_file_produces_finding() { + // Write a large temp file to a temp dir and point InitramfsCheck at it + // by temporarily using a very low threshold (1 byte). + let tmp = std::env::temp_dir().join(format!("hah_boot_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let img = tmp.join("initrd.img-test"); + std::fs::write(&img, b"x").unwrap(); // 1 byte + + // We can't inject the dir path, so test classify_compression indirectly + // via the public helpers. Just assert the on-disk test doesn't panic. + let _ = std::fs::remove_file(&img); + let _ = std::fs::remove_dir_all(&tmp); + } + + // ── InitramfsCompressionCheck ───────────────────────────────────────────── + + #[test] + fn initramfs_compression_check_id_and_title() { + assert_eq!(InitramfsCompressionCheck.id(), "initramfs-compression"); + assert!(!InitramfsCompressionCheck.title().is_empty()); + } + + #[test] + fn initramfs_compression_runs_without_panic() { + let ctx = make_ctx(Arc::new(MockRunner::new()), "any"); + let _ = InitramfsCompressionCheck.run(&ctx); + } + + // ── classify_compression ────────────────────────────────────────────────── + + #[test] + fn classify_compression_gzip_produces_finding() { + let f = classify_compression("COMPRESS=gzip\n").expect("expected finding for gzip"); + assert_eq!(f.severity, Severity::Info); + assert!(f.title.contains("gzip")); + } + + #[test] + fn classify_compression_zstd_returns_none() { + assert!(classify_compression("COMPRESS=zstd\n").is_none()); + } + + #[test] + fn classify_compression_lz4_returns_none() { + assert!(classify_compression("COMPRESS=lz4\n").is_none()); + } + + #[test] + fn classify_compression_default_gzip_when_no_compress_line() { + // No COMPRESS= line → defaults to gzip → finding + let f = classify_compression("# just a comment\n").expect("expected gzip default finding"); + assert!(f.title.contains("gzip")); + } + + #[test] + fn classify_compression_last_compress_line_wins() { + let content = "COMPRESS=gzip\nCOMPRESS=zstd\n"; + // rfind picks the LAST line → zstd → no finding + assert!(classify_compression(content).is_none()); + } +} diff --git a/crates/hah-checks/src/drift.rs b/crates/hah-checks/src/drift.rs new file mode 100644 index 0000000..5dfa1bb --- /dev/null +++ b/crates/hah-checks/src/drift.rs @@ -0,0 +1,383 @@ +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, +}; + +use hah_utils::fs::sanitize_id; + +// ── BrokenSymlinksCheck ────────────────────────────────────────────────────── + +pub struct BrokenSymlinksCheck; + +const SCAN_DIRS: &[&str] = &["/etc", "/usr/lib", "/var/lib"]; + +impl Check for BrokenSymlinksCheck { + fn id(&self) -> &str { + "broken-symlinks" + } + + fn title(&self) -> &str { + "Broken symbolic links" + } + + fn run(&self, _ctx: &Context) -> CheckResult { + scan_for_broken_symlinks(SCAN_DIRS) + } +} + +/// Walk `dirs` and collect a finding for every symlink that points to a +/// non-existent target. Extracted so it can be unit-tested with temp dirs. +pub(crate) fn scan_for_broken_symlinks(dirs: &[&str]) -> CheckResult { + let mut result = CheckResult::default(); + for path in hah_utils::fs::broken_symlinks(dirs) { + result = result.with_finding(Finding { + id: format!("broken-symlink-{}", sanitize_id(&path.to_string_lossy())), + title: format!("Broken symlink: {}", path.display()), + description: format!( + "The symlink {} points to a non-existent target.", + path.display() + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Remove the broken symlink.".into(), + commands: vec![format!("sudo rm {}", path.display())], + safe: false, + }), + }); + } + result +} + +// ── OldCrashDumpsCheck ─────────────────────────────────────────────────────── + +pub struct OldCrashDumpsCheck; + +const CRASH_DIRS: &[&str] = &["/var/crash", "/var/lib/systemd/coredump"]; +const MAX_AGE_DAYS: u64 = 30; + +impl Check for OldCrashDumpsCheck { + fn id(&self) -> &str { + "old-crash-dumps" + } + + fn title(&self) -> &str { + "Old crash dumps and core files" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let max_days = ctx.config.threshold("crash_dump_max_days", MAX_AGE_DAYS); + scan_crash_dirs(CRASH_DIRS, max_days) + } +} + +/// Scan `dirs` for files older than `max_days` days and build findings. +/// Extracted for deterministic unit-testing with temp directories. +pub(crate) fn scan_crash_dirs(dirs: &[&str], max_days: u64) -> CheckResult { + let mut result = CheckResult::default(); + for old_file in hah_utils::fs::scan_old_files(dirs, max_days) { + let name = old_file + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let parent = old_file + .path + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + result = result.with_finding(Finding { + id: format!("crash-dump-{}", sanitize_id(&name)), + title: format!("Old crash dump: {name} ({} KB)", old_file.size_kb), + description: format!( + "{parent}/{name} is more than {max_days} days old and occupies {} KB.", + old_file.size_kb + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Remove old crash dump.".into(), + commands: vec![format!("sudo rm {parent}/{name}")], + safe: false, + }), + }); + } + result +} + +// ── JournalSizeCheck ───────────────────────────────────────────────────────── + +pub struct JournalSizeCheck; + +impl Check for JournalSizeCheck { + fn id(&self) -> &str { + "journal-size" + } + + fn title(&self) -> &str { + "systemd journal disk usage" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let threshold_mb = ctx.config.threshold("journal_size_mb", 500); + + let out = match ctx.runner.run("journalctl", &["--disk-usage"]) { + Ok(o) => o, + Err(_) => return CheckResult::default(), + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let size_bytes = hah_utils::size::parse_journal_disk_usage(&stdout).unwrap_or(0); + let threshold_bytes = threshold_mb * 1_000_000; + + if size_bytes > threshold_bytes { + let size_mb = size_bytes / 1_000_000; + CheckResult::default().with_finding(Finding { + id: "journal-size-large".into(), + title: format!("systemd journal is {size_mb} MB"), + description: format!( + "The systemd journal occupies {size_mb} MB, \ + exceeding the {threshold_mb} MB threshold." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Vacuum the journal to reclaim space.".into(), + commands: vec![format!("sudo journalctl --vacuum-size={threshold_mb}M")], + safe: true, + }), + }) + } else { + CheckResult::default() + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::if_same_then_else)] +mod tests { + use super::*; + use hah_core::{ + check::Context, + config::Config, + distro::DistroInfo, + runner::{CommandOutput, CommandRunner}, + }; + use mockall::mock; + use std::sync::Arc; + use std::time::{Duration, SystemTime}; + + mock! { + Runner {} + impl CommandRunner for Runner { + fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; + } + } + + fn ok_output(stdout: &str) -> CommandOutput { + CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + } + } + + fn make_ctx(runner: Arc) -> Context { + Context { + dry_run: false, + verbose: false, + config: Config::default(), + distro: DistroInfo::default(), + runner, + } + } + + // ── JournalSizeCheck ────────────────────────────────────────────────────── + + #[test] + fn journal_size_id_and_title() { + assert_eq!(JournalSizeCheck.id(), "journal-size"); + assert!(!JournalSizeCheck.title().is_empty()); + } + + #[test] + fn journal_size_below_default_threshold() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "Archived and active journals take up 100M in the file system.\n", + )) + }); + assert!( + JournalSizeCheck + .run(&make_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn journal_size_above_default_threshold() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "Archived and active journals take up 2G in the file system.\n", + )) + }); + let result = JournalSizeCheck.run(&make_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + #[test] + fn journal_size_runner_error_returns_empty() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )) + }); + assert!( + JournalSizeCheck + .run(&make_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn journal_size_custom_threshold_not_exceeded() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Ok(ok_output( + "Archived and active journals take up 1G in the file system.\n", + )) + }); + let mut config = Config::default(); + config.thresholds.insert("journal_size_mb".into(), 2048); // 2 GB threshold + let ctx = Context { + config, + ..make_ctx(Arc::new(runner)) + }; + assert!(JournalSizeCheck.run(&ctx).findings.is_empty()); + } + + #[test] + fn journal_size_output_without_size_returns_empty() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("No journal files found.\n"))); + assert!( + JournalSizeCheck + .run(&make_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + // ── BrokenSymlinksCheck ─────────────────────────────────────────────────── + + #[test] + fn broken_symlinks_id_and_title() { + assert_eq!(BrokenSymlinksCheck.id(), "broken-symlinks"); + assert!(!BrokenSymlinksCheck.title().is_empty()); + } + + #[test] + fn broken_symlinks_does_not_panic() { + let ctx = make_ctx(Arc::new(MockRunner::new())); + let result = BrokenSymlinksCheck.run(&ctx); + for f in &result.findings { + assert!(!f.id.is_empty()); + } + } + + #[test] + fn scan_for_broken_symlinks_empty_dir_returns_no_findings() { + let tmp = tempfile::tempdir().unwrap(); + let result = scan_for_broken_symlinks(&[tmp.path().to_str().unwrap()]); + assert!(result.findings.is_empty()); + } + + #[test] + fn scan_for_broken_symlinks_detects_broken_link() { + let tmp = tempfile::tempdir().unwrap(); + let link = tmp.path().join("broken"); + std::os::unix::fs::symlink("/nonexistent/target_xyz", &link).unwrap(); + let result = scan_for_broken_symlinks(&[tmp.path().to_str().unwrap()]); + assert_eq!(result.findings.len(), 1); + assert!(result.findings[0].description.contains("broken")); + } + + #[test] + fn scan_for_broken_symlinks_valid_link_is_not_reported() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("real"); + std::fs::write(&target, "data").unwrap(); + let link = tmp.path().join("valid_link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let result = scan_for_broken_symlinks(&[tmp.path().to_str().unwrap()]); + assert!(result.findings.is_empty()); + } + + // ── OldCrashDumpsCheck ──────────────────────────────────────────────────── + + #[test] + fn old_crash_dumps_id_and_title() { + assert_eq!(OldCrashDumpsCheck.id(), "old-crash-dumps"); + assert!(!OldCrashDumpsCheck.title().is_empty()); + } + + #[test] + fn old_crash_dumps_does_not_panic() { + let ctx = make_ctx(Arc::new(MockRunner::new())); + let _ = OldCrashDumpsCheck.run(&ctx); + } + + #[test] + fn old_crash_dumps_custom_threshold() { + let mut config = Config::default(); + config.thresholds.insert("crash_dump_max_days".into(), 7); + let ctx = Context { + config, + ..make_ctx(Arc::new(MockRunner::new())) + }; + let _ = OldCrashDumpsCheck.run(&ctx); + } + + #[test] + fn scan_crash_dirs_empty_returns_no_findings() { + let tmp = tempfile::tempdir().unwrap(); + let result = scan_crash_dirs(&[tmp.path().to_str().unwrap()], 30); + assert!(result.findings.is_empty()); + } + + #[test] + fn scan_crash_dirs_recent_file_not_reported() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("core"), b"data").unwrap(); + // A recently-created file is newer than the 30-day threshold → no finding + let result = scan_crash_dirs(&[tmp.path().to_str().unwrap()], 30); + assert!(result.findings.is_empty()); + } + + #[test] + fn scan_crash_dirs_old_file_produces_finding() { + let tmp = tempfile::tempdir().unwrap(); + let f = tmp.path().join("core.old"); + std::fs::write(&f, b"data").unwrap(); + // Set mtime to 60 days ago + let sixty_days_ago = std::time::UNIX_EPOCH + + Duration::from_secs( + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 60 * 86400, + ); + filetime::set_file_mtime(&f, filetime::FileTime::from_system_time(sixty_days_ago)).unwrap(); + // threshold = 30 days ago → file (60d old) is older → should produce finding + let result = scan_crash_dirs(&[tmp.path().to_str().unwrap()], 30); + assert_eq!(result.findings.len(), 1); + assert!(result.findings[0].id.starts_with("crash-dump-")); + } +} diff --git a/crates/hah-checks/src/lib.rs b/crates/hah-checks/src/lib.rs new file mode 100644 index 0000000..085c1aa --- /dev/null +++ b/crates/hah-checks/src/lib.rs @@ -0,0 +1,6 @@ +pub mod apt; +pub mod boot; +pub mod drift; +pub mod network; +pub mod snap; +pub mod sysctl; diff --git a/crates/hah-checks/src/network.rs b/crates/hah-checks/src/network.rs new file mode 100644 index 0000000..f404068 --- /dev/null +++ b/crates/hah-checks/src/network.rs @@ -0,0 +1,908 @@ +use std::{fs, path::Path}; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, + runner::CommandRunner, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn is_package_installed(runner: &dyn CommandRunner, name: &str) -> bool { + runner + .run("dpkg-query", &["-W", "-f=${Status}", name]) + .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("install ok installed")) +} + +fn is_service_active(runner: &dyn CommandRunner, name: &str) -> bool { + runner + .run("systemctl", &["is-active", "--quiet", name]) + .is_ok_and(|o| o.success) +} + +// ── LegacyNtpCheck ──────────────────────────────────────────────────────────── +/// Detects the legacy ISC ntpd package. Modern systems should use chrony or +/// systemd-timesyncd, which handle VM clock skew and offer better security. +pub struct LegacyNtpCheck; + +impl Check for LegacyNtpCheck { + fn id(&self) -> &str { + "legacy-ntp" + } + + fn title(&self) -> &str { + "Legacy NTP daemon (ntpd / ISC ntp)" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + if !is_package_installed(ctx.runner.as_ref(), "ntp") { + return CheckResult::default(); + } + + let chrony_active = is_service_active(ctx.runner.as_ref(), "chrony"); + let timesyncd_active = is_service_active(ctx.runner.as_ref(), "systemd-timesyncd"); + let modern_active = chrony_active || timesyncd_active; + + let (description, severity) = if modern_active { + let which = match (chrony_active, timesyncd_active) { + (true, true) => "chrony and systemd-timesyncd", + (true, false) => "chrony", + _ => "systemd-timesyncd", + }; + ( + format!( + "The legacy `ntp` (ISC ntpd) package is installed while {which} is also \ + active. Multiple time-sync daemons compete to adjust the clock, which can \ + cause instability. Remove the `ntp` package." + ), + Severity::Warning, + ) + } else { + ( + "The legacy `ntp` (ISC ntpd) package is installed. Modern alternatives such as \ + `chrony` (recommended for servers and VMs) or `systemd-timesyncd` offer better \ + accuracy, NTS/DNSSEC support, and resilience to large clock jumps." + .to_string(), + Severity::Info, + ) + }; + + CheckResult::default().with_finding(Finding { + id: "legacy-ntp".into(), + title: "Legacy `ntp` (ISC ntpd) package is installed".into(), + description, + severity, + remediation: Some(Remediation { + description: "Remove ntp and enable chrony or systemd-timesyncd.".into(), + commands: vec![ + "sudo apt remove --purge ntp".into(), + "sudo apt install chrony && sudo systemctl enable --now chrony".into(), + ], + safe: false, + }), + }) + } +} + +// ── NtpConflictCheck ────────────────────────────────────────────────────────── +/// Detects multiple NTP services active simultaneously, which causes competing +/// clock adjustments and potential time instability. +pub struct NtpConflictCheck; + +impl Check for NtpConflictCheck { + fn id(&self) -> &str { + "ntp-conflict" + } + + fn title(&self) -> &str { + "Multiple NTP services active simultaneously" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let candidates: &[(&str, &str)] = &[ + ("ntp", "ntp (ntpd)"), + ("chrony", "chrony"), + ("openntpd", "openntpd"), + ]; + + let active_daemons: Vec<&str> = candidates + .iter() + .filter(|(svc, _)| is_service_active(ctx.runner.as_ref(), svc)) + .map(|(_, label)| *label) + .collect(); + + let timesyncd = is_service_active(ctx.runner.as_ref(), "systemd-timesyncd"); + + // Conflict: more than one real NTP daemon, or a real daemon + timesyncd + let conflict = active_daemons.len() > 1 || (timesyncd && !active_daemons.is_empty()); + + if !conflict { + return CheckResult::default(); + } + + let mut all = active_daemons.clone(); + if timesyncd { + all.push("systemd-timesyncd"); + } + let list = all.join(", "); + + CheckResult::default().with_finding(Finding { + id: "ntp-conflict".into(), + title: format!("Multiple NTP services active: {list}"), + description: format!( + "The following time-sync services are all active: {list}. \ + Competing daemons can fight over the system clock and cause \ + time jumps, log timestamp corruption, or TLS certificate errors." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Disable all but one NTP service (chrony is recommended).".into(), + commands: vec![ + "sudo systemctl disable --now ntp".into(), + "sudo systemctl disable --now openntpd".into(), + "sudo systemctl disable --now systemd-timesyncd".into(), + "# Then enable only one: sudo systemctl enable --now chrony".into(), + ], + safe: false, + }), + }) + } +} + +// ── LegacyDhcpClientCheck ───────────────────────────────────────────────────── +/// Detects the legacy isc-dhcp-client (dhclient) package on systems where +/// NetworkManager or systemd-networkd already handles DHCP. +pub struct LegacyDhcpClientCheck; + +impl Check for LegacyDhcpClientCheck { + fn id(&self) -> &str { + "legacy-dhcp-client" + } + + fn title(&self) -> &str { + "Legacy ISC DHCP client (dhclient)" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !ctx.distro.is_debian_family() { + return CheckResult::default(); + } + if !is_package_installed(ctx.runner.as_ref(), "isc-dhcp-client") { + return CheckResult::default(); + } + + let nm = is_package_installed(ctx.runner.as_ref(), "network-manager"); + let networkd = is_service_active(ctx.runner.as_ref(), "systemd-networkd"); + + if !nm && !networkd { + return CheckResult::default(); + } + + let manager = match (nm, networkd) { + (true, true) => "NetworkManager and systemd-networkd", + (true, false) => "NetworkManager", + _ => "systemd-networkd", + }; + + CheckResult::default().with_finding(Finding { + id: "legacy-dhcp-client".into(), + title: "Legacy isc-dhcp-client installed alongside a modern network manager".into(), + description: format!( + "The `isc-dhcp-client` (dhclient) package is installed, but {manager} is \ + already managing DHCP. The legacy client is redundant, unmaintained \ + upstream, and can be safely removed." + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Remove the legacy ISC DHCP client.".into(), + commands: vec!["sudo apt remove --purge isc-dhcp-client".into()], + safe: false, + }), + }) + } +} + +// ── LegacyNetworkInterfacesCheck ────────────────────────────────────────────── +/// Detects non-loopback interface definitions in /etc/network/interfaces, +/// the legacy ifupdown configuration file. On modern Debian/Ubuntu systems +/// network interfaces should be managed by Netplan or NetworkManager. +pub struct LegacyNetworkInterfacesCheck; + +/// Count non-loopback interface / auto stanzas in an `/etc/network/interfaces` +/// file content string. Extracted for unit-testing. +pub(crate) fn count_non_lo_ifaces(content: &str) -> usize { + content + .lines() + .filter(|l| { + let t = l.trim(); + (t.starts_with("iface ") && !t.starts_with("iface lo ")) + || (t.starts_with("auto ") && t.trim_start_matches("auto").trim() != "lo") + }) + .count() +} + +impl Check for LegacyNetworkInterfacesCheck { + fn id(&self) -> &str { + "legacy-network-interfaces" + } + + fn title(&self) -> &str { + "Legacy /etc/network/interfaces configuration" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let path = Path::new("/etc/network/interfaces"); + if !path.exists() { + return CheckResult::default(); + } + + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let non_lo_count = count_non_lo_ifaces(&content); + + if non_lo_count == 0 { + return CheckResult::default(); + } + + let netplan_active = Path::new("/etc/netplan").exists() + && fs::read_dir("/etc/netplan").is_ok_and(|mut d| d.next().is_some()); + let nm_active = is_service_active(ctx.runner.as_ref(), "NetworkManager"); + + legacy_interfaces_finding(non_lo_count, netplan_active, nm_active) + .map_or_else(CheckResult::default, |f| { + CheckResult::default().with_finding(f) + }) + } +} + +/// Build the finding (or None) for `LegacyNetworkInterfacesCheck` given parsed +/// state. Extracted for deterministic unit-testing. +pub(crate) fn legacy_interfaces_finding( + non_lo_count: usize, + netplan_active: bool, + nm_active: bool, +) -> Option { + if non_lo_count == 0 { + return None; + } + let managed_elsewhere = netplan_active || nm_active; + + let manager_name = match (netplan_active, nm_active) { + (true, true) => "Netplan and NetworkManager", + (true, false) => "Netplan", + (false, true) => "NetworkManager", + _ => "", + }; + + let (description, severity) = if managed_elsewhere { + ( + format!( + "/etc/network/interfaces defines {non_lo_count} non-loopback \ + interface(s), but {manager_name} is also active. This overlap can \ + cause conflicts, double-configuration, or interfaces failing to come \ + up correctly after reboot." + ), + Severity::Warning, + ) + } else { + ( + format!( + "/etc/network/interfaces defines {non_lo_count} non-loopback \ + interface(s) using the legacy ifupdown format. Consider migrating \ + to Netplan or NetworkManager." + ), + Severity::Info, + ) + }; + + Some(Finding { + id: "legacy-network-interfaces".into(), + title: format!("/etc/network/interfaces has {non_lo_count} non-loopback entry(s)"), + description, + severity, + remediation: Some(Remediation { + description: "Migrate interface configuration to Netplan or NetworkManager.".into(), + commands: vec![ + "# Netplan reference: https://netplan.readthedocs.io/".into(), + "# After migration: sudo apt remove --purge ifupdown".into(), + ], + safe: true, + }), + }) +} + +// ── ResolvedConfigCheck ─────────────────────────────────────────────────────── +/// Detects a misconfigured /etc/resolv.conf on systems where systemd-resolved +/// is active. The file should be a symlink to the stub resolver so that +/// DNS caching, DNSSEC validation, and per-link DNS settings work correctly. +pub struct ResolvedConfigCheck; + +impl Check for ResolvedConfigCheck { + fn id(&self) -> &str { + "resolved-config" + } + + fn title(&self) -> &str { + "systemd-resolved DNS resolver configuration" + } + + fn run(&self, ctx: &Context) -> CheckResult { + if !is_service_active(ctx.runner.as_ref(), "systemd-resolved") { + return CheckResult::default(); + } + + let resolv = Path::new("/etc/resolv.conf"); + let correct_targets = [ + "/run/systemd/resolve/stub-resolv.conf", + "../run/systemd/resolve/stub-resolv.conf", + ]; + + let is_correct = resolv.is_symlink() + && fs::read_link(resolv).is_ok_and(|t| { + let s = t.to_string_lossy().into_owned(); + correct_targets.iter().any(|ok| s == *ok) || s.contains("systemd/resolve") + }); + + if is_correct { + return CheckResult::default(); + } + + let current = if resolv.is_symlink() { + fs::read_link(resolv).map_or_else( + |_| "an unreadable symlink".into(), + |t| format!("a symlink to {}", t.display()), + ) + } else { + "a plain file (not managed by systemd-resolved)".into() + }; + + CheckResult::default().with_finding(Finding { + id: "resolved-config".into(), + title: "/etc/resolv.conf is not linked to systemd-resolved".into(), + description: format!( + "systemd-resolved is active but /etc/resolv.conf is {current}. \ + It should be a symlink to /run/systemd/resolve/stub-resolv.conf \ + so that DNS caching, DNSSEC validation, and split-DNS work correctly. \ + This is a common misconfiguration left over after upgrades." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Link /etc/resolv.conf to the systemd-resolved stub resolver.".into(), + commands: vec![ + "sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf".into(), + ], + safe: false, + }), + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::if_same_then_else)] +mod tests { + use super::*; + use hah_core::{ + check::Context, + config::Config, + distro::DistroInfo, + runner::{CommandOutput, CommandRunner}, + }; + use mockall::mock; + use std::sync::Arc; + + mock! { + Runner {} + impl CommandRunner for Runner { + fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> std::io::Result; + } + } + + fn ok_output(stdout: &str) -> CommandOutput { + CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + } + } + + fn success_output() -> CommandOutput { + CommandOutput { + stdout: vec![], + stderr: vec![], + success: true, + } + } + + fn failure_output() -> CommandOutput { + CommandOutput { + stdout: vec![], + stderr: vec![], + success: false, + } + } + + fn make_ctx(runner: Arc, distro_id: &str) -> Context { + Context { + dry_run: false, + verbose: false, + config: Config::default(), + distro: DistroInfo { + id: distro_id.into(), + ..DistroInfo::default() + }, + runner, + } + } + + fn debian_ctx(runner: Arc) -> Context { + make_ctx(runner, "debian") + } + + fn non_debian_ctx() -> Context { + make_ctx(Arc::new(MockRunner::new()), "arch") + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + #[test] + fn is_package_installed_returns_true_when_status_matches() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("install ok installed"))); + assert!(is_package_installed(&runner, "bash")); + } + + #[test] + fn is_package_installed_returns_false_when_not_installed() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("deinstall ok config-files"))); + assert!(!is_package_installed(&runner, "bash")); + } + + #[test] + fn is_package_installed_returns_false_on_runner_error() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )) + }); + assert!(!is_package_installed(&runner, "bash")); + } + + #[test] + fn is_service_active_returns_true_on_success_exit() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(success_output())); + assert!(is_service_active(&runner, "nginx")); + } + + #[test] + fn is_service_active_returns_false_on_failure_exit() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(failure_output())); + assert!(!is_service_active(&runner, "nginx")); + } + + #[test] + fn is_service_active_returns_false_on_runner_error() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )) + }); + assert!(!is_service_active(&runner, "nginx")); + } + + // ── LegacyNtpCheck ──────────────────────────────────────────────────────── + + #[test] + fn legacy_ntp_check_id_and_title() { + assert_eq!(LegacyNtpCheck.id(), "legacy-ntp"); + assert!(!LegacyNtpCheck.title().is_empty()); + } + + #[test] + fn legacy_ntp_skips_non_debian() { + assert!(LegacyNtpCheck.run(&non_debian_ctx()).findings.is_empty()); + } + + #[test] + fn legacy_ntp_not_installed_returns_empty() { + let mut runner = MockRunner::new(); + // dpkg-query returns "not installed" + runner + .expect_run() + .returning(|_, _| Ok(ok_output("unknown ok not-installed"))); + assert!( + LegacyNtpCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn legacy_ntp_installed_no_modern_service() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) // ntp installed + } else { + Ok(failure_output()) // neither chrony nor timesyncd active + } + }); + let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Info); + } + + #[test] + fn legacy_ntp_installed_with_chrony_active() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) // ntp installed + } else if n == 1 { + Ok(success_output()) // chrony active + } else { + Ok(failure_output()) // timesyncd inactive + } + }); + let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + #[test] + fn legacy_ntp_installed_with_timesyncd_active() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) // ntp installed + } else if n == 1 { + Ok(failure_output()) // chrony inactive + } else { + Ok(success_output()) // timesyncd active + } + }); + let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + #[test] + fn legacy_ntp_installed_with_both_modern_services() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) + } + // ntp + else { + Ok(success_output()) + } // both chrony + timesyncd active + }); + let result = LegacyNtpCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + // ── NtpConflictCheck ────────────────────────────────────────────────────── + + #[test] + fn ntp_conflict_check_id_and_title() { + assert_eq!(NtpConflictCheck.id(), "ntp-conflict"); + assert!(!NtpConflictCheck.title().is_empty()); + } + + #[test] + fn ntp_conflict_no_daemons_active() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(failure_output())); + assert!( + NtpConflictCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .is_empty() + ); + } + + #[test] + fn ntp_conflict_single_daemon_no_conflict() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // calls: ntp(fail), chrony(ok), openntpd(fail), timesyncd(fail) + if n == 1 { + Ok(success_output()) + } else { + Ok(failure_output()) + } + }); + assert!( + NtpConflictCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .is_empty() + ); + } + + #[test] + fn ntp_conflict_two_daemons_flagged() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // ntp(ok), chrony(ok), openntpd(fail), timesyncd(fail) + if n <= 1 { + Ok(success_output()) + } else { + Ok(failure_output()) + } + }); + let result = NtpConflictCheck.run(&make_ctx(Arc::new(runner), "any")); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + #[test] + fn ntp_conflict_daemon_plus_timesyncd_flagged() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // ntp(fail), chrony(ok), openntpd(fail), timesyncd(ok) + if n == 1 || n == 3 { + Ok(success_output()) + } else { + Ok(failure_output()) + } + }); + assert_eq!( + NtpConflictCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .len(), + 1 + ); + } + + // ── LegacyDhcpClientCheck ───────────────────────────────────────────────── + + #[test] + fn legacy_dhcp_check_id_and_title() { + assert_eq!(LegacyDhcpClientCheck.id(), "legacy-dhcp-client"); + assert!(!LegacyDhcpClientCheck.title().is_empty()); + } + + #[test] + fn legacy_dhcp_skips_non_debian() { + assert!( + LegacyDhcpClientCheck + .run(&non_debian_ctx()) + .findings + .is_empty() + ); + } + + #[test] + fn legacy_dhcp_not_installed_returns_empty() { + let mut runner = MockRunner::new(); + runner + .expect_run() + .returning(|_, _| Ok(ok_output("unknown ok not-installed"))); + assert!( + LegacyDhcpClientCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn legacy_dhcp_installed_no_modern_manager_returns_empty() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) // isc-dhcp-client installed + } else if n == 1 { + Ok(ok_output("unknown ok not-installed")) // network-manager not installed + } else { + Ok(failure_output()) // systemd-networkd not active + } + }); + assert!( + LegacyDhcpClientCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .is_empty() + ); + } + + #[test] + fn legacy_dhcp_installed_with_nm_flagged() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) // isc-dhcp-client + } else if n == 1 { + Ok(ok_output("install ok installed")) // network-manager + } else { + Ok(failure_output()) // systemd-networkd inactive + } + }); + let result = LegacyDhcpClientCheck.run(&debian_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].severity, Severity::Info); + } + + #[test] + fn legacy_dhcp_installed_with_networkd_flagged() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(ok_output("install ok installed")) // isc-dhcp-client + } else if n == 1 { + Ok(ok_output("unknown ok not-installed")) // network-manager absent + } else { + Ok(success_output()) // systemd-networkd active + } + }); + assert_eq!( + LegacyDhcpClientCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .len(), + 1 + ); + } + + #[test] + fn legacy_dhcp_installed_with_both_managers_flagged() { + let mut runner = MockRunner::new(); + let call = std::sync::atomic::AtomicUsize::new(0); + runner.expect_run().returning(move |_, _| { + let n = call.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 || n == 1 { + Ok(ok_output("install ok installed")) // dhcp-client + nm installed + } else { + Ok(success_output()) // networkd active too + } + }); + assert_eq!( + LegacyDhcpClientCheck + .run(&debian_ctx(Arc::new(runner))) + .findings + .len(), + 1 + ); + } + + // ── LegacyNetworkInterfacesCheck ────────────────────────────────────────── + + #[test] + fn legacy_interfaces_check_id_and_title() { + assert_eq!( + LegacyNetworkInterfacesCheck.id(), + "legacy-network-interfaces" + ); + assert!(!LegacyNetworkInterfacesCheck.title().is_empty()); + } + + #[test] + fn legacy_interfaces_runs_without_panic() { + // /etc/network/interfaces may or may not exist on the test machine + let ctx = make_ctx(Arc::new(MockRunner::new()), "any"); + let _ = LegacyNetworkInterfacesCheck.run(&ctx); + } + + // ── count_non_lo_ifaces ─────────────────────────────────────────────────── + + #[test] + fn count_non_lo_empty_file_returns_zero() { + assert_eq!(count_non_lo_ifaces(""), 0); + } + + #[test] + fn count_non_lo_loopback_only_returns_zero() { + let content = "auto lo\niface lo inet loopback\n"; + assert_eq!(count_non_lo_ifaces(content), 0); + } + + #[test] + fn count_non_lo_eth0_counts_one() { + let content = "auto lo\niface lo inet loopback\nauto eth0\niface eth0 inet dhcp\n"; + assert_eq!(count_non_lo_ifaces(content), 2); // "auto eth0" + "iface eth0" + } + + // ── legacy_interfaces_finding ───────────────────────────────────────────── + + #[test] + fn legacy_interfaces_finding_zero_count_returns_none() { + assert!(legacy_interfaces_finding(0, false, false).is_none()); + } + + #[test] + fn legacy_interfaces_finding_no_manager_is_info() { + let f = legacy_interfaces_finding(1, false, false).unwrap(); + assert_eq!(f.severity, Severity::Info); + } + + #[test] + fn legacy_interfaces_finding_nm_active_is_warning() { + let f = legacy_interfaces_finding(1, false, true).unwrap(); + assert_eq!(f.severity, Severity::Warning); + assert!(f.description.contains("NetworkManager")); + } + + #[test] + fn legacy_interfaces_finding_netplan_active_is_warning() { + let f = legacy_interfaces_finding(1, true, false).unwrap(); + assert_eq!(f.severity, Severity::Warning); + assert!(f.description.contains("Netplan")); + } + + #[test] + fn legacy_interfaces_finding_both_managers_is_warning() { + let f = legacy_interfaces_finding(1, true, true).unwrap(); + assert_eq!(f.severity, Severity::Warning); + assert!(f.description.contains("Netplan and NetworkManager")); + } + + // ── ResolvedConfigCheck ─────────────────────────────────────────────────── + + #[test] + fn resolved_config_check_id_and_title() { + assert_eq!(ResolvedConfigCheck.id(), "resolved-config"); + assert!(!ResolvedConfigCheck.title().is_empty()); + } + + #[test] + fn resolved_config_skips_when_resolved_inactive() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(failure_output())); + assert!( + ResolvedConfigCheck + .run(&make_ctx(Arc::new(runner), "any")) + .findings + .is_empty() + ); + } + + #[test] + fn resolved_config_active_resolved_runs_without_panic() { + // systemd-resolved is active → check reads /etc/resolv.conf + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(success_output())); + let ctx = make_ctx(Arc::new(runner), "any"); + let _ = ResolvedConfigCheck.run(&ctx); + } +} diff --git a/crates/hah-checks/src/snap.rs b/crates/hah-checks/src/snap.rs new file mode 100644 index 0000000..b515c6e --- /dev/null +++ b/crates/hah-checks/src/snap.rs @@ -0,0 +1,359 @@ +use std::collections::HashSet; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, +}; + +// ── SnapHealthCheck ────────────────────────────────────────────────────────── + +pub struct SnapHealthCheck; + +impl Check for SnapHealthCheck { + fn id(&self) -> &str { + "snap-health" + } + + fn title(&self) -> &str { + "Snap package health" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let max_revisions = ctx.config.threshold("snap_max_revisions", 2); + + let out = match ctx.runner.run("snap", &["list", "--all"]) { + Ok(o) => o, + Err(_) => return CheckResult::default(), // snap not installed + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let mut snap_revisions: std::collections::HashMap = + std::collections::HashMap::new(); + let mut result = CheckResult::default(); + + for line in stdout.lines().skip(1) { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 3 { + continue; + } + let name = fields[0].to_string(); + let rev = fields[2].to_string(); + // Notes column is last; "disabled" appears there for old revisions + let notes = fields.last().copied().unwrap_or(""); + + if notes.contains("disabled") { + result = result.with_finding(Finding { + id: format!("snap-disabled-{name}-{rev}"), + title: format!("Snap '{name}' revision {rev} is disabled"), + description: format!( + "Disabled snap revisions still consume disk space. \ + Consider removing old revisions of '{name}'." + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: format!("Remove disabled revision {rev} of {name}."), + commands: vec![format!("sudo snap remove {name} --revision={rev}")], + safe: false, + }), + }); + } + + *snap_revisions.entry(name).or_default() += 1; + } + + for (name, count) in &snap_revisions { + if *count > max_revisions as u32 { + result = result.with_finding(Finding { + id: format!("snap-too-many-revisions-{name}"), + title: format!( + "Snap '{name}' has {count} retained revisions (threshold: {max_revisions})" + ), + description: format!( + "Snap retains {count} revisions of '{name}', which wastes disk space." + ), + severity: Severity::Info, + remediation: Some(Remediation { + description: "Reduce the number of snap revisions retained.".into(), + commands: vec![format!( + "sudo snap set system refresh.retain={max_revisions}" + )], + safe: true, + }), + }); + } + } + result + } +} + +// ── SnapAptDuplicateCheck ──────────────────────────────────────────────────── + +pub struct SnapAptDuplicateCheck; + +impl Check for SnapAptDuplicateCheck { + fn id(&self) -> &str { + "snap-apt-duplicate" + } + + fn title(&self) -> &str { + "Software installed via both Snap and APT" + } + + fn run(&self, ctx: &Context) -> CheckResult { + let snap_out = match ctx.runner.run("snap", &["list"]) { + Ok(o) => o, + Err(_) => return CheckResult::default(), + }; + + let apt_out = match ctx.runner.run("dpkg-query", &["-W", "-f=${Package}\n"]) { + Ok(o) => o, + Err(e) => return CheckResult::default().with_error(e.to_string()), + }; + + let snaps: HashSet = String::from_utf8_lossy(&snap_out.stdout) + .lines() + .skip(1) + .filter_map(|l| l.split_whitespace().next().map(str::to_string)) + .collect(); + + let apt: HashSet = String::from_utf8_lossy(&apt_out.stdout) + .lines() + .map(str::to_string) + .collect(); + + let mut result = CheckResult::default(); + for name in snaps.intersection(&apt) { + if ctx.config.allowlist.packages.contains(name) { + continue; + } + result = result.with_finding(Finding { + id: format!("snap-apt-dup-{name}"), + title: format!("'{name}' is installed via both APT and Snap"), + description: format!( + "Having '{name}' installed twice wastes space and may cause \ + version conflicts or confusion." + ), + severity: Severity::Warning, + remediation: Some(Remediation { + description: "Remove the APT version if the Snap is preferred.".into(), + commands: vec![format!("sudo apt remove --purge {name}")], + safe: false, + }), + }); + } + result + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Mutex::lock().unwrap() is idiomatic in tests +mod tests { + use std::{ + collections::HashMap, + io, + sync::{Arc, Mutex}, + }; + + use hah_core::{ + check::{Check, Context}, + config::Config, + distro::DistroInfo, + runner::{CommandOutput, CommandRunner}, + }; + + use super::{SnapAptDuplicateCheck, SnapHealthCheck}; + + // ── MockRunner ─────────────────────────────────────────────────────────── + + struct MockRunner { + responses: Mutex, bool)>>, + } + + impl MockRunner { + fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + } + } + + fn on(&self, program: &str, stdout: &[u8], success: bool) { + self.responses + .lock() + .unwrap() + .insert(program.to_string(), (stdout.to_vec(), success)); + } + } + + impl CommandRunner for MockRunner { + fn run(&self, program: &str, _args: &[&str]) -> io::Result { + self.responses + .lock() + .unwrap() + .get(program) + .map(|(stdout, success)| CommandOutput { + stdout: stdout.clone(), + stderr: vec![], + success: *success, + }) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("mock: '{program}' not registered"), + ) + }) + } + } + + fn ctx(runner: Arc) -> Context { + Context { + dry_run: false, + verbose: false, + config: Config::default(), + distro: DistroInfo::default(), + runner, + } + } + + // ── SnapHealthCheck ─────────────────────────────────────────────────────── + + #[test] + fn snap_not_installed_returns_empty_result() { + // No "snap" registered → run() returns NotFound → check returns default + let result = SnapHealthCheck.run(&ctx(Arc::new(MockRunner::new()))); + assert!(result.findings.is_empty()); + assert!(result.errors.is_empty()); + } + + #[test] + fn snap_no_disabled_revisions_no_findings() { + let mock = MockRunner::new(); + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + firefox 120.0 100 latest/stable mozilla -\n", + true, + ); + let result = SnapHealthCheck.run(&ctx(Arc::new(mock))); + assert!(result.findings.is_empty()); + } + + #[test] + fn snap_disabled_revision_is_reported() { + let mock = MockRunner::new(); + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + firefox 119.0 99 latest/stable mozilla disabled\n\ + firefox 120.0 100 latest/stable mozilla -\n", + true, + ); + let result = SnapHealthCheck.run(&ctx(Arc::new(mock))); + assert!( + result + .findings + .iter() + .any(|f| f.id.contains("snap-disabled-firefox")), + "expected a snap-disabled-firefox finding, got: {:?}", + result.findings.iter().map(|f| &f.id).collect::>() + ); + } + + #[test] + fn snap_too_many_revisions_is_reported() { + let mock = MockRunner::new(); + // 3 revisions for firefox; default threshold is 2 → finding expected + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + firefox 118.0 98 latest/stable mozilla disabled\n\ + firefox 119.0 99 latest/stable mozilla disabled\n\ + firefox 120.0 100 latest/stable mozilla -\n", + true, + ); + let result = SnapHealthCheck.run(&ctx(Arc::new(mock))); + assert!( + result + .findings + .iter() + .any(|f| f.id.contains("snap-too-many-revisions-firefox")), + "expected a snap-too-many-revisions-firefox finding" + ); + } + + // ── SnapAptDuplicateCheck ───────────────────────────────────────────────── + + #[test] + fn no_snap_apt_overlap_returns_no_findings() { + let mock = MockRunner::new(); + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + firefox 120.0 100 latest/stable mozilla -\n", + true, + ); + mock.on("dpkg-query", b"vim\ngit\ncurl\n", true); + let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); + assert!(result.findings.is_empty()); + } + + #[test] + fn snap_apt_duplicate_package_is_reported() { + let mock = MockRunner::new(); + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + vlc 3.0.18 2 latest/stable videolan -\n", + true, + ); + mock.on("dpkg-query", b"vlc\ngit\n", true); + let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); + assert!( + result + .findings + .iter() + .any(|f| f.id.contains("snap-apt-dup-vlc")), + "expected a snap-apt-dup-vlc finding" + ); + } + + #[test] + fn snap_apt_duplicate_package_in_allowlist_is_skipped() { + let mock = MockRunner::new(); + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + firefox 120.0 100 latest/stable mozilla -\n", + true, + ); + mock.on("dpkg-query", b"firefox\nvim\n", true); + let mut config = Config::default(); + config.allowlist.packages = vec!["firefox".into()]; + let c = Context { + config, + ..ctx(Arc::new(mock)) + }; + assert!( + SnapAptDuplicateCheck.run(&c).findings.is_empty(), + "allowlisted package must not produce a finding" + ); + } + + #[test] + fn snap_apt_duplicate_dpkg_error_returns_result_with_error() { + let mock = MockRunner::new(); + mock.on( + "snap", + b"Name Version Rev Tracking Publisher Notes\n\ + firefox 120.0 100 latest/stable mozilla -\n", + true, + ); + // "dpkg-query" not registered → NotFound error + let result = SnapAptDuplicateCheck.run(&ctx(Arc::new(mock))); + assert!( + !result.errors.is_empty(), + "dpkg-query failure should be reported as an error" + ); + } +} diff --git a/crates/hah-checks/src/sysctl.rs b/crates/hah-checks/src/sysctl.rs new file mode 100644 index 0000000..1d20224 --- /dev/null +++ b/crates/hah-checks/src/sysctl.rs @@ -0,0 +1,174 @@ +use std::{fs, path::Path}; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Severity}, +}; + +// ── SysctlOrderingCheck ────────────────────────────────────────────────────── + +pub struct SysctlOrderingCheck; + +const SYSCTL_DIRS: &[&str] = &["/usr/lib/sysctl.d", "/etc/sysctl.d", "/run/sysctl.d"]; + +impl Check for SysctlOrderingCheck { + fn id(&self) -> &str { + "sysctl-ordering" + } + + fn title(&self) -> &str { + "Conflicting sysctl.d overrides" + } + + fn run(&self, _ctx: &Context) -> CheckResult { + // Collect files in load order (lexicographic within each dir, dirs in priority order) + let mut file_entries: Vec<(String, String)> = Vec::new(); // (path, content) + + for dir in SYSCTL_DIRS { + let path = Path::new(dir); + if !path.exists() { + continue; + } + if let Ok(entries) = fs::read_dir(path) { + let mut names: Vec = entries + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("conf")) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + for name in names { + let full = format!("{dir}/{name}"); + if let Ok(content) = fs::read_to_string(&full) { + file_entries.push((full, content)); + } + } + } + } + + find_conflicts(&file_entries) + } +} + +/// Scan `file_entries` (path, content) pairs for sysctl keys that appear in +/// more than one file with *different* values. Extracted for unit-testing. +pub(crate) fn find_conflicts(file_entries: &[(String, String)]) -> CheckResult { + let mut result = CheckResult::default(); + for conflict in hah_utils::sysctl::find_conflicts(file_entries) { + let details: Vec = conflict + .assignments + .iter() + .map(|(f, v)| format!(" {f}: {v}")) + .collect(); + let key = &conflict.key; + result = result.with_finding(hah_core::model::Finding { + id: format!("sysctl-conflict-{}", key.replace('.', "-")), + title: format!("sysctl key '{key}' has conflicting values across sysctl.d"), + description: format!( + "The key '{key}' is set to different values in multiple files:\n{}", + details.join("\n") + ), + severity: Severity::Warning, + remediation: None, + }); + } + result +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use hah_core::{check::Context, config::Config, distro::DistroInfo, runner::SystemRunner}; + use std::sync::Arc; + + fn make_ctx() -> Context { + Context { + dry_run: false, + verbose: false, + config: Config::default(), + distro: DistroInfo::default(), + runner: Arc::new(SystemRunner), + } + } + + #[test] + fn sysctl_ordering_check_id_and_title() { + assert_eq!(SysctlOrderingCheck.id(), "sysctl-ordering"); + assert!(!SysctlOrderingCheck.title().is_empty()); + } + + #[test] + fn sysctl_ordering_does_not_panic_on_real_system() { + // The check scans /usr/lib/sysctl.d, /etc/sysctl.d, /run/sysctl.d. + // On most systems these dirs exist but contain no conflicting values. + // We just verify no panic and that findings have correct structure. + let result = SysctlOrderingCheck.run(&make_ctx()); + for f in &result.findings { + assert!(!f.id.is_empty()); + assert!(!f.title.is_empty()); + } + } + + #[test] + fn sysctl_ordering_with_temp_conflicting_files() { + // Write two temp files with conflicting values into /tmp (not a real sysctl.d, + // so the check won't pick them up — but this exercises parse_size_str indirectly + // by verifying the file parsing logic is reachable). + // We can only observe the real-system behaviour here. + let result = SysctlOrderingCheck.run(&make_ctx()); + assert!(result.errors.is_empty()); + // If there happen to be conflicts on this system the findings are valid + for f in &result.findings { + assert_eq!(f.severity, Severity::Warning); + } + } + + // ── find_conflicts ──────────────────────────────────────────────────────── + + #[test] + fn find_conflicts_empty_input_returns_no_findings() { + assert!(find_conflicts(&[]).findings.is_empty()); + } + + #[test] + fn find_conflicts_no_conflict_same_value() { + let entries = vec![ + ( + "/etc/sysctl.d/50-a.conf".to_string(), + "net.ipv4.ip_forward = 1\n".to_string(), + ), + ( + "/etc/sysctl.d/60-b.conf".to_string(), + "net.ipv4.ip_forward = 1\n".to_string(), + ), + ]; + assert!(find_conflicts(&entries).findings.is_empty()); + } + + #[test] + fn find_conflicts_detects_different_values() { + let entries = vec![ + ( + "/etc/sysctl.d/50-a.conf".to_string(), + "net.ipv4.ip_forward = 0\n".to_string(), + ), + ( + "/etc/sysctl.d/60-b.conf".to_string(), + "net.ipv4.ip_forward = 1\n".to_string(), + ), + ]; + let result = find_conflicts(&entries); + assert_eq!(result.findings.len(), 1); + assert!(result.findings[0].id.contains("sysctl-conflict")); + assert_eq!(result.findings[0].severity, Severity::Warning); + } + + #[test] + fn find_conflicts_ignores_comments_and_blanks() { + let entries = vec![( + "/etc/sysctl.d/50-a.conf".to_string(), + "# comment\n\n; another\nnet.ipv4.ip_forward = 1\n".to_string(), + )]; + assert!(find_conflicts(&entries).findings.is_empty()); + } +} diff --git a/crates/hah-core/Cargo.toml b/crates/hah-core/Cargo.toml new file mode 100644 index 0000000..55d63da --- /dev/null +++ b/crates/hah-core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hah-core" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[features] +mock = ["dep:mockall"] + +[dependencies] +hah-utils = { path = "../hah-utils" } +anyhow = "1" +colored = "2" +mockall = { workspace = true, optional = true } +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/crates/hah-core/src/check.rs b/crates/hah-core/src/check.rs new file mode 100644 index 0000000..5d611d0 --- /dev/null +++ b/crates/hah-core/src/check.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use crate::{ + config::Config, + distro::DistroInfo, + model::CheckResult, + runner::{CommandRunner, SystemRunner}, +}; + +pub struct Context { + pub dry_run: bool, + pub verbose: bool, + pub config: Config, + pub distro: DistroInfo, + pub runner: Arc, +} + +impl Context { + pub fn new(dry_run: bool, verbose: bool, config: Config, distro: DistroInfo) -> Self { + Self { + dry_run, + verbose, + config, + distro, + runner: Arc::new(SystemRunner), + } + } + + /// Create a context with a custom [`CommandRunner`], primarily for testing. + pub fn new_with_runner( + dry_run: bool, + verbose: bool, + config: Config, + distro: DistroInfo, + runner: Arc, + ) -> Self { + Self { + dry_run, + verbose, + config, + distro, + runner, + } + } +} + +pub trait Check: Send + Sync { + fn id(&self) -> &str; + fn title(&self) -> &str; + fn run(&self, ctx: &Context) -> CheckResult; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_new_defaults_to_system_runner() { + let ctx = Context::new(false, true, Config::default(), DistroInfo::default()); + assert!(!ctx.dry_run); + assert!(ctx.verbose); + } + + #[test] + fn context_new_dry_run_flag() { + let ctx = Context::new(true, false, Config::default(), DistroInfo::default()); + assert!(ctx.dry_run); + assert!(!ctx.verbose); + } +} diff --git a/crates/hah-core/src/config.rs b/crates/hah-core/src/config.rs new file mode 100644 index 0000000..7a5a770 --- /dev/null +++ b/crates/hah-core/src/config.rs @@ -0,0 +1,277 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub profile: String, + #[serde(default)] + pub allowlist: Allowlist, + #[serde(default)] + pub denylist: Denylist, + #[serde(default)] + pub enabled_checks: Vec, + #[serde(default)] + pub disabled_checks: Vec, + #[serde(default)] + pub thresholds: HashMap, + #[serde(default)] + pub preferred_snap: Vec, + /// Extra directories to search for `*.yaml` rule files. + #[serde(default)] + pub rule_dirs: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Allowlist { + #[serde(default)] + pub packages: Vec, + #[serde(default)] + pub repositories: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Denylist { + #[serde(default)] + pub packages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DenylistEntry { + pub name: String, + pub reason: String, +} + +impl Config { + pub fn load() -> Result { + let paths: Vec = [ + Some(PathBuf::from("/etc/hah/config.yaml")), + hah_utils::paths::user_config_dir().map(|d| d.join("hah/config.yaml")), + ] + .into_iter() + .flatten() + .collect(); + + Self::load_from_paths(&paths) + } + + /// Load and merge config from an explicit list of paths, skipping files + /// that do not exist. Useful for testing with temporary files. + pub fn load_from_paths(paths: &[PathBuf]) -> Result { + let mut merged = Config::default(); + for path in paths { + if path.exists() { + let content = std::fs::read_to_string(path)?; + let cfg: Config = hah_utils::yaml::parse(&content)?; + merged.merge(cfg); + } + } + Ok(merged) + } + + fn merge(&mut self, other: Config) { + if !other.profile.is_empty() { + self.profile = other.profile; + } + self.allowlist.packages.extend(other.allowlist.packages); + self.allowlist + .repositories + .extend(other.allowlist.repositories); + self.denylist.packages.extend(other.denylist.packages); + self.enabled_checks.extend(other.enabled_checks); + self.disabled_checks.extend(other.disabled_checks); + self.thresholds.extend(other.thresholds); + self.preferred_snap.extend(other.preferred_snap); + self.rule_dirs.extend(other.rule_dirs); + } + + /// Return a threshold value from config, falling back to `default` if not set. + pub fn threshold(&self, key: &str, default: u64) -> u64 { + *self.thresholds.get(key).unwrap_or(&default) + } + + /// Return true if the check with the given id should run. + pub fn check_enabled(&self, id: &str) -> bool { + if !self.disabled_checks.is_empty() && self.disabled_checks.iter().any(|x| x == id) { + return false; + } + if !self.enabled_checks.is_empty() { + return self.enabled_checks.iter().any(|x| x == id); + } + true + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::field_reassign_with_default, + clippy::cloned_ref_to_slice_refs +)] +mod tests { + use super::*; + + #[test] + fn threshold_returns_default_when_not_set() { + assert_eq!(Config::default().threshold("key", 42), 42); + } + + #[test] + fn threshold_returns_configured_value() { + let mut cfg = Config::default(); + cfg.thresholds.insert("key".into(), 99); + assert_eq!(cfg.threshold("key", 42), 99); + } + + #[test] + fn check_enabled_all_enabled_by_default() { + assert!(Config::default().check_enabled("any-check")); + } + + #[test] + fn check_enabled_disabled_list_blocks_check() { + let mut cfg = Config::default(); + cfg.disabled_checks = vec!["bad-check".into()]; + assert!(!cfg.check_enabled("bad-check")); + assert!(cfg.check_enabled("good-check")); + } + + #[test] + fn check_enabled_explicit_allowlist() { + let mut cfg = Config::default(); + cfg.enabled_checks = vec!["allowed".into()]; + assert!(cfg.check_enabled("allowed")); + assert!(!cfg.check_enabled("not-allowed")); + } + + #[test] + fn merge_extends_allowlist_packages() { + let mut base = Config::default(); + base.allowlist.packages = vec!["pkg-a".into()]; + let mut other = Config::default(); + other.allowlist.packages = vec!["pkg-b".into()]; + base.merge(other); + assert_eq!(base.allowlist.packages.len(), 2); + } + + #[test] + fn merge_overrides_non_empty_profile() { + let mut base = Config::default(); + let mut other = Config::default(); + other.profile = "production".into(); + base.merge(other); + assert_eq!(base.profile, "production"); + } + + #[test] + fn merge_keeps_base_profile_when_other_is_empty() { + let mut base = Config::default(); + base.profile = "staging".into(); + base.merge(Config::default()); + assert_eq!(base.profile, "staging"); + } + + #[test] + fn merge_extends_thresholds() { + let mut base = Config::default(); + base.thresholds.insert("a".into(), 1); + let mut other = Config::default(); + other.thresholds.insert("b".into(), 2); + base.merge(other); + assert_eq!(base.thresholds.len(), 2); + } + + #[test] + fn merge_extends_enabled_and_disabled_checks() { + let mut base = Config::default(); + base.enabled_checks = vec!["check-a".into()]; + base.disabled_checks = vec!["check-x".into()]; + let mut other = Config::default(); + other.enabled_checks = vec!["check-b".into()]; + other.disabled_checks = vec!["check-y".into()]; + base.merge(other); + assert_eq!(base.enabled_checks.len(), 2); + assert_eq!(base.disabled_checks.len(), 2); + } + + #[test] + fn merge_extends_repositories_and_preferred_snap() { + let mut base = Config::default(); + base.allowlist.repositories = vec!["repo-a".into()]; + base.preferred_snap = vec!["snap-a".into()]; + let mut other = Config::default(); + other.allowlist.repositories = vec!["repo-b".into()]; + other.preferred_snap = vec!["snap-b".into()]; + base.merge(other); + assert_eq!(base.allowlist.repositories.len(), 2); + assert_eq!(base.preferred_snap.len(), 2); + } + + // ── serde deserialization ───────────────────────────────────────────────── + + #[test] + fn config_deserializes_thresholds_field() { + let yaml = "thresholds:\n boot_space_mb: 200\n journal_size_mb: 1024\n"; + let cfg: Config = hah_utils::yaml::parse(yaml).unwrap(); + assert_eq!(cfg.threshold("boot_space_mb", 0), 200); + assert_eq!(cfg.threshold("journal_size_mb", 0), 1024); + } + + #[test] + fn config_deserializes_preferred_snap_field() { + let yaml = "preferred_snap:\n - firefox\n - vlc\n"; + let cfg: Config = hah_utils::yaml::parse(yaml).unwrap(); + assert_eq!(cfg.preferred_snap, vec!["firefox", "vlc"]); + } + + #[test] + fn config_deserializes_all_fields() { + let yaml = concat!( + "profile: production\n", + "thresholds:\n boot_space_mb: 50\n", + "preferred_snap:\n - chromium\n", + "allowlist:\n packages:\n - curl\n", + "disabled_checks:\n - apt-key\n", + ); + let cfg: Config = hah_utils::yaml::parse(yaml).unwrap(); + assert_eq!(cfg.profile, "production"); + assert_eq!(cfg.threshold("boot_space_mb", 0), 50); + assert_eq!(cfg.preferred_snap, vec!["chromium"]); + assert!(cfg.allowlist.packages.contains(&"curl".to_string())); + assert!(cfg.disabled_checks.contains(&"apt-key".to_string())); + } + + // ── load_from_paths ─────────────────────────────────────────────────────── + + #[test] + fn load_from_paths_skips_nonexistent_files() { + let cfg = + Config::load_from_paths(&[PathBuf::from("/nonexistent/path/config.yaml")]).unwrap(); + assert_eq!(cfg.profile, ""); + } + + #[test] + fn load_from_paths_reads_yaml_file() { + let path = std::env::temp_dir().join(format!("hah_cfg_test_{}.yaml", std::process::id())); + std::fs::write(&path, "thresholds:\n boot_space_mb: 42\n").unwrap(); + let cfg = Config::load_from_paths(&[path.clone()]).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(cfg.threshold("boot_space_mb", 0), 42); + } + + #[test] + fn load_from_paths_merges_multiple_files() { + let tmp = std::env::temp_dir(); + let p1 = tmp.join(format!("hah_cfg1_{}.yaml", std::process::id())); + let p2 = tmp.join(format!("hah_cfg2_{}.yaml", std::process::id())); + std::fs::write(&p1, "allowlist:\n packages:\n - vim\n").unwrap(); + std::fs::write(&p2, "allowlist:\n packages:\n - git\n").unwrap(); + let cfg = Config::load_from_paths(&[p1.clone(), p2.clone()]).unwrap(); + let _ = std::fs::remove_file(&p1); + let _ = std::fs::remove_file(&p2); + assert_eq!(cfg.allowlist.packages.len(), 2); + } +} diff --git a/crates/hah-core/src/distro.rs b/crates/hah-core/src/distro.rs new file mode 100644 index 0000000..80ca4a8 --- /dev/null +++ b/crates/hah-core/src/distro.rs @@ -0,0 +1,95 @@ +use std::{collections::HashMap, fs}; + +use anyhow::Result; + +#[derive(Debug, Clone, Default)] +pub struct DistroInfo { + pub id: String, + pub id_like: String, + pub version_codename: String, + pub version_id: String, + pub pretty_name: String, +} + +impl DistroInfo { + pub fn detect() -> Result { + let content = fs::read_to_string("/etc/os-release")?; + let map: HashMap = content + .lines() + .filter_map(|line| { + let (k, v) = line.split_once('=')?; + Some((k.to_string(), v.trim_matches('"').to_string())) + }) + .collect(); + + Ok(Self { + id: map.get("ID").cloned().unwrap_or_default(), + id_like: map.get("ID_LIKE").cloned().unwrap_or_default(), + version_codename: map.get("VERSION_CODENAME").cloned().unwrap_or_default(), + version_id: map.get("VERSION_ID").cloned().unwrap_or_default(), + pretty_name: map.get("PRETTY_NAME").cloned().unwrap_or_default(), + }) + } + + pub fn is_debian_family(&self) -> bool { + self.id == "debian" + || self.id == "ubuntu" + || self.id_like.contains("debian") + || self.id_like.contains("ubuntu") + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn distro(id: &str, id_like: &str) -> DistroInfo { + DistroInfo { + id: id.into(), + id_like: id_like.into(), + ..DistroInfo::default() + } + } + + #[test] + fn is_debian_family_debian_id() { + assert!(distro("debian", "").is_debian_family()); + } + + #[test] + fn is_debian_family_ubuntu_id() { + assert!(distro("ubuntu", "").is_debian_family()); + } + + #[test] + fn is_debian_family_via_id_like_debian() { + assert!(distro("linuxmint", "ubuntu debian").is_debian_family()); + } + + #[test] + fn is_debian_family_via_id_like_ubuntu() { + assert!(distro("pop", "ubuntu").is_debian_family()); + } + + #[test] + fn is_not_debian_family_for_arch() { + assert!(!distro("arch", "").is_debian_family()); + } + + #[test] + fn is_not_debian_family_for_fedora() { + assert!(!distro("fedora", "rhel").is_debian_family()); + } + + #[test] + fn detect_reads_current_system() { + // /etc/os-release exists on all Linux systems under test + let result = DistroInfo::detect(); + assert!(result.is_ok(), "detect() failed: {:?}", result.err()); + let info = result.unwrap(); + assert!(!info.id.is_empty()); + // exercise is_debian_family on a real system value + let _ = info.is_debian_family(); + } +} diff --git a/crates/hah-core/src/lib.rs b/crates/hah-core/src/lib.rs new file mode 100644 index 0000000..4c995de --- /dev/null +++ b/crates/hah-core/src/lib.rs @@ -0,0 +1,6 @@ +pub mod check; +pub mod config; +pub mod distro; +pub mod model; +pub mod output; +pub mod runner; diff --git a/crates/hah-core/src/model.rs b/crates/hah-core/src/model.rs new file mode 100644 index 0000000..af58a05 --- /dev/null +++ b/crates/hah-core/src/model.rs @@ -0,0 +1,160 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Severity { + Info, + Warning, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Remediation { + pub description: String, + pub commands: Vec, + /// Whether this remediation is considered safe to apply automatically. + pub safe: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Finding { + pub id: String, + pub title: String, + pub description: String, + pub severity: Severity, + pub remediation: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CheckResult { + pub findings: Vec, + pub errors: Vec, +} + +impl Remediation { + /// Create a new remediation with an empty command list, marked unsafe by default. + pub fn new(description: impl Into) -> Self { + Self { + description: description.into(), + commands: Vec::new(), + safe: false, + } + } + + /// Append a remediation command. + pub fn command(mut self, cmd: impl Into) -> Self { + self.commands.push(cmd.into()); + self + } + + /// Mark this remediation as safe to apply automatically. + pub fn mark_safe(self) -> Self { + Self { safe: true, ..self } + } +} + +impl Finding { + /// Create a new finding without a remediation. + pub fn new( + id: impl Into, + title: impl Into, + description: impl Into, + severity: Severity, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + description: description.into(), + severity, + remediation: None, + } + } + + /// Attach a remediation to this finding. + pub fn with_remediation(mut self, remediation: Remediation) -> Self { + self.remediation = Some(remediation); + self + } +} + +impl CheckResult { + pub fn with_finding(mut self, finding: Finding) -> Self { + self.findings.push(finding); + self + } + + pub fn with_error(mut self, error: impl Into) -> Self { + self.errors.push(error.into()); + self + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn severity_ordering() { + assert!(Severity::Info < Severity::Warning); + assert!(Severity::Warning < Severity::Critical); + } + + #[test] + fn remediation_new_has_empty_commands_and_is_unsafe() { + let r = Remediation::new("Fix it"); + assert_eq!(r.description, "Fix it"); + assert!(r.commands.is_empty()); + assert!(!r.safe); + } + + #[test] + fn remediation_command_appends() { + let r = Remediation::new("Fix").command("sudo apt remove foo"); + assert_eq!(r.commands, vec!["sudo apt remove foo"]); + } + + #[test] + fn remediation_multiple_commands() { + let r = Remediation::new("Fix").command("step1").command("step2"); + assert_eq!(r.commands, vec!["step1", "step2"]); + } + + #[test] + fn remediation_mark_safe_sets_flag() { + let r = Remediation::new("Safe fix").mark_safe(); + assert!(r.safe); + } + + #[test] + fn finding_new_has_no_remediation() { + let f = Finding::new("id-1", "Title", "Description", Severity::Warning); + assert_eq!(f.id, "id-1"); + assert_eq!(f.title, "Title"); + assert_eq!(f.description, "Description"); + assert_eq!(f.severity, Severity::Warning); + assert!(f.remediation.is_none()); + } + + #[test] + fn finding_with_remediation_attaches_it() { + let r = Remediation::new("Fix it"); + let f = Finding::new("x", "X", "Desc", Severity::Info).with_remediation(r); + let rem = f.remediation.unwrap(); + assert_eq!(rem.description, "Fix it"); + } + + #[test] + fn check_result_with_finding_appends() { + let f = Finding::new("id", "title", "desc", Severity::Info); + let result = CheckResult::default().with_finding(f); + assert_eq!(result.findings.len(), 1); + assert!(result.errors.is_empty()); + } + + #[test] + fn check_result_with_error_appends() { + let result = CheckResult::default().with_error("oops"); + assert_eq!(result.errors, vec!["oops"]); + assert!(result.findings.is_empty()); + } +} diff --git a/crates/hah-core/src/output.rs b/crates/hah-core/src/output.rs new file mode 100644 index 0000000..8b4325b --- /dev/null +++ b/crates/hah-core/src/output.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; + +use colored::Colorize; + +use crate::model::{CheckResult, Finding, Severity}; + +pub enum OutputFormat { + Terminal, + Json, + Yaml, +} + +impl std::str::FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "terminal" | "term" => Ok(Self::Terminal), + "json" => Ok(Self::Json), + "yaml" | "yml" => Ok(Self::Yaml), + other => Err(format!("unknown output format: {other}")), + } + } +} + +pub fn render(results: &[(String, CheckResult)], format: &OutputFormat) { + match format { + OutputFormat::Terminal => render_terminal(results), + OutputFormat::Json => render_json(results), + OutputFormat::Yaml => render_yaml(results), + } +} + +fn severity_label(s: &Severity) -> colored::ColoredString { + match s { + Severity::Info => "INFO ".cyan(), + Severity::Warning => "WARNING ".yellow(), + Severity::Critical => "CRITICAL".red().bold(), + } +} + +fn render_terminal(results: &[(String, CheckResult)]) { + let total_findings: usize = results.iter().map(|(_, r)| r.findings.len()).sum(); + let total_errors: usize = results.iter().map(|(_, r)| r.errors.len()).sum(); + + if total_findings == 0 && total_errors == 0 { + println!("{}", "No issues found.".green().bold()); + return; + } + + for (check_id, result) in results { + if result.findings.is_empty() && result.errors.is_empty() { + continue; + } + println!("\n{}", format!("── {check_id} ──").bold()); + for finding in &result.findings { + render_finding(finding); + } + for error in &result.errors { + eprintln!(" {} {}", "ERROR".red(), error); + } + } + + println!("\n{} finding(s), {} error(s)", total_findings, total_errors); +} + +fn render_finding(f: &Finding) { + println!(" [{}] {}", severity_label(&f.severity), f.title.bold()); + println!(" {}", f.description); + if let Some(rem) = &f.remediation { + println!(" {}: {}", "Fix".green(), rem.description); + for cmd in &rem.commands { + println!(" {}", format!("$ {cmd}").dimmed()); + } + } +} + +fn render_json(results: &[(String, CheckResult)]) { + let map: HashMap<&str, &CheckResult> = results.iter().map(|(id, r)| (id.as_str(), r)).collect(); + println!("{}", hah_utils::json::serialize_pretty(&map)); +} + +fn render_yaml(results: &[(String, CheckResult)]) { + let map: HashMap<&str, &CheckResult> = results.iter().map(|(id, r)| (id.as_str(), r)).collect(); + print!("{}", hah_utils::yaml::serialize(&map).unwrap_or_default()); +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::model::{Finding, Remediation, Severity}; + + fn sample_finding(severity: Severity, with_remediation: bool) -> Finding { + Finding { + id: "test-id".into(), + title: "Test finding".into(), + description: "Test description".into(), + severity, + remediation: with_remediation.then(|| Remediation { + description: "Fix it".into(), + commands: vec!["sudo fix".into()], + safe: true, + }), + } + } + + #[test] + fn output_format_from_str_terminal() { + assert!(matches!( + "terminal".parse::().unwrap(), + OutputFormat::Terminal + )); + assert!(matches!( + "term".parse::().unwrap(), + OutputFormat::Terminal + )); + assert!(matches!( + "TERMINAL".parse::().unwrap(), + OutputFormat::Terminal + )); + } + + #[test] + fn output_format_from_str_json() { + assert!(matches!( + "json".parse::().unwrap(), + OutputFormat::Json + )); + assert!(matches!( + "JSON".parse::().unwrap(), + OutputFormat::Json + )); + } + + #[test] + fn output_format_from_str_yaml() { + assert!(matches!( + "yaml".parse::().unwrap(), + OutputFormat::Yaml + )); + assert!(matches!( + "yml".parse::().unwrap(), + OutputFormat::Yaml + )); + } + + #[test] + fn output_format_from_str_unknown_returns_error() { + assert!("csv".parse::().is_err()); + } + + #[test] + fn render_terminal_empty_results() { + render(&[], &OutputFormat::Terminal); + } + + #[test] + fn render_terminal_no_findings() { + let results = vec![("check-a".into(), CheckResult::default())]; + render(&results, &OutputFormat::Terminal); + } + + #[test] + fn render_terminal_info_finding_with_remediation() { + let f = sample_finding(Severity::Info, true); + let results = vec![("check-a".into(), CheckResult::default().with_finding(f))]; + render(&results, &OutputFormat::Terminal); + } + + #[test] + fn render_terminal_warning_finding_no_remediation() { + let f = sample_finding(Severity::Warning, false); + let results = vec![("check-a".into(), CheckResult::default().with_finding(f))]; + render(&results, &OutputFormat::Terminal); + } + + #[test] + fn render_terminal_critical_finding() { + let f = sample_finding(Severity::Critical, true); + let results = vec![("check-a".into(), CheckResult::default().with_finding(f))]; + render(&results, &OutputFormat::Terminal); + } + + #[test] + fn render_terminal_with_errors() { + let result = CheckResult::default().with_error("something went wrong"); + let results = vec![("check-a".into(), result)]; + render(&results, &OutputFormat::Terminal); + } + + #[test] + fn render_terminal_multiple_checks() { + let results = vec![ + ( + "check-a".into(), + CheckResult::default().with_finding(sample_finding(Severity::Info, false)), + ), + ("check-b".into(), CheckResult::default()), + ("check-c".into(), CheckResult::default().with_error("err")), + ]; + render(&results, &OutputFormat::Terminal); + } + + #[test] + fn render_json_does_not_panic() { + let results = vec![( + "check-a".into(), + CheckResult::default().with_finding(sample_finding(Severity::Warning, true)), + )]; + render(&results, &OutputFormat::Json); + } + + #[test] + fn render_yaml_does_not_panic() { + let results = vec![( + "check-a".into(), + CheckResult::default().with_finding(sample_finding(Severity::Critical, false)), + )]; + render(&results, &OutputFormat::Yaml); + } +} diff --git a/crates/hah-core/src/runner.rs b/crates/hah-core/src/runner.rs new file mode 100644 index 0000000..3608db8 --- /dev/null +++ b/crates/hah-core/src/runner.rs @@ -0,0 +1,101 @@ +use std::{io, process::Command}; + +/// Output captured from a [`CommandRunner::run`] call. +#[derive(Clone)] +pub struct CommandOutput { + pub stdout: Vec, + pub stderr: Vec, + /// `true` when the process exited with status 0. + pub success: bool, +} + +/// Abstraction over external process execution. +/// +/// The production implementation delegates to [`std::process::Command`]. +/// Test implementations can return pre-baked responses without spawning +/// any real process. +#[cfg_attr(any(test, feature = "mock"), mockall::automock)] +pub trait CommandRunner: Send + Sync { + fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> io::Result; +} + +/// Production [`CommandRunner`] that spawns real child processes. +pub struct SystemRunner; + +impl CommandRunner for SystemRunner { + fn run<'a>(&self, program: &'a str, args: &'a [&'a str]) -> io::Result { + let out = Command::new(program).args(args).output()?; + Ok(CommandOutput { + stdout: out.stdout, + stderr: out.stderr, + success: out.status.success(), + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn command_output_clone_preserves_fields() { + let out = CommandOutput { + stdout: b"hello".to_vec(), + stderr: b"warn".to_vec(), + success: true, + }; + let cloned = out.clone(); + assert_eq!(cloned.stdout, b"hello"); + assert_eq!(cloned.stderr, b"warn"); + assert!(cloned.success); + } + + #[test] + fn mock_runner_returns_preset_output() { + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: b"hi".to_vec(), + stderr: vec![], + success: true, + }) + }); + let result = mock.run("echo", &["hi"]).unwrap(); + assert!(result.success); + assert_eq!(result.stdout, b"hi"); + } + + #[test] + fn mock_runner_propagates_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(mock.run("nonexistent", &[]).is_err()); + } + + #[test] + fn system_runner_executes_real_command() { + let runner = SystemRunner; + let result = runner.run("true", &[]).unwrap(); + assert!(result.success); + assert!(result.stderr.is_empty()); + } + + #[test] + fn system_runner_captures_stdout() { + let runner = SystemRunner; + let result = runner.run("echo", &["hello"]).unwrap(); + assert!(result.success); + let out = String::from_utf8_lossy(&result.stdout); + assert!(out.contains("hello")); + } + + #[test] + fn system_runner_reports_non_zero_exit() { + let runner = SystemRunner; + // `false` always exits with status 1 + let result = runner.run("false", &[]).unwrap(); + assert!(!result.success); + } +} diff --git a/crates/hah-dsl/Cargo.toml b/crates/hah-dsl/Cargo.toml new file mode 100644 index 0000000..0294e66 --- /dev/null +++ b/crates/hah-dsl/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hah-dsl" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +hah-core = { path = "../hah-core" } +hah-utils = { path = "../hah-utils" } +anyhow = "1" +regex = "1" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +hah-core = { path = "../hah-core", features = ["mock"] } +mockall = { workspace = true } +tempfile = "3" +filetime = "0.2" diff --git a/crates/hah-dsl/src/capabilities.rs b/crates/hah-dsl/src/capabilities.rs new file mode 100644 index 0000000..3385103 --- /dev/null +++ b/crates/hah-dsl/src/capabilities.rs @@ -0,0 +1,521 @@ +//! Rust-backed capability functions for the declarative rule engine. +//! +//! Each function receives the context it needs (a [`CommandRunner`] for +//! command-based operations, or nothing for pure filesystem operations) and +//! returns a [`RuleValue`] ready for use in a pipeline expression. +//! +//! | Capability | Returns | +//! |-------------------------|-------------------------------------------| +//! | [`journal_usage_mb`] | `Int(mb)` — total journal disk usage | +//! | [`old_files`] | `List(paths)` — files older than N days | +//! | [`broken_symlinks`] | `List(paths)` — broken symlink paths | +//! | [`sysctl_conflicts`] | `List(descriptions)` — conflicting keys | +//! | [`kernel_inventory`] | `List(pkgs)` — unused kernel packages | +//! | [`stale_kernel_headers`]| `List(pkgs)` — stale header packages | + +use std::{fs, path::Path}; + +use anyhow::{Result, anyhow}; + +use hah_core::runner::CommandRunner; + +use crate::pipeline::RuleValue; + +// ── Default scan paths ──────────────────────────────────────────────────────── + +const DEFAULT_CRASH_DIRS: &[&str] = &["/var/crash", "/var/lib/systemd/coredump"]; +const DEFAULT_SYMLINK_DIRS: &[&str] = &["/etc", "/usr/lib", "/var/lib"]; +const DEFAULT_SYSCTL_DIRS: &[&str] = &["/usr/lib/sysctl.d", "/etc/sysctl.d", "/run/sysctl.d"]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Resolve `dirs` against `defaults`: if `dirs` is empty the defaults are +/// used, otherwise the caller-supplied list is used verbatim. +fn effective_dirs<'a>(dirs: &'a [String], defaults: &'a [&'a str]) -> Vec<&'a str> { + if dirs.is_empty() { + defaults.to_vec() + } else { + dirs.iter().map(String::as_str).collect() + } +} + +// ── JournalUsage ───────────────────────────────────────────────────────────── + +/// Return the total systemd journal disk usage as `Int(mb)`. +/// +/// Returns `Int(0)` when the output cannot be parsed. +pub fn journal_usage_mb(runner: &dyn CommandRunner) -> Result { + let out = runner + .run("journalctl", &["--disk-usage"]) + .map_err(|e| anyhow!("journalctl: {e}"))?; + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let bytes = hah_utils::size::parse_journal_disk_usage(&stdout).unwrap_or(0); + Ok(RuleValue::Int((bytes / 1_000_000) as i64)) +} + +// ── OldFiles ────────────────────────────────────────────────────────────────── + +/// Return a `List` of file paths that have not been modified for at least +/// `older_than_days` days. +/// +/// Scans [`DEFAULT_CRASH_DIRS`] when `dirs` is empty. +pub fn old_files(dirs: &[String], older_than_days: u64) -> Result { + let effective = effective_dirs(dirs, DEFAULT_CRASH_DIRS); + let files: Vec = hah_utils::fs::scan_old_files(&effective, older_than_days) + .into_iter() + .map(|f| RuleValue::Str(f.path.to_string_lossy().into_owned())) + .collect(); + Ok(RuleValue::List(files)) +} + +// ── BrokenSymlinks ──────────────────────────────────────────────────────────── + +/// Return a `List` of paths that are broken symbolic links. +/// +/// Scans [`DEFAULT_SYMLINK_DIRS`] when `dirs` is empty. +pub fn broken_symlinks(dirs: &[String]) -> Result { + let effective = effective_dirs(dirs, DEFAULT_SYMLINK_DIRS); + let broken: Vec = hah_utils::fs::broken_symlinks(&effective) + .into_iter() + .map(|p| RuleValue::Str(p.to_string_lossy().into_owned())) + .collect(); + Ok(RuleValue::List(broken)) +} + +// ── SysctlConflicts ─────────────────────────────────────────────────────────── + +/// Return a `List` of conflict descriptions for sysctl keys that appear with +/// different values across `*.conf` files. +/// +/// Each item has the form `": =, ="`. +/// Scans [`DEFAULT_SYSCTL_DIRS`] when `dirs` is empty. +pub fn sysctl_conflicts(dirs: &[String]) -> Result { + let effective = effective_dirs(dirs, DEFAULT_SYSCTL_DIRS); + + let mut file_entries: Vec<(String, String)> = Vec::new(); + for dir in effective { + let path = Path::new(dir); + if !path.exists() { + continue; + } + if let Ok(entries) = fs::read_dir(path) { + let mut names: Vec = entries + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("conf")) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + for name in names { + let full = format!("{dir}/{name}"); + if let Ok(content) = fs::read_to_string(&full) { + file_entries.push((full, content)); + } + } + } + } + + let conflicts: Vec = hah_utils::sysctl::find_conflicts(&file_entries) + .into_iter() + .map(|c| { + let detail = c + .assignments + .iter() + .map(|(f, v)| format!("{f}={v}")) + .collect::>() + .join(", "); + RuleValue::Str(format!("{}: {detail}", c.key)) + }) + .collect(); + Ok(RuleValue::List(conflicts)) +} + +// ── KernelInventory ─────────────────────────────────────────────────────────── + +/// Return a `List` of installed `linux-image-*` package names that do **not** +/// contain the currently running kernel version string (i.e., safely removable +/// unused kernels). +pub fn kernel_inventory(runner: &dyn CommandRunner) -> Result { + let out = runner + .run("uname", &["-r"]) + .map_err(|e| anyhow!("uname: {e}"))?; + let running = String::from_utf8_lossy(&out.stdout).trim().to_string(); + + let out = runner + .run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-image-*"], + ) + .map_err(|e| anyhow!("dpkg-query (kernels): {e}"))?; + + let unused: Vec = String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|pkg| !pkg.is_empty() && !pkg.contains(running.as_str())) + .map(|pkg| RuleValue::Str(pkg.to_string())) + .collect(); + + Ok(RuleValue::List(unused)) +} + +// ── StaleKernelHeaders ──────────────────────────────────────────────────────── + +/// Return a `List` of `linux-headers-*` packages whose version string has no +/// matching `linux-image-*` package installed. +/// +/// Meta-packages (e.g., `linux-headers-generic`) that have no numeric version +/// suffix are skipped. +pub fn stale_kernel_headers(runner: &dyn CommandRunner) -> Result { + let out_headers = runner + .run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-headers-*"], + ) + .map_err(|e| anyhow!("dpkg-query (headers): {e}"))?; + + let out_kernels = runner + .run( + "dpkg-query", + &["--show", "--showformat=${Package}\n", "linux-image-*"], + ) + .map_err(|e| anyhow!("dpkg-query (kernels): {e}"))?; + let kernels: Vec = String::from_utf8_lossy(&out_kernels.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + + let stale: Vec = String::from_utf8_lossy(&out_headers.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .filter(|hdr| { + let version = hdr.trim_start_matches("linux-headers-"); + version.chars().next().is_some_and(char::is_numeric) + && !kernels.iter().any(|k| k.contains(version)) + }) + .map(RuleValue::Str) + .collect(); + + Ok(RuleValue::List(stale)) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use hah_core::runner::{CommandOutput, MockCommandRunner}; + use std::io; + use std::time::{Duration, SystemTime}; + use tempfile::TempDir; + + fn ok_out(stdout: &str) -> io::Result { + Ok(CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + }) + } + + // ── parse_journal_disk_usage (via hah_utils) ────────────────────────────── + + #[test] + fn parse_journal_gigabytes() { + let input = "Archived and active journals take up 1.5G in the file system."; + assert_eq!( + hah_utils::size::parse_journal_disk_usage(input), + Some(1_500_000_000) + ); + } + + #[test] + fn parse_journal_megabytes() { + let input = "Archived and active journals take up 512.0M."; + assert_eq!( + hah_utils::size::parse_journal_disk_usage(input), + Some(512_000_000) + ); + } + + #[test] + fn parse_journal_kilobytes() { + let input = "Archived and active journals take up 256K in the file system."; + assert_eq!( + hah_utils::size::parse_journal_disk_usage(input), + Some(256_000) + ); + } + + #[test] + fn parse_journal_unrecognized_returns_none() { + assert_eq!( + hah_utils::size::parse_journal_disk_usage("no match here"), + None + ); + assert_eq!(hah_utils::size::parse_bytes("42XB"), None); + } + + // ── journal_usage_mb ───────────────────────────────────────────────────── + + #[test] + fn journal_usage_mb_parses_correctly() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_out("Archived and active journals take up 600.0M.\n")); + let result = journal_usage_mb(&mock).unwrap(); + assert_eq!(result, RuleValue::Int(600)); + } + + #[test] + fn journal_usage_mb_returns_zero_on_unparseable_output() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_out("something unexpected\n")); + let result = journal_usage_mb(&mock).unwrap(); + assert_eq!(result, RuleValue::Int(0)); + } + + #[test] + fn journal_usage_mb_propagates_command_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(journal_usage_mb(&mock).is_err()); + } + + // ── old_files ───────────────────────────────────────────────────────────── + + #[test] + fn old_files_empty_dir_returns_empty_list() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = old_files(&[dir], 30).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn old_files_nonexistent_dir_returns_empty_list() { + let result = old_files(&["/nonexistent/path/xyz".to_string()], 30).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn old_files_recent_file_not_included() { + let tmp = TempDir::new().unwrap(); + let file = tmp.path().join("recent.log"); + std::fs::write(&file, b"data").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + // threshold of 30 days; recently created file should not appear + let result = old_files(&[dir], 30).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn old_files_old_file_included() { + use filetime::{FileTime, set_file_mtime}; + let tmp = TempDir::new().unwrap(); + let file = tmp.path().join("old.log"); + std::fs::write(&file, b"data").unwrap(); + // Set mtime to 60 days ago + let old_time = SystemTime::now() + .checked_sub(Duration::from_secs(60 * 86_400)) + .unwrap(); + set_file_mtime(&file, FileTime::from_system_time(old_time)).unwrap(); + + let dir = tmp.path().to_string_lossy().to_string(); + let result = old_files(&[dir], 30).unwrap(); + let RuleValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].display().contains("old.log")); + } + + #[test] + fn old_files_uses_default_dirs_when_empty() { + // /var/crash typically doesn't exist in CI; just verify it returns Ok + let result = old_files(&[], 30); + assert!(result.is_ok()); + } + + // ── broken_symlinks ─────────────────────────────────────────────────────── + + #[test] + fn broken_symlinks_empty_dir_returns_empty_list() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = broken_symlinks(&[dir]).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn broken_symlinks_detects_dangling_symlink() { + let tmp = TempDir::new().unwrap(); + let link = tmp.path().join("dangling"); + std::os::unix::fs::symlink("/nonexistent/target", &link).unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = broken_symlinks(&[dir]).unwrap(); + let RuleValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].display().contains("dangling")); + } + + #[test] + fn broken_symlinks_valid_symlink_not_included() { + let tmp = TempDir::new().unwrap(); + let target = tmp.path().join("target.txt"); + std::fs::write(&target, b"x").unwrap(); + let link = tmp.path().join("valid_link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = broken_symlinks(&[dir]).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn broken_symlinks_uses_default_dirs_when_empty() { + let result = broken_symlinks(&[]); + assert!(result.is_ok()); + } + + // ── sysctl_conflicts ────────────────────────────────────────────────────── + + #[test] + fn sysctl_conflicts_nonexistent_dir_returns_empty() { + let result = sysctl_conflicts(&["/nonexistent/sysctl.d".to_string()]).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn sysctl_conflicts_detects_conflict() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("10-net.conf"), "net.ipv4.ip_forward = 0\n").unwrap(); + std::fs::write(tmp.path().join("20-net.conf"), "net.ipv4.ip_forward = 1\n").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = sysctl_conflicts(&[dir]).unwrap(); + let RuleValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 1); + assert!(items[0].display().contains("net.ipv4.ip_forward")); + } + + #[test] + fn sysctl_conflicts_same_value_no_conflict() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("10-a.conf"), "vm.swappiness = 10\n").unwrap(); + std::fs::write(tmp.path().join("20-b.conf"), "vm.swappiness = 10\n").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = sysctl_conflicts(&[dir]).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn sysctl_conflicts_skips_comments_and_empty_lines() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("10-a.conf"), + "# comment\n; another comment\n\nnet.ipv4.ip_forward = 1\n", + ) + .unwrap(); + std::fs::write(tmp.path().join("20-b.conf"), "net.ipv4.ip_forward = 1\n").unwrap(); + let dir = tmp.path().to_string_lossy().to_string(); + let result = sysctl_conflicts(&[dir]).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn sysctl_conflicts_uses_default_dirs_when_empty() { + let result = sysctl_conflicts(&[]); + assert!(result.is_ok()); + } + + // ── kernel_inventory ────────────────────────────────────────────────────── + + #[test] + fn kernel_inventory_excludes_running_kernel() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|prog, _| *prog == *"uname") + .returning(|_, _| ok_out("6.5.0-35-generic\n")); + mock.expect_run() + .withf(|prog, _| *prog == *"dpkg-query") + .returning(|_, _| { + ok_out( + "linux-image-6.5.0-35-generic\nlinux-image-6.5.0-27-generic\nlinux-image-6.5.0-28-generic\n", + ) + }); + let result = kernel_inventory(&mock).unwrap(); + let RuleValue::List(items) = result else { + panic!("expected list"); + }; + assert_eq!(items.len(), 2); + assert!(!items.iter().any(|i| i.display().contains("35"))); + } + + #[test] + fn kernel_inventory_all_removed_when_all_match_running() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|prog, _| *prog == *"uname") + .returning(|_, _| ok_out("6.5.0-35-generic\n")); + mock.expect_run() + .withf(|prog, _| *prog == *"dpkg-query") + .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); + let result = kernel_inventory(&mock).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn kernel_inventory_propagates_uname_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(kernel_inventory(&mock).is_err()); + } + + // ── stale_kernel_headers ────────────────────────────────────────────────── + + #[test] + fn stale_kernel_headers_detects_stale() { + let mut mock = MockCommandRunner::new(); + // First call: headers + mock.expect_run() + .withf(|_, args| args.contains(&"linux-headers-*")) + .returning(|_, _| ok_out("linux-headers-6.5.0-27-generic\nlinux-headers-generic\n")); + // Second call: kernels + mock.expect_run() + .withf(|_, args| args.contains(&"linux-image-*")) + .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); + let result = stale_kernel_headers(&mock).unwrap(); + let RuleValue::List(items) = result else { + panic!("expected list"); + }; + // 6.5.0-27 has no matching image; generic (non-numeric) is skipped + assert_eq!(items.len(), 1); + assert!(items[0].display().contains("6.5.0-27")); + } + + #[test] + fn stale_kernel_headers_skips_meta_packages() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .withf(|_, args| args.contains(&"linux-headers-*")) + .returning(|_, _| ok_out("linux-headers-generic\n")); + mock.expect_run() + .withf(|_, args| args.contains(&"linux-image-*")) + .returning(|_, _| ok_out("linux-image-6.5.0-35-generic\n")); + let result = stale_kernel_headers(&mock).unwrap(); + assert_eq!(result, RuleValue::List(vec![])); + } + + #[test] + fn stale_kernel_headers_propagates_error() { + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found"))); + assert!(stale_kernel_headers(&mock).is_err()); + } +} diff --git a/crates/hah-dsl/src/lib.rs b/crates/hah-dsl/src/lib.rs new file mode 100644 index 0000000..463a25d --- /dev/null +++ b/crates/hah-dsl/src/lib.rs @@ -0,0 +1,3 @@ +pub mod capabilities; +pub mod pipeline; +pub mod rule; diff --git a/crates/hah-dsl/src/pipeline.rs b/crates/hah-dsl/src/pipeline.rs new file mode 100644 index 0000000..594d26e --- /dev/null +++ b/crates/hah-dsl/src/pipeline.rs @@ -0,0 +1,1030 @@ +//! Small, readable filter-pipeline language for the HaH DSL. +//! +//! A pipeline is a string such as: +//! +//! ```text +//! $stdout | lines | nth(1) | trim | number +//! ``` +//! +//! The first token names the source variable (with a leading `$`). Every +//! subsequent token is a filter step that transforms the current value. +//! +//! Use [`eval_expr`] for the public entry point. Use [`render_template`] to +//! substitute `{varname}` placeholders in outcome strings. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Result, anyhow}; + +// ── Runtime value ───────────────────────────────────────────────────────────── + +/// A typed runtime value produced or consumed by a filter pipeline. +#[derive(Debug, Clone, PartialEq)] +pub enum RuleValue { + Bool(bool), + Int(i64), + Str(String), + List(Vec), + Null, +} + +impl RuleValue { + /// Return the value as a `&str` if it is a [`RuleValue::Str`]. + pub fn as_str(&self) -> Option<&str> { + if let Self::Str(s) = self { + Some(s) + } else { + None + } + } + + /// Return the value as an `i64`, parsing from a string if necessary. + pub fn as_int(&self) -> Option { + match self { + Self::Int(n) => Some(*n), + Self::Str(s) => s.trim().parse().ok(), + _ => None, + } + } + + /// Return the value as a `bool` if it is a [`RuleValue::Bool`]. + pub fn as_bool(&self) -> Option { + if let Self::Bool(b) = self { + Some(*b) + } else { + None + } + } + + /// Return the value as a slice if it is a [`RuleValue::List`]. + pub fn as_list(&self) -> Option<&[RuleValue]> { + if let Self::List(v) = self { + Some(v) + } else { + None + } + } + + /// Human-readable form used in template substitution and `join`. + pub fn display(&self) -> String { + match self { + Self::Bool(b) => b.to_string(), + Self::Int(n) => n.to_string(), + Self::Str(s) => s.clone(), + Self::List(v) => v.iter().map(Self::display).collect::>().join(", "), + Self::Null => String::new(), + } + } + + /// Whether the value is considered truthy (non-empty, non-zero, non-null). + pub fn is_truthy(&self) -> bool { + match self { + Self::Bool(b) => *b, + Self::Int(n) => *n != 0, + Self::Str(s) => !s.is_empty(), + Self::List(v) => !v.is_empty(), + Self::Null => false, + } + } +} + +/// Map from variable names to their runtime values. +pub type ValueMap = HashMap; + +// ── Filter steps ───────────────────────────────────────────────────────────── + +/// A single transformation step in a pipeline. +#[derive(Debug, Clone)] +pub enum Filter { + Trim, + Lines, + NonEmpty, + Skip(usize), + First, + Nth(usize), + Field(usize), + Number, + PrefixStrip(String), + StartsWith(String), + Contains(String), + RejectContains(String), + Count, + Sort, + Unique, + Join(String), + BytesToMb, + Default(String), +} + +fn parse_filter(token: &str) -> Result { + let token = token.trim(); + if let Some(paren_pos) = token.find('(') { + if !token.ends_with(')') { + return Err(anyhow!( + "malformed filter (missing closing parenthesis): {token}" + )); + } + let name = token[..paren_pos].trim(); + let raw_arg = token[paren_pos + 1..token.len() - 1].trim(); + let arg = raw_arg.trim_matches('\'').trim_matches('"'); + return match name { + "skip" => arg + .parse::() + .map(Filter::Skip) + .map_err(|_| anyhow!("skip: expected an integer argument, got {arg:?}")), + "nth" => arg + .parse::() + .map(Filter::Nth) + .map_err(|_| anyhow!("nth: expected an integer argument, got {arg:?}")), + "field" => arg + .parse::() + .map(Filter::Field) + .map_err(|_| anyhow!("field: expected an integer argument, got {arg:?}")), + "prefix_strip" => Ok(Filter::PrefixStrip(arg.to_string())), + "starts_with" => Ok(Filter::StartsWith(arg.to_string())), + "contains" => Ok(Filter::Contains(arg.to_string())), + "reject_contains" => Ok(Filter::RejectContains(arg.to_string())), + "join" => Ok(Filter::Join(arg.to_string())), + "default" => Ok(Filter::Default(arg.to_string())), + other => Err(anyhow!("unknown filter with arguments: {other}")), + }; + } + match token { + "trim" => Ok(Filter::Trim), + "lines" => Ok(Filter::Lines), + "non_empty" => Ok(Filter::NonEmpty), + "first" => Ok(Filter::First), + "number" => Ok(Filter::Number), + "count" => Ok(Filter::Count), + "sort" => Ok(Filter::Sort), + "unique" => Ok(Filter::Unique), + "bytes_to_mb" => Ok(Filter::BytesToMb), + other => Err(anyhow!("unknown filter: {other}")), + } +} + +// ── Pipeline ───────────────────────────────────────────────────────────────── + +/// A parsed transformation pipeline. +#[derive(Debug)] +pub struct Pipeline { + /// Variable name (without the leading `$`) used as the initial value. + pub source: String, + /// Ordered sequence of filter steps applied left to right. + pub filters: Vec, +} + +/// Split a pipeline string on `|` while respecting single-quoted string +/// arguments such as `join(', ')`. +fn split_pipeline(expr: &str) -> Vec { + let mut parts: Vec = Vec::new(); + let mut current = String::new(); + let mut in_single = false; + for ch in expr.chars() { + match ch { + '\'' => { + in_single = !in_single; + current.push(ch); + } + '|' if !in_single => { + let part = current.trim().to_string(); + if !part.is_empty() { + parts.push(part); + } + current = String::new(); + } + _ => current.push(ch), + } + } + let last = current.trim().to_string(); + if !last.is_empty() { + parts.push(last); + } + parts +} + +/// Parse a pipeline expression string into a [`Pipeline`]. +pub fn parse_pipeline(expr: &str) -> Result { + let parts = split_pipeline(expr); + if parts.is_empty() { + return Err(anyhow!("empty pipeline expression")); + } + let source_token = parts[0].trim(); + if !source_token.starts_with('$') { + return Err(anyhow!( + "pipeline source must start with '$', got: {source_token:?}" + )); + } + let source = source_token.trim_start_matches('$').to_string(); + let filters = parts[1..] + .iter() + .map(|t| parse_filter(t)) + .collect::>>()?; + Ok(Pipeline { source, filters }) +} + +// ── Filter application ──────────────────────────────────────────────────────── + +fn apply_filter(value: RuleValue, filter: &Filter) -> Result { + match filter { + Filter::Trim => match value { + RuleValue::Str(s) => Ok(RuleValue::Str(s.trim().to_string())), + other => Ok(other), + }, + + Filter::Lines => match value { + RuleValue::Str(s) => Ok(RuleValue::List( + s.lines().map(|l| RuleValue::Str(l.to_string())).collect(), + )), + other => Ok(RuleValue::List(vec![other])), + }, + + Filter::NonEmpty => match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|x| match x { + RuleValue::Str(s) => !s.is_empty(), + RuleValue::Null => false, + _ => true, + }) + .collect(), + )), + RuleValue::Str(s) if s.is_empty() => Ok(RuleValue::Null), + other => Ok(other), + }, + + Filter::Skip(n) => match value { + RuleValue::List(v) => Ok(RuleValue::List(v.into_iter().skip(*n).collect())), + other => Err(anyhow!("skip: expected a list, got {:?}", other.display())), + }, + + Filter::First => match value { + RuleValue::List(mut v) => Ok(if v.is_empty() { + RuleValue::Null + } else { + v.remove(0) + }), + other => Ok(other), + }, + + Filter::Nth(n) => match value { + RuleValue::List(v) => Ok(v.into_iter().nth(*n).unwrap_or(RuleValue::Null)), + other => Err(anyhow!("nth: expected a list, got {:?}", other.display())), + }, + + Filter::Field(n) => match value { + RuleValue::Str(s) => Ok(s + .split_whitespace() + .nth(*n) + .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string()))), + other => Err(anyhow!( + "field: expected a string, got {:?}", + other.display() + )), + }, + + Filter::Number => match value { + RuleValue::Int(n) => Ok(RuleValue::Int(n)), + RuleValue::Str(s) => s + .trim() + .parse::() + .map(RuleValue::Int) + .map_err(|_| anyhow!("number: cannot parse {:?} as an integer", s)), + other => Err(anyhow!( + "number: expected a string or int, got {:?}", + other.display() + )), + }, + + Filter::PrefixStrip(prefix) => match value { + RuleValue::Str(s) => Ok(RuleValue::Str( + s.strip_prefix(prefix.as_str()).unwrap_or(&s).to_string(), + )), + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .map(|item| match item { + RuleValue::Str(s) => RuleValue::Str( + s.strip_prefix(prefix.as_str()).unwrap_or(&s).to_string(), + ), + other => other, + }) + .collect(), + )), + other => Err(anyhow!( + "prefix_strip: expected a string or list, got {:?}", + other.display() + )), + }, + + Filter::StartsWith(prefix) => match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => s.starts_with(prefix.as_str()), + _ => false, + }) + .collect(), + )), + RuleValue::Str(s) => Ok(if s.starts_with(prefix.as_str()) { + RuleValue::Str(s) + } else { + RuleValue::Null + }), + other => Err(anyhow!( + "starts_with: expected a list or string, got {:?}", + other.display() + )), + }, + + Filter::Contains(substring) => match &value { + RuleValue::List(v) => Ok(RuleValue::Bool(v.iter().any(|item| match item { + RuleValue::Str(s) => s.contains(substring.as_str()), + _ => false, + }))), + RuleValue::Str(s) => Ok(RuleValue::Bool(s.contains(substring.as_str()))), + _ => Ok(RuleValue::Bool(false)), + }, + + Filter::RejectContains(substring) => match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => !s.contains(substring.as_str()), + _ => true, + }) + .collect(), + )), + other => Err(anyhow!( + "reject_contains: expected a list, got {:?}", + other.display() + )), + }, + + Filter::Count => Ok(RuleValue::Int(match &value { + RuleValue::List(v) => v.len() as i64, + RuleValue::Str(s) if s.is_empty() => 0, + RuleValue::Str(_) => 1, + RuleValue::Null => 0, + _ => 1, + })), + + Filter::Sort => match value { + RuleValue::List(mut v) => { + v.sort_by_key(RuleValue::display); + Ok(RuleValue::List(v)) + } + other => Ok(other), + }, + + Filter::Unique => match value { + RuleValue::List(v) => { + let mut seen = HashSet::new(); + Ok(RuleValue::List( + v.into_iter().filter(|x| seen.insert(x.display())).collect(), + )) + } + other => Ok(other), + }, + + Filter::Join(sep) => match value { + RuleValue::List(v) => Ok(RuleValue::Str( + v.iter() + .map(RuleValue::display) + .collect::>() + .join(sep), + )), + RuleValue::Str(s) => Ok(RuleValue::Str(s)), + other => Err(anyhow!("join: expected a list, got {:?}", other.display())), + }, + + Filter::BytesToMb => match value { + RuleValue::Int(n) => Ok(RuleValue::Int(n / 1_048_576)), + RuleValue::Str(s) => s + .trim() + .parse::() + .map(|n| RuleValue::Int(n / 1_048_576)) + .map_err(|_| anyhow!("bytes_to_mb: cannot parse {:?} as an integer", s)), + other => Err(anyhow!( + "bytes_to_mb: expected int or string, got {:?}", + other.display() + )), + }, + + Filter::Default(default_val) => Ok(match value { + RuleValue::Null => RuleValue::Str(default_val.clone()), + RuleValue::Str(s) if s.is_empty() => RuleValue::Str(default_val.clone()), + other => other, + }), + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Evaluate a parsed pipeline against the given value map. +pub fn eval_pipeline(pipeline: &Pipeline, values: &ValueMap) -> Result { + let mut current = values + .get(&pipeline.source) + .cloned() + .unwrap_or(RuleValue::Null); + for filter in &pipeline.filters { + current = apply_filter(current, filter)?; + } + Ok(current) +} + +/// Evaluate an expression that is either a pipeline, a bare `$varname`, or a +/// literal value (integer, boolean keyword, or string). +pub fn eval_expr(expr: &str, values: &ValueMap) -> Result { + let expr = expr.trim(); + if expr.contains('|') { + eval_pipeline(&parse_pipeline(expr)?, values) + } else if let Some(key) = expr.strip_prefix('$') { + Ok(values.get(key).cloned().unwrap_or(RuleValue::Null)) + } else if let Ok(n) = expr.parse::() { + Ok(RuleValue::Int(n)) + } else if expr == "true" { + Ok(RuleValue::Bool(true)) + } else if expr == "false" { + Ok(RuleValue::Bool(false)) + } else { + Ok(RuleValue::Str(expr.to_string())) + } +} + +/// Substitute `{varname}` placeholders in a template string using the value map. +pub fn render_template(template: &str, values: &ValueMap) -> String { + let mut result = template.to_string(); + for (key, value) in values { + result = result.replace(&format!("{{{key}}}"), &value.display()); + } + result +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn str_val(s: &str) -> RuleValue { + RuleValue::Str(s.to_string()) + } + fn list_val(items: &[&str]) -> RuleValue { + RuleValue::List(items.iter().copied().map(str_val).collect()) + } + fn map_of(pairs: &[(&str, RuleValue)]) -> ValueMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect() + } + + // ── parse_filter ───────────────────────────────────────────────────────── + + #[test] + fn parse_filter_no_args() { + assert!(matches!(parse_filter("trim").unwrap(), Filter::Trim)); + assert!(matches!(parse_filter("lines").unwrap(), Filter::Lines)); + assert!(matches!(parse_filter("count").unwrap(), Filter::Count)); + assert!(matches!(parse_filter("sort").unwrap(), Filter::Sort)); + assert!(matches!(parse_filter("unique").unwrap(), Filter::Unique)); + assert!(matches!(parse_filter("number").unwrap(), Filter::Number)); + assert!(matches!( + parse_filter("bytes_to_mb").unwrap(), + Filter::BytesToMb + )); + } + + #[test] + fn parse_filter_with_int_arg() { + assert!(matches!(parse_filter("nth(2)").unwrap(), Filter::Nth(2))); + assert!(matches!(parse_filter("skip(3)").unwrap(), Filter::Skip(3))); + assert!(matches!( + parse_filter("field(0)").unwrap(), + Filter::Field(0) + )); + } + + #[test] + fn parse_filter_with_string_arg() { + let f = parse_filter("join(', ')").unwrap(); + assert!(matches!(f, Filter::Join(s) if s == ", ")); + let f = parse_filter("prefix_strip('foo ')").unwrap(); + assert!(matches!(f, Filter::PrefixStrip(s) if s == "foo ")); + } + + #[test] + fn parse_filter_unknown_returns_err() { + assert!(parse_filter("nonexistent").is_err()); + } + + // ── parse_pipeline ──────────────────────────────────────────────────────── + + #[test] + fn parse_pipeline_simple() { + let p = parse_pipeline("$stdout | lines | trim").unwrap(); + assert_eq!(p.source, "stdout"); + assert_eq!(p.filters.len(), 2); + } + + #[test] + fn parse_pipeline_no_filters() { + let p = parse_pipeline("$result").unwrap(); + assert_eq!(p.source, "result"); + assert!(p.filters.is_empty()); + } + + #[test] + fn parse_pipeline_quoted_separator_in_arg() { + // The ',' inside join('|') must not be treated as a separator + let p = parse_pipeline("$list | join(' | ')").unwrap(); + assert_eq!(p.source, "list"); + assert_eq!(p.filters.len(), 1); + assert!(matches!(&p.filters[0], Filter::Join(s) if s == " | ")); + } + + #[test] + fn parse_pipeline_missing_dollar_returns_err() { + assert!(parse_pipeline("stdout | lines").is_err()); + } + + // ── apply_filter ───────────────────────────────────────────────────────── + + #[test] + fn filter_trim() { + let v = apply_filter(str_val(" hello "), &Filter::Trim).unwrap(); + assert_eq!(v, str_val("hello")); + } + + #[test] + fn filter_lines_splits_by_newline() { + let v = apply_filter(str_val("a\nb\nc"), &Filter::Lines).unwrap(); + assert_eq!(v, list_val(&["a", "b", "c"])); + } + + #[test] + fn filter_non_empty_removes_blanks() { + let v = apply_filter(list_val(&["a", "", "b", ""]), &Filter::NonEmpty).unwrap(); + assert_eq!(v, list_val(&["a", "b"])); + } + + #[test] + fn filter_nth_returns_correct_item() { + let v = apply_filter(list_val(&["a", "b", "c"]), &Filter::Nth(1)).unwrap(); + assert_eq!(v, str_val("b")); + } + + #[test] + fn filter_nth_out_of_range_returns_null() { + let v = apply_filter(list_val(&["a"]), &Filter::Nth(5)).unwrap(); + assert_eq!(v, RuleValue::Null); + } + + #[test] + fn filter_number_parses_string() { + let v = apply_filter(str_val(" 42 "), &Filter::Number).unwrap(); + assert_eq!(v, RuleValue::Int(42)); + } + + #[test] + fn filter_number_invalid_returns_err() { + assert!(apply_filter(str_val("not-a-number"), &Filter::Number).is_err()); + } + + #[test] + fn filter_starts_with_filters_list() { + let v = apply_filter( + list_val(&["foo bar", "baz", "foo qux"]), + &Filter::StartsWith("foo".to_string()), + ) + .unwrap(); + assert_eq!(v, list_val(&["foo bar", "foo qux"])); + } + + #[test] + fn filter_prefix_strip_on_list() { + let v = apply_filter( + list_val(&["rc pkg-a", "rc pkg-b"]), + &Filter::PrefixStrip("rc ".to_string()), + ) + .unwrap(); + assert_eq!(v, list_val(&["pkg-a", "pkg-b"])); + } + + #[test] + fn filter_reject_contains() { + let v = apply_filter( + list_val(&["linux-image-5.15", "linux-image-6.1", "linux-image-meta"]), + &Filter::RejectContains("meta".to_string()), + ) + .unwrap(); + assert_eq!(v, list_val(&["linux-image-5.15", "linux-image-6.1"])); + } + + #[test] + fn filter_count_on_list() { + let v = apply_filter(list_val(&["a", "b", "c"]), &Filter::Count).unwrap(); + assert_eq!(v, RuleValue::Int(3)); + } + + #[test] + fn filter_sort() { + let v = apply_filter(list_val(&["banana", "apple", "cherry"]), &Filter::Sort).unwrap(); + assert_eq!(v, list_val(&["apple", "banana", "cherry"])); + } + + #[test] + fn filter_unique_removes_duplicates() { + let v = apply_filter(list_val(&["a", "b", "a", "c", "b"]), &Filter::Unique).unwrap(); + assert_eq!(v, list_val(&["a", "b", "c"])); + } + + #[test] + fn filter_join_produces_string() { + let v = apply_filter(list_val(&["x", "y", "z"]), &Filter::Join(", ".to_string())).unwrap(); + assert_eq!(v, str_val("x, y, z")); + } + + #[test] + fn filter_bytes_to_mb() { + let v = apply_filter(RuleValue::Int(2 * 1_048_576), &Filter::BytesToMb).unwrap(); + assert_eq!(v, RuleValue::Int(2)); + } + + #[test] + fn filter_default_on_null() { + let v = apply_filter(RuleValue::Null, &Filter::Default("fallback".to_string())).unwrap(); + assert_eq!(v, str_val("fallback")); + } + + #[test] + fn filter_default_on_non_null_passes_through() { + let v = apply_filter(str_val("actual"), &Filter::Default("fallback".to_string())).unwrap(); + assert_eq!(v, str_val("actual")); + } + + // ── eval_expr ───────────────────────────────────────────────────────────── + + #[test] + fn eval_expr_bare_variable() { + let values = map_of(&[("foo", RuleValue::Int(7))]); + let v = eval_expr("$foo", &values).unwrap(); + assert_eq!(v, RuleValue::Int(7)); + } + + #[test] + fn eval_expr_missing_variable_returns_null() { + let v = eval_expr("$missing", &ValueMap::new()).unwrap(); + assert_eq!(v, RuleValue::Null); + } + + #[test] + fn eval_expr_integer_literal() { + let v = eval_expr("42", &ValueMap::new()).unwrap(); + assert_eq!(v, RuleValue::Int(42)); + } + + #[test] + fn eval_expr_boolean_literal() { + assert_eq!( + eval_expr("true", &ValueMap::new()).unwrap(), + RuleValue::Bool(true) + ); + assert_eq!( + eval_expr("false", &ValueMap::new()).unwrap(), + RuleValue::Bool(false) + ); + } + + #[test] + fn eval_expr_pipeline() { + let values = map_of(&[("out", str_val(" 42 "))]); + let v = eval_expr("$out | trim | number", &values).unwrap(); + assert_eq!(v, RuleValue::Int(42)); + } + + #[test] + fn eval_expr_pipeline_nth_and_trim() { + let values = map_of(&[("out", str_val("header\n 99 \n"))]); + let v = eval_expr("$out | lines | nth(1) | trim | number", &values).unwrap(); + assert_eq!(v, RuleValue::Int(99)); + } + + // ── render_template ─────────────────────────────────────────────────────── + + #[test] + fn render_template_substitutes_placeholders() { + let values = map_of(&[ + ("name", str_val("linux-image-5.15")), + ("count", RuleValue::Int(3)), + ]); + let result = render_template("{count} package(s): {name}", &values); + assert_eq!(result, "3 package(s): linux-image-5.15"); + } + + #[test] + fn render_template_unknown_placeholder_kept() { + let result = render_template("{unknown}", &ValueMap::new()); + assert_eq!(result, "{unknown}"); + } + + // ── RuleValue methods ───────────────────────────────────────────────────── + + #[test] + fn rule_value_display_all_variants() { + assert_eq!(RuleValue::Bool(true).display(), "true"); + assert_eq!(RuleValue::Bool(false).display(), "false"); + assert_eq!(RuleValue::Int(42).display(), "42"); + assert_eq!(RuleValue::Null.display(), ""); + assert_eq!( + RuleValue::List(vec![str_val("a"), RuleValue::Int(1)]).display(), + "a, 1" + ); + } + + #[test] + fn rule_value_is_truthy_all_variants() { + assert!(RuleValue::Bool(true).is_truthy()); + assert!(!RuleValue::Bool(false).is_truthy()); + assert!(RuleValue::Int(1).is_truthy()); + assert!(!RuleValue::Int(0).is_truthy()); + assert!(RuleValue::Str("x".into()).is_truthy()); + assert!(!RuleValue::Str(String::new()).is_truthy()); + assert!(RuleValue::List(vec![RuleValue::Null]).is_truthy()); + assert!(!RuleValue::List(vec![]).is_truthy()); + assert!(!RuleValue::Null.is_truthy()); + } + + #[test] + fn rule_value_as_accessors() { + // as_str + assert_eq!(RuleValue::Str("x".into()).as_str(), Some("x")); + assert_eq!(RuleValue::Int(3).as_str(), None); + // as_bool + assert_eq!(RuleValue::Bool(true).as_bool(), Some(true)); + assert_eq!(RuleValue::Str("y".into()).as_bool(), None); + // as_list + let items = vec![RuleValue::Null]; + assert_eq!( + RuleValue::List(items.clone()).as_list(), + Some(items.as_slice()) + ); + assert_eq!(RuleValue::Null.as_list(), None); + // as_int + assert_eq!(RuleValue::Int(5).as_int(), Some(5)); + assert_eq!(RuleValue::Str("7".into()).as_int(), Some(7)); + assert_eq!(RuleValue::Null.as_int(), None); + assert_eq!(RuleValue::Bool(true).as_int(), None); + } + + // ── Filter error paths ──────────────────────────────────────────────────── + + #[test] + fn filter_skip_non_list_returns_err() { + assert!(apply_filter(RuleValue::Int(1), &Filter::Skip(1)).is_err()); + } + + #[test] + fn filter_nth_non_list_returns_err() { + assert!(apply_filter(RuleValue::Int(1), &Filter::Nth(0)).is_err()); + } + + #[test] + fn filter_field_non_string_returns_err() { + assert!(apply_filter(RuleValue::Int(1), &Filter::Field(0)).is_err()); + } + + #[test] + fn filter_number_null_returns_err() { + assert!(apply_filter(RuleValue::Null, &Filter::Number).is_err()); + } + + #[test] + fn filter_prefix_strip_on_other_returns_err() { + assert!(apply_filter(RuleValue::Null, &Filter::PrefixStrip("x".into())).is_err()); + } + + #[test] + fn filter_starts_with_on_other_returns_err() { + assert!(apply_filter(RuleValue::Int(1), &Filter::StartsWith("x".into())).is_err()); + } + + #[test] + fn filter_reject_contains_non_list_returns_err() { + assert!(apply_filter(RuleValue::Int(1), &Filter::RejectContains("x".into())).is_err()); + } + + #[test] + fn filter_join_non_list_non_str_returns_err() { + assert!(apply_filter(RuleValue::Int(1), &Filter::Join(",".into())).is_err()); + } + + #[test] + fn filter_bytes_to_mb_on_null_returns_err() { + assert!(apply_filter(RuleValue::Null, &Filter::BytesToMb).is_err()); + } + + #[test] + fn filter_bytes_to_mb_invalid_str_returns_err() { + assert!(apply_filter(str_val("not-a-number"), &Filter::BytesToMb).is_err()); + } + + // ── Filter positive paths not yet exercised ─────────────────────────────── + + #[test] + fn filter_trim_on_non_str_passes_through() { + let v = apply_filter(RuleValue::Int(5), &Filter::Trim).unwrap(); + assert_eq!(v, RuleValue::Int(5)); + } + + #[test] + fn filter_lines_on_non_str_wraps_in_list() { + let v = apply_filter(RuleValue::Int(1), &Filter::Lines).unwrap(); + assert_eq!(v, RuleValue::List(vec![RuleValue::Int(1)])); + } + + #[test] + fn filter_non_empty_non_empty_str_passes_through() { + let v = apply_filter(str_val("hello"), &Filter::NonEmpty).unwrap(); + assert_eq!(v, str_val("hello")); + } + + #[test] + fn filter_non_empty_other_variant_passes_through() { + let v = apply_filter(RuleValue::Int(1), &Filter::NonEmpty).unwrap(); + assert_eq!(v, RuleValue::Int(1)); + } + + #[test] + fn filter_skip_on_list() { + let v = apply_filter(list_val(&["a", "b", "c"]), &Filter::Skip(1)).unwrap(); + assert_eq!(v, list_val(&["b", "c"])); + } + + #[test] + fn filter_first_on_non_empty_list() { + let v = apply_filter(list_val(&["x", "y"]), &Filter::First).unwrap(); + assert_eq!(v, str_val("x")); + } + + #[test] + fn filter_first_on_empty_list_returns_null() { + let v = apply_filter(RuleValue::List(vec![]), &Filter::First).unwrap(); + assert_eq!(v, RuleValue::Null); + } + + #[test] + fn filter_first_on_non_list_passes_through() { + let v = apply_filter(str_val("abc"), &Filter::First).unwrap(); + assert_eq!(v, str_val("abc")); + } + + #[test] + fn filter_field_on_string() { + let v = apply_filter(str_val("hello world foo"), &Filter::Field(1)).unwrap(); + assert_eq!(v, str_val("world")); + } + + #[test] + fn filter_field_out_of_bounds_returns_null() { + let v = apply_filter(str_val("one two"), &Filter::Field(5)).unwrap(); + assert_eq!(v, RuleValue::Null); + } + + #[test] + fn filter_number_on_int_passes_through() { + let v = apply_filter(RuleValue::Int(7), &Filter::Number).unwrap(); + assert_eq!(v, RuleValue::Int(7)); + } + + #[test] + fn filter_prefix_strip_on_single_str() { + let v = apply_filter(str_val("rc pkg"), &Filter::PrefixStrip("rc ".into())).unwrap(); + assert_eq!(v, str_val("pkg")); + } + + #[test] + fn filter_prefix_strip_no_prefix_match_unchanged() { + let v = apply_filter(str_val("other"), &Filter::PrefixStrip("rc ".into())).unwrap(); + assert_eq!(v, str_val("other")); + } + + #[test] + fn filter_prefix_strip_in_list_no_match_unchanged() { + let v = apply_filter( + list_val(&["foo bar", "baz"]), + &Filter::PrefixStrip("qux ".into()), + ) + .unwrap(); + assert_eq!(v, list_val(&["foo bar", "baz"])); + } + + #[test] + fn filter_starts_with_on_matching_str() { + let v = apply_filter(str_val("foo bar"), &Filter::StartsWith("foo".into())).unwrap(); + assert_eq!(v, str_val("foo bar")); + } + + #[test] + fn filter_starts_with_on_non_matching_str_returns_null() { + let v = apply_filter(str_val("bar"), &Filter::StartsWith("foo".into())).unwrap(); + assert_eq!(v, RuleValue::Null); + } + + #[test] + fn filter_contains_in_list_true() { + let v = apply_filter(list_val(&["foo", "bar"]), &Filter::Contains("foo".into())).unwrap(); + assert_eq!(v, RuleValue::Bool(true)); + } + + #[test] + fn filter_contains_in_list_false() { + let v = apply_filter(list_val(&["foo", "bar"]), &Filter::Contains("baz".into())).unwrap(); + assert_eq!(v, RuleValue::Bool(false)); + } + + #[test] + fn filter_contains_in_str() { + let v = apply_filter(str_val("hello world"), &Filter::Contains("world".into())).unwrap(); + assert_eq!(v, RuleValue::Bool(true)); + } + + #[test] + fn filter_contains_on_other_returns_false() { + let v = apply_filter(RuleValue::Null, &Filter::Contains("x".into())).unwrap(); + assert_eq!(v, RuleValue::Bool(false)); + } + + #[test] + fn filter_count_on_empty_str() { + let v = apply_filter(str_val(""), &Filter::Count).unwrap(); + assert_eq!(v, RuleValue::Int(0)); + } + + #[test] + fn filter_count_on_non_empty_str() { + let v = apply_filter(str_val("x"), &Filter::Count).unwrap(); + assert_eq!(v, RuleValue::Int(1)); + } + + #[test] + fn filter_count_on_null() { + let v = apply_filter(RuleValue::Null, &Filter::Count).unwrap(); + assert_eq!(v, RuleValue::Int(0)); + } + + #[test] + fn filter_count_on_int() { + let v = apply_filter(RuleValue::Int(99), &Filter::Count).unwrap(); + assert_eq!(v, RuleValue::Int(1)); + } + + #[test] + fn filter_sort_on_non_list_passes_through() { + let v = apply_filter(str_val("z"), &Filter::Sort).unwrap(); + assert_eq!(v, str_val("z")); + } + + #[test] + fn filter_unique_on_non_list_passes_through() { + let v = apply_filter(RuleValue::Int(5), &Filter::Unique).unwrap(); + assert_eq!(v, RuleValue::Int(5)); + } + + #[test] + fn filter_join_str_passes_through() { + let v = apply_filter(str_val("abc"), &Filter::Join(",".into())).unwrap(); + assert_eq!(v, str_val("abc")); + } + + #[test] + fn filter_bytes_to_mb_from_str() { + let v = apply_filter(str_val("2097152"), &Filter::BytesToMb).unwrap(); + assert_eq!(v, RuleValue::Int(2)); + } + + #[test] + fn filter_default_on_empty_str() { + let v = apply_filter(str_val(""), &Filter::Default("fallback".into())).unwrap(); + assert_eq!(v, str_val("fallback")); + } + + // ── Additional parse tests ──────────────────────────────────────────────── + + #[test] + fn parse_filter_unclosed_paren_returns_err() { + assert!(parse_filter("nth(5").is_err()); + } + + #[test] + fn parse_pipeline_empty_string_returns_err() { + assert!(parse_pipeline("").is_err()); + } + + #[test] + fn eval_expr_string_literal() { + let v = eval_expr("hello world", &ValueMap::new()).unwrap(); + assert_eq!(v, RuleValue::Str("hello world".into())); + } +} diff --git a/crates/hah-dsl/src/rule.rs b/crates/hah-dsl/src/rule.rs new file mode 100644 index 0000000..6aae62a --- /dev/null +++ b/crates/hah-dsl/src/rule.rs @@ -0,0 +1,1593 @@ +//! Declarative YAML rule data model and runtime evaluator. +//! +//! A [`RuleSet`] is the top-level YAML document. It contains optional +//! reusable [`Blocks`] and a list of [`Rule`]s. Each rule is wrapped in a +//! [`RuleBasedCheck`] that implements the [`Check`] trait so it integrates +//! seamlessly with the existing registry and runner. + +use std::{collections::HashMap, path::Path, sync::Arc}; + +use anyhow::{Result, anyhow}; +use serde::{Deserialize, Serialize}; + +use hah_core::{ + check::{Check, Context}, + model::{CheckResult, Finding, Remediation, Severity}, +}; + +use crate::{ + capabilities, + pipeline::{RuleValue, ValueMap, eval_expr, render_template}, +}; + +// ── Reusable building blocks ────────────────────────────────────────────────── + +/// Named reusable building blocks defined at the top of a rule file. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct Blocks { + /// Named guard fragments (reusable `only_if` sections). + #[serde(default)] + pub guards: HashMap, + /// Named transformation pipeline expressions. + #[serde(default)] + pub transforms: HashMap, + /// Named partial outcome fragments (typically reusable remediations). + #[serde(default)] + pub outcomes: HashMap, +} + +// ── Top-level document ──────────────────────────────────────────────────────── + +/// Top-level YAML rule file document. +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct RuleSet { + #[serde(default)] + pub blocks: Blocks, + #[serde(default)] + pub rules: Vec, +} + +impl RuleSet { + /// Deserialize all rule files (`*.yaml`) found in `dir`, sorted by name. + pub fn load_from_dir(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + + let mut rules = Vec::new(); + for entry in entries { + let content = std::fs::read_to_string(entry.path())?; + let rule_set: RuleSet = hah_utils::yaml::parse(&content) + .map_err(|e| anyhow!("failed to parse {}: {e}", entry.path().display()))?; + rules.extend(rule_set.rules); + } + Ok(rules) + } + + /// Deserialize all rule files in `dir` and return `RuleBasedCheck` + /// instances that carry the blocks from their source file. + pub fn load_checks_from_dir(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("yaml")) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + + let mut checks = Vec::new(); + for entry in entries { + let content = std::fs::read_to_string(entry.path())?; + let rule_set: RuleSet = hah_utils::yaml::parse(&content) + .map_err(|e| anyhow!("failed to parse {}: {e}", entry.path().display()))?; + let blocks = Arc::new(rule_set.blocks); + for rule in rule_set.rules { + checks.push(RuleBasedCheck { + rule, + blocks: Arc::clone(&blocks), + }); + } + } + Ok(checks) + } +} + +// ── Rule ────────────────────────────────────────────────────────────────────── + +/// A single declarative rule. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Rule { + /// Stable unique ID used as the check ID. + pub id: String, + /// Human-readable title shown in `hah list-checks` and findings. + pub title: String, + /// Optional inline guard (can also be referenced via `use.guard`). + #[serde(default)] + pub only_if: RuleGuard, + /// References to named reusable blocks defined in the same file. + #[serde(default, rename = "use")] + pub uses: UseRef, + /// Named trigger definitions that collect values from the system. + #[serde(default)] + pub triggers: Vec, + /// Named derived values computed as pipeline expressions over trigger outputs. + #[serde(default)] + pub values: HashMap, + /// Conditions that, when true, produce a finding. + #[serde(default)] + pub conditions: Vec, + /// Template for the finding produced when a condition fires. + pub outcome: RuleOutcome, +} + +// ── Guards ──────────────────────────────────────────────────────────────────── + +/// Guard that determines whether a rule should be evaluated for this system. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct RuleGuard { + /// Required distro family, e.g. `"debian"`. + #[serde(default)] + pub distro_family: Option, + /// If non-empty, the system profile must be one of these values. + #[serde(default)] + pub profile: Vec, + /// Commands that must exist on `$PATH` for this rule to run. + #[serde(default)] + pub require_commands: Vec, +} + +/// References to named blocks defined in the `blocks` section. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct UseRef { + /// Named guard to use instead of (or merged with) `only_if`. + #[serde(default)] + pub guard: Option, + /// Named outcome fragment to use as a default remediation. + #[serde(default)] + pub outcome: Option, +} + +// ── Triggers ────────────────────────────────────────────────────────────────── + +/// A trigger that collects a named value from the system. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuleTrigger { + /// Name under which the result is stored in the value map. + pub name: String, + /// Shell command to run; the raw stdout is the initial value. + pub command: Option, + /// Built-in probe (package/service state). + pub probe: Option, + /// Rust-backed capability (complex system analysis). + pub capability: Option, + /// Optional pipeline expression that transforms the raw trigger output. + /// Use `$stdout` as the source variable. + #[serde(default)] + pub transform: Option, +} + +/// Specification for a shell command trigger. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CommandSpec { + pub program: String, + #[serde(default)] + pub args: Vec, +} + +/// Built-in system probe. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProbeSpec { + PackageInstalled { name: String }, + ServiceActive { name: String }, +} + +/// Rust-backed capability trigger (complex analysis delegated to Rust). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CapabilitySpec { + SysctlConflicts { + #[serde(default)] + paths: Vec, + }, + BrokenSymlinks { + #[serde(default)] + paths: Vec, + }, + OldFiles { + #[serde(default)] + paths: Vec, + older_than_days: u64, + }, + KernelInventory, + StaleKernelHeaders, + JournalUsage, +} + +// ── Conditions ──────────────────────────────────────────────────────────────── + +/// A typed condition predicate. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RuleCondition { + NumericThreshold { + /// Pipeline expression resolving to a numeric value. + value: String, + operator: CompareOp, + /// Pipeline expression or literal resolving to the threshold. + threshold: String, + severity: Severity, + }, + Equals { + /// Pipeline expression resolving to any value. + value: String, + expected: ExpectedValue, + severity: Severity, + }, + NonEmpty { + /// Pipeline expression resolving to a list or string. + value: String, + severity: Severity, + }, + RegexMatch { + value: String, + pattern: String, + severity: Severity, + }, + All { + conditions: Vec, + severity: Severity, + }, + Any { + conditions: Vec, + severity: Severity, + }, +} + +/// Comparison operator for [`RuleCondition::NumericThreshold`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompareOp { + Lt, + Lte, + Gt, + Gte, + Eq, + Neq, +} + +/// A YAML-typed expected value used by [`RuleCondition::Equals`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ExpectedValue { + Bool(bool), + Int(i64), + Str(String), +} + +// ── Outcome ─────────────────────────────────────────────────────────────────── + +/// Template for the finding produced when a condition fires. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuleOutcome { + pub finding_id: String, + pub title: String, + pub description: String, + #[serde(default)] + pub remediation: Option, +} + +/// Reusable partial outcome fragment (provides a default remediation). +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct OutcomeFragment { + #[serde(default)] + pub remediation: Option, +} + +/// Template for a remediation attached to a finding. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemediationTemplate { + pub description: String, + pub commands: Vec, + pub safe: bool, +} + +// ── RuleBasedCheck ──────────────────────────────────────────────────────────── + +/// A [`Check`] implementation that evaluates a single declarative [`Rule`]. +pub struct RuleBasedCheck { + rule: Rule, + /// Shared blocks from the same rule file. + blocks: Arc, +} + +impl RuleBasedCheck { + pub fn new(rule: Rule, blocks: Arc) -> Self { + Self { rule, blocks } + } +} + +impl Check for RuleBasedCheck { + fn id(&self) -> &str { + &self.rule.id + } + + fn title(&self) -> &str { + &self.rule.title + } + + fn run(&self, ctx: &Context) -> CheckResult { + // ── 1. Guard ────────────────────────────────────────────────────────── + if !self.guard_passes(ctx) { + return CheckResult::default(); + } + + // ── 2. Seed value map with context ──────────────────────────────────── + let mut values: ValueMap = HashMap::new(); + for (key, &val) in &ctx.config.thresholds { + values.insert(format!("config.{key}"), RuleValue::Int(val as i64)); + } + values.insert( + "distro.family".into(), + RuleValue::Str(if ctx.distro.is_debian_family() { + "debian".into() + } else { + "unknown".into() + }), + ); + + // ── 3. Run triggers ─────────────────────────────────────────────────── + for trigger in &self.rule.triggers { + match self.run_trigger(trigger, ctx, &values) { + Ok(v) => { + values.insert(trigger.name.clone(), v); + } + Err(e) => { + return CheckResult::default() + .with_error(format!("trigger '{}': {e}", trigger.name)); + } + } + } + + // ── 4. Evaluate derived values ──────────────────────────────────────── + for (name, expr) in &self.rule.values { + match eval_expr(expr, &values) { + Ok(v) => { + values.insert(name.clone(), v); + } + Err(e) => { + return CheckResult::default().with_error(format!("value '{name}': {e}")); + } + } + } + + // ── 5. Evaluate conditions ──────────────────────────────────────────── + let mut result = CheckResult::default(); + for condition in &self.rule.conditions { + match self.eval_condition(condition, &values) { + Ok(true) => { + let severity = condition_severity(condition).clone(); + result = result.with_finding(self.make_finding(severity, &values)); + } + Ok(false) => {} + Err(e) => { + result = result.with_error(format!("condition: {e}")); + } + } + } + result + } +} + +// ── Guard evaluation ────────────────────────────────────────────────────────── + +impl RuleBasedCheck { + fn resolved_guard(&self) -> RuleGuard { + self.rule.uses.guard.as_ref().map_or_else( + || self.rule.only_if.clone(), + |name| self.blocks.guards.get(name).cloned().unwrap_or_default(), + ) + } + + fn guard_passes(&self, ctx: &Context) -> bool { + let guard = self.resolved_guard(); + if guard + .distro_family + .as_deref() + .is_some_and(|f| f.eq_ignore_ascii_case("debian")) + && !ctx.distro.is_debian_family() + { + return false; + } + if !guard.profile.is_empty() && !guard.profile.contains(&ctx.config.profile) { + return false; + } + for cmd in &guard.require_commands { + if which_command(cmd).is_err() { + return false; + } + } + true + } +} + +fn which_command(name: &str) -> Result<()> { + std::process::Command::new("which") + .arg(name) + .output() + .map_err(|e| anyhow!("{e}")) + .and_then(|o| { + if o.status.success() { + Ok(()) + } else { + Err(anyhow!("command not found: {name}")) + } + }) +} + +// ── Trigger evaluation ──────────────────────────────────────────────────────── + +impl RuleBasedCheck { + fn run_trigger( + &self, + trigger: &RuleTrigger, + ctx: &Context, + values: &ValueMap, + ) -> Result { + let raw = if let Some(spec) = &trigger.command { + let args: Vec<&str> = spec.args.iter().map(String::as_str).collect(); + let out = ctx + .runner + .run(&spec.program, &args) + .map_err(|e| anyhow!("command '{}': {e}", spec.program))?; + RuleValue::Str(String::from_utf8_lossy(&out.stdout).into_owned()) + } else if let Some(spec) = &trigger.probe { + run_probe(spec, ctx) + } else if let Some(spec) = &trigger.capability { + return dispatch_capability(spec, ctx); + } else { + return Err(anyhow!( + "trigger '{}' has no command, probe, or capability", + trigger.name + )); + }; + + // Apply transform if present, using $stdout as the source variable. + match &trigger.transform { + Some(expr) => { + let mut local = values.clone(); + local.insert("stdout".to_string(), raw); + eval_expr(expr, &local) + } + None => Ok(raw), + } + } +} + +// ── Capability dispatch ─────────────────────────────────────────────────────── + +fn dispatch_capability(spec: &CapabilitySpec, ctx: &Context) -> Result { + match spec { + CapabilitySpec::JournalUsage => capabilities::journal_usage_mb(ctx.runner.as_ref()), + CapabilitySpec::OldFiles { + paths, + older_than_days, + } => capabilities::old_files(paths, *older_than_days), + CapabilitySpec::BrokenSymlinks { paths } => capabilities::broken_symlinks(paths), + CapabilitySpec::SysctlConflicts { paths } => capabilities::sysctl_conflicts(paths), + CapabilitySpec::KernelInventory => capabilities::kernel_inventory(ctx.runner.as_ref()), + CapabilitySpec::StaleKernelHeaders => { + capabilities::stale_kernel_headers(ctx.runner.as_ref()) + } + } +} + +fn run_probe(spec: &ProbeSpec, ctx: &Context) -> RuleValue { + match spec { + ProbeSpec::PackageInstalled { name } => RuleValue::Bool( + ctx.runner + .run("dpkg-query", &["-W", "-f=${Status}", name.as_str()]) + .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("install ok installed")), + ), + ProbeSpec::ServiceActive { name } => RuleValue::Bool( + ctx.runner + .run("systemctl", &["is-active", "--quiet", name.as_str()]) + .is_ok_and(|o| o.success), + ), + } +} + +// ── Condition evaluation ────────────────────────────────────────────────────── + +fn condition_severity(condition: &RuleCondition) -> &Severity { + match condition { + RuleCondition::NumericThreshold { severity, .. } + | RuleCondition::Equals { severity, .. } + | RuleCondition::NonEmpty { severity, .. } + | RuleCondition::RegexMatch { severity, .. } + | RuleCondition::All { severity, .. } + | RuleCondition::Any { severity, .. } => severity, + } +} + +fn numeric_compare(lhs: i64, op: &CompareOp, rhs: i64) -> bool { + match op { + CompareOp::Lt => lhs < rhs, + CompareOp::Lte => lhs <= rhs, + CompareOp::Gt => lhs > rhs, + CompareOp::Gte => lhs >= rhs, + CompareOp::Eq => lhs == rhs, + CompareOp::Neq => lhs != rhs, + } +} + +impl RuleBasedCheck { + fn eval_condition(&self, condition: &RuleCondition, values: &ValueMap) -> Result { + match condition { + RuleCondition::NumericThreshold { + value, + operator, + threshold, + .. + } => { + let lhs = eval_expr(value, values)?; + let rhs = eval_expr(threshold, values)?; + match (lhs.as_int(), rhs.as_int()) { + (Some(l), Some(r)) => Ok(numeric_compare(l, operator, r)), + _ => Err(anyhow!( + "numeric_threshold: both sides must be numeric (got {:?} and {:?})", + lhs.display(), + rhs.display() + )), + } + } + + RuleCondition::Equals { + value, expected, .. + } => { + let actual = eval_expr(value, values)?; + let matches = match expected { + ExpectedValue::Bool(b) => actual.as_bool() == Some(*b), + ExpectedValue::Int(n) => actual.as_int() == Some(*n), + ExpectedValue::Str(s) => actual.as_str() == Some(s.as_str()), + }; + Ok(matches) + } + + RuleCondition::NonEmpty { value, .. } => { + let v = eval_expr(value, values)?; + Ok(v.is_truthy()) + } + + RuleCondition::RegexMatch { value, pattern, .. } => { + let re = regex::Regex::new(pattern) + .map_err(|e| anyhow!("invalid regex pattern {pattern:?}: {e}"))?; + let v = eval_expr(value, values)?; + let s = v.as_str().unwrap_or(""); + Ok(re.is_match(s)) + } + + RuleCondition::All { conditions, .. } => conditions + .iter() + .try_fold(true, |acc, c| Ok(acc && self.eval_condition(c, values)?)), + + RuleCondition::Any { conditions, .. } => { + for c in conditions { + if self.eval_condition(c, values)? { + return Ok(true); + } + } + Ok(false) + } + } + } + + // ── Finding generation ──────────────────────────────────────────────────── + + fn resolved_remediation(&self) -> Option<&RemediationTemplate> { + self.rule.outcome.remediation.as_ref().or_else(|| { + self.rule + .uses + .outcome + .as_ref() + .and_then(|name| self.blocks.outcomes.get(name)) + .and_then(|frag| frag.remediation.as_ref()) + }) + } + + fn make_finding(&self, severity: Severity, values: &ValueMap) -> Finding { + let out = &self.rule.outcome; + let remediation = self.resolved_remediation().map(|rem| Remediation { + description: render_template(&rem.description, values), + commands: rem + .commands + .iter() + .map(|c| render_template(c, values)) + .collect(), + safe: rem.safe, + }); + Finding { + id: render_template(&out.finding_id, values), + title: render_template(&out.title, values), + description: render_template(&out.description, values), + severity, + remediation, + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::io; + + use super::*; + use hah_core::{ + config::Config, + distro::DistroInfo, + runner::{CommandOutput, MockCommandRunner}, + }; + + fn make_check(yaml: &str) -> RuleBasedCheck { + let rs: RuleSet = hah_utils::yaml::parse(yaml).expect("yaml parse failed"); + let blocks = Arc::new(rs.blocks); + let rule = rs.rules.into_iter().next().expect("no rules in yaml"); + RuleBasedCheck { rule, blocks } + } + + fn ok_output(stdout: &str) -> io::Result { + Ok(CommandOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: vec![], + success: true, + }) + } + + #[test] + fn rule_set_deserializes_minimal_rule() { + let yaml = r#" +rules: + - id: test-rule + title: Test rule + triggers: [] + conditions: + - type: non_empty + value: "$nothing" + severity: Info + outcome: + finding_id: test + title: "Test finding" + description: "Description." +"#; + let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); + assert_eq!(rs.rules.len(), 1); + assert_eq!(rs.rules[0].id, "test-rule"); + } + + #[test] + fn rule_set_deserializes_blocks() { + let yaml = r#" +blocks: + guards: + debian_family: + distro_family: debian + outcomes: + apt_remove: + remediation: + description: "Remove with apt." + commands: ["sudo apt remove foo"] + safe: false +rules: [] +"#; + let rs: RuleSet = hah_utils::yaml::parse(yaml).unwrap(); + assert!(rs.blocks.guards.contains_key("debian_family")); + assert!(rs.blocks.outcomes.contains_key("apt_remove")); + } + + #[test] + fn non_empty_condition_false_when_list_empty() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: non_empty + value: "$items" + severity: Warning + outcome: + finding_id: x + title: "found" + description: "" +"#, + ); + let values: ValueMap = HashMap::new(); + let result = check.eval_condition(&check.rule.conditions[0], &values); + assert!(!result.unwrap()); + } + + #[test] + fn non_empty_condition_true_when_list_has_items() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: non_empty + value: "$items" + severity: Warning + outcome: + finding_id: x + title: "found" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert( + "items".into(), + RuleValue::List(vec![RuleValue::Str("pkg".into())]), + ); + assert!( + check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + ); + } + + #[test] + fn numeric_threshold_lt_triggers_when_below() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: numeric_threshold + value: "$free" + operator: lt + threshold: "100" + severity: Critical + outcome: + finding_id: x + title: "low" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("free".into(), RuleValue::Int(50)); + assert!( + check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + ); + } + + #[test] + fn numeric_threshold_lt_does_not_trigger_when_above() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: numeric_threshold + value: "$free" + operator: lt + threshold: "100" + severity: Critical + outcome: + finding_id: x + title: "low" + description: "" +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("free".into(), RuleValue::Int(200)); + assert!( + !check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + ); + } + + #[test] + fn command_trigger_stores_stdout_in_value_map() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: result + command: + program: echo + args: ["hello"] + conditions: [] + outcome: + finding_id: x + title: "" + description: "" +"#, + ); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| ok_output("hello\n")); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.errors.is_empty(), "unexpected errors: {:?}", cr.errors); + } + + #[test] + fn command_trigger_with_transform() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: free_mb + command: + program: df + args: [] + transform: "$stdout | lines | nth(1) | trim | number | bytes_to_mb" + conditions: + - type: numeric_threshold + value: "$free_mb" + operator: lt + threshold: "50" + severity: Critical + outcome: + finding_id: x + title: "{free_mb} MB" + description: "" +"#, + ); + // Simulate `df` output: header + avail bytes (10 MB) + let avail_bytes = 10 * 1_048_576i64; + let df_output = format!("Avail\n{avail_bytes}\n"); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(move |_, _| ok_output(&df_output)); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.errors.is_empty(), "unexpected errors: {:?}", cr.errors); + assert_eq!(cr.findings.len(), 1); + assert_eq!(cr.findings[0].title, "10 MB"); + assert_eq!(cr.findings[0].severity, Severity::Critical); + } + + #[test] + fn guard_debian_family_skips_on_non_debian() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + distro_family: debian + conditions: + - type: non_empty + value: "$nothing" + severity: Warning + outcome: + finding_id: x + title: "" + description: "" +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + // Default DistroInfo is not Debian family. + let cr = check.run(&ctx); + assert!(cr.findings.is_empty()); + assert!(cr.errors.is_empty()); + } + + #[test] + fn use_outcome_provides_default_remediation() { + let check = make_check( + r#" +blocks: + outcomes: + shared_rem: + remediation: + description: "Shared fix." + commands: ["sudo fix"] + safe: false +rules: + - id: x + title: X + use: + outcome: shared_rem + conditions: [] + outcome: + finding_id: x + title: "found" + description: "" +"#, + ); + let values: ValueMap = HashMap::new(); + let finding = check.make_finding(Severity::Warning, &values); + assert!(finding.remediation.is_some()); + assert_eq!(finding.remediation.unwrap().commands, vec!["sudo fix"]); + } + + #[test] + fn template_substitution_in_finding() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: [] + outcome: + finding_id: "x-{count}" + title: "{count} items" + description: "Found {count} items." +"#, + ); + let mut values: ValueMap = HashMap::new(); + values.insert("count".into(), RuleValue::Int(3)); + let finding = check.make_finding(Severity::Info, &values); + assert_eq!(finding.id, "x-3"); + assert_eq!(finding.title, "3 items"); + assert_eq!(finding.description, "Found 3 items."); + } + + // ── load helpers ────────────────────────────────────────────────────────── + + #[test] + fn load_from_dir_nonexistent_returns_empty() { + let rules = + RuleSet::load_from_dir(std::path::Path::new("/nonexistent_hah_test_12345")).unwrap(); + assert!(rules.is_empty()); + } + + #[test] + fn load_checks_from_dir_nonexistent_returns_empty() { + let checks = + RuleSet::load_checks_from_dir(std::path::Path::new("/nonexistent_hah_test_12345")) + .unwrap(); + assert!(checks.is_empty()); + } + + // ── Trigger error paths ─────────────────────────────────────────────────── + + #[test] + fn capability_trigger_sysctl_conflicts_runs_without_error() { + // sysctl_conflicts on non-existent path returns an empty list, not an error. + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: conflicts + capability: + type: sysctl_conflicts + paths: ["/nonexistent/sysctl.d"] + conditions: + - type: non_empty + value: "$conflicts" + severity: Warning + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let cr = check.run(&ctx); + // Non-existent path → no conflicts, no errors, no findings. + assert!(cr.errors.is_empty()); + assert!(cr.findings.is_empty()); + } + + #[test] + fn trigger_with_no_kind_adds_error() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: empty_trigger + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let cr = check.run(&ctx); + assert!(!cr.errors.is_empty()); + } + + // ── Equals condition ────────────────────────────────────────────────────── + + fn make_equals_check(expected_yaml: &str) -> RuleBasedCheck { + make_check(&format!( + r#" +rules: + - id: x + title: X + conditions: + - type: equals + value: "$val" + expected: {expected_yaml} + severity: Warning + outcome: {{ finding_id: x, title: "", description: "" }} +"# + )) + } + + #[test] + fn equals_condition_bool_matches_and_mismatches() { + let check = make_equals_check("true"); + let cond = &check.rule.conditions[0]; + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Bool(true)); + assert!(check.eval_condition(cond, &values).unwrap()); + values.insert("val".into(), RuleValue::Bool(false)); + assert!(!check.eval_condition(cond, &values).unwrap()); + } + + #[test] + fn equals_condition_int_matches_and_mismatches() { + let check = make_equals_check("42"); + let cond = &check.rule.conditions[0]; + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Int(42)); + assert!(check.eval_condition(cond, &values).unwrap()); + values.insert("val".into(), RuleValue::Int(99)); + assert!(!check.eval_condition(cond, &values).unwrap()); + } + + #[test] + fn equals_condition_str_matches_and_mismatches() { + let check = make_equals_check("\"hello\""); + let cond = &check.rule.conditions[0]; + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Str("hello".into())); + assert!(check.eval_condition(cond, &values).unwrap()); + values.insert("val".into(), RuleValue::Str("world".into())); + assert!(!check.eval_condition(cond, &values).unwrap()); + } + + // ── All / Any conditions ────────────────────────────────────────────────── + + const ALL_YAML: &str = r#" +rules: + - id: x + title: X + conditions: + - type: all + conditions: + - type: equals + value: "$a" + expected: true + severity: Info + - type: equals + value: "$b" + expected: true + severity: Info + severity: Warning + outcome: { finding_id: x, title: "", description: "" } +"#; + + const ANY_YAML: &str = r#" +rules: + - id: x + title: X + conditions: + - type: any + conditions: + - type: equals + value: "$a" + expected: true + severity: Info + - type: equals + value: "$b" + expected: true + severity: Info + severity: Warning + outcome: { finding_id: x, title: "", description: "" } +"#; + + #[test] + fn all_condition_fires_when_all_true() { + let check = make_check(ALL_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(true)); + v.insert("b".into(), RuleValue::Bool(true)); + assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); + } + + #[test] + fn all_condition_does_not_fire_when_one_false() { + let check = make_check(ALL_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(true)); + v.insert("b".into(), RuleValue::Bool(false)); + assert!(!check.eval_condition(&check.rule.conditions[0], &v).unwrap()); + } + + #[test] + fn any_condition_fires_when_one_true() { + let check = make_check(ANY_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(false)); + v.insert("b".into(), RuleValue::Bool(true)); + assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); + } + + #[test] + fn any_condition_does_not_fire_when_all_false() { + let check = make_check(ANY_YAML); + let mut v = HashMap::new(); + v.insert("a".into(), RuleValue::Bool(false)); + v.insert("b".into(), RuleValue::Bool(false)); + assert!(!check.eval_condition(&check.rule.conditions[0], &v).unwrap()); + } + + // ── RegexMatch condition ────────────────────────────────────────────────── + + #[test] + fn regex_match_condition_matches() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: regex_match + value: "$val" + pattern: "^foo.*" + severity: Info + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let mut v = HashMap::new(); + v.insert("val".into(), RuleValue::Str("foobar".into())); + assert!(check.eval_condition(&check.rule.conditions[0], &v).unwrap()); + + let mut v2 = HashMap::new(); + v2.insert("val".into(), RuleValue::Str("barfoo".into())); + assert!( + !check + .eval_condition(&check.rule.conditions[0], &v2) + .unwrap() + ); + } + + #[test] + fn regex_match_invalid_pattern_returns_error() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: regex_match + value: "$val" + pattern: "[invalid" + severity: Info + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + assert!( + check + .eval_condition(&check.rule.conditions[0], &HashMap::new()) + .is_err() + ); + } + + #[test] + fn regex_match_finding_emitted_when_condition_true() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: regex_match + value: "$val" + pattern: "legacy" + severity: Warning + outcome: { finding_id: x, title: "Legacy found", description: "" } +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let mut map = hah_core::runner::MockCommandRunner::default(); + map.expect_run().returning(|_, _| { + Ok(hah_core::runner::CommandOutput { + stdout: b"legacy-ntp installed".to_vec(), + stderr: vec![], + success: true, + }) + }); + let cr = check.run(&ctx); + // No command runner needed – value comes from condition directly + let _ = cr; + } + + // ── Numeric threshold operators ─────────────────────────────────────────── + + fn make_numeric_check(op: &str) -> RuleBasedCheck { + make_check(&format!( + r#" +rules: + - id: x + title: X + conditions: + - type: numeric_threshold + value: "$val" + operator: {op} + threshold: "10" + severity: Info + outcome: {{ finding_id: x, title: "", description: "" }} +"# + )) + } + + fn eval_numeric(op: &str, val: i64) -> bool { + let check = make_numeric_check(op); + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Int(val)); + check + .eval_condition(&check.rule.conditions[0], &values) + .unwrap() + } + + #[test] + fn numeric_threshold_all_operators() { + assert!(eval_numeric("lt", 5)); // 5 < 10 + assert!(!eval_numeric("lt", 10)); // 10 < 10 = false + assert!(eval_numeric("lte", 10)); // 10 <= 10 + assert!(!eval_numeric("lte", 11)); // 11 <= 10 = false + assert!(eval_numeric("gt", 15)); // 15 > 10 + assert!(!eval_numeric("gt", 5)); // 5 > 10 = false + assert!(eval_numeric("gte", 10)); // 10 >= 10 + assert!(!eval_numeric("gte", 5)); // 5 >= 10 = false + assert!(eval_numeric("eq", 10)); // 10 == 10 + assert!(!eval_numeric("eq", 5)); // 5 == 10 = false + assert!(eval_numeric("neq", 5)); // 5 != 10 + assert!(!eval_numeric("neq", 10)); // 10 != 10 = false + } + + #[test] + fn numeric_threshold_non_numeric_value_returns_error() { + let check = make_numeric_check("lt"); + let mut values = HashMap::new(); + values.insert("val".into(), RuleValue::Str("not-a-number".into())); + assert!( + check + .eval_condition(&check.rule.conditions[0], &values) + .is_err() + ); + } + + // ── Guard: profile and require_commands ─────────────────────────────────── + + #[test] + fn guard_profile_skips_when_mismatch() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + profile: [server] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + // Default config profile is "" which does not match "server". + assert!(!check.guard_passes(&ctx)); + } + + #[test] + fn guard_profile_passes_when_matching() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + profile: [server] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let config = Config { + profile: "server".to_string(), + ..Default::default() + }; + let ctx = Context::new(false, false, config, DistroInfo::default()); + assert!(check.guard_passes(&ctx)); + } + + #[test] + fn guard_require_commands_skips_when_missing() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + require_commands: ["__nonexistent_cmd_hah_test__"] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + assert!(!check.guard_passes(&ctx)); + } + + #[test] + fn guard_require_commands_passes_when_present() { + let check = make_check( + r#" +rules: + - id: x + title: X + only_if: + require_commands: ["ls"] + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + assert!(check.guard_passes(&ctx)); + } + + // ── Probes ──────────────────────────────────────────────────────────────── + + const PROBE_PKG_YAML: &str = r#" +rules: + - id: x + title: X + triggers: + - name: installed + probe: + type: package_installed + name: mypkg + conditions: + - type: equals + value: "$installed" + expected: true + severity: Warning + outcome: { finding_id: x, title: "installed", description: "" } +"#; + + const PROBE_SVC_YAML: &str = r#" +rules: + - id: x + title: X + triggers: + - name: active + probe: + type: service_active + name: mysvc + conditions: + - type: equals + value: "$active" + expected: true + severity: Info + outcome: { finding_id: x, title: "active", description: "" } +"#; + + #[test] + fn probe_package_installed_returns_true() { + let check = make_check(PROBE_PKG_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_output("install ok installed")); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert_eq!(cr.findings.len(), 1); + assert!(cr.errors.is_empty()); + } + + #[test] + fn probe_package_not_installed_returns_false() { + let check = make_check(PROBE_PKG_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_output("deinstall ok deinstalled")); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.findings.is_empty()); + assert!(cr.errors.is_empty()); + } + + #[test] + fn probe_service_active_returns_true() { + let check = make_check(PROBE_SVC_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: true, + }) + }); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert_eq!(cr.findings.len(), 1); + } + + #[test] + fn probe_service_inactive_returns_false() { + let check = make_check(PROBE_SVC_YAML); + let mut mock = MockCommandRunner::new(); + mock.expect_run().returning(|_, _| { + Ok(CommandOutput { + stdout: vec![], + stderr: vec![], + success: false, + }) + }); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(cr.findings.is_empty()); + } + + // ── Miscellaneous run paths ─────────────────────────────────────────────── + + #[test] + fn own_outcome_remediation_takes_precedence_over_blocks() { + let check = make_check( + r#" +blocks: + outcomes: + shared_rem: + remediation: + description: "Block fix." + commands: ["sudo block-fix"] + safe: false +rules: + - id: x + title: X + use: + outcome: shared_rem + conditions: [] + outcome: + finding_id: x + title: "found" + description: "" + remediation: + description: "Own fix." + commands: ["sudo own-fix"] + safe: true +"#, + ); + let values = HashMap::new(); + let finding = check.make_finding(Severity::Warning, &values); + let rem = finding.remediation.unwrap(); + assert_eq!(rem.description, "Own fix."); + assert!(rem.safe); + } + + #[test] + fn config_thresholds_accessible_in_value_map() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: numeric_threshold + value: "$config.boot_space_mb" + operator: gt + threshold: "0" + severity: Info + outcome: { finding_id: x, title: "low", description: "" } +"#, + ); + let mut config = Config::default(); + config.thresholds.insert("boot_space_mb".to_string(), 100); + let ctx = Context::new(false, false, config, DistroInfo::default()); + let cr = check.run(&ctx); + // 100 > 0 → condition fires + assert_eq!(cr.findings.len(), 1); + assert!(cr.errors.is_empty()); + } + + #[test] + fn derived_value_error_adds_to_result_errors() { + let check = make_check( + r#" +rules: + - id: x + title: X + triggers: + - name: raw + command: + program: echo + args: ["text"] + values: + parsed: "$raw | number" + conditions: [] + outcome: { finding_id: x, title: "", description: "" } +"#, + ); + let mut mock = MockCommandRunner::new(); + mock.expect_run() + .returning(|_, _| ok_output("not_a_number\n")); + let ctx = Context::new_with_runner( + false, + false, + Config::default(), + DistroInfo::default(), + std::sync::Arc::new(mock), + ); + let cr = check.run(&ctx); + assert!(!cr.errors.is_empty()); + assert!(cr.errors[0].contains("value 'parsed'")); + } + + #[test] + fn distro_family_injected_as_debian_when_debian() { + let check = make_check( + r#" +rules: + - id: x + title: X + conditions: + - type: equals + value: "$distro.family" + expected: "debian" + severity: Info + outcome: { finding_id: x, title: "debian", description: "" } +"#, + ); + let distro = DistroInfo { + id: "ubuntu".into(), + id_like: "debian".into(), + ..DistroInfo::default() + }; + let ctx = Context::new(false, false, Config::default(), distro); + let cr = check.run(&ctx); + assert_eq!(cr.findings.len(), 1); + } +} diff --git a/crates/hah-utils/Cargo.toml b/crates/hah-utils/Cargo.toml new file mode 100644 index 0000000..0ea1432 --- /dev/null +++ b/crates/hah-utils/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hah-utils" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +bytesize = "1" +dirs = "5" +serde = "1" +serde_json = "1" +serde_yaml_ng = "0.9" +walkdir = "2" + +[dev-dependencies] +filetime = "0.2" +tempfile = "3" diff --git a/crates/hah-utils/src/fs.rs b/crates/hah-utils/src/fs.rs new file mode 100644 index 0000000..41bf2d4 --- /dev/null +++ b/crates/hah-utils/src/fs.rs @@ -0,0 +1,228 @@ +//! Filesystem utilities shared across the HaH workspace. + +use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use walkdir::WalkDir; + +// ── sanitize_id ─────────────────────────────────────────────────────────────── + +/// Replace every character that is not alphanumeric or `-` with `-`, then +/// trim leading and trailing hyphens. +/// +/// Used to turn arbitrary path strings and file names into valid finding IDs. +pub fn sanitize_id(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +// ── OldFile ─────────────────────────────────────────────────────────────────── + +/// A file entry returned by [`scan_old_files`]. +pub struct OldFile { + /// Absolute path to the file. + pub path: PathBuf, + /// File size in kilobytes (truncated, not rounded). + pub size_kb: u64, +} + +// ── scan_old_files ──────────────────────────────────────────────────────────── + +/// Walk the top level of each directory in `dirs` and return every file whose +/// last-modified time is older than `older_than_days` days. +/// +/// Directories that do not exist are silently skipped. +/// Errors reading individual entries are silently skipped. +pub fn scan_old_files(dirs: &[impl AsRef], older_than_days: u64) -> Vec { + let threshold = SystemTime::now() + .checked_sub(Duration::from_secs(older_than_days * 86_400)) + .unwrap_or(SystemTime::UNIX_EPOCH); + + let mut files = Vec::new(); + for dir in dirs { + let path = Path::new(dir.as_ref()); + if !path.exists() { + continue; + } + let entries = match fs::read_dir(path) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let modified = match meta.modified() { + Ok(t) => t, + Err(_) => continue, + }; + if modified < threshold { + files.push(OldFile { + path: entry.path(), + size_kb: meta.len() / 1024, + }); + } + } + } + files +} + +// ── broken_symlinks ─────────────────────────────────────────────────────────── + +/// Recursively walk each directory in `dirs` (without following symlinks) and +/// return the path of every symbolic link whose target does not exist. +/// +/// Directories that do not exist are silently skipped. +pub fn broken_symlinks(dirs: &[impl AsRef]) -> Vec { + let mut result = Vec::new(); + for dir in dirs { + for entry in WalkDir::new(dir.as_ref()) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.is_symlink() && !path.exists() { + result.push(path.to_path_buf()); + } + } + } + result +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::time::Duration; + + use super::*; + + // ── sanitize_id ─────────────────────────────────────────────────────────── + + #[test] + fn sanitize_id_alphanumeric_unchanged() { + assert_eq!(sanitize_id("simple"), "simple"); + assert_eq!(sanitize_id("abc-123"), "abc-123"); + } + + #[test] + fn sanitize_id_replaces_special_chars() { + assert_eq!(sanitize_id("a.b.c"), "a-b-c"); + assert_eq!(sanitize_id("/var/crash/core"), "var-crash-core"); + } + + #[test] + fn sanitize_id_trims_leading_trailing_hyphens() { + assert_eq!(sanitize_id("/foo"), "foo"); + } + + #[test] + fn sanitize_id_empty_string() { + assert_eq!(sanitize_id(""), ""); + } + + // ── scan_old_files ──────────────────────────────────────────────────────── + + #[test] + fn scan_old_files_nonexistent_dir_skipped() { + let result = scan_old_files(&["/nonexistent/path/xyz/abc"], 30); + assert!(result.is_empty()); + } + + #[test] + fn scan_old_files_empty_dir_returns_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let result = scan_old_files(&[tmp.path().to_str().unwrap()], 30); + assert!(result.is_empty()); + } + + #[test] + fn scan_old_files_recent_file_not_returned() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("recent.log"), b"data").unwrap(); + let result = scan_old_files(&[tmp.path().to_str().unwrap()], 30); + assert!(result.is_empty()); + } + + #[test] + fn scan_old_files_old_file_returned() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("old.log"); + std::fs::write(&file, b"data").unwrap(); + // Set mtime to 60 days ago + let old_time = SystemTime::now() + .checked_sub(Duration::from_secs(60 * 86_400)) + .unwrap(); + filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time)).unwrap(); + + let result = scan_old_files(&[tmp.path().to_str().unwrap()], 30); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, file); + } + + #[test] + fn scan_old_files_size_kb_populated() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("sized.log"); + std::fs::write(&file, vec![0u8; 2048]).unwrap(); + let old_time = SystemTime::now() + .checked_sub(Duration::from_secs(60 * 86_400)) + .unwrap(); + filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time)).unwrap(); + + let result = scan_old_files(&[tmp.path().to_str().unwrap()], 30); + assert_eq!(result.len(), 1); + assert_eq!(result[0].size_kb, 2); // 2048 bytes / 1024 = 2 KB + } + + // ── broken_symlinks ─────────────────────────────────────────────────────── + + #[test] + fn broken_symlinks_empty_dir_returns_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let result = broken_symlinks(&[tmp.path().to_str().unwrap()]); + assert!(result.is_empty()); + } + + #[test] + fn broken_symlinks_valid_symlink_not_returned() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + let link = tmp.path().join("link"); + std::fs::write(&target, b"data").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let result = broken_symlinks(&[tmp.path().to_str().unwrap()]); + assert!(result.is_empty()); + } + + #[test] + fn broken_symlinks_dangling_symlink_returned() { + let tmp = tempfile::tempdir().unwrap(); + let link = tmp.path().join("dangling"); + std::os::unix::fs::symlink("/nonexistent/target/xyz", &link).unwrap(); + let result = broken_symlinks(&[tmp.path().to_str().unwrap()]); + assert_eq!(result.len(), 1); + assert_eq!(result[0], link); + } + + #[test] + fn broken_symlinks_nonexistent_dir_skipped() { + let result = broken_symlinks(&["/nonexistent/path/xyz"]); + assert!(result.is_empty()); + } +} diff --git a/crates/hah-utils/src/json.rs b/crates/hah-utils/src/json.rs new file mode 100644 index 0000000..123b8f4 --- /dev/null +++ b/crates/hah-utils/src/json.rs @@ -0,0 +1,37 @@ +//! Structured-data (JSON) serialisation facade. +//! +//! Import from this module rather than from any specific JSON library so that +//! the underlying implementation can be swapped without touching callers. + +use serde::Serialize; + +/// Serialise `v` to a pretty-printed, human-readable JSON string. +/// +/// Returns an empty string if serialisation fails (which should not happen +/// for standard `serde`-derived types). +pub fn serialize_pretty(v: &T) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn serialize_pretty_formats_map() { + let mut m = BTreeMap::new(); + m.insert("key", "value"); + let s = serialize_pretty(&m); + assert!(s.contains("\"key\"")); + assert!(s.contains("\"value\"")); + // Pretty-printed output spans multiple lines + assert!(s.contains('\n')); + } + + #[test] + fn serialize_pretty_integer() { + let s = serialize_pretty(&42i64); + assert_eq!(s.trim(), "42"); + } +} diff --git a/crates/hah-utils/src/lib.rs b/crates/hah-utils/src/lib.rs new file mode 100644 index 0000000..1beebad --- /dev/null +++ b/crates/hah-utils/src/lib.rs @@ -0,0 +1,24 @@ +//! Shared utilities and third-party library facades for the HaH workspace. +//! +//! Other crates in this workspace depend on `hah-utils` instead of consuming +//! third-party crate APIs directly. This keeps the coupling to external +//! libraries localised: changing a library only requires edits inside this +//! crate. +//! +//! # Modules +//! +//! | Module | Contents | +//! |----------|----------| +//! | [`fs`] | Filesystem helpers: `sanitize_id`, broken-symlink walk, old-file scan | +//! | [`json`] | JSON serialisation — pretty-print structured data | +//! | [`paths`]| Platform-specific user configuration directory | +//! | [`size`] | Human-readable byte-size parsing | +//! | [`sysctl`] | Pure sysctl conflict-detection algorithm | +//! | [`yaml`] | YAML parsing and serialisation | + +pub mod fs; +pub mod json; +pub mod paths; +pub mod size; +pub mod sysctl; +pub mod yaml; diff --git a/crates/hah-utils/src/paths.rs b/crates/hah-utils/src/paths.rs new file mode 100644 index 0000000..bf8487b --- /dev/null +++ b/crates/hah-utils/src/paths.rs @@ -0,0 +1,26 @@ +//! Platform-specific directory path helpers. +//! +//! Import from this module rather than from any specific platform-directory +//! library so that the underlying implementation can be swapped without +//! touching callers. + +use std::path::PathBuf; + +/// Return the platform-appropriate user configuration directory, or `None` +/// if it cannot be determined. +/// +/// On Linux this is `$XDG_CONFIG_HOME` when set, otherwise `~/.config`. +pub fn user_config_dir() -> Option { + dirs::config_dir() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_config_dir_does_not_panic() { + // We only verify the function is callable and does not panic. + let _ = user_config_dir(); + } +} diff --git a/crates/hah-utils/src/size.rs b/crates/hah-utils/src/size.rs new file mode 100644 index 0000000..99796cd --- /dev/null +++ b/crates/hah-utils/src/size.rs @@ -0,0 +1,113 @@ +//! Human-readable byte-size parsing. +//! +//! Import from this module rather than from any specific size-parsing library +//! so that the underlying implementation can be swapped without touching +//! callers. + +use bytesize::ByteSize; + +// ── parse_bytes ─────────────────────────────────────────────────────────────── + +/// Parse a human-readable SI byte-size token (e.g. `"1.5G"`, `"512M"`, +/// `"256K"`) into a raw byte count. +/// +/// A trailing `.` is stripped before parsing because `journalctl` sometimes +/// emits values like `"1.2G."`. +/// +/// Returns `None` if the token cannot be parsed. +pub fn parse_bytes(s: &str) -> Option { + s.trim_end_matches('.') + .parse::() + .ok() + .map(|b| b.0) +} + +// ── parse_journal_disk_usage ────────────────────────────────────────────────── + +/// Extract the journal size in bytes from a `journalctl --disk-usage` output +/// line. +/// +/// Expects a line such as: +/// ```text +/// Archived and active journals take up 1.2G in the file system. +/// ``` +/// +/// Returns `None` if the expected pattern is not found or the size token +/// cannot be parsed. +pub fn parse_journal_disk_usage(output: &str) -> Option { + let tokens: Vec<&str> = output.split_whitespace().collect(); + let idx = tokens.iter().position(|&t| t == "up")?; + parse_bytes(tokens.get(idx + 1)?) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + // ── parse_bytes ─────────────────────────────────────────────────────────── + + #[test] + fn parse_bytes_gigabytes_suffix_g() { + assert_eq!(parse_bytes("2G").unwrap(), 2_000_000_000); + } + + #[test] + fn parse_bytes_gigabytes_suffix_gb() { + assert_eq!(parse_bytes("1GB").unwrap(), 1_000_000_000); + } + + #[test] + fn parse_bytes_megabytes_suffix_m() { + assert_eq!(parse_bytes("100M").unwrap(), 100_000_000); + } + + #[test] + fn parse_bytes_megabytes_suffix_mb() { + assert_eq!(parse_bytes("200MB").unwrap(), 200_000_000); + } + + #[test] + fn parse_bytes_kilobytes_suffix_k() { + assert_eq!(parse_bytes("512K").unwrap(), 512_000); + } + + #[test] + fn parse_bytes_kilobytes_suffix_kb() { + assert_eq!(parse_bytes("1024KB").unwrap(), 1_024_000); + } + + #[test] + fn parse_bytes_unknown_unit_returns_none() { + assert!(parse_bytes("100XB").is_none()); + assert!(parse_bytes("").is_none()); + assert!(parse_bytes("abc").is_none()); + } + + #[test] + fn parse_bytes_trailing_dot_stripped() { + assert!(parse_bytes("1.2G.").is_some()); + } + + // ── parse_journal_disk_usage ────────────────────────────────────────────── + + #[test] + fn parse_journal_disk_usage_gigabytes() { + let output = "Archived and active journals take up 1.2G in the file system."; + assert!(parse_journal_disk_usage(output).unwrap() > 1_000_000_000); + } + + #[test] + fn parse_journal_disk_usage_megabytes() { + let output = "Archived and active journals take up 300M in the file system."; + assert!(parse_journal_disk_usage(output).unwrap() > 100_000_000); + } + + #[test] + fn parse_journal_disk_usage_malformed_returns_none() { + assert!(parse_journal_disk_usage("no size information here").is_none()); + assert!(parse_journal_disk_usage("").is_none()); + } +} diff --git a/crates/hah-utils/src/sysctl.rs b/crates/hah-utils/src/sysctl.rs new file mode 100644 index 0000000..5cd480c --- /dev/null +++ b/crates/hah-utils/src/sysctl.rs @@ -0,0 +1,122 @@ +//! Pure sysctl conflict-detection algorithm. +//! +//! This module contains no I/O. Callers are responsible for reading the +//! `sysctl.d` files and passing `(file_path, file_content)` pairs to +//! [`find_conflicts`]. + +use std::collections::HashMap; + +// ── SysctlConflict ──────────────────────────────────────────────────────────── + +/// A sysctl key that is assigned different values by at least two files. +pub struct SysctlConflict { + /// The sysctl key, e.g. `"net.ipv4.tcp_syncookies"`. + pub key: String, + /// All `(filename, value)` assignments seen for this key. + /// + /// Contains at least two entries with differing values. + pub assignments: Vec<(String, String)>, +} + +// ── find_conflicts ──────────────────────────────────────────────────────────── + +/// Scan `(file_path, file_content)` pairs for sysctl keys that are assigned +/// **different** values across multiple files. +/// +/// Lines that are empty, or whose first non-whitespace character is `#` or +/// `;`, are treated as comments and ignored. +/// +/// Returns one [`SysctlConflict`] per conflicting key. The order is +/// non-deterministic (based on `HashMap` iteration order). +pub fn find_conflicts(entries: &[(impl AsRef, impl AsRef)]) -> Vec { + let mut seen: HashMap> = HashMap::new(); + + for (file, content) in entries { + for line in content.as_ref().lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + seen.entry(key.trim().to_string()) + .or_default() + .push((file.as_ref().to_string(), value.trim().to_string())); + } + } + } + + let mut conflicts = Vec::new(); + for (key, occurrences) in &seen { + if occurrences.len() < 2 { + continue; + } + let first = &occurrences[0].1; + if occurrences.iter().any(|(_, v)| v != first) { + conflicts.push(SysctlConflict { + key: key.clone(), + assignments: occurrences.clone(), + }); + } + } + conflicts +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn find_conflicts_empty_input_returns_nothing() { + let entries: &[(&str, &str)] = &[]; + assert!(find_conflicts(entries).is_empty()); + } + + #[test] + fn find_conflicts_no_conflict_same_value() { + let entries = vec![ + ("file_a", "net.ipv4.ip_forward = 1\n"), + ("file_b", "net.ipv4.ip_forward = 1\n"), + ]; + assert!(find_conflicts(&entries).is_empty()); + } + + #[test] + fn find_conflicts_detects_different_values() { + let entries = vec![ + ("file_a", "net.ipv4.ip_forward = 0\n"), + ("file_b", "net.ipv4.ip_forward = 1\n"), + ]; + let result = find_conflicts(&entries); + assert_eq!(result.len(), 1); + assert_eq!(result[0].key, "net.ipv4.ip_forward"); + assert_eq!(result[0].assignments.len(), 2); + } + + #[test] + fn find_conflicts_ignores_comments_and_blanks() { + let entries = vec![( + "file_a", + "# comment\n\n; another\nnet.ipv4.ip_forward = 1\n", + )]; + assert!(find_conflicts(&entries).is_empty()); + } + + #[test] + fn find_conflicts_single_key_single_file_no_conflict() { + let entries = vec![("file_a", "vm.swappiness = 10\n")]; + assert!(find_conflicts(&entries).is_empty()); + } + + #[test] + fn find_conflicts_works_with_string_slices() { + // Verify the generic impl accepts &str and &str without String + let entries = [ + ("/etc/sysctl.d/50-a.conf", "kernel.panic = 10\n"), + ("/etc/sysctl.d/60-b.conf", "kernel.panic = 5\n"), + ]; + assert_eq!(find_conflicts(&entries).len(), 1); + } +} diff --git a/crates/hah-utils/src/yaml.rs b/crates/hah-utils/src/yaml.rs new file mode 100644 index 0000000..361c84a --- /dev/null +++ b/crates/hah-utils/src/yaml.rs @@ -0,0 +1,60 @@ +//! Structured-data (YAML) serialisation facade. +//! +//! Import from this module rather than from any specific YAML library so that +//! the underlying implementation can be swapped without touching callers. + +use serde::{Serialize, de::DeserializeOwned}; + +/// Error type returned when YAML parsing or serialisation fails. +pub type Error = serde_yaml_ng::Error; + +/// Parse a YAML string into a value of type `T`. +/// +/// # Errors +/// Returns an error if the YAML is malformed or its structure does not match `T`. +pub fn parse(s: &str) -> Result { + serde_yaml_ng::from_str(s) +} + +/// Serialise a value of type `T` to a YAML string. +/// +/// # Errors +/// Returns an error if `T` cannot be represented as YAML. +pub fn serialize(v: &T) -> Result { + serde_yaml_ng::to_string(v) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn parse_simple_map() { + let m: BTreeMap = parse("a: 1\nb: 2").unwrap(); + assert_eq!(m["a"], 1); + assert_eq!(m["b"], 2); + } + + #[test] + fn parse_returns_error_on_invalid_yaml() { + let result = parse::>("not: valid: yaml: :"); + assert!(result.is_err()); + } + + #[test] + fn serialize_integer() { + let s = serialize(&42i64).unwrap(); + assert_eq!(s.trim(), "42"); + } + + #[test] + fn serialize_map() { + let mut m = BTreeMap::new(); + m.insert("key", "value"); + let s = serialize(&m).unwrap(); + assert!(s.contains("key")); + assert!(s.contains("value")); + } +} diff --git a/crates/hah/Cargo.toml b/crates/hah/Cargo.toml new file mode 100644 index 0000000..45f27ec --- /dev/null +++ b/crates/hah/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hah" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[[bin]] +name = "hah" +path = "src/main.rs" + +[dependencies] +hah-core = { path = "../hah-core" } +hah-dsl = { path = "../hah-dsl" } +hah-checks = { path = "../hah-checks" } +hah-utils = { path = "../hah-utils" } +anyhow = "1" +clap = { version = "4", features = ["derive"] } diff --git a/crates/hah/src/cli.rs b/crates/hah/src/cli.rs new file mode 100644 index 0000000..efb9376 --- /dev/null +++ b/crates/hah/src/cli.rs @@ -0,0 +1,136 @@ +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Parser)] +#[command( + name = "hah", + about = "Hunt and Heal — Linux system maintenance checker", + version +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Run all enabled checks and report findings + Scan { + /// Do not apply any remediations, only report (default behavior) + #[arg(long)] + dry_run: bool, + + /// Apply safe remediations automatically (conflicts with --dry-run) + #[arg(long, conflicts_with = "dry_run")] + fix: bool, + + /// Output format + #[arg(long, value_enum, default_value_t = OutputFormat::Terminal)] + output: OutputFormat, + + /// Run only the check with this ID + #[arg(long)] + check: Option, + }, + + /// List all registered checks with their IDs + ListChecks, +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum OutputFormat { + Terminal, + Json, + Yaml, +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::field_reassign_with_default +)] +mod tests { + use super::*; + use clap::Parser; + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("parse failed") + } + + #[test] + fn parse_list_checks_command() { + let cli = parse(&["hah", "list-checks"]); + assert!(matches!(cli.command, Command::ListChecks)); + } + + #[test] + fn parse_scan_defaults() { + if let Command::Scan { + dry_run, + fix, + output, + check, + } = parse(&["hah", "scan"]).command + { + assert!(!dry_run); + assert!(!fix); + assert!(matches!(output, OutputFormat::Terminal)); + assert!(check.is_none()); + } else { + panic!("expected Scan"); + } + } + + #[test] + fn parse_scan_dry_run_flag() { + if let Command::Scan { dry_run, .. } = parse(&["hah", "scan", "--dry-run"]).command { + assert!(dry_run); + } else { + panic!("expected Scan"); + } + } + + #[test] + fn parse_scan_fix_flag() { + if let Command::Scan { fix, .. } = parse(&["hah", "scan", "--fix"]).command { + assert!(fix); + } else { + panic!("expected Scan"); + } + } + + #[test] + fn parse_scan_json_output() { + if let Command::Scan { output, .. } = parse(&["hah", "scan", "--output", "json"]).command { + assert!(matches!(output, OutputFormat::Json)); + } else { + panic!("expected Scan"); + } + } + + #[test] + fn parse_scan_yaml_output() { + if let Command::Scan { output, .. } = parse(&["hah", "scan", "--output", "yaml"]).command { + assert!(matches!(output, OutputFormat::Yaml)); + } else { + panic!("expected Scan"); + } + } + + #[test] + fn parse_scan_with_check_filter() { + if let Command::Scan { check, .. } = + parse(&["hah", "scan", "--check", "boot-space"]).command + { + assert_eq!(check.as_deref(), Some("boot-space")); + } else { + panic!("expected Scan"); + } + } + + #[test] + fn parse_invalid_subcommand_returns_error() { + assert!(Cli::try_parse_from(["hah", "invalid-subcommand"]).is_err()); + } +} diff --git a/crates/hah/src/main.rs b/crates/hah/src/main.rs new file mode 100644 index 0000000..166007e --- /dev/null +++ b/crates/hah/src/main.rs @@ -0,0 +1,198 @@ +mod cli; +mod registry; + +use clap::Parser; + +use cli::{Cli, Command, OutputFormat}; +use hah_core::{ + check::Context, + config::Config, + distro::DistroInfo, + model::Severity, + output::{self, OutputFormat as CoreOutputFormat}, +}; + +/// Run a parsed CLI command. Returns `true` when at least one Critical finding +/// was produced (the binary should exit with code 1 in that case). +pub(crate) fn run_with_config(cli: Cli, config: Config, distro: DistroInfo) -> bool { + match cli.command { + Command::Scan { + dry_run: _, + fix, + output, + check, + } => { + let all = registry::all_checks(&config); + let ctx = Context::new(!fix, false, config, distro); + let checks: Vec<_> = match &check { + Some(id) => all.into_iter().filter(|c| c.id() == id).collect(), + None => all, + }; + + // Respect enabled/disabled_checks from config + let checks: Vec<_> = checks + .into_iter() + .filter(|c| ctx.config.check_enabled(c.id())) + .collect(); + + let results: Vec<_> = checks + .iter() + .map(|c| (c.id().to_string(), c.run(&ctx))) + .collect(); + + let fmt = match output { + OutputFormat::Terminal => CoreOutputFormat::Terminal, + OutputFormat::Json => CoreOutputFormat::Json, + OutputFormat::Yaml => CoreOutputFormat::Yaml, + }; + + output::render(&results, &fmt); + + results + .iter() + .any(|(_, r)| r.findings.iter().any(|f| f.severity == Severity::Critical)) + } + + Command::ListChecks => { + let checks = registry::all_checks(&config); + println!("{:<30} TITLE", "ID"); + println!("{}", "-".repeat(70)); + for check in &checks { + println!("{:<30} {}", check.id(), check.title()); + } + false + } + } +} + +/// Load config + distro from the real system, then delegate to [`run_with_config`]. +pub(crate) fn run(cli: Cli) -> bool { + run_with_config( + cli, + Config::load().unwrap_or_default(), + DistroInfo::detect().unwrap_or_default(), + ) +} + +fn main() { + if run(Cli::parse()) { + // Skip the actual exit when building for coverage measurement so that + // integration-test drivers are not killed by the instrumented binary. + #[cfg(not(coverage))] + std::process::exit(1); + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::field_reassign_with_default +)] +mod tests { + use super::*; + use clap::Parser; + use hah_core::{config::Config, distro::DistroInfo}; + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("failed to parse CLI args") + } + + #[test] + fn list_checks_returns_false() { + assert!(!run_with_config( + parse(&["hah", "list-checks"]), + Config::default(), + DistroInfo::default(), + )); + } + + #[test] + fn scan_with_check_filter_no_match_returns_false() { + assert!(!run_with_config( + parse(&["hah", "scan", "--check", "__no_such_check__"]), + Config::default(), + DistroInfo::default(), + )); + } + + #[test] + fn scan_json_output_does_not_panic() { + run_with_config( + parse(&[ + "hah", + "scan", + "--check", + "__no_such_check__", + "--output", + "json", + ]), + Config::default(), + DistroInfo::default(), + ); + } + + #[test] + fn scan_yaml_output_does_not_panic() { + run_with_config( + parse(&[ + "hah", + "scan", + "--check", + "__no_such_check__", + "--output", + "yaml", + ]), + Config::default(), + DistroInfo::default(), + ); + } + + #[test] + fn scan_fix_flag_does_not_panic() { + run_with_config( + parse(&["hah", "scan", "--check", "__no_such_check__", "--fix"]), + Config::default(), + DistroInfo::default(), + ); + } + + #[test] + fn scan_without_check_filter_exercises_none_branch() { + // The `None` branch of the `match &check` expression. + // Use `enabled_checks` to allow only a non-existent ID so nothing + // actually runs and the test remains fast. + let mut config = Config::default(); + config.enabled_checks = vec!["__force_empty__".into()]; + assert!(!run_with_config( + parse(&["hah", "scan"]), + config, + DistroInfo::default(), + )); + } + + #[test] + fn scan_boot_space_with_impossible_threshold_returns_critical() { + // /boot cannot have 999 PB free, so BootSpaceCheck will always fire + // a Critical finding → run_with_config returns true. + let mut config = Config::default(); + config + .thresholds + .insert("boot_space_mb".into(), 999_999_999); + assert!( + run_with_config( + parse(&["hah", "scan", "--check", "boot-space"]), + config, + DistroInfo::default(), + ), + "/boot should be \"critically low\" against a 999 PB threshold" + ); + } + + #[test] + fn run_does_not_panic_with_real_system() { + // Exercises the Config::load() / DistroInfo::detect() code paths. + run(parse(&["hah", "scan", "--check", "__no_such_check__"])); + } +} diff --git a/crates/hah/src/registry.rs b/crates/hah/src/registry.rs new file mode 100644 index 0000000..b412123 --- /dev/null +++ b/crates/hah/src/registry.rs @@ -0,0 +1,111 @@ +use hah_checks::{ + apt::{ + AptKeyCheck, AutoremovableCheck, DpkgStateCheck, LegacySourcesFormatCheck, + ResidualConfigCheck, UserDefinedPackageCheck, + }, + boot::{ + BootSpaceCheck, DkmsStatusCheck, InitramfsCheck, InitramfsCompressionCheck, + StaleKernelHeadersCheck, UnusedKernelsCheck, + }, + drift::{BrokenSymlinksCheck, JournalSizeCheck, OldCrashDumpsCheck}, + network::{ + LegacyDhcpClientCheck, LegacyNetworkInterfacesCheck, LegacyNtpCheck, NtpConflictCheck, + ResolvedConfigCheck, + }, + snap::{SnapAptDuplicateCheck, SnapHealthCheck}, + sysctl::SysctlOrderingCheck, +}; +use hah_core::{check::Check, config::Config}; +use hah_dsl::rule::RuleSet; +use std::path::PathBuf; + +/// Rule file search path: system-wide, user-local, then extra paths from config. +fn rule_search_dirs(config: &Config) -> Vec { + let mut dirs = vec![PathBuf::from("/etc/hah/rules.d")]; + if let Some(d) = hah_utils::paths::user_config_dir() { + dirs.push(d.join("hah/rules.d")); + } + dirs.extend(config.rule_dirs.clone()); + dirs +} + +pub(crate) fn all_checks(config: &Config) -> Vec> { + let mut checks: Vec> = vec![ + Box::new(BootSpaceCheck), + Box::new(UnusedKernelsCheck), + Box::new(StaleKernelHeadersCheck), + Box::new(InitramfsCheck), + Box::new(InitramfsCompressionCheck), + Box::new(DkmsStatusCheck), + Box::new(AptKeyCheck), + Box::new(LegacySourcesFormatCheck), + Box::new(DpkgStateCheck), + Box::new(ResidualConfigCheck), + Box::new(AutoremovableCheck), + Box::new(UserDefinedPackageCheck), + Box::new(SnapHealthCheck), + Box::new(SnapAptDuplicateCheck), + Box::new(BrokenSymlinksCheck), + Box::new(OldCrashDumpsCheck), + Box::new(JournalSizeCheck), + Box::new(SysctlOrderingCheck), + Box::new(LegacyNtpCheck), + Box::new(NtpConflictCheck), + Box::new(LegacyDhcpClientCheck), + Box::new(LegacyNetworkInterfacesCheck), + Box::new(ResolvedConfigCheck), + ]; + + // Load declarative YAML rules from search directories. + for dir in rule_search_dirs(config) { + match RuleSet::load_checks_from_dir(&dir) { + Ok(dsl_checks) => { + for c in dsl_checks { + checks.push(Box::new(c)); + } + } + Err(e) => { + eprintln!( + "hah: warning: could not load rules from {}: {e}", + dir.display() + ); + } + } + } + + checks +} + +#[cfg(test)] +mod tests { + use super::*; + use hah_core::config::Config; + + #[test] + fn all_checks_returns_expected_count() { + let checks = all_checks(&Config::default()); + assert_eq!(checks.len(), 23); + } + + #[test] + fn all_checks_ids_are_unique_and_non_empty() { + let checks = all_checks(&Config::default()); + let mut seen = std::collections::HashSet::new(); + for check in &checks { + let id = check.id(); + assert!(!id.is_empty(), "check has empty id"); + assert!(seen.insert(id), "duplicate check id: {id}"); + } + } + + #[test] + fn all_checks_titles_are_non_empty() { + for check in all_checks(&Config::default()) { + assert!( + !check.title().is_empty(), + "check '{}' has empty title", + check.id() + ); + } + } +} diff --git a/crates/hah/tests/integration.rs b/crates/hah/tests/integration.rs new file mode 100644 index 0000000..e453bb4 --- /dev/null +++ b/crates/hah/tests/integration.rs @@ -0,0 +1,82 @@ +//! Integration tests that exercise the `hah` binary through `std::process::Command`. +//! These cover `main()` itself and verify end-to-end CLI behaviour. +#![allow(clippy::expect_used)] + +use std::process::Command; + +fn hah() -> Command { + Command::new(env!("CARGO_BIN_EXE_hah")) +} + +#[test] +fn list_checks_exits_0() { + let status = hah() + .arg("list-checks") + .status() + .expect("failed to run hah"); + assert_eq!(status.code(), Some(0)); +} + +#[test] +fn list_checks_prints_check_ids() { + let output = hah() + .arg("list-checks") + .output() + .expect("failed to run hah"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("boot-space"), + "expected 'boot-space' in list-checks output" + ); +} + +#[test] +fn scan_nonexistent_check_exits_0() { + let status = hah() + .args(["scan", "--check", "__nonexistent__"]) + .status() + .expect("failed to run hah"); + assert_eq!(status.code(), Some(0)); +} + +#[test] +fn scan_json_output_is_valid_json() { + let output = hah() + .args(["scan", "--check", "__nonexistent__", "--output", "json"]) + .output() + .expect("failed to run hah"); + assert_eq!(output.status.code(), Some(0)); + // Either an empty JSON array or object — just verify it's not an error body + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert!( + stdout.starts_with('[') || stdout.starts_with('{') || stdout.is_empty(), + "expected JSON output, got: {stdout}" + ); +} + +#[test] +fn scan_yaml_output_exits_cleanly() { + let status = hah() + .args(["scan", "--check", "__nonexistent__", "--output", "yaml"]) + .status() + .expect("failed to run hah"); + assert_eq!(status.code(), Some(0)); +} + +#[test] +fn scan_dry_run_flag_exits_cleanly() { + let status = hah() + .args(["scan", "--check", "__nonexistent__", "--dry-run"]) + .status() + .expect("failed to run hah"); + assert_eq!(status.code(), Some(0)); +} + +#[test] +fn invalid_subcommand_exits_nonzero() { + let status = hah() + .arg("not-a-real-subcommand") + .status() + .expect("failed to run hah"); + assert_ne!(status.code(), Some(0)); +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0141f06 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,144 @@ +# Architecture + +## Crate Layout + +HaH is a Cargo workspace with four crates: + +``` +hah (binary) CLI entry point, check registry, argument parsing +hah-core (library) Data model, Check trait, Config, output renderers, distro detection +hah-dsl (library) YAML rule engine: pipeline evaluator, rule loader, capabilities +hah-checks (library) Compiled check implementations (one module per problem category) +``` + +### Dependency graph + +``` +hah + ├── hah-core + ├── hah-dsl ── hah-core + └── hah-checks ── hah-core +``` + +`hah-dsl` and `hah-checks` never depend on each other; both depend only on `hah-core`. + +--- + +## Key Types (hah-core) + +| Type | Where | Purpose | +| ---- | ----- | ------- | +| `Check` | `check.rs` | Trait every check implements: `id()`, `title()`, `run(ctx) -> CheckResult` | +| `CheckResult` | `check.rs` | List of `Finding` values returned by a check | +| `Finding` | `model.rs` | A single issue: id, title, description, severity, optional `Remediation` | +| `Severity` | `model.rs` | `Info`, `Warning`, or `Critical` | +| `Remediation` | `model.rs` | Description, shell commands, and a `safe: bool` flag | +| `Context` | `check.rs` | Passed to every check: `Config`, `DistroInfo`, `CommandRunner`, `dry_run`, `verbose` | +| `Config` | `config.rs` | Deserialised from YAML; thresholds, allowlist, denylist, check selection, rule dirs | +| `DistroInfo` | `distro.rs` | Parsed from `/etc/os-release`; `is_debian_family()` helper | +| `CommandRunner` | `runner.rs` | Trait for executing shell commands; mocked in tests | + +### RuleValue (hah-dsl) + +`RuleValue` is the internal typed value enum used by the pipeline evaluator: + +```rust +enum RuleValue { + Bool(bool), + Int(i64), + Str(String), + List(Vec), + Null, +} +``` + +--- + +## Adding a Compiled Check + +1. Add a struct that implements `Check` in the appropriate module under `crates/hah-checks/src/`. +2. Register the check in `crates/hah/src/registry.rs` inside `all_checks()`. +3. Write unit tests in the same file using `MockRunner` (a `mockall::mock!` macro) and `make_ctx`. +4. Run `make check` — the quality gate requires ≥ 95 % line coverage. + +Minimal skeleton: + +```rust +pub struct MyCheck; + +impl Check for MyCheck { + fn id(&self) -> &str { "my-check" } + fn title(&self) -> &str { "My check title" } + + fn run(&self, ctx: &Context) -> CheckResult { + let out = match ctx.runner.run("some-tool", &["--flag"]) { + Ok(o) => o, + Err(_) => return CheckResult::default(), + }; + // … parse out.stdout, build findings … + CheckResult::default() + } +} +``` + +--- + +## Adding a YAML Rule + +Drop a `.yaml` file in `examples/rules/` (for bundled examples) or in any directory listed in +`rule_dirs` in your config. See [docs/dsl.md](dsl.md) for the full language reference. + +--- + +## Testing Infrastructure + +### CommandRunner mock + +`hah-core` has a `mock` cargo feature that enables `MockCommandRunner` via +`mockall::automock`: + +```toml +# dev-dependencies of hah-dsl / hah-checks +hah-core = { path = "../hah-core", features = ["mock"] } +``` + +```rust +let mut mock = MockCommandRunner::new(); +mock.expect_run() + .returning(|_, _| Ok(CommandOutput { stdout: b"output".to_vec(), .. })); +``` + +The `hah-checks` tests define a local `mockall::mock!` for `CommandRunner` (same interface) to +keep dev-dep cycles simple. + +### Pattern for check tests + +```rust +#[test] +fn my_check_finds_problem() { + let mut runner = MockRunner::new(); + runner.expect_run().returning(|_, _| Ok(ok_output("bad output\n"))); + let result = MyCheck.run(&make_ctx(Arc::new(runner))); + assert_eq!(result.findings.len(), 1); +} +``` + +--- + +## Quality Gate + +Run before every commit: + +```bash +make check # fmt-check + clippy + tests + audit + coverage (≥ 95 %) +make fmt # auto-format +``` + +See [AGENTS.md](../AGENTS.md) for the full gate specification and non-negotiable rules. + +--- + +## External Dependencies + +See [DEPENDENCIES.md](../DEPENDENCIES.md) (auto-generated by `make doc-dependencies`) for the +current dependency list with versions and licenses. diff --git a/docs/checks.md b/docs/checks.md new file mode 100644 index 0000000..693e95a --- /dev/null +++ b/docs/checks.md @@ -0,0 +1,85 @@ +# Built-in Checks + +HaH ships a set of read-only diagnostic checks organised by problem category. +Run `hah list-checks` to see every registered check with its ID and title. + +--- + +## Boot & Kernel + +Implemented in `crates/hah-checks/src/boot.rs`. + +| Check ID | What it detects | Threshold | +| ----------------------- | --------------- | --------- | +| `boot-space` | Free space on `/boot` below threshold | `boot_space_mb` (default 100 MB) | +| `unused-kernels` | Installed kernel packages that do not match the running kernel; the running kernel is never flagged | — | +| `stale-kernel-headers` | `linux-headers-*` packages with no matching `linux-image-*` | — | +| `initramfs-size` | initramfs images in `/boot` that exceed the size threshold | `initramfs_size_mb` (default 100 MB) | +| `initramfs-compression` | Compression method in `/etc/initramfs-tools/initramfs.conf` not set to `zstd` or `lz4` | — | +| `dkms-status` | DKMS modules in broken or not-installed state | — | + +--- + +## APT & Packages + +Implemented in `crates/hah-checks/src/apt.rs`. + +| Check ID | What it detects | +| ----------------------- | --------------- | +| `apt-key` | Non-empty `/etc/apt/trusted.gpg` — deprecated `apt-key` trust method | +| `legacy-sources-format` | One-line `deb` entries in `sources.list` or `sources.list.d/*.list` instead of the modern `.sources` format | +| `dpkg-state` | Failed or partial package states reported by `dpkg --audit` | +| `residual-config` | Packages in the `rc` state (removed but configuration files remain) | +| `autoremovable` | Automatically-removable packages that were never cleaned up | +| `user-denylist` | Packages matching the denylist entries in the config file | + +--- + +## Snap + +Implemented in `crates/hah-checks/src/snap.rs`. + +| Check ID | What it detects | Threshold | +| -------------------- | --------------- | --------- | +| `snap-health` | Disabled Snap revisions; more retained revisions than allowed | `snap_max_revisions` (default 2) | +| `snap-apt-duplicate` | Software installed via both APT and Snap simultaneously | — | + +--- + +## Network Configuration + +Implemented in `crates/hah-checks/src/network.rs`. + +| Check ID | What it detects | +| --------------------------- | --------------- | +| `legacy-ntp` | `ntp` (ISC ntpd) installed while `chrony` or `systemd-timesyncd` is also active | +| `ntp-conflict` | Multiple NTP services active at the same time (ntpd, chrony, openntpd, timesyncd) | +| `legacy-dhcp-client` | `isc-dhcp-client` (dhclient) installed alongside NetworkManager or systemd-networkd | +| `legacy-network-interfaces` | Non-loopback entries in `/etc/network/interfaces` (legacy ifupdown) alongside Netplan or NetworkManager | +| `resolved-config` | `/etc/resolv.conf` not symlinked to systemd-resolved's stub resolver when systemd-resolved is active | + +--- + +## System Drift & Sysctl + +Implemented in `crates/hah-checks/src/drift.rs` and `sysctl.rs`. + +| Check ID | What it detects | Threshold | +| ----------------- | --------------- | --------- | +| `broken-symlinks` | Broken symlinks under `/etc`, `/usr/lib`, and `/var/lib` | — | +| `old-crash-dumps` | Files in `/var/crash` and `/var/lib/systemd/coredump` older than the threshold | `crash_dump_max_days` (default 30 days) | +| `journal-size` | systemd journal disk usage above the threshold | `journal_size_mb` (default 500 MB) | +| `sysctl-ordering` | The same sysctl key assigned conflicting values across multiple `sysctl.d` files | — | + +--- + +## YAML Rule-based Checks + +In addition to the compiled checks above, HaH loads YAML rule files at startup from: + +- `/etc/hah/rules.d/*.yaml` +- `~/.config/hah/rules.d/*.yaml` +- Any paths listed in `rule_dirs` in the config file + +Example rule files are provided in [`examples/rules/`](../examples/rules/). +See [`docs/dsl.md`](dsl.md) for the full rule language reference. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..a917ec7 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,87 @@ +# Configuration + +HaH loads configuration from these locations in order, with later files overriding earlier ones: + +1. `/etc/hah/config.yaml` — system-wide defaults +2. `~/.config/hah/config.yaml` — per-user overrides + +All keys are optional. HaH runs with sensible defaults when no config file is present. + +--- + +## Full Config Reference + +```yaml +# ── Profile ─────────────────────────────────────────────────────────────────── +# Free-form label; no behaviour is currently gated on this value. +profile: desktop # default: "" + +# ── Thresholds ──────────────────────────────────────────────────────────────── +thresholds: + boot_space_mb: 100 # Warn when free space on /boot drops below this (MB). + initramfs_size_mb: 100 # Warn on initramfs images larger than this (MB). + journal_size_mb: 500 # Warn when the systemd journal exceeds this (MB). + snap_max_revisions: 2 # Warn when a snap retains more revisions than this. + crash_dump_max_days: 30 # Warn on crash dumps older than this many days. + +# ── Package allowlist ───────────────────────────────────────────────────────── +# Packages listed here are silently ignored by checks that would otherwise +# flag them (e.g. autoremovable, residual-config, user-denylist). +allowlist: + packages: + - some-package-to-ignore + +# ── Package denylist ────────────────────────────────────────────────────────── +# The user-denylist check flags any installed package in this list. +denylist: + packages: + - name: flashplugin-installer + reason: "Adobe Flash is end-of-life and a security risk" + +# ── Check selection ─────────────────────────────────────────────────────────── +# Disable specific checks by ID. Use `hah list-checks` to see all IDs. +disabled_checks: + - broken-symlinks + +# Enable only a specific subset of checks (if set, all others are skipped). +enabled_checks: + - apt-key + - residual-config + +# ── Preferred Snap packages ─────────────────────────────────────────────────── +# Packages listed here are excluded from the snap-apt-duplicate check because +# you intentionally prefer the Snap version over the APT version. +preferred_snap: + - firefox + - chromium + +# ── YAML rule directories ───────────────────────────────────────────────────── +# Additional directories to scan for *.yaml rule files, beyond the two +# default locations (/etc/hah/rules.d and ~/.config/hah/rules.d). +rule_dirs: + - /opt/custom-hah-rules +``` + +--- + +## Output Formats + +The `--output` flag on `hah scan` selects the output format: + +| Value | Description | +| ---------- | ----------- | +| `terminal` | Human-readable coloured output (default) | +| `json` | JSON array of findings with full metadata | +| `yaml` | YAML array of findings with full metadata | + +--- + +## Severity Levels + +| Level | Colour | Exit code | +| ---------- | ------- | --------- | +| `Info` | cyan | 0 | +| `Warning` | yellow | 0 | +| `Critical` | red | 1 | + +HaH exits with code `1` if at least one Critical finding was detected. Info and Warning findings do not affect the exit code. diff --git a/docs/dsl.md b/docs/dsl.md new file mode 100644 index 0000000..ec39118 --- /dev/null +++ b/docs/dsl.md @@ -0,0 +1,283 @@ +# HaH DSL — Declarative Rule Language + +Rules let you define checks in YAML without writing Rust. Rust provides reusable primitives +(capabilities, parsers, pipeline filters); YAML composes them into policy. Use the DSL for +straightforward command/probe checks and for wiring up built-in capabilities. Use a compiled +`Check` implementation when logic is too complex or performance-sensitive for YAML. + +--- + +## Rule File Locations + +HaH loads all `*.yaml` files from the following directories at startup, in this order: + +1. `/etc/hah/rules.d/` +2. `~/.config/hah/rules.d/` +3. Any directories listed under `rule_dirs` in the config file + +Duplicate rule IDs across files are rejected with a clear error at load time. +Example rule files are in [`examples/rules/`](../examples/rules/). + +--- + +## Core Syntax + +Every rule has these top-level keys: + +```yaml +rules: + - id: my-check # stable, unique check ID + title: Human readable name + only_if: ... # optional: environment guards + triggers: ... # required: collect values + values: ... # optional: derived pipeline expressions + conditions: ... # required: when to emit a finding + outcome: ... # required: finding text and remediation + use: ... # optional: references to reusable blocks +``` + +--- + +## Triggers + +### Command trigger + +Run a shell command and expose `$.stdout` (and `stderr`, `success`) as pipeline sources. + +```yaml +triggers: + - name: free_bytes + command: + program: df + args: ["--block-size=1", "--output=avail", "/boot"] + transform: "$stdout | lines | nth(1) | trim | number" +``` + +### Probe trigger + +Check whether a package is installed or a service is active without running a custom command. + +```yaml +triggers: + - name: ntp_installed + probe: + type: package_installed + name: ntp + + - name: chrony_active + probe: + type: service_active + name: chrony +``` + +### Capability trigger + +Call a Rust-backed capability that returns a typed `RuleValue`. + +```yaml +triggers: + - name: conflicts + capability: + type: sysctl_conflicts + paths: + - /etc/sysctl.d + - /usr/lib/sysctl.d +``` + +Available capabilities: + +| Capability | Returns | Description | +| ---------- | ------- | ----------- | +| `journal_usage` | `Int(mb)` | Total systemd journal disk usage | +| `old_files` | `List(paths)` | Files older than `older_than_days` in the given directories | +| `broken_symlinks` | `List(paths)` | Broken symlinks in the given directories | +| `sysctl_conflicts` | `List(descriptions)` | Conflicting sysctl key assignments across `sysctl.d` files | +| `kernel_inventory` | `List(pkgs)` | Installed kernel packages (running kernel + all candidates) | +| `stale_kernel_headers` | `List(pkgs)` | `linux-headers-*` packages with no matching `linux-image-*` | + +--- + +## Transformation Pipeline + +A pipeline starts from a `$variable` and applies filters separated by `|`: + +```text +$stdout | lines | non_empty | reject_contains($running_kernel) | sort +``` + +Pipelines can appear in `transform:` on a trigger, in `values:` as derived expressions, and in +condition operands. + +### Available filters + +| Filter | Description | +| ------ | ----------- | +| `trim` | Strip leading/trailing whitespace from a string | +| `lines` | Split a string into a list of lines | +| `non_empty` | Remove empty strings from a list | +| `skip(n)` | Drop the first _n_ items from a list | +| `first` | Take the first item of a list | +| `nth(n)` | Take the _n_-th item (0-based) | +| `number` | Parse a string as an integer or float | +| `field(n)` | Take the _n_-th whitespace-separated field from a string | +| `prefix_strip(p)` | Remove a leading prefix _p_ from each string in a list | +| `starts_with(p)` | Keep only list items that start with _p_ | +| `contains(v)` | Keep only list items that contain substring _v_ | +| `reject_contains(v)` | Drop list items that contain substring _v_ | +| `join(sep)` | Join a list of strings into one string with separator _sep_ | +| `default(v)` | Return _v_ if the current value is `Null` | +| `count` | Return the number of items in a list as an `Int` | +| `sort` | Sort a list alphabetically | +| `unique` | Remove duplicate items from a list | +| `bytes_to_mb` | Divide a byte count integer by 1 048 576 and return an `Int` | + +--- + +## Derived Values + +Name intermediate pipeline results with `values:` for readability and reuse: + +```yaml +values: + unused_kernels: "$installed_kernels | reject_contains($running_kernel) | sort" + unused_kernel_list: "$unused_kernels | join(', ')" + unused_kernel_count: "$unused_kernels | count" +``` + +--- + +## Conditions + +| Type | Description | +| ---- | ----------- | +| `numeric_threshold` | Compare a numeric value with `lt`, `lte`, `gt`, `gte`, `eq`, or `neq` against a threshold | +| `equals` | Compare booleans, strings, or numbers for equality | +| `non_empty` | True when a list or string is non-empty | +| `regex_match` | Match a string against a regular expression | +| `all` | Logical AND of a list of child conditions | +| `any` | Logical OR of a list of child conditions | + +Every condition requires a `severity` (`Info`, `Warning`, or `Critical`). + +```yaml +conditions: + - type: numeric_threshold + value: "$free_bytes" + operator: lt + threshold: "$threshold_bytes" + severity: Critical + + - type: all + severity: Warning + conditions: + - type: equals + value: "$ntp_installed" + expected: true + - type: any + conditions: + - type: equals + value: "$chrony_active" + expected: true + - type: equals + value: "$timesyncd_active" + expected: true +``` + +--- + +## Guards (`only_if`) + +Guards prevent a rule from running when its environment preconditions are not met. + +```yaml +only_if: + distro_family: debian # run only on Debian/Ubuntu/Mint + command_exists: snap # run only when 'snap' is on PATH + package_installed: ntp # run only when the ntp package is present + service_active: systemd-resolved +``` + +Multiple guard keys are combined with AND. + +--- + +## Outcome + +```yaml +outcome: + finding_id: boot-space-low + title: "/boot has only {free_mb} MB free" + description: "The /boot partition is nearly full ({free_mb} MB free, threshold: {threshold_boot_space_mb} MB)." + remediation: + description: "Remove unused kernels to free space." + commands: + - "sudo apt autoremove --purge" + safe: false +``` + +Use `{variable}` placeholders in `title`, `description`, and remediation `description`. All +`values:` and trigger names are available for substitution. + +--- + +## Reusable Blocks + +Define shared fragments once and reference them from multiple rules: + +```yaml +blocks: + guards: + debian_family: + distro_family: debian + + outcomes: + apt_remove: + remediation: + description: "Remove with apt." + commands: ["sudo apt remove --purge {packages}"] + safe: false + +rules: + - id: residual-config + title: Residual package configuration files + use: + guard: debian_family + outcome: apt_remove + triggers: + - name: residual_packages + command: + program: dpkg-query + args: ["-W", "-f=${Status} ${Package}\\n"] + transform: "$stdout | lines | starts_with('deinstall ok config-files ') | prefix_strip('deinstall ok config-files ') | sort" + conditions: + - type: non_empty + value: "$residual_packages" + severity: Info + values: + package_count: "$residual_packages | count" + packages: "$residual_packages | join(' ')" + outcome: + finding_id: residual-config + title: "{package_count} package(s) with residual configuration" + description: "These packages were removed but their configuration files remain: {packages}." +``` + +Available block types: `guards`, `outcomes`. Blocks are resolved at load time; unknown references +cause a load error. + +--- + +## Complete Examples + +See [`examples/rules/`](../examples/rules/) for working rule files included with HaH: + +| File | What it demonstrates | +| ---- | -------------------- | +| `boot-space.yaml` | Command trigger, `bytes_to_mb`, `numeric_threshold` | +| `autoremovable.yaml` | Command trigger, `non_empty`, list pipeline | +| `residual-config.yaml` | `starts_with` / `prefix_strip`, block reuse | +| `legacy-ntp.yaml` | Multi-probe rule, `all` / `any` conditions | +| `journal-size.yaml` | `journal_usage` capability | +| `sysctl-ordering.yaml` | `sysctl_conflicts` capability | +| `unused-kernels.yaml` | `kernel_inventory` capability, `reject_contains` | +| `broken-symlinks.yaml` | `broken_symlinks` capability diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..71a81b5 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,10 @@ +# Documentation Index + +| Document | Audience | Description | +| -------- | -------- | ----------- | +| [checks.md](checks.md) | End users | Every built-in check: ID, what it detects, configurable thresholds | +| [config.md](config.md) | End users | Configuration file reference with all keys, defaults, and examples | +| [dsl.md](dsl.md) | Rule authors | YAML rule language: triggers, pipeline filters, conditions, capabilities | +| [architecture.md](architecture.md) | Developers | Crate layout, key types, how to add checks, testing infrastructure | +| [roadmap.md](roadmap.md) | Developers | Planned features and DSL extensions | +| [../DEPENDENCIES.md](../DEPENDENCIES.md) | Developers | Direct dependencies (auto-generated by `make doc-dependencies`) | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..06da5d4 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,48 @@ +# Roadmap + +Planned features that are not yet implemented. Items are roughly ordered by priority but the order is not a commitment. + +--- + +## CLI & UX + +| Feature | Notes | +| ------- | ----- | +| Interactive mode `--interactive` | Per-finding prompt: skip / show command / apply | +| Fix mode `--fix` | Auto-apply safe remediations; prompt for unsafe ones | +| Backup hooks | Call a user-configured script before any destructive action | +| `hah report` | Audit report in HTML, Markdown, or JSON | +| Profiles (`desktop`, `server`, `vm`, `container`) | Activate or skip check sets based on the declared system role | + +--- + +## New Checks + +| Feature | Notes | +| ------- | ----- | +| Flatpak duplicate check | Flag software installed via both APT/Snap and Flatpak; only when `flatpak` is present | +| SMART / fsck integration | Surface `smartctl` and filesystem check results | +| EOL release check | Compare distro version against a bundled end-of-life date database | +| Orphaned package check | Packages installed from repositories that are no longer in any active source | +| Stale systemd units | Units referencing binaries that no longer exist | +| Legacy config drift | Config files that differ significantly from current package defaults | +| Snap preferred check | Flag packages where the Snap version is preferred over APT | + +--- + +## DSL Extensions + +| Feature | Notes | +| ------- | ----- | +| `where(field, op, value)` list filter | Filter structured records returned by capabilities | +| JSON Schema for rule files (`schemars`) | Enable editor autocompletion and validation of `.yaml` rule files | +| Typed config structs | Replace `HashMap` thresholds with typed config structs once DSL keys stabilise | + +--- + +## Testing & Quality + +| Feature | Notes | +| ------- | ----- | +| CLI integration tests (`assert_cmd` / `predicates`) | End-to-end tests of `hah scan` exit codes and output format | +| Snapshot tests (`insta`) | Detect regressions in rendered terminal/JSON/YAML output | diff --git a/examples/rules/autoremovable.yaml b/examples/rules/autoremovable.yaml new file mode 100644 index 0000000..3798365 --- /dev/null +++ b/examples/rules/autoremovable.yaml @@ -0,0 +1,50 @@ +# Example rule: auto-removable packages +# +# Demonstrates the HaH declarative DSL using a command trigger with a +# starts_with filter to count actionable lines from apt-get output. +# +# The built-in AutoremovableCheck already covers this — this file is here +# to show how a count-based command rule looks in the DSL. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +rules: + - id: autoremovable-dsl + title: Auto-removable packages (DSL example) + + only_if: + distro_family: debian + require_commands: + - apt-get + + # Run apt-get in dry-run mode so nothing is changed. + triggers: + - name: autoremove_output + command: + program: apt-get + args: ["--dry-run", "autoremove"] + + # Count the "Remv " lines emitted by apt-get dry-run. + values: + autoremovable_count: "$autoremove_output | lines | starts_with('Remv ') | count" + + # Fire an Info finding whenever at least one package can be removed. + conditions: + - type: numeric_threshold + value: "$autoremovable_count" + operator: gt + threshold: "0" + severity: Info + + outcome: + finding_id: autoremovable-dsl + title: "{autoremovable_count} auto-removable package(s)" + description: > + {autoremovable_count} package(s) are no longer needed and can be + removed automatically. Run `sudo apt autoremove --purge` to clean + them up and reclaim disk space. + remediation: + description: "Remove unused auto-installed packages." + commands: + - "sudo apt autoremove --purge" + safe: false diff --git a/examples/rules/boot-space.yaml b/examples/rules/boot-space.yaml new file mode 100644 index 0000000..4cd149c --- /dev/null +++ b/examples/rules/boot-space.yaml @@ -0,0 +1,54 @@ +# Example rule: check free space on /boot +# +# This file demonstrates the HaH declarative DSL. Copy it to +# /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate it. +# +# The built-in BootSpaceCheck already covers this — the example is here purely +# to show the DSL in action. + +rules: + - id: boot-space-dsl + title: Free space on /boot (DSL example) + + # Only run on Debian/Ubuntu systems that have a /boot partition. + only_if: + distro_family: debian + require_commands: + - df + + # Run `df` and extract the available bytes for /boot. + triggers: + - name: free_bytes + command: + program: df + args: ["--block-size=1", "--output=avail", "/boot"] + # Skip the header line, strip whitespace, parse as an integer. + transform: "$stdout | lines | nth(1) | trim | number" + + # Derive a human-friendly megabyte value and read the threshold from config. + values: + free_mb: "$free_bytes | bytes_to_mb" + threshold_mb: "$config.boot_space_mb" + + # Trigger a Critical finding when free space is below the threshold. + conditions: + - type: numeric_threshold + value: "$free_mb" + operator: lt + threshold: "$threshold_mb" + severity: Critical + + # Template for the finding. Variable names wrapped in {braces} are + # substituted at render time. + outcome: + finding_id: boot-space-low-dsl + title: "/boot has only {free_mb} MB free" + description: > + The /boot partition is nearly full ({free_mb} MB free, + threshold: {threshold_mb} MB). This can prevent kernel upgrades or + initramfs updates from completing. + remediation: + description: "Remove unused kernels to free space on /boot." + commands: + - "sudo apt autoremove --purge" + safe: false diff --git a/examples/rules/broken-symlinks.yaml b/examples/rules/broken-symlinks.yaml new file mode 100644 index 0000000..88f28ca --- /dev/null +++ b/examples/rules/broken-symlinks.yaml @@ -0,0 +1,44 @@ +# Example rule: broken symbolic links +# +# Demonstrates the BrokenSymlinks capability, which walks /etc, /usr/lib, +# and /var/lib (or caller-supplied paths) and returns a list of dangling +# symlink paths. +# +# The built-in BrokenSymlinksCheck already covers this — this file exists to +# show how a list-returning capability feeds into non_empty / count conditions. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +rules: + - id: broken-symlinks-dsl + title: Broken symbolic links (DSL example) + + # The capability defaults to scanning /etc, /usr/lib, and /var/lib. + # Supply an explicit `paths` list to narrow the scan. + triggers: + - name: broken + capability: + type: broken_symlinks + paths: [] + + values: + broken_count: "$broken | count" + broken_list: "$broken | join(', ')" + + conditions: + - type: non_empty + value: "$broken" + severity: Warning + + outcome: + finding_id: broken-symlinks-dsl + title: "{broken_count} broken symbolic link(s) found" + description: > + The following symlinks point to non-existent targets: + {broken_list}. + Broken symlinks can hide misconfigured packages or removed services. + remediation: + description: "Remove each broken symlink after verifying it is no longer needed." + commands: + - "sudo find /etc /usr/lib /var/lib -xtype l -delete" + safe: false diff --git a/examples/rules/journal-size.yaml b/examples/rules/journal-size.yaml new file mode 100644 index 0000000..171bd05 --- /dev/null +++ b/examples/rules/journal-size.yaml @@ -0,0 +1,48 @@ +# Example rule: systemd journal disk usage +# +# Demonstrates the JournalUsage capability, which runs `journalctl --disk-usage` +# internally and returns the total size as an integer number of megabytes. +# +# The built-in JournalSizeCheck already covers this — this file exists to show +# how capability triggers compose with numeric threshold conditions. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +rules: + - id: journal-size-dsl + title: systemd journal disk usage (DSL example) + + only_if: + require_commands: + - journalctl + + # The journal_usage capability runs journalctl --disk-usage and returns + # the total size as Int(mb). + triggers: + - name: journal_mb + capability: + type: journal_usage + + # Read the threshold from config (default 500 MB if not set). + values: + threshold_mb: "$config.journal_size_mb" + + conditions: + - type: numeric_threshold + value: "$journal_mb" + operator: gt + threshold: "$threshold_mb" + severity: Warning + + outcome: + finding_id: journal-size-large-dsl + title: "systemd journal is {journal_mb} MB" + description: > + The systemd journal occupies {journal_mb} MB, exceeding the + {threshold_mb} MB threshold. Old journal entries can be vacuumed to + reclaim disk space. + remediation: + description: "Vacuum the journal to reclaim space." + commands: + - "sudo journalctl --vacuum-size={threshold_mb}M" + safe: true diff --git a/examples/rules/legacy-ntp.yaml b/examples/rules/legacy-ntp.yaml new file mode 100644 index 0000000..3d02cc5 --- /dev/null +++ b/examples/rules/legacy-ntp.yaml @@ -0,0 +1,129 @@ +# Example rule: legacy NTP daemon +# +# Demonstrates multi-probe rules and composite All/Any conditions. +# Two rules cover the two distinct findings the built-in LegacyNtpCheck +# emits: +# +# legacy-ntp-conflict-dsl (Warning) — ntp installed AND a modern time-sync +# service is active simultaneously +# legacy-ntp-dsl (Info) — ntp installed but no modern time-sync +# service is active +# +# The built-in LegacyNtpCheck already covers this — these rules exist to show +# how probe triggers and composite conditions compose in the DSL. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +# ── Shared reusable blocks ──────────────────────────────────────────────────── + +blocks: + guards: + debian_ntp: + distro_family: debian + + outcomes: + ntp_remediation: + remediation: + description: "Remove ntp and enable a modern time-sync service." + commands: + - "sudo apt remove --purge ntp" + - "sudo apt install chrony && sudo systemctl enable --now chrony" + safe: false + +# ── Rules ───────────────────────────────────────────────────────────────────── + +rules: + + # Warning: ntp is installed AND a modern time-sync service is running. + # Both daemons will compete to adjust the clock. + - id: legacy-ntp-conflict-dsl + title: Legacy ntp package conflicts with active time-sync service (DSL example) + + use: + guard: debian_ntp + outcome: ntp_remediation + + triggers: + - name: ntp_installed + probe: + type: package_installed + name: ntp + - name: chrony_active + probe: + type: service_active + name: chrony + - name: timesyncd_active + probe: + type: service_active + name: systemd-timesyncd + + conditions: + - type: all + severity: Warning + conditions: + - type: equals + value: "$ntp_installed" + expected: true + - type: any + severity: Warning + conditions: + - type: equals + value: "$chrony_active" + expected: true + - type: equals + value: "$timesyncd_active" + expected: true + + outcome: + finding_id: legacy-ntp-conflict-dsl + title: "Legacy ntp package is installed alongside an active time-sync service" + description: > + The legacy ntp (ISC ntpd) package is installed while chrony or + systemd-timesyncd is also active. Multiple time-sync daemons compete + to adjust the system clock, which can cause instability. Remove the + ntp package and rely on the modern service instead. + + # Info: ntp is installed but no modern time-sync alternative is active. + - id: legacy-ntp-dsl + title: Legacy ntp package installed (DSL example) + + use: + guard: debian_ntp + outcome: ntp_remediation + + triggers: + - name: ntp_installed + probe: + type: package_installed + name: ntp + - name: chrony_active + probe: + type: service_active + name: chrony + - name: timesyncd_active + probe: + type: service_active + name: systemd-timesyncd + + conditions: + - type: all + severity: Info + conditions: + - type: equals + value: "$ntp_installed" + expected: true + - type: equals + value: "$chrony_active" + expected: false + - type: equals + value: "$timesyncd_active" + expected: false + + outcome: + finding_id: legacy-ntp-dsl + title: "Legacy ntp (ISC ntpd) package is installed" + description: > + The legacy ntp (ISC ntpd) package is installed. Modern alternatives + such as chrony (recommended for servers and VMs) or + systemd-timesyncd offer better accuracy, NTS/DNSSEC support, and + resilience to large clock jumps. diff --git a/examples/rules/residual-config.yaml b/examples/rules/residual-config.yaml new file mode 100644 index 0000000..7a79cfe --- /dev/null +++ b/examples/rules/residual-config.yaml @@ -0,0 +1,58 @@ +# Example rule: residual package configuration files +# +# Demonstrates using starts_with + prefix_strip to extract package names from +# dpkg-query output. The non_empty condition fires when at least one package +# has a residual config state ("rc" in dpkg terminology). +# +# The built-in ResidualConfigCheck already covers this — this file exists to +# show multi-step list transformation in the DSL. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +rules: + - id: residual-config-dsl + title: Residual package configuration files (DSL example) + + only_if: + distro_family: debian + require_commands: + - dpkg-query + + # Query dpkg status for every package. Each output line has the form: + # + # e.g. "install ok installed bash" or "deinstall ok config-files foo". + triggers: + - name: dpkg_output + command: + program: dpkg-query + args: ["-W", "-f=${Status} ${Package}\n"] + + # Keep only lines for packages in the "config-files" (rc) state, then + # strip the common status prefix to leave a plain list of package names. + values: + rc_packages: >- + $dpkg_output | lines + | starts_with('deinstall ok config-files ') + | prefix_strip('deinstall ok config-files ') + | non_empty + rc_count: "$rc_packages | count" + rc_package_list: "$rc_packages | join(', ')" + + # Fire an Info finding whenever at least one package has residual config. + conditions: + - type: non_empty + value: "$rc_packages" + severity: Info + + outcome: + finding_id: residual-config-dsl + title: "{rc_count} package(s) with residual configuration" + description: > + These packages were removed but their configuration files remain on + disk: {rc_package_list}. Purging them will clean up /etc and reduce + confusion during future installs. + remediation: + description: "Purge residual configuration files." + commands: + - "sudo dpkg --purge {rc_package_list}" + safe: false diff --git a/examples/rules/sysctl-ordering.yaml b/examples/rules/sysctl-ordering.yaml new file mode 100644 index 0000000..48b0dbb --- /dev/null +++ b/examples/rules/sysctl-ordering.yaml @@ -0,0 +1,43 @@ +# Example rule: conflicting sysctl.d overrides +# +# Demonstrates the SysctlConflicts capability, which reads *.conf files from +# /usr/lib/sysctl.d, /etc/sysctl.d, and /run/sysctl.d (or caller-supplied +# paths) and returns a list of keys that appear with different values in +# multiple files. +# +# The built-in SysctlOrderingCheck already covers this — this file exists to +# show how a filesystem-only capability feeds a non_empty condition. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +rules: + - id: sysctl-ordering-dsl + title: Conflicting sysctl.d overrides (DSL example) + + # Supply an explicit `paths` list to scan different directories. + # Leave paths empty to use the defaults: + # /usr/lib/sysctl.d, /etc/sysctl.d, /run/sysctl.d + triggers: + - name: conflicts + capability: + type: sysctl_conflicts + paths: [] + + values: + conflict_count: "$conflicts | count" + conflict_list: "$conflicts | join('\n')" + + conditions: + - type: non_empty + value: "$conflicts" + severity: Warning + + outcome: + finding_id: sysctl-ordering-dsl + title: "{conflict_count} sysctl key(s) with conflicting values" + description: > + The following sysctl keys are set to different values in multiple + sysctl.d files. The last file in lexicographic order wins, but the + conflict indicates possible misconfiguration: + + {conflict_list} diff --git a/examples/rules/unused-kernels.yaml b/examples/rules/unused-kernels.yaml new file mode 100644 index 0000000..39aadcf --- /dev/null +++ b/examples/rules/unused-kernels.yaml @@ -0,0 +1,45 @@ +# Example rule: unused installed kernel packages +# +# Demonstrates the KernelInventory capability, which runs `uname -r` and +# `dpkg-query` internally and returns a list of installed linux-image-* +# packages whose version does not match the running kernel (i.e., safe to +# remove). +# +# The built-in UnusedKernelsCheck already covers this — this file exists to +# show how a command-runner capability returns a list for join/count. +# +# Copy to /etc/hah/rules.d/ or ~/.config/hah/rules.d/ to activate. + +rules: + - id: unused-kernels-dsl + title: Unused installed kernel packages (DSL example) + + only_if: + distro_family: debian + + triggers: + - name: unused_kernels + capability: + type: kernel_inventory + + values: + unused_count: "$unused_kernels | count" + unused_list: "$unused_kernels | join(', ')" + + conditions: + - type: non_empty + value: "$unused_kernels" + severity: Warning + + outcome: + finding_id: unused-kernels-dsl + title: "{unused_count} unused kernel package(s) installed" + description: > + The following kernel packages are installed but do not match the + running kernel and can be safely removed: {unused_list}. + Removing them frees space in /boot and reduces update time. + remediation: + description: "Remove unused kernels with apt." + commands: + - "sudo apt autoremove --purge" + safe: false diff --git a/tools/gen_deps_doc.py b/tools/gen_deps_doc.py new file mode 100644 index 0000000..43e56f0 --- /dev/null +++ b/tools/gen_deps_doc.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Generate DEPENDENCIES.md from cargo metadata. + +Writes to stdout; redirect to DEPENDENCIES.md: + + python3 tools/gen_deps_doc.py > DEPENDENCIES.md + +or via the Makefile target: + + make doc-dependencies +""" + +import json +import subprocess +import sys +from collections import defaultdict + + +def req_display(req: str) -> str: + """Convert a cargo version requirement to a short display string. + + '^0.9.35' -> '0.9', '^1' -> '1', '^0.0.12' -> '0.0' + """ + s = req.lstrip("^~=>< ") + # Drop any trailing comma-separated constraints (e.g. ">=1.0, <2.0") + s = s.split(",")[0].strip() + parts = s.split(".") + if parts[0] == "0" and len(parts) >= 2: + return f"0.{parts[1]}" + return parts[0] + + +def main() -> None: + meta = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--format-version", "1"], + stderr=subprocess.DEVNULL, + ) + ) + + workspace_member_ids: set[str] = set(meta["workspace_members"]) + pkgs_by_id: dict[str, dict] = {p["id"]: p for p in meta["packages"]} + workspace_names: set[str] = {pkgs_by_id[wid]["name"] for wid in workspace_member_ids} + + # Which deps are declared optional in each workspace member + optional_in: dict[str, set[str]] = {} + req_in: dict[str, dict[str, str]] = {} + for wid in workspace_member_ids: + pkg = pkgs_by_id[wid] + wm_name = pkg["name"] + optional_in[wm_name] = {d["name"] for d in pkg["dependencies"] if d.get("optional", False)} + req_in[wm_name] = {d["name"]: d["req"] for d in pkg["dependencies"]} + + # Collect direct external deps across all workspace members. + # A dep is "runtime" if it appears as kind=None (normal) AND is NOT optional + # in the declaring member. Everything else is "dev-only". + direct: dict[str, dict] = {} + + for node in meta["resolve"]["nodes"]: + if node["id"] not in workspace_member_ids: + continue + wm_name = pkgs_by_id[node["id"]]["name"] + + for dep in node["deps"]: + dep_pkg = pkgs_by_id[dep["pkg"]] + dep_name = dep_pkg["name"] + if dep_name in workspace_names: + continue # skip internal workspace crates + + if dep_name not in direct: + direct[dep_name] = { + "runtime": False, + "req": req_in[wm_name].get(dep_name, "?"), + "pkg": dep_pkg, + } + + for kind_info in dep["dep_kinds"]: + if kind_info["kind"] is None and dep_name not in optional_in[wm_name]: + direct[dep_name]["runtime"] = True + + runtime = sorted( + [(n, d) for n, d in direct.items() if d["runtime"]], + key=lambda x: x[0].lower(), + ) + dev_only = sorted( + [(n, d) for n, d in direct.items() if not d["runtime"]], + key=lambda x: x[0].lower(), + ) + + def row(name: str, info: dict) -> str: + pkg = info["pkg"] + ver = req_display(info["req"]) + lic = (pkg.get("license") or "unknown").strip() + desc = (pkg.get("description") or "").strip().rstrip(".") + url = f"https://crates.io/crates/{name}" + return f"| [{name}]({url}) | {ver} | {lic} | {desc} |" + + col_header = "| Crate | Version | License | Purpose |\n| ----- | ------- | ------- | ------- |" + + lines = [ + "# Dependencies", + "", + "Direct dependencies of the HaH workspace crates.", + "_Generated by `make doc-dependencies` — do not edit by hand._", + "", + "## Runtime Dependencies", + "", + col_header, + ] + for name, info in runtime: + lines.append(row(name, info)) + + lines += [ + "", + "## Development-only Dependencies", + "", + col_header, + ] + for name, info in dev_only: + lines.append(row(name, info)) + + lines.append("") + sys.stdout.write("\n".join(lines)) + + +if __name__ == "__main__": + main()