From cad65f19bba24c9c526aefb09e12cfe9d0ab22dd Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Sat, 16 May 2026 18:47:39 +0200 Subject: [PATCH 1/2] feat: Avoid mutating the system Removal of System-Changing Logic: CLI: Removed --fix and --dry-run flags. The tool now always operates in a read-only "dry run" mode by design. Core Logic: Removed the dry_run flag from Context and the safe boolean from Remediation. Roadmap: Excised planned features related to automated fixing, interactive modes, and backup hooks. Project Philosophy: Updated documentation to emphasize that HaH only detects issues and suggests remediations; execution is strictly the user's responsibility. --- crates/hah-checks/src/apt.rs | 7 ------- crates/hah-checks/src/boot.rs | 7 ------- crates/hah-checks/src/drift.rs | 4 ---- crates/hah-checks/src/network.rs | 6 ------ crates/hah-checks/src/snap.rs | 4 ---- crates/hah-checks/src/sysctl.rs | 1 - crates/hah-core/src/check.rs | 14 ++++--------- crates/hah-core/src/model.rs | 19 ++--------------- crates/hah-core/src/output.rs | 1 - crates/hah-dsl/src/rule.rs | 29 +++++++++---------------- crates/hah/src/cli.rs | 36 +------------------------------- crates/hah/src/main.rs | 18 ++-------------- crates/hah/tests/integration.rs | 9 -------- 13 files changed, 19 insertions(+), 136 deletions(-) diff --git a/crates/hah-checks/src/apt.rs b/crates/hah-checks/src/apt.rs index 89595af..9468390 100644 --- a/crates/hah-checks/src/apt.rs +++ b/crates/hah-checks/src/apt.rs @@ -60,7 +60,6 @@ impl Check for ResidualConfigCheck { remediation: Some(Remediation { description: "Purge residual configurations.".into(), commands: vec![format!("sudo dpkg --purge {list}")], - safe: false, }), }) } @@ -105,7 +104,6 @@ impl Check for DpkgStateCheck { "sudo dpkg --configure -a".into(), "sudo apt-get install -f".into(), ], - safe: false, }), }) } @@ -152,7 +150,6 @@ impl Check for AutoremovableCheck { remediation: Some(Remediation { description: "Remove unused auto-installed packages.".into(), commands: vec!["sudo apt autoremove --purge".into()], - safe: false, }), }) } @@ -186,7 +183,6 @@ pub(crate) fn apt_key_finding(path: &Path) -> Option { --import /tmp/key.asc" .into(), ], - safe: true, }), }) } else { @@ -265,7 +261,6 @@ fn legacy_sources_finding(legacy_files: Vec) -> Option { 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, }), }) } @@ -332,7 +327,6 @@ impl Check for UserDefinedPackageCheck { remediation: Some(Remediation { description: format!("Remove {}", entry.name), commands: vec![format!("sudo apt remove --purge {}", entry.name)], - safe: false, }), }); } @@ -371,7 +365,6 @@ mod tests { fn make_ctx(runner: Arc, config: Config, distro_id: &str) -> Context { Context { - dry_run: false, verbose: false, config, distro: DistroInfo { diff --git a/crates/hah-checks/src/boot.rs b/crates/hah-checks/src/boot.rs index 5795f99..dff0303 100644 --- a/crates/hah-checks/src/boot.rs +++ b/crates/hah-checks/src/boot.rs @@ -85,7 +85,6 @@ impl Check for BootSpaceCheck { remediation: Some(Remediation { description: "Remove unused kernels to free space.".into(), commands: vec!["sudo apt autoremove --purge".into()], - safe: false, }), }) } else { @@ -143,7 +142,6 @@ impl Check for UnusedKernelsCheck { remediation: Some(Remediation { description: "Remove unused kernels with apt.".into(), commands: vec!["sudo apt autoremove --purge".into()], - safe: false, }), }) } @@ -205,7 +203,6 @@ impl Check for StaleKernelHeadersCheck { .iter() .map(|p| format!("sudo apt remove --purge {p}")) .collect(), - safe: false, }), }) } @@ -255,7 +252,6 @@ impl Check for InitramfsCheck { remediation: Some(Remediation { description: "Regenerate initramfs images.".into(), commands: vec!["sudo update-initramfs -u -k all".into()], - safe: false, }), }); } @@ -301,7 +297,6 @@ impl Check for DkmsStatusCheck { remediation: Some(Remediation { description: "Attempt DKMS rebuild.".into(), commands: vec!["sudo dkms autoinstall".into()], - safe: false, }), }); } @@ -367,7 +362,6 @@ pub(crate) fn classify_compression(content: &str) -> Option { .into(), "sudo update-initramfs -u -k all".into(), ], - safe: false, }), }) } else { @@ -405,7 +399,6 @@ mod tests { fn make_ctx(runner: Arc, distro_id: &str) -> Context { Context { - dry_run: false, verbose: false, config: Config::default(), distro: DistroInfo { diff --git a/crates/hah-checks/src/drift.rs b/crates/hah-checks/src/drift.rs index 5dfa1bb..fd1579b 100644 --- a/crates/hah-checks/src/drift.rs +++ b/crates/hah-checks/src/drift.rs @@ -41,7 +41,6 @@ pub(crate) fn scan_for_broken_symlinks(dirs: &[&str]) -> CheckResult { remediation: Some(Remediation { description: "Remove the broken symlink.".into(), commands: vec![format!("sudo rm {}", path.display())], - safe: false, }), }); } @@ -96,7 +95,6 @@ pub(crate) fn scan_crash_dirs(dirs: &[&str], max_days: u64) -> CheckResult { remediation: Some(Remediation { description: "Remove old crash dump.".into(), commands: vec![format!("sudo rm {parent}/{name}")], - safe: false, }), }); } @@ -141,7 +139,6 @@ impl Check for JournalSizeCheck { remediation: Some(Remediation { description: "Vacuum the journal to reclaim space.".into(), commands: vec![format!("sudo journalctl --vacuum-size={threshold_mb}M")], - safe: true, }), }) } else { @@ -181,7 +178,6 @@ mod tests { fn make_ctx(runner: Arc) -> Context { Context { - dry_run: false, verbose: false, config: Config::default(), distro: DistroInfo::default(), diff --git a/crates/hah-checks/src/network.rs b/crates/hah-checks/src/network.rs index f404068..b98f8f5 100644 --- a/crates/hah-checks/src/network.rs +++ b/crates/hah-checks/src/network.rs @@ -81,7 +81,6 @@ impl Check for LegacyNtpCheck { "sudo apt remove --purge ntp".into(), "sudo apt install chrony && sudo systemctl enable --now chrony".into(), ], - safe: false, }), }) } @@ -146,7 +145,6 @@ impl Check for NtpConflictCheck { "sudo systemctl disable --now systemd-timesyncd".into(), "# Then enable only one: sudo systemctl enable --now chrony".into(), ], - safe: false, }), }) } @@ -199,7 +197,6 @@ impl Check for LegacyDhcpClientCheck { remediation: Some(Remediation { description: "Remove the legacy ISC DHCP client.".into(), commands: vec!["sudo apt remove --purge isc-dhcp-client".into()], - safe: false, }), }) } @@ -312,7 +309,6 @@ pub(crate) fn legacy_interfaces_finding( "# Netplan reference: https://netplan.readthedocs.io/".into(), "# After migration: sudo apt remove --purge ifupdown".into(), ], - safe: true, }), }) } @@ -377,7 +373,6 @@ impl Check for ResolvedConfigCheck { commands: vec![ "sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf".into(), ], - safe: false, }), }) } @@ -429,7 +424,6 @@ mod tests { fn make_ctx(runner: Arc, distro_id: &str) -> Context { Context { - dry_run: false, verbose: false, config: Config::default(), distro: DistroInfo { diff --git a/crates/hah-checks/src/snap.rs b/crates/hah-checks/src/snap.rs index 7af4b65..7144eed 100644 --- a/crates/hah-checks/src/snap.rs +++ b/crates/hah-checks/src/snap.rs @@ -19,7 +19,6 @@ fn disabled_revision_finding(name: &str, rev: &str) -> Finding { remediation: Some(Remediation { description: format!("Remove disabled revision {rev} of {name}."), commands: vec![format!("sudo snap remove {name} --revision={rev}")], - safe: false, }), } } @@ -37,7 +36,6 @@ fn excess_revisions_finding(name: &str, count: u32, max_revisions: u64) -> Findi commands: vec![format!( "sudo snap set system refresh.retain={max_revisions}" )], - safe: true, }), } } @@ -142,7 +140,6 @@ impl Check for SnapAptDuplicateCheck { remediation: Some(Remediation { description: "Remove the APT version if the Snap is preferred.".into(), commands: vec![format!("sudo apt remove --purge {name}")], - safe: false, }), }); } @@ -213,7 +210,6 @@ mod tests { fn ctx(runner: Arc) -> Context { Context { - dry_run: false, verbose: false, config: Config::default(), distro: DistroInfo::default(), diff --git a/crates/hah-checks/src/sysctl.rs b/crates/hah-checks/src/sysctl.rs index 1d20224..7ab7fe7 100644 --- a/crates/hah-checks/src/sysctl.rs +++ b/crates/hah-checks/src/sysctl.rs @@ -83,7 +83,6 @@ mod tests { fn make_ctx() -> Context { Context { - dry_run: false, verbose: false, config: Config::default(), distro: DistroInfo::default(), diff --git a/crates/hah-core/src/check.rs b/crates/hah-core/src/check.rs index 5d611d0..1141f0c 100644 --- a/crates/hah-core/src/check.rs +++ b/crates/hah-core/src/check.rs @@ -8,7 +8,6 @@ use crate::{ }; pub struct Context { - pub dry_run: bool, pub verbose: bool, pub config: Config, pub distro: DistroInfo, @@ -16,9 +15,8 @@ pub struct Context { } impl Context { - pub fn new(dry_run: bool, verbose: bool, config: Config, distro: DistroInfo) -> Self { + pub fn new(verbose: bool, config: Config, distro: DistroInfo) -> Self { Self { - dry_run, verbose, config, distro, @@ -28,14 +26,12 @@ impl Context { /// 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, @@ -56,15 +52,13 @@ mod tests { #[test] fn context_new_defaults_to_system_runner() { - let ctx = Context::new(false, true, Config::default(), DistroInfo::default()); - assert!(!ctx.dry_run); + let ctx = Context::new(true, Config::default(), DistroInfo::default()); assert!(ctx.verbose); } #[test] - fn context_new_dry_run_flag() { - let ctx = Context::new(true, false, Config::default(), DistroInfo::default()); - assert!(ctx.dry_run); + fn context_new_verbose_flag() { + let ctx = Context::new(false, Config::default(), DistroInfo::default()); assert!(!ctx.verbose); } } diff --git a/crates/hah-core/src/model.rs b/crates/hah-core/src/model.rs index af58a05..d6ef250 100644 --- a/crates/hah-core/src/model.rs +++ b/crates/hah-core/src/model.rs @@ -11,8 +11,6 @@ pub enum Severity { 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)] @@ -31,12 +29,11 @@ pub struct CheckResult { } impl Remediation { - /// Create a new remediation with an empty command list, marked unsafe by default. + /// Create a new remediation with an empty command list. pub fn new(description: impl Into) -> Self { Self { description: description.into(), commands: Vec::new(), - safe: false, } } @@ -45,11 +42,6 @@ impl Remediation { 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 { @@ -100,11 +92,10 @@ mod tests { } #[test] - fn remediation_new_has_empty_commands_and_is_unsafe() { + fn remediation_new_has_empty_commands() { let r = Remediation::new("Fix it"); assert_eq!(r.description, "Fix it"); assert!(r.commands.is_empty()); - assert!(!r.safe); } #[test] @@ -119,12 +110,6 @@ mod tests { 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); diff --git a/crates/hah-core/src/output.rs b/crates/hah-core/src/output.rs index 8b4325b..af7fa4d 100644 --- a/crates/hah-core/src/output.rs +++ b/crates/hah-core/src/output.rs @@ -100,7 +100,6 @@ mod tests { remediation: with_remediation.then(|| Remediation { description: "Fix it".into(), commands: vec!["sudo fix".into()], - safe: true, }), } } diff --git a/crates/hah-dsl/src/rule.rs b/crates/hah-dsl/src/rule.rs index 1d4f8e3..873e460 100644 --- a/crates/hah-dsl/src/rule.rs +++ b/crates/hah-dsl/src/rule.rs @@ -627,7 +627,6 @@ impl RuleBasedCheck { .iter() .map(|c| render_template(c, values)) .collect(), - safe: rem.safe, }); Finding { id: render_template(&out.finding_id, values), @@ -838,7 +837,6 @@ rules: 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(), @@ -880,7 +878,6 @@ rules: mock.expect_run() .returning(move |_, _| ok_output(&df_output)); let ctx = Context::new_with_runner( - false, false, Config::default(), DistroInfo::default(), @@ -912,7 +909,7 @@ rules: description: "" "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); // Default DistroInfo is not Debian family. let cr = check.run(&ctx); assert!(cr.findings.is_empty()); @@ -1009,7 +1006,7 @@ rules: outcome: { finding_id: x, title: "", description: "" } "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); let cr = check.run(&ctx); // Non-existent path → no conflicts, no errors, no findings. assert!(cr.errors.is_empty()); @@ -1029,7 +1026,7 @@ rules: outcome: { finding_id: x, title: "", description: "" } "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); let cr = check.run(&ctx); assert!(!cr.errors.is_empty()); } @@ -1228,7 +1225,7 @@ rules: outcome: { finding_id: x, title: "Legacy found", description: "" } "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); let mut map = hah_core::runner::MockCommandRunner::default(); map.expect_run().returning(|_, _| { Ok(hah_core::runner::CommandOutput { @@ -1313,7 +1310,7 @@ rules: outcome: { finding_id: x, title: "", description: "" } "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); // Default config profile is "" which does not match "server". assert!(!check.guard_passes(&ctx)); } @@ -1335,7 +1332,7 @@ rules: profile: "server".to_string(), ..Default::default() }; - let ctx = Context::new(false, false, config, DistroInfo::default()); + let ctx = Context::new(false, config, DistroInfo::default()); assert!(check.guard_passes(&ctx)); } @@ -1352,7 +1349,7 @@ rules: outcome: { finding_id: x, title: "", description: "" } "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); assert!(!check.guard_passes(&ctx)); } @@ -1369,7 +1366,7 @@ rules: outcome: { finding_id: x, title: "", description: "" } "#, ); - let ctx = Context::new(false, false, Config::default(), DistroInfo::default()); + let ctx = Context::new(false, Config::default(), DistroInfo::default()); assert!(check.guard_passes(&ctx)); } @@ -1416,7 +1413,6 @@ rules: mock.expect_run() .returning(|_, _| ok_output("install ok installed")); let ctx = Context::new_with_runner( - false, false, Config::default(), DistroInfo::default(), @@ -1434,7 +1430,6 @@ rules: mock.expect_run() .returning(|_, _| ok_output("deinstall ok deinstalled")); let ctx = Context::new_with_runner( - false, false, Config::default(), DistroInfo::default(), @@ -1457,7 +1452,6 @@ rules: }) }); let ctx = Context::new_with_runner( - false, false, Config::default(), DistroInfo::default(), @@ -1479,7 +1473,6 @@ rules: }) }); let ctx = Context::new_with_runner( - false, false, Config::default(), DistroInfo::default(), @@ -1522,7 +1515,6 @@ rules: let finding = check.make_finding(Severity::Warning, &values); let rem = finding.remediation.unwrap(); assert_eq!(rem.description, "Own fix."); - assert!(rem.safe); } #[test] @@ -1543,7 +1535,7 @@ rules: ); let mut config = Config::default(); config.thresholds.insert("boot_space_mb".to_string(), 100); - let ctx = Context::new(false, false, config, DistroInfo::default()); + let ctx = Context::new(false, config, DistroInfo::default()); let cr = check.run(&ctx); // 100 > 0 → condition fires assert_eq!(cr.findings.len(), 1); @@ -1572,7 +1564,6 @@ rules: mock.expect_run() .returning(|_, _| ok_output("not_a_number\n")); let ctx = Context::new_with_runner( - false, false, Config::default(), DistroInfo::default(), @@ -1603,7 +1594,7 @@ rules: id_like: "debian".into(), ..DistroInfo::default() }; - let ctx = Context::new(false, false, Config::default(), distro); + let ctx = Context::new(false, Config::default(), distro); let cr = check.run(&ctx); assert_eq!(cr.findings.len(), 1); } diff --git a/crates/hah/src/cli.rs b/crates/hah/src/cli.rs index efb9376..22ce86e 100644 --- a/crates/hah/src/cli.rs +++ b/crates/hah/src/cli.rs @@ -15,14 +15,6 @@ pub struct Cli { 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, @@ -66,15 +58,7 @@ mod tests { #[test] fn parse_scan_defaults() { - if let Command::Scan { - dry_run, - fix, - output, - check, - } = parse(&["hah", "scan"]).command - { - assert!(!dry_run); - assert!(!fix); + if let Command::Scan { output, check } = parse(&["hah", "scan"]).command { assert!(matches!(output, OutputFormat::Terminal)); assert!(check.is_none()); } else { @@ -82,24 +66,6 @@ mod tests { } } - #[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 { diff --git a/crates/hah/src/main.rs b/crates/hah/src/main.rs index 166007e..58ca59d 100644 --- a/crates/hah/src/main.rs +++ b/crates/hah/src/main.rs @@ -16,14 +16,9 @@ use hah_core::{ /// 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, - } => { + Command::Scan { output, check } => { let all = registry::all_checks(&config); - let ctx = Context::new(!fix, false, config, distro); + let ctx = Context::new(false, config, distro); let checks: Vec<_> = match &check { Some(id) => all.into_iter().filter(|c| c.id() == id).collect(), None => all, @@ -149,15 +144,6 @@ mod tests { ); } - #[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. diff --git a/crates/hah/tests/integration.rs b/crates/hah/tests/integration.rs index e453bb4..4294a7a 100644 --- a/crates/hah/tests/integration.rs +++ b/crates/hah/tests/integration.rs @@ -63,15 +63,6 @@ fn scan_yaml_output_exits_cleanly() { 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() From e6a8136c3fc8d6559db8a945f288371e0522e14b Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Sat, 16 May 2026 18:47:54 +0200 Subject: [PATCH 2/2] docs: Adjust the documentation --- README.md | 159 ++++++++++++------------------------------- docs/architecture.md | 6 +- docs/dev/README.md | 31 +++++++++ docs/dev/utils.md | 37 ++++++++++ docs/roadmap.md | 3 - docs/user/README.md | 43 ++++++++++++ 6 files changed, 156 insertions(+), 123 deletions(-) create mode 100644 docs/dev/README.md create mode 100644 docs/dev/utils.md create mode 100644 docs/user/README.md diff --git a/README.md b/README.md index 0f47594..5488f2b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # HaH -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. +HaH is a diagnostic utility for inspecting Linux systems. The goal is to detect common system maintenance problems, explain why they matter, and offer safe remediation suggestions. ## Usage -``` +```bash hah ``` @@ -17,12 +17,10 @@ hah ### `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`) | +| Option | Default | Description | +| ------------------- | -------------- | -------------------------------------------- | +| `--output ` | `terminal` | Output format: `terminal`, `json`, or `yaml` | +| `--check ` | _(all checks)_ | Run only the single check with this ID | ### Exit codes @@ -31,35 +29,15 @@ hah | `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" +## Documentation -disabled_checks: - - broken-symlinks # skip this check entirely -``` +- [User Guide](docs/user/README.md) — Getting started and usage +- [Configuration Guide](docs/config.md) — Customizing thresholds and filters +- [Built-in Checks](docs/checks.md) — List of what HaH detects +- [DSL Reference](docs/dsl.md) — Writing custom YAML rules +- [Developer Guide](docs/dev/README.md) — Working on the HaH codebase --- @@ -75,98 +53,45 @@ HaH is intended to help with: - network configuration hygiene (NTP, DHCP, DNS, interface management) - general system health checks -## Target Problems - -### Boot and Kernel Maintenance +## Capabilities -- 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 +HaH detects a wide range of system maintenance issues and provides information on why they matter, along with remediation suggestions. -### Drivers and DKMS +### Boot and Kernel -- 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 +- **Disk Space**: Low free space on `/boot`. +- **Cleanup**: Unused kernels and stale kernel headers/modules. +- **Configuration**: Suboptimal initramfs compression or oversized images. +- **Drivers**: DKMS modules that fail to build or broken driver states. -### APT and Repository Cleanup +### Package Hygiene (APT, Snap, Dpkg) -- 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` +- **State**: Failed or partial package states (`dpkg --audit`). +- **Cleanup**: Residual configuration files (`rc` state) and auto-removable packages. +- **Security**: Deprecated `apt-key` usage and legacy repository formats. +- **Conflicts**: Software duplicated across multiple package managers (e.g., APT and Snap). +- **Custom Rules**: Support for user-defined package denylists via configuration. -### Package Hygiene +### Network Configuration -- 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 +- **Redundancy**: Multiple active NTP or DHCP clients causing management overlap. +- **Legacy**: Outdated network tooling (`ifupdown`, `ntp`) alongside modern managers. +- **Resolved**: Incorrect `systemd-resolved` stub resolver configuration. -### Snap and Cross-Package-Manager Conflicts +### System Drift and Tuning -- 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 +- **Integrity**: Broken symbolic links and stale systemd units. +- **Resources**: Excessive journal growth and old crash dumps. +- **Kernel Tuning**: Conflicting or redundant `sysctl` parameters across different files. -### 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. +HaH is evolving into a comprehensive diagnostic assistant for long-lived Linux systems. Future goals include: + +- **Audit Reports**: Generation of detailed maintenance reports in HTML or Markdown. +- **System Profiles**: Check sets tailored for specific roles (Desktop, Server, Container). +- **Extended Diagnostics**: Integration with SMART data, filesystem health, and hardware metrics. +- **Release Lifecycle**: Detection of unsupported end-of-life distribution releases. +- **DSL Expansion**: More powerful data sources and filtering for the YAML rule engine. diff --git a/docs/architecture.md b/docs/architecture.md index 0141f06..9f6d3b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,11 +32,11 @@ hah | `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` | +| `Remediation` | `model.rs` | Description and suggested shell commands | +| `Context` | `check.rs` | Passed to every check: `Config`, `DistroInfo`, `CommandRunner`, `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 | +| `CommandRunner` | `runner.rs` | Trait for executing shell commands for data collection; mocked in tests | ### RuleValue (hah-dsl) diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 0000000..5804ec3 --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,31 @@ +# Developer Guide + +Welcome to the HaH development documentation. This section covers the internals, project structure, and how to contribute to HaH. + +## Project Structure + +HaH is organized as a Cargo workspace: + +- `hah`: The main CLI binary. +- `hah-core`: Core data models, traits, and common functionality. +- `hah-checks`: Implementations of built-in diagnostic checks. +- `hah-dsl`: YAML rule engine for custom, data-driven checks. +- `hah-utils`: Low-level shared utilities and library facades. + +## Key Concepts + +- **Checks**: Units of diagnostic logic that implement the `Check` trait. +- **Findings**: Results returned by checks, containing a severity and remediation suggestions. +- **Capabilities**: (DSL only) Data sources (like `apt`, `files`, `sysctl`) that rules can query. + +## Documentation Index + +- [Architecture Overview](architecture.md) +- [DSL Language Reference](../dsl.md) +- [Utilities Library (hah-utils)](utils.md) +- [Project Plan](plan.md) +- [Roadmap](roadmap.md) + +## Development Workflow + +Refer to the [Agent Guidelines](../../AGENTS.md) for the full quality gate requirements (`make check`). diff --git a/docs/dev/utils.md b/docs/dev/utils.md new file mode 100644 index 0000000..e00e47e --- /dev/null +++ b/docs/dev/utils.md @@ -0,0 +1,37 @@ +# hah-utils + +The `hah-utils` crate provides low-level shared utilities and facades for third-party libraries used across the HaH workspace. It serves as a HAL (Hardware Abstraction Layer) of sorts for OS and library interactions, ensuring consistency and making it easier to swap out dependencies. + +## Modules + +### `fs` + +Filesystem helpers including: + +- ID sanitization for filenames. +- Helpers for walking directories to find broken symlinks. +- Scanning for files older than a certain duration. + +### `json` + +Pretty-printing and serialization helpers for JSON output. + +### `paths` + +Utilities for resolving platform-specific configuration and data directories (e.g., following XDG Base Directory Specification on Linux). + +### `size` + +Parsing and formatting for human-readable byte sizes (e.g., "100MB", "1.5GiB"). + +### `sysctl` + +Algorithms for detecting conflicts in `sysctl` configurations, such as multiple files setting the same key to different values. + +### `yaml` + +Thin wrapper around YAML parsing and serialization to ensure consistent configuration throughout the workspace. + +## Philosophy + +Other crates in the workspace should generally prefer using `hah-utils` over adding direct dependencies on common low-level crates like `serde_json`, `walkdir`, or `directories`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 06da5d4..b7b212a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,9 +8,6 @@ Planned features that are not yet implemented. Items are roughly ordered by prio | 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 | diff --git a/docs/user/README.md b/docs/user/README.md new file mode 100644 index 0000000..6aff01d --- /dev/null +++ b/docs/user/README.md @@ -0,0 +1,43 @@ +# User Guide + +HaH is a diagnostic tool for Linux systems. It scans your system for common maintenance issues and provides information on why they might be problematic and how to address them. + +## Installation + +HaH is currently in development. You can build it from source using Rust: + +```bash +cargo build --release +``` + +The binary will be available at `target/release/hah`. + +## Basic Usage + +Run a full system scan: + +```bash +hah scan +``` + +Scan for specific issues: + +```bash +hah scan --check boot-space +``` + +List all available checks: + +```bash +hah list-checks +``` + +## Remediation + +When HaH finds an issue, it provides a "Remediation" section. This contains suggestions on how to fix the problem. + +**Important:** HaH only detects issues. It is up to you to evaluate the suggestions and decide whether to execute any commands or make changes to your system. HaH will never modify your system automatically. + +## Configuration + +See the [Configuration Guide](../config.md) for details on how to customize thresholds and filter results.