fix(cli): reject -j 0 instead of silently falling through to serial

`--threads` was an `Option<usize>` with no value-parser bound. Passing
`-j 0` slipped past clap and reached the dispatch in `crypto::encrypt`
/ `decrypt`, where the `threads > 1` check evaluates to false for 0 and
the call quietly fell through to the serial path. The user thought
they had asked for "no worker threads" and got something else instead;
either way, 0 workers is not a meaningful configuration.

Switch the field to `Option<u32>` and add `value_parser!(u32).range(1..)`
so clap rejects `-j 0` at parse time with a usage error. Cast to
`usize` at the single use site. Using `u32` rather than `usize` avoids
shipping a host-pointer-width-dependent CLI surface; thread counts well
past 4 billion are not a thing we need to plan for.

Test plan:
  - New integration test `rejects_zero_threads` invokes the binary
    with `-j 0` and asserts non-zero exit.
  - Existing `roundtrip_multi_threaded` (`-j 4`) still passes, so the
    range bound has not broken normal usage.

Refs: external review (GLM51 #1; Gemini #3 misdescribed the symptom
as "silent corruption" — verified the actual behaviour was a fall-
through to the serial path, not output corruption, but the fix is
the same).
This commit is contained in:
2026-05-02 21:29:28 +02:00
parent 91b459657e
commit 2c101abdbd
2 changed files with 24 additions and 3 deletions
+3 -3
View File
@@ -67,8 +67,8 @@ struct Cli {
/// Number of worker threads for AEAD work. Defaults to the number of /// Number of worker threads for AEAD work. Defaults to the number of
/// available CPUs. Set to 1 for fully serial encrypt/decrypt. /// available CPUs. Set to 1 for fully serial encrypt/decrypt.
#[clap(short = 'j', long)] #[clap(short = 'j', long, value_parser = clap::value_parser!(u32).range(1..))]
threads: Option<usize>, threads: Option<u32>,
/// Random-access decrypt: byte offset of the slice to read. /// Random-access decrypt: byte offset of the slice to read.
/// Requires `--decrypt`, an `--input-file` whose header has the /// Requires `--decrypt`, an `--input-file` whose header has the
@@ -175,7 +175,7 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
let argon_memory = cli.argon_memory; let argon_memory = cli.argon_memory;
let argon_passes = cli.argon_passes; let argon_passes = cli.argon_passes;
let argon_parallelism = cli.argon_parallelism; let argon_parallelism = cli.argon_parallelism;
let threads = cli.threads.unwrap_or_else(|| { let threads = cli.threads.map(|n| n as usize).unwrap_or_else(|| {
std::thread::available_parallelism() std::thread::available_parallelism()
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(1) .unwrap_or(1)
+21
View File
@@ -723,6 +723,27 @@ fn random_access_tampered_length_fails() {
); );
} }
#[test]
fn rejects_zero_threads() {
// -j 0 is almost certainly a user mistake. Clap should reject it before
// we ever reach the pipeline.
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
fs::write(&plain, b"hello").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--raw-key")
.arg(KEY_STR)
.arg("-j")
.arg("0")
.output()
.unwrap();
assert!(!out.status.success(), "-j 0 should be rejected");
}
#[test] #[test]
fn header_chunk_size_is_authoritative_on_decrypt() { fn header_chunk_size_is_authoritative_on_decrypt() {
// Encrypt with a non-default chunk size; decrypt without specifying one. // Encrypt with a non-default chunk size; decrypt without specifying one.