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: 2 additions & 1 deletion .copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ You are an expert Rust developer working on the `HaH` project. Follow these proj

## Project Architecture
- `hah-core`: The base logic, models, and rule engine.
- `hah-checks`: Implementations of specific system checks (Apt, Boot, Network, etc.).
- `hah-dsl`: YAML rule engine, pipeline evaluator, and capability functions.
- `hah-utils`: Low-level shared utilities and library facades.
- `hah`: The CLI entry point.

## Coding Patterns
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
members = [
"crates/hah",
"crates/hah-core",
"crates/hah-caps",
"crates/hah-dsl",
"crates/hah-checks",
"crates/hah-utils",
]
resolver = "2"
Expand Down
3 changes: 2 additions & 1 deletion crates/hah-checks/Cargo.toml → crates/hah-caps/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
name = "hah-checks"
name = "hah-caps"
version = "0.1.0"
edition = "2024"

Expand All @@ -12,6 +12,7 @@ hah-utils = { path = "../hah-utils" }
anyhow = "1"

[dev-dependencies]
hah-core = { path = "../hah-core", features = ["mock"] }
mockall = { workspace = true }
tempfile = "3"
filetime = "0.2"
98 changes: 98 additions & 0 deletions crates/hah-caps/src/apt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! APT package capabilities: installed denylist.

use std::collections::HashSet;

use anyhow::{Result, anyhow};
use hah_core::{config::DenylistEntry, runner::CommandRunner};

use crate::CapValue;

/// Return a list of `"name|reason"` strings for denylist packages that are
/// currently installed.
pub fn installed_denylist(
runner: &dyn CommandRunner,
packages: &[DenylistEntry],
) -> Result<CapValue> {
if packages.is_empty() {
return Ok(CapValue::List(vec![]));
}
let out = runner
.run("dpkg-query", &["-W", "-f=${Package}\n"])
.map_err(|e| anyhow!("dpkg-query: {e}"))?;

let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let installed: HashSet<&str> = stdout.lines().collect();

let matches: Vec<String> = packages
.iter()
.filter(|p| installed.contains(p.name.as_str()))
.map(|p| format!("{}|{}", p.name, p.reason))
.collect();

Ok(CapValue::List(matches))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use hah_core::runner::{CommandOutput, MockCommandRunner};
use std::io;

fn ok_out(stdout: &str) -> io::Result<CommandOutput> {
Ok(CommandOutput {
stdout: stdout.as_bytes().to_vec(),
stderr: vec![],
success: true,
})
}

#[test]
fn empty_packages_returns_empty() {
let mock = MockCommandRunner::new();
let result = installed_denylist(&mock, &[]).unwrap();
assert_eq!(result, CapValue::List(vec![]));
}

#[test]
fn finds_matching_package() {
let mut mock = MockCommandRunner::new();
mock.expect_run()
.returning(|_, _| ok_out("bash\nbad-pkg\nvim\n"));
let packages = vec![DenylistEntry {
name: "bad-pkg".into(),
reason: "insecure".into(),
}];
let result = installed_denylist(&mock, &packages).unwrap();
let CapValue::List(items) = result else {
panic!("expected list");
};
assert_eq!(items.len(), 1);
assert!(items[0].contains("bad-pkg"));
assert!(items[0].contains("insecure"));
}

#[test]
fn no_match_returns_empty() {
let mut mock = MockCommandRunner::new();
mock.expect_run().returning(|_, _| ok_out("bash\nvim\n"));
let packages = vec![DenylistEntry {
name: "bad-pkg".into(),
reason: "insecure".into(),
}];
let result = installed_denylist(&mock, &packages).unwrap();
assert_eq!(result, CapValue::List(vec![]));
}

#[test]
fn propagates_error() {
let mut mock = MockCommandRunner::new();
mock.expect_run()
.returning(|_, _| Err(io::Error::new(io::ErrorKind::NotFound, "not found")));
let packages = vec![DenylistEntry {
name: "x".into(),
reason: "y".into(),
}];
assert!(installed_denylist(&mock, &packages).is_err());
}
}
244 changes: 244 additions & 0 deletions crates/hah-caps/src/files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
//! Filesystem scan capabilities: old files and broken symlinks.

use std::path::Path;

use anyhow::Result;

use crate::CapValue;

const DEFAULT_CRASH_DIRS: &[&str] = &["/var/crash", "/var/lib/systemd/coredump"];
const DEFAULT_SYMLINK_DIRS: &[&str] = &["/etc", "/usr/lib", "/var/lib"];

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()
}
}

/// Return a list of file paths that have not been modified for at least
/// `older_than_days` days.
///
/// Scans `/var/crash` and `/var/lib/systemd/coredump` when `dirs` is empty.
pub fn old_files(dirs: &[String], older_than_days: u64) -> Result<CapValue> {
let effective = effective_dirs(dirs, DEFAULT_CRASH_DIRS);
let files: Vec<String> = hah_utils::fs::scan_old_files(&effective, older_than_days)
.into_iter()
.map(|f| f.path.to_string_lossy().into_owned())
.collect();
Ok(CapValue::List(files))
}

/// Return a list of paths that are broken symbolic links.
///
/// Scans `/etc`, `/usr/lib`, and `/var/lib` when `dirs` is empty.
pub fn broken_symlinks(dirs: &[String]) -> Result<CapValue> {
let effective = effective_dirs(dirs, DEFAULT_SYMLINK_DIRS);
let broken: Vec<String> = hah_utils::fs::broken_symlinks(&effective)
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
Ok(CapValue::List(broken))
}

/// Return a list of file paths using the legacy one-line `deb`/`deb-src`
/// APT source format.
///
/// Checks `/etc/apt/sources.list` and all `*.list` files in
/// `/etc/apt/sources.list.d/`.
pub fn legacy_apt_sources() -> Result<CapValue> {
collect_legacy_sources(
Path::new("/etc/apt/sources.list"),
Path::new("/etc/apt/sources.list.d"),
)
}

pub(crate) fn collect_legacy_sources(sources_list: &Path, sources_d: &Path) -> Result<CapValue> {
use std::fs;

let mut legacy: Vec<String> = Vec::new();

if sources_list.exists()
&& fs::read_to_string(sources_list).is_ok_and(|content| {
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")
&& fs::read_to_string(&path).is_ok_and(|content| {
content
.lines()
.any(|l| l.starts_with("deb ") || l.starts_with("deb-src "))
})
{
legacy.push(path.to_string_lossy().into_owned());
}
}
}

Ok(CapValue::List(legacy))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
use tempfile::TempDir;

// ── 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, CapValue::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, CapValue::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();
let result = old_files(&[dir], 30).unwrap();
assert_eq!(result, CapValue::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();
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 CapValue::List(items) = result else {
panic!("expected list");
};
assert_eq!(items.len(), 1);
assert!(items[0].contains("old.log"));
}

#[test]
fn old_files_uses_default_dirs_when_empty() {
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, CapValue::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 CapValue::List(items) = result else {
panic!("expected list");
};
assert_eq!(items.len(), 1);
assert!(items[0].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, CapValue::List(vec![]));
}

#[test]
fn broken_symlinks_uses_default_dirs_when_empty() {
let result = broken_symlinks(&[]);
assert!(result.is_ok());
}

// ── legacy_apt_sources ────────────────────────────────────────────────────

#[test]
fn legacy_apt_sources_returns_ok() {
let result = legacy_apt_sources();
assert!(result.is_ok());
}

#[test]
fn collect_legacy_sources_detects_deb_line() {
let tmp = TempDir::new().unwrap();
let list = tmp.path().join("sources.list");
std::fs::write(&list, "deb http://archive.ubuntu.com/ubuntu focal main\n").unwrap();
let d = tmp.path().join("sources.list.d");
std::fs::create_dir_all(&d).unwrap();
let result = collect_legacy_sources(&list, &d).unwrap();
let CapValue::List(items) = result else {
panic!("expected list");
};
assert_eq!(items.len(), 1);
}

#[test]
fn collect_legacy_sources_detects_list_files_in_dir() {
let tmp = TempDir::new().unwrap();
let list = tmp.path().join("sources.list");
std::fs::write(&list, "# no deb lines\n").unwrap();
let d = tmp.path().join("sources.list.d");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("extra.list"),
"deb-src http://example.com/ stable main\n",
)
.unwrap();
std::fs::write(d.join("modern.sources"), "Types: deb\n").unwrap();
let result = collect_legacy_sources(&list, &d).unwrap();
let CapValue::List(items) = result else {
panic!("expected list");
};
assert_eq!(items.len(), 1);
assert!(items[0].contains("extra.list"));
}

#[test]
fn collect_legacy_sources_empty_when_no_deb_lines() {
let tmp = TempDir::new().unwrap();
let list = tmp.path().join("sources.list");
std::fs::write(&list, "# comment only\n").unwrap();
let d = tmp.path().join("sources.list.d");
std::fs::create_dir_all(&d).unwrap();
let result = collect_legacy_sources(&list, &d).unwrap();
assert_eq!(result, CapValue::List(vec![]));
}
}
Loading
Loading