diff --git a/.gitignore b/.gitignore index b54a61f..d13f479 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ scripts/node_modules/ # Test artifacts random.bin + +# build-test runtime files +camera-qrng.log +.camera-qrng.pid diff --git a/Cargo.lock b/Cargo.lock index b137382..2d1934f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,6 @@ dependencies = [ "async-stream", "axum", "bytes", - "futures-util", "hex", "nokhwa", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6081742..f08b478 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ hex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" bytes = "1" -futures-util = { version = "0.3", default-features = false, features = ["alloc"] } async-stream = "0.3" [profile.release] diff --git a/README.md b/README.md index b5b8622..922a8d2 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The output is cryptographic quality - suitable for generating encryption keys, s | Endpoint | Description | |----------|-------------| -| `GET /random?bytes=N` | Get N random bytes (max 1024) | +| `GET /random?bytes=N` | Get N random bytes (max 1MB) | | `GET /random?bytes=N&hex=true` | Get N random bytes as hex string | | `GET /stream` | Continuous stream of random bytes | | `GET /health` | Health check | diff --git a/TECHNICAL.md b/TECHNICAL.md index 13806f4..9946dec 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -94,7 +94,7 @@ docker run -d \ Returns random bytes from camera thermal noise. **Query Parameters:** -- `bytes` - Number of bytes to return (default: 32, max: 1024) +- `bytes` - Number of bytes to return (default: 32, max: 1048576 (1MB)) - `hex` - Return as hex string instead of raw bytes (default: false) **Examples:** @@ -116,7 +116,7 @@ Returns `ok` if the server is running. ## Rate Limiting - Maximum 4 concurrent requests -- Maximum 1024 bytes per request +- Maximum 1MB (1048576) bytes per request - Returns 429 Too Many Requests when overloaded ## Cross-Platform Support diff --git a/scripts/build-test.sh b/scripts/build-test.sh new file mode 100755 index 0000000..3b4f5fb --- /dev/null +++ b/scripts/build-test.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# Build, test, and relaunch camera-qrng locally. +# +# 1. Kills any running camera-qrng process +# 2. Runs cargo check and cargo test +# 3. Builds release for native + all installed cross targets +# 4. Verifies each built binary +# 5. Relaunches the native binary in the background +# +# Usage: +# ./scripts/build-test.sh # full build-test cycle +# ./scripts/build-test.sh --no-launch # build + test only, don't relaunch +# ./scripts/build-test.sh --quick # native only, skip cross targets + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +LOG_FILE="$PROJECT_DIR/camera-qrng.log" +PID_FILE="$PROJECT_DIR/.camera-qrng.pid" +BINARY_NAME="camera-qrng" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +err() { echo -e "${RED}[ERR]${NC} $1"; } +step() { echo -e "${CYAN}[STEP]${NC} $1"; } + +# --- Parse flags --- +NO_LAUNCH=false +QUICK=false +for arg in "$@"; do + case "$arg" in + --no-launch) NO_LAUNCH=true ;; + --quick) QUICK=true ;; + -h|--help) + echo "Usage: $0 [--no-launch] [--quick]" + echo " --no-launch Build and test only, don't relaunch the service" + echo " --quick Native build only, skip cross-compilation targets" + exit 0 + ;; + *) err "Unknown flag: $arg"; exit 1 ;; + esac +done + +cd "$PROJECT_DIR" + +# ================================================== +# 1. Kill previous instance +# ================================================== +step "Killing any running $BINARY_NAME..." + +killed=false +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + if kill -0 "$old_pid" 2>/dev/null; then + kill "$old_pid" 2>/dev/null && killed=true + # Wait up to 5s for graceful shutdown + for i in $(seq 1 10); do + kill -0 "$old_pid" 2>/dev/null || break + sleep 0.5 + done + # Force kill if still alive + if kill -0 "$old_pid" 2>/dev/null; then + kill -9 "$old_pid" 2>/dev/null + sleep 0.5 + fi + fi + rm -f "$PID_FILE" +fi + +# Also kill any stray processes not tracked by PID file +pgrep -f "target/release/$BINARY_NAME" 2>/dev/null | while read -r pid; do + if [[ "$pid" != "$$" ]]; then + kill "$pid" 2>/dev/null && killed=true + fi +done +sleep 0.5 + +if [[ "$killed" == true ]]; then + info "Previous instance stopped" +else + info "No previous instance running" +fi + +# ================================================== +# 2. Check and test +# ================================================== +step "Running cargo check..." +cargo check 2>&1 | tail -3 +info "Check passed" + +step "Running cargo test..." +cargo test 2>&1 | tail -5 +info "Tests passed" + +# ================================================== +# 3. Build targets +# ================================================== + +# Detect native target triple +ARCH=$(uname -m) +OS=$(uname -s) +case "${ARCH}-${OS}" in + arm64-Darwin) NATIVE_TARGET="aarch64-apple-darwin" ;; + x86_64-Darwin) NATIVE_TARGET="x86_64-apple-darwin" ;; + aarch64-Linux) NATIVE_TARGET="aarch64-unknown-linux-gnu" ;; + x86_64-Linux) NATIVE_TARGET="x86_64-unknown-linux-gnu" ;; + *) NATIVE_TARGET="native" ;; +esac + +# Build targets: always native, optionally cross +CROSS_TARGETS=() +if [[ "$QUICK" == false ]]; then + case "$OS" in + Darwin) + # Build both macOS architectures + if [[ "$ARCH" == "arm64" ]]; then + CROSS_TARGETS+=("x86_64-apple-darwin") + else + CROSS_TARGETS+=("aarch64-apple-darwin") + fi + # Linux targets if cross is installed + if command -v cross &>/dev/null; then + CROSS_TARGETS+=("aarch64-unknown-linux-gnu" "x86_64-unknown-linux-gnu") + else + warn "Skipping Linux targets (install 'cross': cargo install cross)" + fi + ;; + Linux) + if [[ "$ARCH" == "aarch64" ]]; then + CROSS_TARGETS+=("x86_64-unknown-linux-gnu") + else + CROSS_TARGETS+=("aarch64-unknown-linux-gnu") + fi + ;; + esac +fi + +success=0 +failed=0 + +# --- Native build --- +step "Building native release ($NATIVE_TARGET)..." +cargo build --release 2>&1 | tail -3 +NATIVE_BIN="target/release/$BINARY_NAME" +if [[ -f "$NATIVE_BIN" ]]; then + info "Native: $(file "$NATIVE_BIN" | sed "s|$PROJECT_DIR/||")" + success=$((success + 1)) +else + err "Native binary not found at $NATIVE_BIN" + failed=$((failed + 1)) +fi + +# --- Cross targets --- +for target in "${CROSS_TARGETS[@]}"; do + step "Building $target..." + + use_cross=false + if [[ "$OS" == "Darwin" ]] && [[ "$target" == *"linux"* ]]; then + use_cross=true + fi + + if [[ "$use_cross" == true ]]; then + if cross build --release --target "$target" 2>&1 | tail -3; then + bin="target/$target/release/$BINARY_NAME" + if [[ -f "$bin" ]]; then + info "$target: $(file "$bin" | sed "s|$PROJECT_DIR/||")" + success=$((success + 1)) + else + err "$target binary not found" + failed=$((failed + 1)) + fi + else + err "$target build failed" + failed=$((failed + 1)) + fi + else + if cargo build --release --target "$target" 2>&1 | tail -3; then + bin="target/$target/release/$BINARY_NAME" + if [[ -f "$bin" ]]; then + info "$target: $(file "$bin" | sed "s|$PROJECT_DIR/||")" + success=$((success + 1)) + else + err "$target binary not found" + failed=$((failed + 1)) + fi + else + err "$target build failed" + failed=$((failed + 1)) + fi + fi +done + +echo "" +info "Build results: $success succeeded, $failed failed" + +if [[ $failed -gt 0 ]]; then + err "Some builds failed - check output above" + if [[ "$NO_LAUNCH" == true ]]; then + exit 1 + fi + warn "Continuing to launch native binary despite cross-build failures..." +fi + +# ================================================== +# 4. Verify native binary runs +# ================================================== +step "Verifying native binary..." +if [[ -x "$NATIVE_BIN" ]]; then + info "Binary OK: $(ls -lh "$NATIVE_BIN" | awk '{print $5}') $(file "$NATIVE_BIN" | grep -o 'Mach-O\|ELF\|PE32' || echo 'binary')" +else + err "Native binary not executable" + exit 1 +fi + +# ================================================== +# 5. Relaunch +# ================================================== +if [[ "$NO_LAUNCH" == true ]]; then + info "Skipping launch (--no-launch)" + exit 0 +fi + +step "Launching $BINARY_NAME in background..." +nohup "$NATIVE_BIN" > "$LOG_FILE" 2>&1 & +NEW_PID=$! +echo "$NEW_PID" > "$PID_FILE" + +# Give it a moment to start +sleep 2 + +if kill -0 "$NEW_PID" 2>/dev/null; then + info "$BINARY_NAME running (PID $NEW_PID)" + info "Log: $LOG_FILE" + info "PID file: $PID_FILE" + echo "" + head -10 "$LOG_FILE" 2>/dev/null | while IFS= read -r line; do + echo " $line" + done + echo "" + info "Health check: http://localhost:${PORT:-8787}/health" +else + err "$BINARY_NAME failed to start. Log output:" + cat "$LOG_FILE" 2>/dev/null + rm -f "$PID_FILE" + exit 1 +fi diff --git a/scripts/start-obs-noise.sh b/scripts/start-obs-noise.sh index 550ddd2..e206a1c 100755 --- a/scripts/start-obs-noise.sh +++ b/scripts/start-obs-noise.sh @@ -53,5 +53,5 @@ echo "" echo " cargo run --release" echo "" echo "To stop the virtual camera:" -echo " ./start-obs-noise.sh stop" +echo " ./stop-obs-noise.sh" echo "" diff --git a/src/lib.rs b/src/lib.rs index 8c50776..578c834 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod entropy; pub mod provider; +pub mod tools; pub use entropy::{ extract_entropy, extract_entropy_camera, fill_entropy, @@ -15,4 +16,7 @@ pub use entropy::{ }; // Re-export the OpenSSL provider init for cdylib +pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH}; + + pub use provider::OSSL_provider_init; diff --git a/src/main.rs b/src/main.rs index 53e21c0..5faefd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ use axum::{ Router, }; use camera_trng::{extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, CameraConfig, CHUNK_SIZE}; +mod qrng_handlers; +use qrng_handlers::{get_dice, get_password, get_coin}; use bytes::Bytes; use std::sync::{Arc, Mutex}; use serde_json::json; @@ -69,6 +71,9 @@ async fn main() -> Result<(), Box> { .route("/cameras", get(get_cameras)) .route("/random", get(get_random)) .route("/stream", get(get_stream)) + .route("/dice", get(get_dice)) + .route("/password", get(get_password)) + .route("/coin", get(get_coin)) .route("/health", get(health)) .route("/.well-known/mcp.json", get(mcp_wellknown)) .route("/.well-known/skill.md", get(get_skill_md)) diff --git a/src/qrng_handlers.rs b/src/qrng_handlers.rs new file mode 100644 index 0000000..0335d77 --- /dev/null +++ b/src/qrng_handlers.rs @@ -0,0 +1,151 @@ +//! HTTP handlers for QTRNG-backed tools: dice, passwords. + +use axum::{ + extract::Query, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use camera_trng::{ + extract_entropy, + roll_dice, + generate_password, + dice_bytes_needed, + charset_alphanumeric, + charset_full, + charset_hex, + charset_from_flags, + CameraConfig, + DEFAULT_SIDES, + DEFAULT_COUNT, + MIN_SIDES, + MAX_SIDES, + PASSWORD_DEFAULT_LENGTH, + PASSWORD_MAX_LENGTH, +}; +use serde::Deserialize; + +fn default_dice_sides() -> u32 { + DEFAULT_SIDES +} +fn default_dice_count() -> usize { + DEFAULT_COUNT +} + +#[derive(Deserialize)] +pub struct DiceQuery { + #[serde(default = "default_dice_sides")] + pub d: u32, + #[serde(default = "default_dice_count")] + pub count: usize, +} + +#[derive(Deserialize)] +pub struct PasswordQuery { + #[serde(default)] + pub length: Option, + #[serde(default)] + pub charset: Option, +} + +pub async fn get_dice(Query(params): Query) -> Response { + let sides = params.d; + let count = params.count; + + if sides < MIN_SIDES || sides > MAX_SIDES { + return (StatusCode::BAD_REQUEST, format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES)).into_response(); + } + if count == 0 || count > camera_trng::DICE_MAX_COUNT { + return (StatusCode::BAD_REQUEST, format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT)).into_response(); + } + + let need = dice_bytes_needed(sides, count); + let config = CameraConfig::from_env(); + let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await; + + let data = match result { + Ok(Ok(d)) => d, + Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let rolls = match roll_dice(sides, count, &data) { + Some(r) => r, + None => return (StatusCode::INTERNAL_SERVER_ERROR, "dice roll failed").into_response(), + }; + + let body = serde_json::json!({ + "sides": sides, + "count": count, + "rolls": rolls, + "sum": rolls.iter().sum::(), + }); + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-store") + .body(axum::body::Body::from(body.to_string())) + .unwrap() +} + +pub async fn get_password(Query(params): Query) -> Response { + let length = params.length.unwrap_or(PASSWORD_DEFAULT_LENGTH).min(PASSWORD_MAX_LENGTH); + if length == 0 { + return (StatusCode::BAD_REQUEST, "length must be >= 1").into_response(); + } + + let charset: Vec = match params.charset.as_deref() { + Some("alphanumeric") | None => charset_alphanumeric(), + Some("full") => charset_full(), + Some("hex") => charset_hex(), + Some(flags) => { + let l = flags.to_lowercase(); + charset_from_flags( + l.contains('l'), + l.contains('u'), + l.contains('d'), + l.contains('s'), + ) + } + }; + + let need = length * 4 + 64; + let config = CameraConfig::from_env(); + let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await; + + let data = match result { + Ok(Ok(d)) => d, + Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let password = match generate_password(length, &charset, &data) { + Some(p) => p, + None => return (StatusCode::INTERNAL_SERVER_ERROR, "password generation failed").into_response(), + }; + + let body = serde_json::json!({ + "password": password, + "length": length, + }); + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-store") + .body(axum::body::Body::from(body.to_string())) + .unwrap() +} + +/// Quantum coin flip: one random bit from QTRNG. +pub async fn get_coin() -> Response { + let config = CameraConfig::from_env(); + let result = tokio::task::spawn_blocking(move || extract_entropy(1, &config)).await; + let data = match result { + Ok(Ok(d)) if !d.is_empty() => d[0], + _ => return (StatusCode::INTERNAL_SERVER_ERROR, "entropy failed").into_response(), + }; + let side = if data & 1 == 0 { "heads" } else { "tails" }; + let body = serde_json::json!({ "result": side }); + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-store") + .body(axum::body::Body::from(body.to_string())) + .unwrap() +} diff --git a/src/tools/dice.rs b/src/tools/dice.rs new file mode 100644 index 0000000..8d1aac2 --- /dev/null +++ b/src/tools/dice.rs @@ -0,0 +1,98 @@ +//! Quantum dice rolling using QTRNG bytes. +//! +//! Configurable sides (d4, d6, d8, d12, d20, etc.) and count. +//! Uses rejection sampling for uniform distribution. + +/// Default number of sides (d6). +pub const DEFAULT_SIDES: u32 = 6; + +/// Default number of dice to roll. +pub const DEFAULT_COUNT: usize = 1; + +/// Max dice per request to avoid excessive entropy use. +pub const MAX_COUNT: usize = 100; + +/// Min/max sides (d2 = coin, up to d256). +pub const MIN_SIDES: u32 = 2; +pub const MAX_SIDES: u32 = 256; + +/// Roll one die with given sides using bytes from a RNG. +/// Returns value in 1..=sides. Uses rejection sampling for uniformity. +#[inline] +fn roll_one(sides: u32, bytes: &[u8], byte_index: &mut usize) -> Option { + if sides < MIN_SIDES || sides > MAX_SIDES { + return None; + } + let n = sides as u64; + let threshold = (u32::MAX as u64 / n) * n; // largest multiple of n that fits in u32 + loop { + if *byte_index + 4 > bytes.len() { + return None; + } + let word = u32::from_be_bytes([ + bytes[*byte_index], + bytes[*byte_index + 1], + bytes[*byte_index + 2], + bytes[*byte_index + 3], + ]); + *byte_index += 4; + if (word as u64) < threshold { + return Some((word as u32 % sides as u32) + 1); + } + } +} + +/// Roll `count` dice with `sides` each, using the provided random bytes. +/// Returns `None` if not enough bytes or invalid params; otherwise `Some(vec of rolls)`. +pub fn roll_dice(sides: u32, count: usize, bytes: &[u8]) -> Option> { + if count == 0 || count > MAX_COUNT { + return None; + } + if sides < MIN_SIDES || sides > MAX_SIDES { + return None; + } + let mut out = Vec::with_capacity(count); + let mut idx = 0; + for _ in 0..count { + let v = roll_one(sides, bytes, &mut idx)?; + out.push(v); + } + Some(out) +} + +/// Estimate bytes needed for `count` dice of `sides` (worst-case rejection). +#[allow(dead_code)] +pub fn bytes_needed(sides: u32, count: usize) -> usize { + let n = sides as u64; + let threshold = (u32::MAX as u64 / n) * n; + let accept_prob = threshold as f64 / u32::MAX as f64; + let per_roll = 4.0_f64 / accept_prob; + (per_roll * count as f64).ceil() as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roll_d6_one() { + let bytes = [0u8; 64]; + let r = roll_dice(6, 1, &bytes); + assert!(r.is_some()); + let r = r.unwrap(); + assert_eq!(r.len(), 1); + assert!(r[0] >= 1 && r[0] <= 6); + } + + #[test] + fn roll_d20_five() { + let bytes = [0u8; 128]; + let r = roll_dice(20, 5, &bytes); + assert!(r.is_some()); + let r = r.unwrap(); + assert_eq!(r.len(), 5); + for &v in &r { + assert!(v >= 1 && v <= 20); + } + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..90ccedf --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,10 @@ +//! QTRNG-backed tools: dice, passwords, etc. + +mod dice; +mod password; + +pub use dice::{roll_dice, bytes_needed as dice_bytes_needed, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT, MAX_SIDES, MIN_SIDES}; +pub use password::{ + generate_password, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, + DEFAULT_LENGTH, MAX_LENGTH, +}; diff --git a/src/tools/password.rs b/src/tools/password.rs new file mode 100644 index 0000000..bf5c474 --- /dev/null +++ b/src/tools/password.rs @@ -0,0 +1,96 @@ +//! Quantum password/passphrase generation from QTRNG bytes. +//! +//! Configurable length and character set (lowercase, uppercase, digits, symbols). + +/// Default password length. +pub const DEFAULT_LENGTH: usize = 16; + +/// Max length per request. +pub const MAX_LENGTH: usize = 128; + +/// Character set presets (no spaces; safe for passwords). +pub const CHARSET_LOWER: &str = "abcdefghijklmnopqrstuvwxyz"; +pub const CHARSET_UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +pub const CHARSET_DIGIT: &str = "0123456789"; +pub const CHARSET_SYMBOL: &str = "!@#$%^&*()-_=+[]{}|;:,.<>?"; + +/// Build charset from flags: lower, upper, digit, symbol. +pub fn charset_from_flags(lower: bool, upper: bool, digit: bool, symbol: bool) -> Vec { + let mut v = Vec::new(); + if lower { + v.extend_from_slice(CHARSET_LOWER.as_bytes()); + } + if upper { + v.extend_from_slice(CHARSET_UPPER.as_bytes()); + } + if digit { + v.extend_from_slice(CHARSET_DIGIT.as_bytes()); + } + if symbol { + v.extend_from_slice(CHARSET_SYMBOL.as_bytes()); + } + if v.is_empty() { + v.extend_from_slice(CHARSET_LOWER.as_bytes()); + v.extend_from_slice(CHARSET_DIGIT.as_bytes()); + } + v +} + +/// "alphanumeric" = lower + upper + digit +pub fn charset_alphanumeric() -> Vec { + charset_from_flags(true, true, true, false) +} + +/// "full" = lower + upper + digit + symbol +pub fn charset_full() -> Vec { + charset_from_flags(true, true, true, true) +} + +/// "hex" = 0-9a-f +pub fn charset_hex() -> Vec { + b"0123456789abcdef".to_vec() +} + +/// Generate a password of `length` from random `bytes` using the given charset. +/// Charset must be non-empty. Returns `None` if not enough bytes or invalid params. +pub fn generate_password(length: usize, charset: &[u8], bytes: &[u8]) -> Option { + if length == 0 || length > MAX_LENGTH || charset.is_empty() { + return None; + } + let n = charset.len() as u32; + let threshold = (u32::MAX / n) * n; + let mut out = String::with_capacity(length); + let mut idx = 0; + for _ in 0..length { + loop { + if idx + 4 > bytes.len() { + return None; + } + let word = u32::from_be_bytes([ + bytes[idx], + bytes[idx + 1], + bytes[idx + 2], + bytes[idx + 3], + ]); + idx += 4; + if word < threshold { + let i = (word % n) as usize; + out.push(charset[i] as char); + break; + } + } + } + Some(out) +} + +/// Estimate bytes needed for a password of given length with charset size. +#[allow(dead_code)] +pub fn bytes_needed(length: usize, charset_len: usize) -> usize { + if charset_len == 0 { + return 0; + } + let n = charset_len as u32; + let threshold = (u32::MAX / n) * n; + let accept_prob = threshold as f64 / u32::MAX as f64; + (4.0_f64 / accept_prob * length as f64).ceil() as usize +}