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 <cursoragent@cursor.com>
This commit is contained in:
parent
0314c598fb
commit
26a6209600
|
|
@ -6,7 +6,8 @@
|
|||
<string>camera-trng</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>BINARY_PATH</string>
|
||||
<string>/bin/bash</string>
|
||||
<string>WORKING_DIR/scripts/launch-wrapper.sh</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>WORKING_DIR</string>
|
||||
|
|
@ -14,6 +15,8 @@
|
|||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>WORKING_DIR/logs/camera-qrng.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
|
|
@ -22,6 +25,8 @@
|
|||
<dict>
|
||||
<key>PORT</key>
|
||||
<string>8787</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:HOME_DIR/.cargo/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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."
|
||||
|
|
@ -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<u8> = (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<u8> = (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<u8> = 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<u8> = (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<Vec<u8>, String> {
|
||||
use nokhwa::utils::CameraIndex;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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<u8> = (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<u8> = 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");
|
||||
}
|
||||
Loading…
Reference in New Issue