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
13 changes: 7 additions & 6 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ jobs:
uses: Swatinem/rust-cache@v2

- name: Check formatting
run: cargo fmt --all -- --check
run: make fmt-check

- name: Clippy (deny warnings)
run: cargo clippy --all-targets -- -D warnings
run: make lint

- name: Run tests
run: cargo test --all
run: make test

- name: Security audit
uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
run: make audit

- name: Code metrics
run: make metrics
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
/tools/hah-metrics/target
Cargo.lock
32 changes: 18 additions & 14 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,44 +1,48 @@
.PHONY: all setup fmt fmt-check lint test audit check doc-dependencies
.PHONY: all setup fmt fmt-check lint test audit coverage coverage-ci metrics check doc-dependencies

# Configuration for quality gates
COVERAGE_MIN_THRESHOLD ?= 95
METRIC_MAX_COMPLEXITY ?= 15
METRIC_MAX_LENGTH ?= 60

all: check

## Install required Cargo tools (run once)
## Install required Cargo tools and pre-build the metrics analyser (run once)
setup:
rustup component add llvm-tools-preview clippy rustfmt
cargo install cargo-audit
cargo install cargo-llvm-cov
rustup component add llvm-tools-preview
python3 --version
cargo build --manifest-path tools/hah-metrics/Cargo.toml --release

## 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
cargo llvm-cov --all-targets --workspace --fail-under-lines $(COVERAGE_MIN_THRESHOLD)

## Full quality gate: format-check + lint + test + audit + coverage
check: fmt-check lint test audit coverage-ci
metrics:
cargo run --manifest-path tools/hah-metrics/Cargo.toml --release --quiet -- \
--max-complexity $(METRIC_MAX_COMPLEXITY) \
--max-length $(METRIC_MAX_LENGTH)

## Regenerate DEPENDENCIES.md from live cargo metadata
doc-dependencies:
python3 tools/gen_deps_doc.py > DEPENDENCIES.md

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

85 changes: 42 additions & 43 deletions crates/hah-checks/src/snap.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,47 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};

use hah_core::{
check::{Check, Context},
model::{CheckResult, Finding, Remediation, Severity},
};

// ── Finding builders ────────────────────────────────────────────────────────

fn disabled_revision_finding(name: &str, rev: &str) -> 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,
}),
}
}

fn excess_revisions_finding(name: &str, count: u32, max_revisions: u64) -> 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,
}),
}
}

// ── SnapHealthCheck ──────────────────────────────────────────────────────────

pub struct SnapHealthCheck;
Expand All @@ -20,66 +57,28 @@ impl Check for SnapHealthCheck {

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<String, u32> =
std::collections::HashMap::new();
let mut snap_revisions: HashMap<String, u32> = 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,
}),
});
if fields.last().copied().unwrap_or("").contains("disabled") {
result = result.with_finding(disabled_revision_finding(&name, &rev));
}

*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 = result.with_finding(excess_revisions_finding(name, *count, max_revisions));
}
}
result
Expand Down
Loading
Loading