Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ jobs:

- name: Code metrics
run: make metrics

- name: Validate rules
run: make validate
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all setup fmt fmt-check lint test audit coverage coverage-ci metrics check doc-dependencies
.PHONY: all setup fmt fmt-check lint test audit coverage coverage-ci metrics check doc-dependencies validate

# Configuration for quality gates
COVERAGE_MIN_THRESHOLD ?= 95
Expand Down Expand Up @@ -47,5 +47,8 @@ doc-dependencies:
check-dependencies:
cargo run --manifest-path tools/hah-deps/Cargo.toml --release --quiet -- --check

check: fmt-check lint test audit coverage-ci metrics check-dependencies
validate:
cargo run -p hah --quiet -- validate rules/

check: fmt-check lint test audit coverage-ci metrics validate check-dependencies

46 changes: 2 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ hah <COMMAND>
| ----------------- | ------------------------------------------------- |
| `hah scan` | Run all enabled checks and report findings |
| `hah list-checks` | List every registered check with its ID and title |
| `hah validate` | Validate rule file syntax without running checks |

### `hah scan` options

Expand All @@ -35,7 +36,7 @@ hah <COMMAND>

- [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
- [Shipped Checks](docs/checks.md) — List shipped rules and how to browse them
- [DSL Reference](docs/dsl.md) — Writing custom YAML rules
- [Developer Guide](docs/dev/README.md) — Working on the HaH codebase

Expand All @@ -52,46 +53,3 @@ HaH is intended to help with:
- duplicate software installs across package managers
- network configuration hygiene (NTP, DHCP, DNS, interface management)
- general system health checks

## Capabilities

HaH detects a wide range of system maintenance issues and provides information on why they matter, along with remediation suggestions.

### Boot and Kernel

- **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.

### Package Hygiene (APT, Snap, Dpkg)

- **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.

### Network Configuration

- **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.

### System Drift and Tuning

- **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.

---

## Future Direction

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.
106 changes: 5 additions & 101 deletions crates/hah-dsl/src/expr.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Strongly typed expression AST for the HaH DSL.

use crate::pipeline::{Filter, RuleValue, ValueMap};
use crate::pipeline::{RuleValue, ValueMap};
use anyhow::{Result, anyhow};

#[derive(Debug, Clone, PartialEq)]
Expand All @@ -26,7 +26,7 @@ impl Expression {
for arg in args {
evaled_args.push(arg.eval(values)?);
}
apply_filter_new(RuleValue::Null, name, evaled_args)
apply_filter(RuleValue::Null, name, evaled_args)
}
Self::Pipeline(steps) => {
if steps.is_empty() {
Expand All @@ -41,7 +41,7 @@ impl Expression {
for arg in args {
evaled_args.push(arg.eval(values)?);
}
apply_filter_new(current, name, evaled_args)?
apply_filter(current, name, evaled_args)?
}
_ => return Err(anyhow!("Expected filter in pipeline, got {:?}", step)),
};
Expand All @@ -52,107 +52,11 @@ impl Expression {
}
}

fn apply_filter_new(value: RuleValue, name: &str, args: Vec<RuleValue>) -> Result<RuleValue> {
let filter = build_filter(name, args)?;
fn apply_filter(value: RuleValue, name: &str, args: Vec<RuleValue>) -> Result<RuleValue> {
let filter = crate::filters::build::build_filter(name, args)?;
crate::filters::apply(value, &filter)
}

fn build_filter(name: &str, args: Vec<RuleValue>) -> Result<Filter> {
// Zero-argument filters
if let Some(f) = zero_arg_filter(name) {
return Ok(f);
}
// Filters that take an integer argument
if let Some(f) = int_arg_filter(name, &args)? {
return Ok(f);
}
// Filters that take a string argument
if let Some(f) = str_arg_filter(name, &args)? {
return Ok(f);
}
// Filters that take a list argument
if let Some(f) = list_arg_filter(name, &args)? {
return Ok(f);
}
Err(anyhow!("Unknown filter: {}", name))
}

fn zero_arg_filter(name: &str) -> Option<Filter> {
match name {
"trim" => Some(Filter::Trim),
"lines" => Some(Filter::Lines),
"non_empty" => Some(Filter::NonEmpty),
"first" => Some(Filter::First),
"last" => Some(Filter::Last),
"number" => Some(Filter::Number),
"count" => Some(Filter::Count),
"sort" => Some(Filter::Sort),
"unique" => Some(Filter::Unique),
"bytes_to_mb" => Some(Filter::BytesToMb),
_ => None,
}
}

fn int_arg_filter(name: &str, args: &[RuleValue]) -> Result<Option<Filter>> {
let n = || -> Result<usize> {
args.first()
.and_then(RuleValue::as_int)
.map(|n| n as usize)
.ok_or_else(|| anyhow!("{} requires an integer argument", name))
};
match name {
"skip" => Ok(Some(Filter::Skip(n()?))),
"nth" => Ok(Some(Filter::Nth(n()?))),
"field" => Ok(Some(Filter::Field(n()?))),
"group_count" => Ok(Some(Filter::GroupCount(n()?))),
"where_gt" => {
let v = args
.first()
.and_then(RuleValue::as_int)
.ok_or_else(|| anyhow!("where_gt requires an integer argument"))?;
Ok(Some(Filter::WhereGt(v)))
}
_ => Ok(None),
}
}

fn require_str_arg<'a>(name: &str, args: &'a [RuleValue]) -> Result<&'a str> {
args.first()
.and_then(RuleValue::as_str)
.ok_or_else(|| anyhow!("{} requires a string argument", name))
}

fn str_arg_filter(name: &str, args: &[RuleValue]) -> Result<Option<Filter>> {
let ctor: fn(String) -> Filter = match name {
"prefix_strip" => Filter::PrefixStrip,
"starts_with" => Filter::StartsWith,
"contains" => Filter::Contains,
"reject_contains" => Filter::RejectContains,
"icontains" => Filter::IContains,
"join" => Filter::Join,
"default" => Filter::Default,
_ => return Ok(None),
};
Ok(Some(ctor(require_str_arg(name, args)?.to_string())))
}

fn list_arg_filter(name: &str, args: &[RuleValue]) -> Result<Option<Filter>> {
match name {
"intersect" | "reject_in" => {
let items = args
.first()
.and_then(RuleValue::as_list)
.ok_or_else(|| anyhow!("{name} requires a list argument"))?;
let strings: Vec<String> = items.iter().map(RuleValue::display).collect();
Ok(Some(match name {
"intersect" => Filter::Intersect(strings),
_ => Filter::RejectIn(strings),
}))
}
_ => Ok(None),
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
Expand Down
120 changes: 120 additions & 0 deletions crates/hah-dsl/src/filters/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Filter construction: maps filter names and arguments to [`Filter`] variants.

use crate::pipeline::{Filter, RuleValue};
use anyhow::{Result, anyhow};

macro_rules! zero_arg_filters {
($name:expr; $( $filter_name:literal => $variant:ident ),+ $(,)?) => {
match $name {
$( $filter_name => Some(Self::$variant), )+
_ => None,
}
};
}

macro_rules! unary_arg_filters {
($name:expr, $arg:expr; $( $filter_name:literal => $variant:ident ),+ $(,)?) => {
match $name {
$( $filter_name => Ok(Some(Self::$variant($arg))), )+
_ => Ok(None),
}
};
}

/// Build a [`Filter`] from a name and evaluated arguments.
pub fn build_filter(name: &str, args: Vec<RuleValue>) -> Result<Filter> {
Filter::build(name, args)
}

impl Filter {
pub fn build(name: &str, args: Vec<RuleValue>) -> Result<Self> {
if let Some(filter) = Self::zero_arg(name) {
return Ok(filter);
}
if let Some(filter) = Self::int_arg(name, &args)? {
return Ok(filter);
}
if let Some(filter) = Self::str_arg(name, &args)? {
return Ok(filter);
}
if let Some(filter) = Self::list_arg(name, &args)? {
return Ok(filter);
}
Err(anyhow!("Unknown filter: {}", name))
}

fn zero_arg(name: &str) -> Option<Self> {
zero_arg_filters!(name;
"trim" => Trim,
"lines" => Lines,
"non_empty" => NonEmpty,
"first" => First,
"last" => Last,
"number" => Number,
"count" => Count,
"sort" => Sort,
"unique" => Unique,
"bytes_to_mb" => BytesToMb,
)
}

fn int_arg(name: &str, args: &[RuleValue]) -> Result<Option<Self>> {
match name {
"where_gt" => Ok(Some(Self::WhereGt(Self::require_int_arg(name, args)?))),
_ => unary_arg_filters!(name, Self::require_usize_arg(name, args)?;
"skip" => Skip,
"nth" => Nth,
"field" => Field,
"group_count" => GroupCount,
),
}
}

fn require_int_arg(name: &str, args: &[RuleValue]) -> Result<i64> {
args.first()
.and_then(RuleValue::as_int)
.ok_or_else(|| anyhow!("{} requires an integer argument", name))
}

fn require_usize_arg(name: &str, args: &[RuleValue]) -> Result<usize> {
Ok(Self::require_int_arg(name, args)? as usize)
}

fn require_str_arg<'a>(name: &str, args: &'a [RuleValue]) -> Result<&'a str> {
args.first()
.and_then(RuleValue::as_str)
.ok_or_else(|| anyhow!("{} requires a string argument", name))
}

fn require_list_arg<'a>(name: &str, args: &'a [RuleValue]) -> Result<&'a [RuleValue]> {
args.first()
.and_then(RuleValue::as_list)
.ok_or_else(|| anyhow!("{name} requires a list argument"))
}

fn str_arg(name: &str, args: &[RuleValue]) -> Result<Option<Self>> {
unary_arg_filters!(name, Self::require_str_arg(name, args)?.to_string();
"prefix_strip" => PrefixStrip,
"starts_with" => StartsWith,
"contains" => Contains,
"reject_contains" => RejectContains,
"icontains" => IContains,
"join" => Join,
"default" => Default,
)
}

fn list_arg(name: &str, args: &[RuleValue]) -> Result<Option<Self>> {
match name {
"intersect" | "reject_in" => {
let items = Self::require_list_arg(name, args)?;
let strings: Vec<String> = items.iter().map(RuleValue::display).collect();
Ok(Some(match name {
"intersect" => Self::Intersect(strings),
_ => Self::RejectIn(strings),
}))
}
_ => Ok(None),
}
}
}
Loading
Loading