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");
+}