Private
Public Access
1
0

Compare commits

..

10 Commits

Author SHA1 Message Date
fed5e386ce fix(enroll): skip TLS validation during enrollment bootstrap to allow certificate acquisition
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Failing after 43s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Failing after 56s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 4s
2026-05-17 22:20:48 +00:00
f3555c1570 fix(ci): use github.ref_type for upload conditions to fix Gitea runner compatibility
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m12s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m17s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m22s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m37s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build Alpine Package (push) Successful in 4m8s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m27s
2026-05-17 21:05:43 +00:00
cea162b048 fix(ci): force IPv4 for rustup download on Alpine runner
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 2s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m11s
CI/CD Pipeline / Enrollment Tests (push) Has been cancelled
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been cancelled
CI/CD Pipeline / Build Debian Package (push) Has been cancelled
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been cancelled
CI/CD Pipeline / Build RPM Package (push) Has been cancelled
CI/CD Pipeline / Build Alpine Package (push) Has been cancelled
CI/CD Pipeline / Build Arch Package (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
2026-05-17 20:35:48 +00:00
08493fc782 fix(ci): add openssl runtime package for Alpine musl builds
All checks were successful
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m11s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m1s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m29s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m54s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m36s
CI/CD Pipeline / Build Alpine Package (push) Successful in 3m55s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m45s
2026-05-17 18:40:47 +00:00
8b890625f6 fix(ci): disable reqwest default features to eliminate OpenSSL on musl builds
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 5s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 58s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m40s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m14s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m12s
CI/CD Pipeline / Build Alpine Package (push) Failing after 5m2s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m3s
Requiring default-features=false on reqwest prevents native-tls/openssl-sys
from being pulled in as transitive dependencies, which broke static linking
on Alpine musl target. Also reverts invalid openssl-static package from CI.

- Cargo.toml: add default-features = false to reqwest dependency
- ci.yml: revert non-existent openssl-static package
2026-05-17 17:18:35 +00:00
835c8d79cf fix(ci): add openssl-static for Alpine musl static linking
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m10s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m31s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m17s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m50s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m25s
The Alpine build job links against musl which requires static OpenSSL
libraries. Adding openssl-static package to resolve -lssl and -lcrypto
linker errors.
2026-05-17 17:07:10 +00:00
8fd7d7620a fix(ci): add OpenSSL dev dependencies to all build jobs
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m16s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m9s
CI/CD Pipeline / Build Arch Package (push) Successful in 3m2s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m37s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m47s
CI/CD Pipeline / Build Alpine Package (push) Failing after 4m9s
CI/CD Pipeline / Build RPM Package (push) Successful in 4m56s
Add libssl-dev to Ubuntu-based runners and openssl-devel to Fedora runner
to resolve openssl-sys crate compilation failures in CI pipeline.

- clippy, test, audit: +libssl-dev
- enrollment-tests, verify-enrollment-cli: +libssl-dev
- build-deb, build-deb-u2204: +libssl-dev
- build-rpm (Fedora): +openssl-devel
2026-05-17 16:48:43 +00:00
3e8eacab9a fix(tests): resolve all clippy warnings for CI compliance
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m13s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m15s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m4s
CI/CD Pipeline / Build RPM Package (push) Failing after 42s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m55s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Successful in 2m35s
CI/CD Pipeline / Build Debian Package (push) Successful in 2m37s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m50s
- Remove needless borrows on format!() in set_body_string() calls (needless_borrows_for_generic_args)
- Replace assert!(false, ...) with collected assertion (assertions_on_constants + never_loop)
- Use direct Method::POST comparison instead of to_string() (cmp_owned)
- Simplify negated equality to != operator (nonminimal_bool)

CI pipeline now passes with -D warnings enabled
2026-05-17 16:02:57 +00:00
a09e3eaa68 fix: add truncate(true) to lock file OpenOptions for clippy compliance
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 5s
Resolves clippy::suspicious_open_options warning on whitelist lock file creation.
2026-05-17 15:21:52 +00:00
6cfef766a7 fix: apply cargo fmt to resolve CI formatting failures
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 4s
Format all enrollment module source files and tests per rustfmt standards.
Resolves Gitea CI workflow cargo fmt check failures.
2026-05-17 05:49:26 +00:00
12 changed files with 520 additions and 318 deletions

View File

@ -49,7 +49,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings
@ -71,7 +71,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run tests
run: cargo test --all-features
@ -93,7 +93,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run cargo-audit
run: |
cargo install cargo-audit
@ -118,7 +118,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Run enrollment unit tests
run: cargo test --test enroll_identity
- name: Run enrollment integration tests
@ -145,7 +145,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential libsystemd-dev pkg-config
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
- name: Build binary
run: cargo build
- name: Verify --enroll flag exists
@ -170,12 +170,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev
- name: Build Debian package
run: |
sudo dpkg-buildpackage -us -uc -b -d
- name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/')
if: github.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: |
@ -203,12 +203,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get -f install -y
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev
- name: Build Debian package
run: |
sudo dpkg-buildpackage -us -uc -b -d
- name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/')
if: github.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: |
@ -240,7 +240,7 @@ jobs:
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install build dependencies
run: |
sudo dnf install -y gcc rpm-build systemd-devel pkg-config
sudo dnf install -y gcc rpm-build systemd-devel pkg-config openssl-devel
- name: Build release binary
run: cargo build --release
- name: Build RPM package
@ -248,7 +248,7 @@ jobs:
chmod +x build-rpm.sh
./build-rpm.sh
- name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/')
if: github.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: |
@ -271,13 +271,13 @@ jobs:
- name: Install Rust
run: |
apk add --no-cache curl bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
curl --ipv4 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
. "$HOME/.cargo/env"
rustup target add x86_64-unknown-linux-musl
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install build dependencies
run: |
apk add --no-cache alpine-sdk rust cargo openssl-dev elogind-dev musl-dev abuild gcc
apk add --no-cache alpine-sdk rust cargo openssl openssl-dev elogind-dev musl-dev abuild gcc
- name: Build release binary
run: cargo build --release --target x86_64-unknown-linux-musl
- name: Build Alpine package
@ -285,7 +285,7 @@ jobs:
chmod +x build-alpine.sh
SKIP_CARGO_BUILD=1 ./build-alpine.sh
- name: Upload to Gitea Release
if: startsWith(github.ref, 'refs/tags/')
if: github.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: |

View File

@ -64,7 +64,7 @@ addr = "0.15"
if-addrs = "0.13"
# HTTP client for enrollment communication
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Clap for CLI arguments
clap = { version = "4", features = ["derive", "env"] }

View File

@ -94,23 +94,32 @@ impl WhitelistManager {
// Parse to validate - must be IPv4 or CIDR, no hostnames in auto-append
let parsed_entry = if let Some((ip_str, prefix_str)) = entry_str.split_once('/') {
let ip: Ipv4Addr = ip_str.parse()
let ip: Ipv4Addr = ip_str
.parse()
.with_context(|| format!("Invalid IP in CIDR notation: {}", entry_str))?;
let prefix: u8 = prefix_str.parse()
let prefix: u8 = prefix_str
.parse()
.with_context(|| format!("Invalid prefix in CIDR notation: {}", entry_str))?;
if prefix > 32 {
anyhow::bail!("Invalid CIDR prefix (must be 0-32): {}", entry_str);
}
WhitelistEntry::Cidr { network: ip, prefix }
WhitelistEntry::Cidr {
network: ip,
prefix,
}
} else {
let ip: Ipv4Addr = entry_str.parse()
let ip: Ipv4Addr = entry_str
.parse()
.with_context(|| format!("Invalid IPv4 address: {}", entry_str))?;
WhitelistEntry::Ip(ip)
};
// 2. Check for duplicate in current in-memory state
{
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
let entries = self
.entries
.read()
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
for existing in entries.iter() {
if *existing == parsed_entry {
info!(
@ -129,15 +138,21 @@ impl WhitelistManager {
let lock_path = format!("{}.lock", self.config_path);
let lock_file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("Failed to create lock file: {}", lock_path))?;
lock_file.lock_exclusive().context("Failed to acquire exclusive whitelist lock")?;
lock_file
.lock_exclusive()
.context("Failed to acquire exclusive whitelist lock")?;
// Double-check for duplicates after acquiring lock (concurrent append scenario)
{
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
let entries = self
.entries
.read()
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
for existing in entries.iter() {
if *existing == parsed_entry {
info!(
@ -154,9 +169,12 @@ impl WhitelistManager {
// 4. Read current whitelist YAML or create empty config
let mut config = if Path::new(&self.config_path).exists() {
self.load_config().context("Failed to load existing whitelist for append")?
self.load_config()
.context("Failed to load existing whitelist for append")?
} else {
WhitelistConfig { entries: Vec::new() }
WhitelistConfig {
entries: Vec::new(),
}
};
// 5. Append new entry to allowed_ips list
@ -168,8 +186,9 @@ impl WhitelistManager {
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create whitelist directory: {}", parent.display()))?;
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create whitelist directory: {}", parent.display())
})?;
}
}
@ -182,28 +201,35 @@ impl WhitelistManager {
.create_new(true)
.truncate(true)
.open(&temp_path)
.with_context(|| format!("Failed to create temp whitelist file: {}", temp_path.display()))?;
file.write_all(yaml_content.as_bytes())
.with_context(|| format!("Failed to write whitelist data to: {}", temp_path.display()))?;
file.flush()
.with_context(|| format!("Failed to flush whitelist data to: {}", temp_path.display()))?;
// Atomic rename
fs::rename(&temp_path, config_path)
.with_context(|| {
format!(
"Failed to atomically rename whitelist temp file {} to {}",
temp_path.display(),
config_path.display()
"Failed to create temp whitelist file: {}",
temp_path.display()
)
})?;
file.write_all(yaml_content.as_bytes()).with_context(|| {
format!("Failed to write whitelist data to: {}", temp_path.display())
})?;
file.flush().with_context(|| {
format!("Failed to flush whitelist data to: {}", temp_path.display())
})?;
// Atomic rename
fs::rename(&temp_path, config_path).with_context(|| {
format!(
"Failed to atomically rename whitelist temp file {} to {}",
temp_path.display(),
config_path.display()
)
})?;
// Release lock explicitly before reload (drop happens at end of scope)
drop(lock_file);
// 7. Reload in-memory state
self.reload().context("Failed to reload whitelist after append")?;
self.reload()
.context("Failed to reload whitelist after append")?;
// 8. Log audit event
tracing::info!(

View File

@ -142,16 +142,16 @@ pub struct AppConfig {
impl AppConfig {
/// Load configuration from a YAML file
pub fn load(path: &str) -> Result<Self> {
pub fn load(path: &str, skip_tls_validation: bool) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path))?;
let config: AppConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path))?;
// Validate TLS configuration if enabled
// Validate TLS configuration if enabled (skip during enrollment bootstrap)
if let Some(ref tls) = config.tls {
if tls.enabled {
if tls.enabled && !skip_tls_validation {
if !std::path::Path::new(&tls.ca_cert).exists() {
anyhow::bail!("TLS CA certificate not found: {}", tls.ca_cert);
}

View File

@ -7,7 +7,7 @@
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tokio::signal::unix::{SignalKind, signal as unix_signal};
use tokio::signal::unix::{signal as unix_signal, SignalKind};
use crate::enroll::identity;
@ -99,7 +99,7 @@ impl EnrollmentClient {
.expect("Failed to parse manager URL");
match parsed.scheme() {
"http" | "https" => {}, // Allowed schemes
"http" | "https" => {} // Allowed schemes
other => panic!(
"Invalid manager URL scheme '{}' — only 'http' and 'https' are allowed. \
Refused dangerous scheme to prevent SSRF/path traversal.",
@ -139,12 +139,11 @@ impl EnrollmentClient {
/// - `Err` if URL parsing fails or DNS resolution yields no results
pub async fn manager_ip(&self) -> Result<String> {
// Parse URL to extract host using url crate for RFC-compliant parsing
let parsed = url::Url::parse(&self.manager_url).with_context(|| {
format!("Failed to parse manager URL '{}'", self.manager_url)
})?;
let host_str = parsed.host_str().with_context(|| {
format!("Manager URL '{}' has no host component", self.manager_url)
})?;
let parsed = url::Url::parse(&self.manager_url)
.with_context(|| format!("Failed to parse manager URL '{}'", self.manager_url))?;
let host_str = parsed
.host_str()
.with_context(|| format!("Manager URL '{}' has no host component", self.manager_url))?;
// Check if already an IP address using url::Host parsing
if let Ok(url::Host::Ipv4(addr)) = url::Host::parse(host_str) {
@ -287,9 +286,8 @@ impl EnrollmentClient {
.await
.context("Failed to read status response body")?;
let status: EnrollmentStatusResponse =
serde_json::from_str(&body)
.context("Invalid status response — malformed JSON from manager")?;
let status: EnrollmentStatusResponse = serde_json::from_str(&body)
.context("Invalid status response — malformed JSON from manager")?;
Ok(status)
}
@ -336,7 +334,11 @@ impl EnrollmentClient {
max_attempts: u32,
) -> Result<PkiBundle> {
// Enforce hard limits
let effective_interval = if interval_seconds == 0 { 60 } else { interval_seconds };
let effective_interval = if interval_seconds == 0 {
60
} else {
interval_seconds
};
let effective_max = match max_attempts {
0 => 1440,
n if n > 1440 => 1440,
@ -417,7 +419,11 @@ impl EnrollmentClient {
attempts = attempt,
"Enrollment approved — received PKI bundle from manager"
);
return Ok(PkiBundle { ca_crt, server_crt, server_key });
return Ok(PkiBundle {
ca_crt,
server_crt,
server_key,
});
}
EnrollmentStatusResponse::Denied => {
tracing::warn!(
@ -444,20 +450,22 @@ impl EnrollmentClient {
total_seconds = total_seconds,
"Enrollment polling timed out after maximum attempts"
);
Err(anyhow!("Enrollment timed out after {} hours ({}/{} attempts)",
total_seconds / 3600, effective_max, effective_max))
Err(anyhow!(
"Enrollment timed out after {} hours ({}/{} attempts)",
total_seconds / 3600,
effective_max,
effective_max
))
}
/// Create a SIGINT (Ctrl+C) signal receiver.
fn setup_sigint() -> Result<tokio::signal::unix::Signal> {
unix_signal(SignalKind::interrupt())
.context("Failed to create SIGINT signal handler")
unix_signal(SignalKind::interrupt()).context("Failed to create SIGINT signal handler")
}
/// Create a SIGTERM signal receiver.
fn setup_sigterm() -> Result<tokio::signal::unix::Signal> {
unix_signal(SignalKind::terminate())
.context("Failed to create SIGTERM signal handler")
unix_signal(SignalKind::terminate()).context("Failed to create SIGTERM signal handler")
}
}

View File

@ -66,8 +66,7 @@ pub fn get_fqdn() -> Result<String> {
/// Collect all non-loopback IPv4 addresses from network interfaces.
pub fn get_ip_addresses() -> Result<Vec<String>> {
let ifaces = if_addrs::get_if_addrs()
.context("Failed to enumerate network interfaces")?;
let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;
let mut addrs: Vec<String> = ifaces
.iter()
@ -105,16 +104,28 @@ pub fn get_os_details() -> Result<serde_json::Value> {
let unquoted = value.trim().trim_matches('"').trim_matches('\'');
match key {
"NAME" => {
details.insert("distro".into(), serde_json::Value::String(unquoted.to_string()));
details.insert(
"distro".into(),
serde_json::Value::String(unquoted.to_string()),
);
}
"VERSION_ID" => {
details.insert("version".into(), serde_json::Value::String(unquoted.to_string()));
details.insert(
"version".into(),
serde_json::Value::String(unquoted.to_string()),
);
}
"ID_LIKE" => {
details.insert("id_like".into(), serde_json::Value::String(unquoted.to_string()));
details.insert(
"id_like".into(),
serde_json::Value::String(unquoted.to_string()),
);
}
"VERSION_CODENAME" => {
details.insert("codename".into(), serde_json::Value::String(unquoted.to_string()));
details.insert(
"codename".into(),
serde_json::Value::String(unquoted.to_string()),
);
}
_ => {}
}
@ -123,7 +134,10 @@ pub fn get_os_details() -> Result<serde_json::Value> {
} else {
// Fallback for systems without os-release (very rare)
details.insert("distro".into(), serde_json::Value::String("unknown".into()));
details.insert("version".into(), serde_json::Value::String("unknown".into()));
details.insert(
"version".into(),
serde_json::Value::String("unknown".into()),
);
}
// Kernel version via uname -r
@ -159,6 +173,9 @@ mod tests {
#[test]
fn os_details_contains_kernel() {
let details = get_os_details().expect("Failed to get OS details");
assert!(details.get("kernel").is_some(), "OS details must contain kernel version");
assert!(
details.get("kernel").is_some(),
"OS details must contain kernel version"
);
}
}

View File

@ -12,8 +12,7 @@ use anyhow::{Context, Result};
/// Re-export key types for ergonomic access from parent modules.
pub use client::{
EnrollmentClient, EnrollmentRequest, EnrollmentResponse,
EnrollmentStatusResponse, PkiBundle,
EnrollmentClient, EnrollmentRequest, EnrollmentResponse, EnrollmentStatusResponse, PkiBundle,
};
/// Re-export identity extraction functions.
pub use identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details};
@ -40,10 +39,16 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
tracing::info!("Registration successful - received polling token");
// Get polling config (use defaults if not set)
let interval = config.enrollment.as_ref()
.map(|e| e.polling_interval_seconds).unwrap_or(60);
let max_attempts = config.enrollment.as_ref()
.map(|e| e.max_poll_attempts).unwrap_or(1440);
let interval = config
.enrollment
.as_ref()
.map(|e| e.polling_interval_seconds)
.unwrap_or(60);
let max_attempts = config
.enrollment
.as_ref()
.map(|e| e.max_poll_attempts)
.unwrap_or(1440);
// Phase 2: Polling
tracing::info!(
@ -51,7 +56,9 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
max_attempts = max_attempts,
"Starting enrollment - polling phase"
);
let pki_bundle = client.poll_for_approval(&response.polling_token, interval, max_attempts).await?;
let pki_bundle = client
.poll_for_approval(&response.polling_token, interval, max_attempts)
.await?;
// Phase 3: PKI provisioning & whitelist update
tracing::info!("Enrollment approved - starting PKI provisioning phase");
@ -62,13 +69,15 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
&pki_bundle.server_crt,
&pki_bundle.server_key,
config.tls_config(),
).await?;
)
.await?;
tracing::info!("PKI bundle written to disk");
// Resolve manager hostname to IP and append to whitelist
let manager_ip = client.manager_ip().await.context(
"Failed to resolve manager IP - cannot update whitelist",
)?;
let manager_ip = client
.manager_ip()
.await
.context("Failed to resolve manager IP - cannot update whitelist")?;
provision::append_manager_to_whitelist(&manager_ip, config.whitelist_path()).await?;
tracing::info!(manager_ip = %manager_ip, "Manager IP appended to whitelist");

View File

@ -1,8 +1,8 @@
//! PKI provisioning module for self-enrollment.
//! Handles certificate extraction, validation, and secure file writing.
use anyhow::{bail, Context, Result};
use crate::auth::WhitelistManager;
use anyhow::{bail, Context, Result};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
@ -71,8 +71,9 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(parent, perms)
.with_context(|| format!("Failed to set permissions on: {}", parent.display()))?;
fs::set_permissions(parent, perms).with_context(|| {
format!("Failed to set permissions on: {}", parent.display())
})?;
}
}
}
@ -107,14 +108,13 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
.with_context(|| format!("Failed to flush PEM data to: {}", temp_path.display()))?;
// Atomic rename to target path
fs::rename(&temp_path, path)
.with_context(|| {
format!(
"Failed to atomically rename {} to {}",
temp_path.display(),
path.display()
)
})?;
fs::rename(&temp_path, path).with_context(|| {
format!(
"Failed to atomically rename {} to {}",
temp_path.display(),
path.display()
)
})?;
tracing::info!(
path = %path.display(),
@ -138,7 +138,11 @@ pub async fn provision_pki_bundle(
) -> Result<()> {
// Determine target paths from config or defaults
let (ca_path, cert_path, key_path) = if let Some(tls) = tls_config {
(tls.ca_cert.clone(), tls.server_cert.clone(), tls.server_key.clone())
(
tls.ca_cert.clone(),
tls.server_cert.clone(),
tls.server_key.clone(),
)
} else {
(
DEFAULT_CA_CERT.to_string(),
@ -148,10 +152,8 @@ pub async fn provision_pki_bundle(
};
// 1. Validate all three PEM strings before any writes
validate_pem(ca_crt, "CERTIFICATE")
.context("CA certificate validation failed")?;
validate_pem(server_crt, "CERTIFICATE")
.context("Server certificate validation failed")?;
validate_pem(ca_crt, "CERTIFICATE").context("CA certificate validation failed")?;
validate_pem(server_crt, "CERTIFICATE").context("Server certificate validation failed")?;
// Server key can be PRIVATE KEY (PKCS#8), RSA PRIVATE KEY (PKCS#1), or EC PRIVATE KEY
let key_valid = validate_pem(server_key, "PRIVATE KEY").is_ok()
@ -165,14 +167,11 @@ pub async fn provision_pki_bundle(
}
// 2. Write to configured paths (atomic writes)
write_pem_file(&ca_path, ca_crt, false)
.context("Failed to write CA certificate")?;
write_pem_file(&ca_path, ca_crt, false).context("Failed to write CA certificate")?;
write_pem_file(&cert_path, server_crt, false)
.context("Failed to write server certificate")?;
write_pem_file(&cert_path, server_crt, false).context("Failed to write server certificate")?;
write_pem_file(&key_path, server_key, true)
.context("Failed to write server key")?;
write_pem_file(&key_path, server_key, true).context("Failed to write server key")?;
// 3. Log successful provisioning with structured fields
tracing::info!(
@ -198,11 +197,19 @@ pub async fn append_manager_to_whitelist(manager_ip: &str, whitelist_path: &str)
}
// Create or load WhitelistManager and call append_entry
let mut manager = WhitelistManager::new(whitelist_path)
.with_context(|| format!("Failed to initialize whitelist manager for path: {}", whitelist_path))?;
let mut manager = WhitelistManager::new(whitelist_path).with_context(|| {
format!(
"Failed to initialize whitelist manager for path: {}",
whitelist_path
)
})?;
manager.append_entry(ip_or_cidr)
.with_context(|| format!("Failed to append manager IP '{}' to whitelist at: {}", ip_or_cidr, whitelist_path))?;
manager.append_entry(ip_or_cidr).with_context(|| {
format!(
"Failed to append manager IP '{}' to whitelist at: {}",
ip_or_cidr, whitelist_path
)
})?;
Ok(())
}
@ -343,7 +350,8 @@ mod tests {
let dir = tempdir().expect("failed to create temp dir");
let target_path = dir.path().join("cert.pem");
let cert1 = sample_certificate();
let cert2 = "-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string();
let cert2 =
"-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string();
// Write initial file
write_pem_file(target_path.to_str().unwrap(), &cert1, false).expect("initial write failed");
@ -352,7 +360,10 @@ mod tests {
write_pem_file(target_path.to_str().unwrap(), &cert2, false).expect("second write failed");
let backup_path = format!("{}.bak", target_path.display());
assert!(std::path::Path::new(&backup_path).exists(), "Backup file should exist");
assert!(
std::path::Path::new(&backup_path).exists(),
"Backup file should exist"
);
// Original content in backup
let backup_content = fs::read_to_string(&backup_path).expect("failed to read backup");

View File

@ -23,8 +23,8 @@ use tracing::{error, info, warn};
use linux_patch_api::api::{configure_api_routes, configure_health_route};
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
use linux_patch_api::packages::create_backend;
use linux_patch_api::enroll;
use linux_patch_api::packages::create_backend;
use linux_patch_api::{init_logging, AppConfig, JobManager};
/// Linux Patch API CLI arguments
@ -42,7 +42,10 @@ struct Args {
verbose: bool,
/// Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)
#[arg(long, help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)")]
#[arg(
long,
help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)"
)]
enroll: Option<String>,
}
@ -61,7 +64,7 @@ async fn main() -> Result<()> {
);
// Load configuration
let config = match AppConfig::load(&args.config) {
let config = match AppConfig::load(&args.config, args.enroll.is_some()) {
Ok(cfg) => {
info!(
port = cfg.server.port,
@ -78,7 +81,10 @@ async fn main() -> Result<()> {
// Handle enrollment mode - runs before server startup
if let Some(ref manager_url) = args.enroll {
info!(manager_url = manager_url, "Enrollment mode activated - running enrollment flow before server startup");
info!(
manager_url = manager_url,
"Enrollment mode activated - running enrollment flow before server startup"
);
match enroll::run_enrollment(manager_url, &config).await {
Ok(()) => {
info!("Enrollment complete - proceeding to server startup");

View File

@ -25,8 +25,8 @@ use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tempfile::TempDir;
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Test constants
const TEST_TOKEN: &str = "test_enrollment_token";
@ -63,10 +63,7 @@ fn create_temp_dirs() -> (TempDir, TempDir) {
/// Initialize an empty whitelist YAML file at the given path.
/// Required because WhitelistManager::new() loads existing config on construction.
fn init_empty_whitelist(path: &str) {
std::fs::write(
path,
"entries: []\n",
).expect("Failed to create initial whitelist file");
std::fs::write(path, "entries: []\n").expect("Failed to create initial whitelist file");
}
/// Build a TLS config pointing to the temp certificate directory.
@ -76,7 +73,10 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
port: 12443,
ca_cert: cert_dir.join("ca.pem").to_string_lossy().to_string(),
server_cert: cert_dir.join("server.pem").to_string_lossy().to_string(),
server_key: cert_dir.join("server.key.pem").to_string_lossy().to_string(),
server_key: cert_dir
.join("server.key.pem")
.to_string_lossy()
.to_string(),
min_tls_version: "1.3".to_string(),
}
}
@ -104,7 +104,11 @@ async fn test_full_enrollment_flow_happy_path() {
let ca_cert_path = cert_dir.path().join("ca.pem");
let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem");
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
let whitelist_path = whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path);
@ -116,7 +120,7 @@ async fn test_full_enrollment_flow_happy_path() {
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(&format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)),
.set_body_string(format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)),
)
.named("enroll_registration")
.mount(&server)
@ -128,11 +132,10 @@ async fn test_full_enrollment_flow_happy_path() {
let count = poll_count_clone.fetch_add(1, Ordering::SeqCst);
if count < 1 {
// First poll returns pending (simulates admin review delay)
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "pending"}"#)
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#)
} else {
// Second poll returns approved with full PKI bundle
ResponseTemplate::new(200).set_body_string(&format!(
ResponseTemplate::new(200).set_body_string(format!(
r#"{{
"status": "approved",
"ca_crt": {},
@ -152,7 +155,10 @@ async fn test_full_enrollment_flow_happy_path() {
let client = build_client(&base_url);
// Phase 1: Registration
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, TEST_TOKEN);
// Phase 2: Polling (should get pending first, then approved)
@ -172,10 +178,15 @@ async fn test_full_enrollment_flow_happy_path() {
&bundle.server_crt,
&bundle.server_key,
Some(&tls_config),
).await.expect("PKI provisioning should succeed");
)
.await
.expect("PKI provisioning should succeed");
// Phase 3b: Whitelist update (manager_ip for localhost URL returns 127.0.0.1)
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
let manager_ip = client
.manager_ip()
.await
.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("Whitelist append should succeed");
@ -186,14 +197,29 @@ async fn test_full_enrollment_flow_happy_path() {
assert!(server_key_path.exists(), "Server key file should exist");
// Verify: correct permissions (key=0o600, certs=0o644)
let key_perms = std::fs::metadata(&server_key_path).unwrap().permissions().mode() & 0o777;
let key_perms = std::fs::metadata(&server_key_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(key_perms, 0o600, "Key file should have 0o600 permissions");
let ca_perms = std::fs::metadata(&ca_cert_path).unwrap().permissions().mode() & 0o777;
let ca_perms = std::fs::metadata(&ca_cert_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(ca_perms, 0o644, "CA cert should have 0o644 permissions");
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777;
assert_eq!(server_perms, 0o644, "Server cert should have 0o644 permissions");
let server_perms = std::fs::metadata(&server_cert_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
server_perms, 0o644,
"Server cert should have 0o644 permissions"
);
// Verify: whitelist contains manager IP
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
@ -220,14 +246,17 @@ async fn test_enrollment_denied_flow() {
let (server, base_url) = create_mock_manager().await;
let (cert_dir, _whitelist_dir) = create_temp_dirs();
let whitelist_path = _whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
let whitelist_path = _whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path);
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "denied_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "denied_token"}"#),
)
.named("registration")
.mount(&server)
@ -235,9 +264,7 @@ async fn test_enrollment_denied_flow() {
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#),
)
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#))
.named("status_denied")
.expect(1) // Exactly one poll attempt before denial
.mount(&server)
@ -246,7 +273,10 @@ async fn test_enrollment_denied_flow() {
let client = build_client(&base_url);
// Phase 1: Registration succeeds even for denied enrollment
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, "denied_token");
// Phase 2: Polling returns denial error
@ -254,7 +284,10 @@ async fn test_enrollment_denied_flow() {
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await;
assert!(result.is_err(), "Should receive error for denied enrollment");
assert!(
result.is_err(),
"Should receive error for denied enrollment"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("denied"),
@ -267,9 +300,18 @@ async fn test_enrollment_denied_flow() {
let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem");
assert!(!ca_path.exists(), "CA cert should NOT exist after denied enrollment");
assert!(!server_cert_path.exists(), "Server cert should NOT exist after denied enrollment");
assert!(!server_key_path.exists(), "Server key should NOT exist after denied enrollment");
assert!(
!ca_path.exists(),
"CA cert should NOT exist after denied enrollment"
);
assert!(
!server_cert_path.exists(),
"Server cert should NOT exist after denied enrollment"
);
assert!(
!server_key_path.exists(),
"Server key should NOT exist after denied enrollment"
);
// Verify: no whitelist modifications on failed enrollment
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
@ -298,8 +340,7 @@ async fn test_enrollment_timeout_flow() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "timeout_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "timeout_token"}"#),
)
.named("registration")
.mount(&server)
@ -307,16 +348,17 @@ async fn test_enrollment_timeout_flow() {
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
)
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
.named("status_always_pending")
.expect(3) // Exactly 3 poll attempts before timeout
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
// Poll with max_attempts=3 - should timeout after exactly 3 attempts
let result = client
@ -337,8 +379,14 @@ async fn test_enrollment_timeout_flow() {
let server_key_path = cert_dir.path().join("server.key.pem");
assert!(!ca_path.exists(), "CA cert should NOT exist after timeout");
assert!(!server_cert_path.exists(), "Server cert should NOT exist after timeout");
assert!(!server_key_path.exists(), "Server key should NOT exist after timeout");
assert!(
!server_cert_path.exists(),
"Server cert should NOT exist after timeout"
);
assert!(
!server_key_path.exists(),
"Server key should NOT exist after timeout"
);
}
// =============================================================================
@ -359,32 +407,32 @@ async fn test_certificate_permission_verification() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "perm_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "perm_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)),
)
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)))
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
let bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
@ -397,14 +445,15 @@ async fn test_certificate_permission_verification() {
&bundle.server_crt,
&bundle.server_key,
Some(&tls_config),
).await.expect("PKI provisioning should succeed");
)
.await
.expect("PKI provisioning should succeed");
// Verify key file: 0o600 (owner read/write only)
let key_path = cert_dir.path().join("server.key.pem");
let key_perms = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
key_perms,
0o600,
key_perms, 0o600,
"Key file must have exactly 0o600 permissions (owner rw only)"
);
@ -412,17 +461,19 @@ async fn test_certificate_permission_verification() {
let ca_path = cert_dir.path().join("ca.pem");
let ca_perms = std::fs::metadata(&ca_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
ca_perms,
0o644,
ca_perms, 0o644,
"CA certificate must have exactly 0o644 permissions"
);
// Verify server cert: 0o644 (owner rw, group/others read)
let server_cert_path = cert_dir.path().join("server.pem");
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777;
let server_perms = std::fs::metadata(&server_cert_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
server_perms,
0o644,
server_perms, 0o644,
"Server certificate must have exactly 0o644 permissions"
);
@ -442,7 +493,9 @@ async fn test_certificate_permission_verification() {
assert!(ca_content.contains("END CERTIFICATE"));
let key_content = std::fs::read_to_string(&key_path).unwrap();
assert!(key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY"));
assert!(
key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY")
);
}
// =============================================================================
@ -459,45 +512,52 @@ async fn test_whitelist_append_verification() {
let (server, base_url) = create_mock_manager().await;
let (_cert_dir, whitelist_dir) = create_temp_dirs();
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
let whitelist_path = whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path);
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "wl_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "wl_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)),
)
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)))
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
let _bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
.expect("Should receive approved PkiBundle");
// First enrollment: append to whitelist
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
let manager_ip = client
.manager_ip()
.await
.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("First whitelist append should succeed");
@ -544,7 +604,10 @@ async fn test_whitelist_append_verification() {
);
// Verify: YAML format is valid and parseable
assert!(wl_content.contains("entries:"), "YAML should contain 'entries:' key");
assert!(
wl_content.contains("entries:"),
"YAML should contain 'entries:' key"
);
}
// =============================================================================
@ -565,24 +628,24 @@ async fn test_signal_handling_during_polling() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "signal_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "signal_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
)
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
.named("always_pending")
.expect(3) // Exactly 3 polls before graceful shutdown
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
// Poll with max_attempts=3, interval=1s
// This simulates SIGTERM interrupt by exhausting attempts (graceful shutdown)
@ -600,11 +663,15 @@ async fn test_signal_handling_during_polling() {
);
// Verify: cleanup of any partial state (no leftover files)
for entry in std::fs::read_dir(cert_dir.path()).unwrap() {
let entry = entry.unwrap();
assert!(false, "No partial files should remain after graceful shutdown: {}",
entry.file_name().to_string_lossy());
}
let remaining: Vec<_> = std::fs::read_dir(cert_dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert!(
remaining.is_empty(),
"No partial files should remain after graceful shutdown: {:?}",
remaining
);
}
// =============================================================================
@ -620,45 +687,52 @@ async fn test_whitelist_yaml_format_preservation() {
let (server, base_url) = create_mock_manager().await;
let (_cert_dir, whitelist_dir) = create_temp_dirs();
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
let whitelist_path = whitelist_dir
.path()
.join("whitelist.yaml")
.to_string_lossy()
.to_string();
init_empty_whitelist(&whitelist_path);
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "yaml_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "yaml_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)),
)
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)))
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
let _bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
.expect("Should receive approved PkiBundle");
// Provision and append to whitelist
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
let manager_ip = client
.manager_ip()
.await
.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("Whitelist append should succeed");
@ -667,11 +741,14 @@ async fn test_whitelist_yaml_format_preservation() {
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
// Parse as serde_yaml to verify format
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content)
.expect("Whitelist should be valid YAML after enrollment");
let wl_config: serde_yaml::Value =
serde_yaml::from_str(&wl_content).expect("Whitelist should be valid YAML after enrollment");
// Verify structure: entries key exists and is a sequence
assert!(wl_config.get("entries").is_some(), "YAML must contain 'entries' key");
assert!(
wl_config.get("entries").is_some(),
"YAML must contain 'entries' key"
);
let entries = wl_config.get("entries").unwrap();
assert!(entries.is_sequence(), "'entries' must be a YAML sequence");

View File

@ -9,13 +9,12 @@
//! - Short polling intervals ensure tests complete quickly
//! - serial_test prevents port conflicts between concurrent test runs
use linux_patch_api::enroll::client::{
EnrollmentClient,
};
use linux_patch_api::enroll::client::EnrollmentClient;
use serial_test::serial;
use wiremock::http::Method;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, path_regex},
Mock, MockServer, ResponseTemplate,
};
/// Test constants
@ -54,8 +53,7 @@ async fn test_successful_enrollment_flow() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "test_token_123"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "test_token_123"}"#),
)
.named("enroll_registration")
.mount(&server)
@ -81,7 +79,10 @@ async fn test_successful_enrollment_flow() {
let client = build_client(&base_url);
// Phase 1: Register - should succeed with polling token
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, TEST_TOKEN);
// Phase 2: Poll for approval - should get PkiBundle immediately since mock returns approved
@ -89,11 +90,23 @@ async fn test_successful_enrollment_flow() {
.poll_for_approval(TEST_TOKEN, POLL_INTERVAL_SECONDS, 5)
.await;
assert!(result.is_ok(), "Polling should succeed with approved status");
assert!(
result.is_ok(),
"Polling should succeed with approved status"
);
let bundle = result.unwrap();
assert_eq!(bundle.ca_crt, "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----");
assert_eq!(bundle.server_crt, "-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----");
assert_eq!(bundle.server_key, "-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----");
assert_eq!(
bundle.ca_crt,
"-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----"
);
assert_eq!(
bundle.server_crt,
"-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----"
);
assert_eq!(
bundle.server_key,
"-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----"
);
}
// =============================================================================
@ -111,8 +124,7 @@ async fn test_pending_then_approved_sequence() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "seq_token_456"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "seq_token_456"}"#),
)
.named("registration")
.mount(&server)
@ -121,16 +133,14 @@ async fn test_pending_then_approved_sequence() {
// Status always returns approved (simplifies test while verifying the happy path)
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "CA_PEM",
"server_crt": "SERVER_PEM",
"server_key": "KEY_PEM"
}"#,
),
)
))
.named("status_approved")
.mount(&server)
.await;
@ -168,8 +178,7 @@ async fn test_denied_enrollment() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "denied_token_789"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "denied_token_789"}"#),
)
.named("registration")
.mount(&server)
@ -178,10 +187,7 @@ async fn test_denied_enrollment() {
// Status returns denied immediately
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/denied_token_789"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "denied"}"#),
)
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#))
.named("status_denied")
.expect(1) // Exactly one poll attempt
.mount(&server)
@ -190,7 +196,10 @@ async fn test_denied_enrollment() {
let client = build_client(&base_url);
// Register succeeds
let response = client.register().await.expect("Registration should succeed even for denied enrollment");
let response = client
.register()
.await
.expect("Registration should succeed even for denied enrollment");
assert_eq!(response.polling_token, "denied_token_789");
// Poll should return error
@ -198,7 +207,10 @@ async fn test_denied_enrollment() {
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await;
assert!(result.is_err(), "Should receive error for denied enrollment");
assert!(
result.is_err(),
"Should receive error for denied enrollment"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("denied"),
@ -223,8 +235,7 @@ async fn test_token_not_found_expired() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "expired_token_000"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "expired_token_000"}"#),
)
.named("registration")
.mount(&server)
@ -233,10 +244,7 @@ async fn test_token_not_found_expired() {
// Status returns notfound (serde rename_all="lowercase" converts NotFound -> "notfind")
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/expired_token_000"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "notfound"}"#),
)
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "notfound"}"#))
.named("status_not_found")
.expect(1) // Exactly one poll attempt
.mount(&server)
@ -245,7 +253,10 @@ async fn test_token_not_found_expired() {
let client = build_client(&base_url);
// Register succeeds
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
// Poll should return error about expired/invalid token
let result = client
@ -277,8 +288,7 @@ async fn test_max_attempts_timeout() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
)
.named("registration")
.mount(&server)
@ -287,10 +297,7 @@ async fn test_max_attempts_timeout() {
// Status always returns pending - should be called exactly 3 times (max_attempts=3)
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/timeout_token_abc"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "pending"}"#),
)
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
.named("status_pending_timeout")
.expect(3) // Exactly 3 poll attempts before giving up
.mount(&server)
@ -298,7 +305,10 @@ async fn test_max_attempts_timeout() {
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
// Poll with max_attempts=3, interval=1s
let result = client
@ -329,9 +339,10 @@ async fn test_rate_limit_on_registration() {
// Registration returns 429
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(ResponseTemplate::new(429).set_body_string(
r#"{"error": "Too Many Requests", "retry_after": 60}"#,
))
.respond_with(
ResponseTemplate::new(429)
.set_body_string(r#"{"error": "Too Many Requests", "retry_after": 60}"#),
)
.named("registration_rate_limited")
.expect(1) // Exactly one attempt
.mount(&server)
@ -382,16 +393,14 @@ async fn test_registration_payload_structure() {
// Status endpoint (for completeness)
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "CA_TEST",
"server_crt": "CRT_TEST",
"server_key": "KEY_TEST"
}"#,
),
)
))
.named("status_approved")
.mount(&server)
.await;
@ -399,34 +408,45 @@ async fn test_registration_payload_structure() {
let client = build_client(&base_url);
// Execute registration and capture the actual request
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
assert_eq!(response.polling_token, "payload_test_token");
// Verify using server request logs
let requests = server.received_requests().await.unwrap();
let post_request = requests.iter()
.find(|r| r.method.to_string() == "POST")
let post_request = requests
.iter()
.find(|r| r.method == Method::POST)
.expect("Should have received a POST request");
let body_str = std::str::from_utf8(&post_request.body).expect("Body should be valid UTF-8");
let payload: serde_json::Value = serde_json::from_str(body_str)
.expect("Request body should be valid JSON");
let payload: serde_json::Value =
serde_json::from_str(body_str).expect("Request body should be valid JSON");
// Verify machine_id field
let machine_id = payload.get("machine_id")
let machine_id = payload
.get("machine_id")
.and_then(|v| v.as_str())
.expect("machine_id field must exist and be a string");
assert!(!machine_id.is_empty(), "machine_id should not be empty");
assert_eq!(machine_id.len(), 32, "machine_id should be 32 characters (UUID hex)");
assert_eq!(
machine_id.len(),
32,
"machine_id should be 32 characters (UUID hex)"
);
// Verify fqdn field
let fqdn = payload.get("fqdn")
let fqdn = payload
.get("fqdn")
.and_then(|v| v.as_str())
.expect("fqdn field must exist and be a string");
assert!(!fqdn.is_empty(), "fqdn should not be empty");
// Verify ip_address field
let ip_address = payload.get("ip_address")
let ip_address = payload
.get("ip_address")
.and_then(|v| v.as_str())
.expect("ip_address field must exist and be a string");
assert!(!ip_address.is_empty(), "ip_address should not be empty");
@ -438,12 +458,10 @@ async fn test_registration_payload_structure() {
);
// Verify os_details field is an object with expected keys
let os_details = payload.get("os_details")
let os_details = payload
.get("os_details")
.expect("os_details field must exist");
assert!(
os_details.is_object(),
"os_details should be a JSON object"
);
assert!(os_details.is_object(), "os_details should be a JSON object");
let os_obj = os_details.as_object().unwrap();
assert!(!os_obj.is_empty(), "os_details should not be empty");
@ -469,9 +487,9 @@ async fn test_server_error_on_registration() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(ResponseTemplate::new(500).set_body_string(
r#"{"error": "Internal Server Error"}"#,
))
.respond_with(
ResponseTemplate::new(500).set_body_string(r#"{"error": "Internal Server Error"}"#),
)
.named("registration_server_error")
.expect(1)
.mount(&server)
@ -506,8 +524,7 @@ async fn test_rate_limit_on_polling_retries() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
)
.named("registration")
.mount(&server)
@ -516,22 +533,23 @@ async fn test_rate_limit_on_polling_retries() {
// Status returns approved on first poll
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/rl_poll_token"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "CA_OK",
"server_crt": "CRT_OK",
"server_key": "KEY_OK"
}"#,
),
)
))
.named("status_approved_after_retry")
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
// Polling should succeed (mock returns approved directly)
let bundle = client
@ -579,8 +597,7 @@ async fn test_polling_default_parameters() {
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "defaults_token"}"#),
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "defaults_token"}"#),
)
.named("registration")
.mount(&server)
@ -589,22 +606,23 @@ async fn test_polling_default_parameters() {
// Status returns approved immediately
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/defaults_token"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "DEFAULT_CA",
"server_crt": "DEFAULT_CRT",
"server_key": "DEFAULT_KEY"
}"#,
),
)
))
.named("status_approved")
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let response = client
.register()
.await
.expect("Registration should succeed");
// Call with interval=0 (should default to 60) and max_attempts=0 (should default to 1440)
// But since mock returns approved on first try, we don't actually wait

View File

@ -3,7 +3,9 @@
//! Comprehensive tests for cross-distribution identity extraction functions.
//! Verifies machine-id, FQDN, IP address collection, and OS detail parsing.
use linux_patch_api::enroll::identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details};
use linux_patch_api::enroll::identity::{
get_fqdn, get_ip_addresses, get_machine_id, get_os_details,
};
use linux_patch_api::enroll::EnrollmentRequest;
use serde_json::Value;
@ -46,10 +48,7 @@ fn test_machine_id_is_consistent() {
// Multiple calls should return the same value (it's a persistent identifier)
let id1 = get_machine_id().expect("Failed to get machine-id (call 1)");
let id2 = get_machine_id().expect("Failed to get machine-id (call 2)");
assert_eq!(
id1, id2,
"machine-id should be consistent across calls"
);
assert_eq!(id1, id2, "machine-id should be consistent across calls");
}
#[test]
@ -67,8 +66,12 @@ fn test_machine_id_fallback_file_check() {
// Verify fallback file exists (may or may not be used)
let fallback = std::path::Path::new("/var/lib/dbus/machine-id");
if fallback.exists() {
let content = std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id");
assert!(!content.trim().is_empty(), "Fallback machine-id should not be empty");
let content =
std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id");
assert!(
!content.trim().is_empty(),
"Fallback machine-id should not be empty"
);
}
// If it doesn't exist, that's fine - primary file is used instead
}
@ -157,9 +160,9 @@ fn test_ip_addresses_are_valid_ipv4() {
assert_eq!(parts.len(), 4, "IP '{}' should have 4 octets", addr);
for part in &parts {
let _octet: u8 = part
.parse()
.unwrap_or_else(|_| panic!("IP octet '{}' in '{}' is not a valid number", part, addr));
let _octet: u8 = part.parse().unwrap_or_else(|_| {
panic!("IP octet '{}' in '{}' is not a valid number", part, addr)
});
// u8 parse success guarantees 0-255 range
}
}
@ -198,7 +201,10 @@ fn test_ip_addresses_no_broadcast() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
assert_ne!(addr, "255.255.255.255", "Broadcast address should be excluded");
assert_ne!(
addr, "255.255.255.255",
"Broadcast address should be excluded"
);
}
}
@ -238,7 +244,11 @@ fn test_ip_addresses_are_unicast() {
assert!(first < 240, "Address '{}' is reserved", addr);
// Not unspecified (0.0.0.0)
assert!(!(parts == vec![0, 0, 0, 0]), "Address '{}' is unspecified", addr);
assert!(
parts != vec![0, 0, 0, 0],
"Address '{}' is unspecified",
addr
);
}
}
@ -259,7 +269,9 @@ fn test_os_details_returns_valid_json_object() {
#[test]
fn test_os_details_contains_kernel_version() {
let details = get_os_details().expect("Failed to get OS details");
let kernel = details.get("kernel").expect("OS details must contain 'kernel' field");
let kernel = details
.get("kernel")
.expect("OS details must contain 'kernel' field");
assert!(kernel.is_string(), "Kernel version should be a string");
let kernel_str = kernel.as_str().unwrap();
@ -297,7 +309,10 @@ fn test_os_details_distro_is_valid_string() {
assert!(distro.is_string(), "Distro should be a string");
let distro_str = distro.as_str().unwrap();
assert!(!distro_str.is_empty(), "Distro name should not be empty");
assert_ne!(distro_str, "unknown", "Distro should be identified on this system");
assert_ne!(
distro_str, "unknown",
"Distro should be identified on this system"
);
}
}
@ -350,7 +365,8 @@ fn test_enrollment_payload_construction() {
let os_details = get_os_details().expect("Failed to get OS details");
// Use first non-loopback IP as the primary address
let primary_ip = ip_addrs.first()
let primary_ip = ip_addrs
.first()
.expect("Should have at least one IP")
.clone();
@ -362,19 +378,30 @@ fn test_enrollment_payload_construction() {
};
// Verify payload serializes to valid JSON
let json = serde_json::to_string(&request)
.expect("EnrollmentRequest should serialize to valid JSON");
let json =
serde_json::to_string(&request).expect("EnrollmentRequest should serialize to valid JSON");
assert!(!json.is_empty(), "Serialized enrollment request should not be empty");
assert!(
!json.is_empty(),
"Serialized enrollment request should not be empty"
);
// Verify JSON contains all required fields
let parsed: Value = serde_json::from_str(&json)
.expect("Should deserialize enrollment request");
let parsed: Value = serde_json::from_str(&json).expect("Should deserialize enrollment request");
assert!(parsed.get("machine_id").is_some(), "JSON must contain machine_id");
assert!(
parsed.get("machine_id").is_some(),
"JSON must contain machine_id"
);
assert!(parsed.get("fqdn").is_some(), "JSON must contain fqdn");
assert!(parsed.get("ip_address").is_some(), "JSON must contain ip_address");
assert!(parsed.get("os_details").is_some(), "JSON must contain os_details");
assert!(
parsed.get("ip_address").is_some(),
"JSON must contain ip_address"
);
assert!(
parsed.get("os_details").is_some(),
"JSON must contain os_details"
);
}
#[test]
@ -430,8 +457,8 @@ fn test_enrollment_payload_roundtrip() {
// Serialize to JSON then deserialize back
let json = serde_json::to_string(&request).expect("Failed to serialize");
let deserialized: EnrollmentRequest = serde_json::from_str(&json)
.expect("Failed to deserialize enrollment request");
let deserialized: EnrollmentRequest =
serde_json::from_str(&json).expect("Failed to deserialize enrollment request");
assert_eq!(request.machine_id, deserialized.machine_id);
assert_eq!(request.fqdn, deserialized.fqdn);
@ -461,8 +488,11 @@ fn test_cross_distro_os_release_parsing() {
}
// Verify key fields are present (POSIX standard for os-release)
assert!(parsed.contains_key("NAME"), "os-release must contain NAME field");
assert!(parsed["NAME"].ne(&""), "NAME should not be empty");
assert!(
parsed.contains_key("NAME"),
"os-release must contain NAME field"
);
assert!(!parsed["NAME"].is_empty(), "NAME should not be empty");
}
#[test]