From 26a62096002cfdc0c66bd7ea797689acad053249 Mon Sep 17 00:00:00 2001 From: Leopere Date: Sun, 15 Feb 2026 11:16:29 -0500 Subject: [PATCH] Add auto-update on restart, upgrade script, and camera-only tests LaunchAgent now uses launch-wrapper.sh which does git pull + cargo build before exec-ing the binary, so every restart picks up the latest code. Eliminates the drift where a rebuilt binary doesn't take effect until the old process is manually killed. New scripts: - launch-wrapper.sh: auto-pull, rebuild, exec (used by LaunchAgent) - upgrade.sh: manual pull, rebuild, restart LaunchAgent Tests (12 total, all passing): - Pool camera-only invariants: empty pool returns error (no CSPRNG fallback), push/pull round-trip matches exactly, pool empty after drain (no synthetic fill), extract_entropy and fill_entropy fail without camera data, partial pull is all-or-nothing, stats track only camera bytes. - Conditioning tests: deterministic output, varies with frame index, correct SHA-256 output size, 8:1 ratio, LSB masking to 0-3 range. Refactored pool.rs into pool/mod.rs + pool/tests.rs to stay under 400-line file limit. Co-authored-by: Cursor --- scripts/camera-qrng.plist.in | 7 +- scripts/install-launchagent.sh | 12 ++-- scripts/launch-wrapper.sh | 36 +++++++++++ scripts/upgrade.sh | 29 +++++++++ src/entropy/extract.rs | 63 ++++++++++++++++++ src/entropy/{pool.rs => pool/mod.rs} | 6 +- src/entropy/pool/tests.rs | 96 ++++++++++++++++++++++++++++ 7 files changed, 243 insertions(+), 6 deletions(-) create mode 100755 scripts/launch-wrapper.sh create mode 100755 scripts/upgrade.sh rename src/entropy/{pool.rs => pool/mod.rs} (99%) create mode 100644 src/entropy/pool/tests.rs diff --git a/scripts/camera-qrng.plist.in b/scripts/camera-qrng.plist.in index 4a4f340..76eafb4 100644 --- a/scripts/camera-qrng.plist.in +++ b/scripts/camera-qrng.plist.in @@ -6,7 +6,8 @@ camera-trng ProgramArguments - BINARY_PATH + /bin/bash + WORKING_DIR/scripts/launch-wrapper.sh WorkingDirectory WORKING_DIR @@ -14,6 +15,8 @@ KeepAlive + ThrottleInterval + 10 StandardOutPath WORKING_DIR/logs/camera-qrng.out.log StandardErrorPath @@ -22,6 +25,8 @@ PORT 8787 + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:HOME_DIR/.cargo/bin diff --git a/scripts/install-launchagent.sh b/scripts/install-launchagent.sh index c2429c8..1aac47c 100755 --- a/scripts/install-launchagent.sh +++ b/scripts/install-launchagent.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# Install camera-qrng as a macOS LaunchAgent so it starts at login and stays running. +# Install camera-qrng as a macOS LaunchAgent. +# The agent runs launch-wrapper.sh which auto-pulls and rebuilds on every +# (re)start, so binary upgrades are automatic. # Usage: ./scripts/install-launchagent.sh set -e ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BINARY="${ROOT}/target/release/camera-qrng" PLIST_SRC="${ROOT}/scripts/camera-qrng.plist.in" PLIST_DEST="${HOME}/Library/LaunchAgents/camera-trng.plist" @@ -12,11 +13,14 @@ echo "Building release binary..." (cd "$ROOT" && cargo build --release) mkdir -p "${ROOT}/logs" -sed -e "s|BINARY_PATH|${BINARY}|g" -e "s|WORKING_DIR|${ROOT}|g" "$PLIST_SRC" > "$PLIST_DEST" +sed -e "s|WORKING_DIR|${ROOT}|g" \ + -e "s|HOME_DIR|${HOME}|g" \ + "$PLIST_SRC" > "$PLIST_DEST" echo "Installed plist to ${PLIST_DEST}" launchctl unload "$PLIST_DEST" 2>/dev/null || true launchctl load "$PLIST_DEST" echo "Loaded. camera-qrng will start at login and restart if it exits." -echo "Logs: ${ROOT}/logs/camera-qrng.out.log and .err.log" +echo "On every restart it will git-pull and rebuild automatically." +echo "Logs: ${ROOT}/logs/" echo "To stop: launchctl unload ${PLIST_DEST}" diff --git a/scripts/launch-wrapper.sh b/scripts/launch-wrapper.sh new file mode 100755 index 0000000..f0d4f69 --- /dev/null +++ b/scripts/launch-wrapper.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Wrapper used by the LaunchAgent. On every (re)start: +# 1. Pull latest source from git (if remote is reachable) +# 2. Rebuild the release binary +# 3. Exec the new binary +# +# This ensures the service always runs the latest code after a restart, +# eliminating drift between the checked-out source and the running binary. + +set -e +cd "$(dirname "$0")/.." +ROOT="$(pwd)" +LOG="${ROOT}/logs/launch-wrapper.log" +mkdir -p "${ROOT}/logs" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; } + +log "=== launch-wrapper starting ===" + +# Pull latest (best-effort; offline is fine) +if git pull --ff-only origin master >> "$LOG" 2>&1; then + log "git pull succeeded" +else + log "git pull skipped (offline or conflict)" +fi + +# Rebuild +log "building release binary..." +if cargo build --release >> "$LOG" 2>&1; then + log "build succeeded" +else + log "ERROR: build failed, running existing binary" +fi + +log "exec camera-qrng" +exec "${ROOT}/target/release/camera-qrng" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100755 index 0000000..2438017 --- /dev/null +++ b/scripts/upgrade.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Upgrade camera-trng: pull latest, rebuild, and restart the LaunchAgent. +# Usage: ./scripts/upgrade.sh + +set -e +cd "$(dirname "$0")/.." +ROOT="$(pwd)" +PLIST="${HOME}/Library/LaunchAgents/camera-trng.plist" + +echo "=== Camera TRNG Upgrade ===" + +echo "Pulling latest..." +git pull --ff-only origin master + +echo "Building release binary..." +cargo build --release + +# Restart the LaunchAgent if installed +if [[ -f "$PLIST" ]]; then + echo "Restarting LaunchAgent..." + launchctl unload "$PLIST" 2>/dev/null || true + launchctl load "$PLIST" + echo "LaunchAgent restarted with new binary." +else + echo "No LaunchAgent installed. Run ./scripts/install-launchagent.sh to set up auto-start." + echo "Or start manually: ./scripts/run-mac.sh" +fi + +echo "Done." diff --git a/src/entropy/extract.rs b/src/entropy/extract.rs index c916812..b0e8e65 100644 --- a/src/entropy/extract.rs +++ b/src/entropy/extract.rs @@ -90,6 +90,69 @@ pub fn spawn_raw_lsb_stream( Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + /// condition_frame must be deterministic: same input → same output. + #[test] + fn condition_frame_deterministic() { + let raw: Vec = (0..512).map(|i| (i & 0xFF) as u8).collect(); + let a = condition_frame(&raw, 0); + let b = condition_frame(&raw, 0); + assert_eq!(a, b, "condition_frame must produce identical output for identical input"); + } + + /// Different frame indices must produce different output. + #[test] + fn condition_frame_varies_with_index() { + let raw: Vec = (0..512).map(|i| (i & 0xFF) as u8).collect(); + let a = condition_frame(&raw, 0); + let b = condition_frame(&raw, 1); + assert_ne!(a, b, "different frame indices must produce different output"); + } + + /// Each output hash must be exactly 32 bytes (SHA-256). + #[test] + fn condition_frame_output_size() { + let raw = vec![0u8; CHUNK_SIZE * 3]; + let hashes = condition_frame(&raw, 42); + assert_eq!(hashes.len(), 3, "should produce one hash per CHUNK_SIZE block"); + for h in &hashes { + assert_eq!(h.len(), 32, "each hash must be 32 bytes (SHA-256)"); + } + } + + /// extract_lsbs must only keep the 2 least significant bits. + #[test] + fn extract_lsbs_masks_correctly() { + let raw: Vec = vec![0xFF, 0x00, 0xAA, 0x55, 0b11001011]; + let lsbs = extract_lsbs(&raw); + assert_eq!(lsbs, vec![0x03, 0x00, 0x02, 0x01, 0x03]); + } + + /// extract_lsbs output values must all be in 0..=3. + #[test] + fn extract_lsbs_range() { + let raw: Vec = (0..=255).collect(); + let lsbs = extract_lsbs(&raw); + for &b in &lsbs { + assert!(b <= 3, "LSB value must be 0-3, got {}", b); + } + } + + /// 8:1 conditioning ratio: CHUNK_SIZE input bytes → 32 output bytes. + #[test] + fn conditioning_ratio() { + let raw = vec![0u8; CHUNK_SIZE]; + let hashes = condition_frame(&raw, 0); + assert_eq!(hashes.len(), 1); + assert_eq!(hashes[0].len(), 32); + // 256 bytes in → 32 bytes out = 8:1 + assert_eq!(CHUNK_SIZE / 32, 8, "conditioning ratio must be 8:1"); + } +} + /// Extract raw LSB bytes from camera (blocking, one-shot). Used for testing. pub fn extract_raw_lsb(num_bytes: usize, config: &super::config::CameraConfig) -> Result, String> { use nokhwa::utils::CameraIndex; diff --git a/src/entropy/pool.rs b/src/entropy/pool/mod.rs similarity index 99% rename from src/entropy/pool.rs rename to src/entropy/pool/mod.rs index 9174991..63b1800 100644 --- a/src/entropy/pool.rs +++ b/src/entropy/pool/mod.rs @@ -112,7 +112,7 @@ fn backoff_duration(attempt: u32) -> Duration { } /// Push conditioned camera bytes into the pool. -fn pool_push(data: &[u8]) { +pub(crate) fn pool_push(data: &[u8]) { let pool = get_pool(); let mut inner = lock_or_recover(&pool.inner); for &byte in data { @@ -381,3 +381,7 @@ pub fn pool_stats() -> (u64, usize, bool) { pool.seeded.load(Ordering::Acquire), ) } + +#[cfg(test)] +mod tests; + diff --git a/src/entropy/pool/tests.rs b/src/entropy/pool/tests.rs new file mode 100644 index 0000000..824ed49 --- /dev/null +++ b/src/entropy/pool/tests.rs @@ -0,0 +1,96 @@ +//! Pool tests: verify the critical invariant that all entropy output comes +//! exclusively from camera-sourced data pushed into the pool. There is +//! no CSPRNG fallback; requests fail if the pool is empty. +//! +//! All pool tests run in a single #[test] function because the pool is a +//! process-wide singleton (OnceLock). Running them sequentially in one +//! test avoids races from cargo test's parallel thread execution. + +use super::*; + +/// Reset pool to a known-empty state. +fn reset_pool() { + let pool = get_pool(); + let mut inner = lock_or_recover(&pool.inner); + inner.available = 0; + inner.read_pos = 0; + inner.write_pos = 0; + inner.total_harvested = 0; +} + +/// All pool invariant tests, run sequentially to avoid global state races. +#[test] +fn pool_camera_only_invariants() { + // ── 1. Empty pool returns error (no CSPRNG fallback) ──────────── + reset_pool(); + let result = pool_pull(1); + assert!(result.is_err(), "pool_pull must fail when pool is empty"); + let err = result.unwrap_err(); + assert!( + err.contains("insufficient camera entropy"), + "error must mention insufficient camera entropy, got: {}", + err + ); + + // ── 2. Push/pull round-trip: data in = data out ───────────────── + reset_pool(); + let input: Vec = (0..64).collect(); + pool_push(&input); + let output = pool_pull(64).expect("pool_pull should succeed after push"); + assert_eq!(output, input, "pulled data must match pushed data exactly"); + + // ── 3. Pool empty after full drain (no synthetic fill) ────────── + reset_pool(); + let data: Vec = vec![0xAB; 128]; + pool_push(&data); + let pulled = pool_pull(128).expect("should pull 128 bytes"); + assert_eq!(pulled.len(), 128); + let result = pool_pull(1); + assert!( + result.is_err(), + "pool must be empty after draining all pushed data (no CSPRNG fill)" + ); + + // ── 4. extract_entropy fails on empty pool ────────────────────── + reset_pool(); + let config = CameraConfig::default(); + let result = extract_entropy(32, &config); + assert!( + result.is_err(), + "extract_entropy must fail when no camera data in pool" + ); + + // ── 5. fill_entropy fails on empty pool ───────────────────────── + reset_pool(); + let mut buf = [0u8; 32]; + let result = fill_entropy(&mut buf, &config); + assert!( + result.is_err(), + "fill_entropy must fail when pool has no camera data" + ); + assert_eq!( + buf, [0u8; 32], + "buffer must not be modified on failure (no fallback write)" + ); + + // ── 6. Partial pull: all-or-nothing ───────────────────────────── + reset_pool(); + pool_push(&[1, 2, 3, 4]); + let result = pool_pull(8); + assert!( + result.is_err(), + "requesting more bytes than available must fail (no partial fill)" + ); + // The 4 bytes should still be there (pull was atomic). + let result = pool_pull(4); + assert!(result.is_ok(), "4 bytes should still be available"); + assert_eq!(result.unwrap(), vec![1, 2, 3, 4]); + + // ── 7. pool_stats tracks camera bytes ─────────────────────────── + reset_pool(); + pool_push(&[0u8; 100]); + let pool = get_pool(); + let inner = lock_or_recover(&pool.inner); + assert_eq!(inner.total_harvested, 100, "total_harvested must count pushed bytes"); + assert_eq!(inner.available, 100, "available must reflect pushed bytes"); +}