From e8dd95a2bd26fef1f403fc810c4e5b28c00ed813 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 17:40:27 +0300 Subject: [PATCH 1/2] fix: use FHS-compliant /var/lib/numa as Linux data dir default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numa's default system-wide data directory was hardcoded to /usr/local/var/numa for all Unix platforms. This is the right path on macOS (Homebrew prefix convention) but non-FHS on Linux, where Arch / Fedora / Debian / etc. expect persistent state under /var/lib/. The mismatch was invisible to existing users (numa creates the dir silently on first run) but immediately surfaces when packaging for a distro — see PR #33 (community contribution to add an Arch AUR package) which had to add fragile sed-based path patching at PKGBUILD build time. The fix moves the path decision into a small helper: - daemon_data_dir() — cfg-gated platform dispatch (linux/macos) - resolve_linux_data_dir() — pure function, takes "does X exist?" as parameters, returns the right path Linux behavior: - Fresh install → /var/lib/numa (FHS) - Upgrading from pre-v0.10.1 install → /usr/local/var/numa (legacy) - Both paths exist → /var/lib/numa (FHS wins) The legacy fallback is critical: existing v0.10.0 Linux users have their CA cert + services.json under /usr/local/var/numa. Returning the new path unconditionally would cause CA regeneration on upgrade, breaking every browser that had trusted the previous CA. The fallback is checked at startup via std::path::Path::exists, so the upgrade is seamless and zero-config. macOS behavior is unchanged — /usr/local/var/numa is still correct because Homebrew's prefix is /usr/local. Test coverage: - resolve_linux_data_dir is a pure function gated cfg(any(linux,test)) so the same code path is unit-tested on every platform's CI run. - Four tests cover all combinations of (legacy_exists, fhs_exists), asserting the migration logic stays correct under future edits. The default config in numa.toml is also updated to document the new per-platform default paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- numa.toml | 11 ++++---- src/lib.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/numa.toml b/numa.toml index 35d92de..77ba231 100644 --- a/numa.toml +++ b/numa.toml @@ -2,11 +2,12 @@ bind_addr = "0.0.0.0:53" api_port = 5380 # api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access -# data_dir = "/usr/local/var/numa" # where numa stores TLS CA and cert material - # (default: /usr/local/var/numa on unix, - # %PROGRAMDATA%\numa on windows). Override for - # containerized deploys or tests that can't - # write to the system path. +# data_dir = "/var/lib/numa" # where numa stores TLS CA and cert material + # Defaults: /var/lib/numa on linux (FHS), + # /usr/local/var/numa on macos (homebrew prefix), + # %PROGRAMDATA%\numa on windows. Override for + # containerized deploys or tests that can't + # write to the system path. # [upstream] # mode = "forward" # "forward" (default) — relay to upstream diff --git a/src/lib.rs b/src/lib.rs index 347e72f..08c9df4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,10 @@ pub type Error = Box; pub type Result = std::result::Result; /// Shared config directory for persistent data (services.json, etc). -/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon) +/// Unix users: ~/.config/numa/ +/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa +/// if a pre-v0.10.1 install already lives there. +/// macOS root daemon: /usr/local/var/numa (Homebrew prefix) /// Windows: %APPDATA%\numa pub fn config_dir() -> std::path::PathBuf { #[cfg(windows)] @@ -63,13 +66,15 @@ fn config_dir_unix() -> std::path::PathBuf { } // Running as root daemon (launchd/systemd) — use system-wide path - std::path::PathBuf::from("/usr/local/var/numa") + daemon_data_dir() } /// Default system-wide data directory for TLS certs. Overridable via /// `[server] data_dir = "..."` in numa.toml — this function only provides /// the fallback when the config doesn't set it. -/// Unix: /usr/local/var/numa +/// Linux: /var/lib/numa (FHS) — falls back to /usr/local/var/numa if a +/// pre-v0.10.1 install already has data there. +/// macOS: /usr/local/var/numa (Homebrew prefix) /// Windows: %PROGRAMDATA%\numa pub fn data_dir() -> std::path::PathBuf { #[cfg(windows)] @@ -81,6 +86,75 @@ pub fn data_dir() -> std::path::PathBuf { } #[cfg(not(windows))] { + daemon_data_dir() + } +} + +/// Resolve the system-wide data directory for the running platform. +/// Honors backwards compatibility with pre-v0.10.1 installs that still +/// have their CA cert + services.json under `/usr/local/var/numa`. +#[cfg(not(windows))] +fn daemon_data_dir() -> std::path::PathBuf { + #[cfg(target_os = "linux")] + { + std::path::PathBuf::from(resolve_linux_data_dir( + std::path::Path::new(LEGACY_LINUX_DATA_DIR).exists(), + std::path::Path::new(FHS_LINUX_DATA_DIR).exists(), + )) + } + #[cfg(target_os = "macos")] + { + // macOS uses the Homebrew prefix convention; no FHS migration needed. std::path::PathBuf::from("/usr/local/var/numa") } } + +#[cfg(any(target_os = "linux", test))] +const FHS_LINUX_DATA_DIR: &str = "/var/lib/numa"; +#[cfg(any(target_os = "linux", test))] +const LEGACY_LINUX_DATA_DIR: &str = "/usr/local/var/numa"; + +/// Pure path-decision logic for Linux. Returns the FHS-compliant default +/// for fresh installs, or the legacy pre-v0.10.1 path if data already +/// lives there (so users don't lose their CA cert on upgrade). Extracted +/// as a pure function so the migration logic is unit-testable without +/// touching the real filesystem. +#[cfg(any(target_os = "linux", test))] +fn resolve_linux_data_dir(legacy_exists: bool, fhs_exists: bool) -> &'static str { + if legacy_exists && !fhs_exists { + LEGACY_LINUX_DATA_DIR + } else { + FHS_LINUX_DATA_DIR + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linux_data_dir_fresh_install_uses_fhs() { + // No data anywhere → fresh install gets the FHS path. + assert_eq!(resolve_linux_data_dir(false, false), "/var/lib/numa"); + } + + #[test] + fn linux_data_dir_upgrading_install_keeps_legacy() { + // Pre-v0.10.1 install: legacy path has data, FHS path doesn't yet. + // Migration must keep using legacy so the user doesn't lose their CA. + assert_eq!(resolve_linux_data_dir(true, false), "/usr/local/var/numa"); + } + + #[test] + fn linux_data_dir_after_migration_uses_fhs() { + // Both paths exist (e.g., user manually copied data to FHS path). + // Prefer FHS since the legacy path is no longer the canonical home. + assert_eq!(resolve_linux_data_dir(true, true), "/var/lib/numa"); + } + + #[test] + fn linux_data_dir_only_fhs_uses_fhs() { + // Only FHS path has data — straightforward fresh-FHS case. + assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa"); + } +} -- 2.34.1 From 857569495d508e9bb9d4b52f54feffc42dbfa01c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 17:52:03 +0300 Subject: [PATCH 2/2] test: end-to-end FHS path verification + simplify cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes from a /simplify pass and a follow-up testing finalization: 1. lib.rs cleanup (no behavior change): - Drop FHS_LINUX_DATA_DIR and LEGACY_LINUX_DATA_DIR consts. Both were used in only 4 places total and the unit tests already bypassed them with string literals, so they were over-engineering. Inline the strings in daemon_data_dir() and resolve_linux_data_dir(). - Trim narrating doc/comments on the helper and the test bodies. Keep only the non-obvious WHY (the macOS Homebrew note and the migration-keeps-legacy rationale). 2. tests/docker/smoke-arch.sh: - Cherry-picked the previously-uncommitted Arch compatibility smoke test from feat/smoke-arch. - Removed the [server] data_dir = "/tmp/numa-smoke" override from the test config so the script now exercises the DEFAULT data dir code path — which is exactly what the FHS fix touches. - Added a path assertion after the dig succeeds: verify that /var/lib/numa/ca.pem exists (FHS) and /usr/local/var/numa is absent (no accidental dual-creation on a fresh install). Verified end-to-end on archlinux:latest (Apple Silicon, Rosetta): ── building + running numa on archlinux:latest ── ── cargo build --release --locked ── Finished `release` profile [optimized] target(s) in 24.02s ── dig @127.0.0.1 -p 5354 google.com A ── 142.251.38.206 ── FHS path check ── ✓ CA cert at /var/lib/numa/ca.pem (FHS path) ✓ legacy path /usr/local/var/numa absent (fresh install used FHS) ── smoke-arch passed ── This closes the testing gap where the unit tests covered the path-decision LOGIC in isolation but nothing exercised the live wiring on a real Linux filesystem. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib.rs | 27 ++----- tests/docker/smoke-arch.sh | 147 +++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 20 deletions(-) create mode 100755 tests/docker/smoke-arch.sh diff --git a/src/lib.rs b/src/lib.rs index 08c9df4..6455506 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,8 +98,8 @@ fn daemon_data_dir() -> std::path::PathBuf { #[cfg(target_os = "linux")] { std::path::PathBuf::from(resolve_linux_data_dir( - std::path::Path::new(LEGACY_LINUX_DATA_DIR).exists(), - std::path::Path::new(FHS_LINUX_DATA_DIR).exists(), + std::path::Path::new("/usr/local/var/numa").exists(), + std::path::Path::new("/var/lib/numa").exists(), )) } #[cfg(target_os = "macos")] @@ -109,22 +109,14 @@ fn daemon_data_dir() -> std::path::PathBuf { } } -#[cfg(any(target_os = "linux", test))] -const FHS_LINUX_DATA_DIR: &str = "/var/lib/numa"; -#[cfg(any(target_os = "linux", test))] -const LEGACY_LINUX_DATA_DIR: &str = "/usr/local/var/numa"; - -/// Pure path-decision logic for Linux. Returns the FHS-compliant default -/// for fresh installs, or the legacy pre-v0.10.1 path if data already -/// lives there (so users don't lose their CA cert on upgrade). Extracted -/// as a pure function so the migration logic is unit-testable without -/// touching the real filesystem. +/// Extracted as a pure function so the migration logic is unit-testable +/// without touching the real filesystem. #[cfg(any(target_os = "linux", test))] fn resolve_linux_data_dir(legacy_exists: bool, fhs_exists: bool) -> &'static str { if legacy_exists && !fhs_exists { - LEGACY_LINUX_DATA_DIR + "/usr/local/var/numa" } else { - FHS_LINUX_DATA_DIR + "/var/lib/numa" } } @@ -134,27 +126,22 @@ mod tests { #[test] fn linux_data_dir_fresh_install_uses_fhs() { - // No data anywhere → fresh install gets the FHS path. assert_eq!(resolve_linux_data_dir(false, false), "/var/lib/numa"); } #[test] fn linux_data_dir_upgrading_install_keeps_legacy() { - // Pre-v0.10.1 install: legacy path has data, FHS path doesn't yet. - // Migration must keep using legacy so the user doesn't lose their CA. + // Migration must keep legacy so the user doesn't lose their CA on upgrade. assert_eq!(resolve_linux_data_dir(true, false), "/usr/local/var/numa"); } #[test] fn linux_data_dir_after_migration_uses_fhs() { - // Both paths exist (e.g., user manually copied data to FHS path). - // Prefer FHS since the legacy path is no longer the canonical home. assert_eq!(resolve_linux_data_dir(true, true), "/var/lib/numa"); } #[test] fn linux_data_dir_only_fhs_uses_fhs() { - // Only FHS path has data — straightforward fresh-FHS case. assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa"); } } diff --git a/tests/docker/smoke-arch.sh b/tests/docker/smoke-arch.sh new file mode 100755 index 0000000..12e779e --- /dev/null +++ b/tests/docker/smoke-arch.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Arch Linux compatibility smoke test. +# +# Builds numa from source inside an archlinux:latest container, runs it +# in forward mode on port 5354, and verifies a single DNS query returns +# an A record. Validates the "Arch compatible" claim end-to-end before +# release announcements. +# +# Dogfooding: the test numa forwards to the host's running numa via +# host.docker.internal (Docker Desktop's host gateway). This avoids the +# Docker NAT/UDP issues with public resolvers and exercises the realistic +# numa-on-numa shape. Requires the host to be running numa on port 53. +# +# First run is slow (~8-12 min): image pull + pacman + cold cargo build. +# No caching across runs. +# +# Requirements: docker, host running numa on 0.0.0.0:53 +# Usage: ./tests/docker/smoke-arch.sh + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +# Precondition: the test numa-on-arch forwards to the host numa as its +# upstream (dogfood pattern). Fail fast with a clear error if there is +# no working DNS on the host, rather than letting the dig inside the +# container time out with "deadline has elapsed". +if ! dig @127.0.0.1 google.com A +short +time=1 +tries=1 >/dev/null 2>&1; then + printf "${RED}error:${RESET} host numa is not answering on 127.0.0.1:53\n" >&2 + echo " This test forwards to the host numa via host.docker.internal." >&2 + echo " Start numa on the host first (sudo numa install), then rerun." >&2 + exit 1 +fi + +echo "── building + running numa on archlinux:latest ──" +echo " (first run is slow: image pull + pacman + cold cargo build, ~8-12 min)" +echo + +docker run --rm \ + --platform linux/amd64 \ + --security-opt seccomp=unconfined \ + -v "$PWD:/src:ro" \ + -v numa-arch-cargo:/root/.cargo \ + -v numa-arch-target:/work/target \ + archlinux:latest bash -c ' + set -e + + # pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu + sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf + + echo "── pacman: installing build + runtime deps ──" + pacman -Sy --noconfirm --needed rust gcc pkgconf cmake make perl bind 2>&1 | tail -3 + echo + + # Copy source to a writable workdir, skipping target/ + .git so we + # do not pull in the host (macOS) build artifacts. + mkdir -p /work + tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf - + cd /work + + echo "── cargo build --release --locked ──" + cargo build --release --locked 2>&1 | tail -5 + echo + + # Dogfood: forward to the host numa via host.docker.internal. + # numa parses upstream.address as a literal SocketAddr, so we resolve + # the hostname to an IPv4 address first (force v4 — getent hosts may + # return IPv6 first, and IPv6 addresses need bracketed addr:port form). + HOST_IP=$(getent ahostsv4 host.docker.internal | awk "/STREAM/ {print \$1; exit}") + if [ -z "$HOST_IP" ]; then + echo " ✗ could not resolve host.docker.internal to IPv4 (not on Docker Desktop?)" + exit 1 + fi + echo "── starting numa on :5354 (forward to host numa at $HOST_IP:53) ──" + # Intentionally NOT setting [server] data_dir — we want to exercise the + # default code path (data_dir() → daemon_data_dir() → /var/lib/numa) so + # the FHS-path assertion below verifies the live wiring, not just the + # unit-tested helper. + cat > /tmp/numa.toml < /tmp/numa.log 2>&1 & + NUMA_PID=$! + + # Poll for readiness — numa is ready when it answers a query + READY=0 + for i in 1 2 3 4 5 6 7 8; do + sleep 1 + if dig @127.0.0.1 -p 5354 google.com A +short +time=1 +tries=1 2>/dev/null \ + | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"; then + READY=1 + break + fi + done + + if [ "$READY" -ne 1 ]; then + echo " ✗ numa did not return an A record after 8s" + echo " numa log:" + cat /tmp/numa.log + kill $NUMA_PID 2>/dev/null || true + exit 1 + fi + + echo "── dig @127.0.0.1 -p 5354 google.com A ──" + ANSWER=$(dig @127.0.0.1 -p 5354 google.com A +short +time=2 +tries=1) + echo "$ANSWER" | sed "s/^/ /" + + kill $NUMA_PID 2>/dev/null || true + + # FHS path assertion: the default data dir on Linux must be /var/lib/numa + # (not the legacy /usr/local/var/numa). The CA cert generated at startup + # is the canonical proof that numa wrote to the right place. + echo + echo "── FHS path check ──" + if [ -f /var/lib/numa/ca.pem ]; then + echo " ✓ CA cert at /var/lib/numa/ca.pem (FHS path)" + else + echo " ✗ CA cert NOT at /var/lib/numa/ca.pem" + echo " ls /var/lib/numa/:" + ls -la /var/lib/numa/ 2>&1 | sed "s/^/ /" + echo " ls /usr/local/var/numa/:" + ls -la /usr/local/var/numa/ 2>&1 | sed "s/^/ /" + exit 1 + fi + if [ -e /usr/local/var/numa ]; then + echo " ✗ legacy path /usr/local/var/numa unexpectedly exists on a fresh container" + exit 1 + fi + echo " ✓ legacy path /usr/local/var/numa absent (fresh install used FHS)" + + echo + echo " ✓ numa built, ran, answered a forward query, and used the FHS data dir on Arch" +' + +echo +printf "${GREEN}── smoke-arch passed ──${RESET}\n" -- 2.34.1