diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dff831f..4460407 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: env: CARGO_TERM_COLOR: always BUILD_PROFILE: debug + WHITAKER_INSTALLER_VERSION: '0.2.5' steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install system dependencies @@ -30,6 +31,24 @@ jobs: **/*.md !**/target/** !**/dist/** + - name: Cache whitaker-installer + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/bin/whitaker-installer + ~/.cache/cargo-binstall + key: whitaker-installer-${{ runner.os }}-${{ runner.arch }}-${{ env.WHITAKER_INSTALLER_VERSION }} + - name: Install the Whitaker Dylint suite + run: | + if ! command -v whitaker-installer >/dev/null 2>&1; then + if cargo binstall --version >/dev/null 2>&1; then + cargo binstall --no-confirm --locked "whitaker-installer@${WHITAKER_INSTALLER_VERSION}" + else + echo "cargo-binstall unavailable; building whitaker-installer from crates.io" + cargo install --locked whitaker-installer --version "${WHITAKER_INSTALLER_VERSION}" + fi + fi + whitaker-installer - name: Lint run: make lint - name: Test and Measure Coverage diff --git a/Cargo.lock b/Cargo.lock index 9fb262a..d13119c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -167,6 +173,36 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cc" version = "1.2.41" @@ -362,6 +398,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_cmd", + "cap-std", "chrono", "clap", "dashmap", @@ -438,6 +475,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -830,6 +878,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "iovec" version = "0.1.4" @@ -943,6 +1013,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.7.6" @@ -1525,6 +1601,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2529,6 +2615,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.10.0", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index f2d3f2a..3e94081 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "fmt"] } dashmap = "6.2.1" metrics = "0.22.4" metrics-exporter-prometheus = "0.13.1" +cap-std = "4.0.2" [dev-dependencies] assert_cmd = "2.0.16" diff --git a/Makefile b/Makefile index 55de57e..a5b7db2 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ BUILD_JOBS ?= CLIPPY_FLAGS ?= --all-targets --all-features -- -D warnings MDLINT ?= markdownlint-cli2 NIXIE ?= nixie +WHITAKER ?= whitaker build: target/debug/$(APP) ## Build debug binary release: target/release/$(APP) ## Build release binary @@ -21,8 +22,9 @@ test: ## Run tests with warnings treated as errors target/%/$(APP): ## Build binary in debug or release mode $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(APP) -lint: ## Run Clippy with warnings denied +lint: ## Run Clippy and the Whitaker Dylint suite with warnings denied $(CARGO) clippy --all-targets --all-features -- -D warnings + RUSTFLAGS="-D warnings" $(WHITAKER) --all -- --all-targets --all-features typecheck: ## Run cargo check for fast no-link verification $(CARGO) check --all-targets $(BUILD_JOBS) diff --git a/README.md b/README.md index d2eff0a..a7863c1 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,22 @@ directories. Fast feedback is available through: - `cargo fmt` for formatting checks. -- `cargo clippy --all-targets --all-features -- -D warnings` for linting. +- `make lint` for linting, which runs Clippy + (`cargo clippy --all-targets --all-features -- -D warnings`) followed by the + [Whitaker](https://github.com/leynos/whitaker) Dylint suite with warnings + denied. - `cargo test` for unit tests built with `rstest` and `tokio`. +Linting requires the Whitaker suite. Install it with +[`whitaker-installer`](https://github.com/leynos/whitaker): + +```bash +cargo binstall --no-confirm --locked whitaker-installer # or: cargo install --locked whitaker-installer +whitaker-installer +``` + +This provisions the pinned toolchain, `cargo-dylint`, and the `whitaker` +wrapper used by `make lint`. + The rate limiter depends on the `mockable` clock abstraction, enabling deterministic control of timestamps in the test suite. diff --git a/src/config.rs b/src/config.rs index 6081296..5d57944 100644 --- a/src/config.rs +++ b/src/config.rs @@ -153,7 +153,10 @@ fn ensure_directory(path: &Path) -> Result<()> { path.display() )); } - std::fs::create_dir_all(path) + // Creating the store root from operator-supplied configuration is the one + // ambient filesystem operation the server performs; cap-std makes that + // ambient authority explicit. + cap_std::fs::Dir::create_ambient_dir_all(path, cap_std::ambient_authority()) .with_context(|| format!("failed to create store root {}", path.display()))?; Ok(()) } diff --git a/src/framing.rs b/src/framing.rs index b8df676..48ca978 100644 --- a/src/framing.rs +++ b/src/framing.rs @@ -36,6 +36,7 @@ pub fn trim_line_endings(line: &str) -> &str { #[cfg(test)] mod tests { + //! Unit tests for finger response framing and line endings. use super::*; use rstest::rstest; diff --git a/src/identity.rs b/src/identity.rs index fe34a3d..97e1489 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -119,6 +119,7 @@ impl fmt::Display for HostName { #[cfg(test)] mod tests { + //! Unit tests for username and hostname validation. use super::*; use rstest::rstest; diff --git a/src/main.rs b/src/main.rs index 843bd00..794ba9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,10 +20,18 @@ use server::FingerServer; use storage::ObjectStoreUserStore; use tracing_subscriber::EnvFilter; -#[tokio::main] -async fn main() -> Result<()> { +fn main() -> Result<()> { install_tracing(); + // Build the runtime explicitly so runtime construction errors propagate + // instead of panicking inside the `#[tokio::main]` expansion. + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(run()) +} + +async fn run() -> Result<()> { let cli = CliOptions::parse(); let config = ServerConfig::from_cli(cli)?; let metrics_endpoint = telemetry::install_metrics(config.metrics_listen).await?; diff --git a/src/query.rs b/src/query.rs index a8eda27..6697c31 100644 --- a/src/query.rs +++ b/src/query.rs @@ -135,6 +135,7 @@ impl FingerQuery { #[cfg(test)] mod tests { + //! Unit tests for finger query parsing. use super::*; use rstest::rstest; diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 6ca07fb..de5c6ea 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -175,6 +175,7 @@ fn chrono_duration(window: Duration) -> ChronoDuration { #[cfg(test)] mod tests { + //! Unit tests for the per-client rate limiter. use super::*; use std::sync::Mutex as StdMutex; diff --git a/src/server.rs b/src/server.rs index 5696513..3c4d453 100644 --- a/src/server.rs +++ b/src/server.rs @@ -214,6 +214,7 @@ fn render_message(message: &str) -> Vec { #[cfg(test)] mod tests { + //! Unit tests for finger server request handling. use super::*; use crate::identity::{HostName, Username}; use crate::rate_limit::RateLimitSettings; diff --git a/src/storage.rs b/src/storage.rs index f648736..7992b40 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -146,18 +146,20 @@ fn trim_slashes(input: impl AsRef) -> String { #[cfg(test)] mod tests { + //! Behavioural tests for the object-store backed user repository. use super::*; use crate::identity::Username; use anyhow::{Result, anyhow}; + use cap_std::fs::Dir; use object_store::local::LocalFileSystem; use rstest::rstest; use tempfile::TempDir; - fn write_file(path: &std::path::Path, contents: &str) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + fn write_file(root: &Dir, relative: &str, contents: &str) -> Result<()> { + if let Some((parent, _)) = relative.rsplit_once('/') { + root.create_dir_all(parent)?; } - std::fs::write(path, contents)?; + root.write(relative, contents)?; Ok(()) } @@ -224,15 +226,17 @@ mod tests { async fn load_user_behaviour(#[case] case: LoadCase) -> Result<()> { let tmp = TempDir::new()?; let root = tmp.path(); + let root_dir = Dir::open_ambient_dir(root, cap_std::ambient_authority())?; if let Some(user) = &case.existing_user { // mirror production repository layout so we exercise the full IO path write_file( - &root.join(format!("profiles/{}.toml", user.username)), + &root_dir, + &format!("profiles/{}.toml", user.username), user.profile, )?; if let Some(plan) = user.plan { - write_file(&root.join(format!("plans/{}.plan", user.username)), plan)?; + write_file(&root_dir, &format!("plans/{}.plan", user.username), plan)?; } } diff --git a/src/user.rs b/src/user.rs index 3186aad..c857d4f 100644 --- a/src/user.rs +++ b/src/user.rs @@ -150,108 +150,87 @@ fn sanitise_line(input: &str) -> String { #[cfg(test)] mod tests { + //! Unit tests for finger profile parsing and rendering. use super::*; use crate::identity::Username; + use anyhow::{Result, anyhow, ensure}; use rstest::rstest; fn profile_bytes(body: &str) -> &[u8] { body.as_bytes() } - fn parse_username(input: &str) -> Username { - Username::parse(input) - .unwrap_or_else(|err| panic!("failed to parse username '{input}': {err}")) + fn parse_username(input: &str) -> Result { + Username::parse(input).map_err(|err| anyhow!("failed to parse username '{input}': {err}")) } - fn expect_profile_error(username: Username, body: &str) -> ProfileError { + fn expect_profile_error(username: Username, body: &str) -> Result { match FingerProfile::parse(username, profile_bytes(body)) { - Ok(_) => panic!("expected profile parsing to fail"), - Err(err) => err, + Ok(_) => Err(anyhow!("expected profile parsing to fail")), + Err(err) => Ok(err), } } #[test] - fn parses_profile() { - let username = parse_username("alice"); + fn parses_profile() -> Result<()> { + let username = parse_username("alice")?; let body = r#" username = "alice" full_name = "Alice Smith" email = "alice@example.com" "#; - let profile = FingerProfile::parse(username.clone(), profile_bytes(body)) - .unwrap_or_else(|err| panic!("profile parse failed: {err}")); + let profile = FingerProfile::parse(username.clone(), profile_bytes(body))?; let response = profile.render(false, None); - let text = String::from_utf8(response.as_bytes()) - .unwrap_or_else(|err| panic!("utf8 conversion failed: {err}")); - assert!(text.contains("User: alice")); - assert!(text.contains("Full name: Alice Smith")); - assert!(text.contains("Email: alice@example.com")); - } - - #[test] - fn rejects_mismatched_username() { - let username = parse_username("alice"); - let body = r#" - username = "bob" - full_name = "Alice Smith" - "#; - let err = expect_profile_error(username, body); - assert!(matches!(err, ProfileError::UsernameMismatch)); - } - - #[test] - fn rejects_invalid_username_value() { - let username = parse_username("alice"); - let body = r#" - username = "bad name" - full_name = "Alice Smith" - "#; - let err = expect_profile_error(username, body); - assert!(matches!(err, ProfileError::UsernameInvalid(_))); - } - - #[test] - fn rejects_missing_username_field() { - let username = parse_username("alice"); - let body = r#" - full_name = "Alice Smith" - "#; - let err = expect_profile_error(username, body); - assert!(matches!(err, ProfileError::MissingUsername)); - } - - #[test] - fn rejects_non_string_value() { - let username = parse_username("alice"); - let body = r#" - username = "alice" - age = 42 - "#; - let err = expect_profile_error(username, body); - assert!(matches!(err, ProfileError::NonStringValue { ref key } if key == "age")); + let text = String::from_utf8(response.as_bytes())?; + ensure!(text.contains("User: alice")); + ensure!(text.contains("Full name: Alice Smith")); + ensure!(text.contains("Email: alice@example.com")); + Ok(()) } - #[test] - fn rejects_invalid_toml() { - let username = parse_username("alice"); - let body = "username = \"alice\"\nfull_name ="; - let err = expect_profile_error(username, body); - assert!(matches!(err, ProfileError::Toml(_))); + #[rstest] + #[case::mismatched_username( + "username = \"bob\"\nfull_name = \"Alice Smith\"\n", + |err: &ProfileError| matches!(err, ProfileError::UsernameMismatch) + )] + #[case::invalid_username_value( + "username = \"bad name\"\nfull_name = \"Alice Smith\"\n", + |err: &ProfileError| matches!(err, ProfileError::UsernameInvalid(_)) + )] + #[case::missing_username_field( + "full_name = \"Alice Smith\"\n", + |err: &ProfileError| matches!(err, ProfileError::MissingUsername) + )] + #[case::non_string_value( + "username = \"alice\"\nage = 42\n", + |err: &ProfileError| matches!(err, ProfileError::NonStringValue { key } if key == "age") + )] + #[case::invalid_toml( + "username = \"alice\"\nfull_name =", + |err: &ProfileError| matches!(err, ProfileError::Toml(_)) + )] + fn rejects_invalid_profile( + #[case] body: &str, + #[case] is_expected: fn(&ProfileError) -> bool, + ) -> Result<()> { + let username = parse_username("alice")?; + let err = expect_profile_error(username, body)?; + ensure!(is_expected(&err), "unexpected error variant: {err:?}"); + Ok(()) } #[rstest] #[case(Some(""), "(empty plan)")] #[case(None, "(no plan)")] - fn renders_plan_variants(#[case] plan: Option<&str>, #[case] expected: &str) { - let username = parse_username("alice"); + fn renders_plan_variants(#[case] plan: Option<&str>, #[case] expected: &str) -> Result<()> { + let username = parse_username("alice")?; let body = r#" username = "alice" "#; - let profile = FingerProfile::parse(username, profile_bytes(body)) - .unwrap_or_else(|err| panic!("profile parse failed: {err}")); + let profile = FingerProfile::parse(username, profile_bytes(body))?; let response = profile.render(true, plan); - let text = String::from_utf8(response.as_bytes()) - .unwrap_or_else(|err| panic!("utf8 conversion failed: {err}")); - assert!(text.contains(expected)); + let text = String::from_utf8(response.as_bytes())?; + ensure!(text.contains(expected)); + Ok(()) } }