feat: harden fcry format and IO policy

Introduce a central policy module for format and resource validation, then
route header parsing, KDF acceptance, range arithmetic, and pipeline sizing
through that policy. New encryptions now write v3 headers that include an
authenticated key commitment, which lets decrypt reject wrong keys or
passphrases before chunk processing while preserving valid v1/v2 decrypt
compatibility inside the configured caps.

Replace process-list-visible raw key input with --key-file, add passphrase NFC
normalization, enforce stronger new-encryption passphrase/KDF floors unless
--allow-weak-kdf is supplied, and add a configurable decrypt Argon2 memory
ceiling. Chunk buffers in the serial, parallel, and lookahead paths now use
zeroizing storage.

Rework output handling around randomized create-new temporary files with Unix
0600 mode, file fsync before persist, best-effort parent directory fsync,
default no-overwrite behavior, safe in-place replacement, --force, --temp-dir,
and --buffer-verify for decrypt-to-stdout.

Known caveat: --key-file currently reads with a single read call. That is fine
for regular files but can reject short reads from pipes or process
substitution. A follow-up fix will make key-file reads loop before EOF.

Test Plan:
- cargo fmt --check
- cargo clippy --all-targets -- -D warnings
- cargo test
- git diff --check
- cargo run -- --help

Refs: fcry security hardening plan
This commit is contained in:
2026-06-09 23:45:02 +02:00
parent d7b0127d20
commit 81ac1475ad
12 changed files with 1625 additions and 227 deletions
+480 -47
View File
@@ -14,12 +14,21 @@ use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;
const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
const KEY_STR: &str = "0123456789abcdef0123456789abcdef";
fn fcry() -> Command {
Command::cargo_bin("fcry").unwrap()
}
fn write_key_file(dir: &std::path::Path) -> std::path::PathBuf {
let key = dir.join("key.bin");
fs::write(&key, KEY).unwrap();
key
}
fn key_file_near(path: &std::path::Path) -> std::path::PathBuf {
write_key_file(path.parent().unwrap())
}
/// Deterministic pseudo-random plaintext of `n` bytes (xorshift, seedable).
/// We avoid `/dev/urandom` so tests are reproducible on failure.
fn pseudo_random(seed: u64, n: usize) -> Vec<u8> {
@@ -37,12 +46,13 @@ fn pseudo_random(seed: u64, n: usize) -> Vec<u8> {
fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Option<u32>) {
let mut cmd = fcry();
let key = key_file_near(ct);
cmd.arg("-i")
.arg(plain)
.arg("-o")
.arg(ct)
.arg("--raw-key")
.arg(KEY_STR);
.arg("--key-file")
.arg(key);
if let Some(cs) = chunk_size {
cmd.arg("--chunk-size").arg(cs.to_string());
}
@@ -55,14 +65,15 @@ fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Optio
}
fn decrypt_file(ct: &std::path::Path, rt: &std::path::Path) {
let key = key_file_near(ct);
let out = fcry()
.arg("-d")
.arg("-i")
.arg(ct)
.arg("-o")
.arg(rt)
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key)
.output()
.unwrap();
assert!(
@@ -133,10 +144,12 @@ fn roundtrip_chunk_size_one_byte() {
#[test]
fn roundtrip_pipe_stdin_stdout() {
let data = pseudo_random(42, 200_000);
let dir = TempDir::new().unwrap();
let key = write_key_file(dir.path());
let mut enc = fcry()
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -152,8 +165,8 @@ fn roundtrip_pipe_stdin_stdout() {
let mut dec = fcry()
.arg("-d")
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -182,19 +195,24 @@ fn rejects_wrong_key() {
fs::write(&plain, pseudo_random(1, 1000)).unwrap();
encrypt_file(&plain, &ct, None);
let wrong = "ffffffffffffffffffffffffffffffff";
assert_ne!(wrong.as_bytes(), KEY);
let wrong = dir.path().join("wrong.key");
fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--raw-key")
.arg("--key-file")
.arg(wrong)
.output()
.unwrap();
assert!(!out.status.success(), "decrypt with wrong key should fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("WrongKey"),
"expected distinct WrongKey error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
@@ -216,8 +234,8 @@ fn rejects_tampered_header() {
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
@@ -246,8 +264,8 @@ fn rejects_tampered_ciphertext() {
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
@@ -275,8 +293,8 @@ fn rejects_truncated_ciphertext() {
.arg(&ct)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
@@ -296,8 +314,8 @@ fn rejects_bad_magic() {
.arg(&bogus)
.arg("-o")
.arg(dir.path().join("rt.bin"))
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
@@ -312,25 +330,95 @@ fn rejects_bad_magic() {
}
#[test]
fn rejects_short_raw_key() {
fn rejects_short_key_file() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let key = dir.path().join("short.key");
fs::write(&plain, b"hello").unwrap();
fs::write(&key, b"tooshort").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--raw-key")
.arg("tooshort")
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(
!out.status.success(),
"encrypt with short raw_key should fail"
"encrypt with short key file should fail"
);
}
#[test]
fn rejects_long_key_file_and_trailing_newline() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let key = dir.path().join("long.key");
fs::write(&plain, b"hello").unwrap();
fs::write(&key, b"0123456789abcdef0123456789abcdef\n").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(!out.status.success(), "long key file should fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("too long"),
"expected too-long error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn non_utf8_key_file_roundtrips() {
let dir = TempDir::new().unwrap();
let key = dir.path().join("key.bin");
let plain = dir.path().join("plain.bin");
let ct = dir.path().join("ct.bin");
let rt = dir.path().join("rt.bin");
let key_bytes: Vec<u8> = (0..32u8).map(|b| b ^ 0x80).collect();
let data = pseudo_random(31, 8192);
fs::write(&key, key_bytes).unwrap();
fs::write(&plain, &data).unwrap();
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(
enc.status.success(),
"non-UTF-8 key encrypt failed: {}",
String::from_utf8_lossy(&enc.stderr)
);
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--key-file")
.arg(&key)
.output()
.unwrap();
assert!(
dec.status.success(),
"non-UTF-8 key decrypt failed: {}",
String::from_utf8_lossy(&dec.stderr)
);
assert_eq!(fs::read(&rt).unwrap(), data);
}
#[test]
fn roundtrip_passphrase_argon2id() {
let dir = TempDir::new().unwrap();
@@ -352,6 +440,7 @@ fn roundtrip_passphrase_argon2id() {
.arg("8")
.arg("--argon-passes")
.arg("1")
.arg("--allow-weak-kdf")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
@@ -394,6 +483,70 @@ fn roundtrip_passphrase_argon2id() {
assert!(!bad.status.success(), "wrong passphrase should fail");
}
#[test]
fn weak_passphrase_kdf_rejected_without_override() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
fs::write(&plain, b"hello").unwrap();
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--argon-memory")
.arg("8")
.arg("--argon-passes")
.arg("1")
.env("FCRY_TEST_PW", "short")
.output()
.unwrap();
assert!(!enc.status.success(), "weak KDF/passphrase should fail");
}
#[test]
fn decrypt_argon_memory_cap_rejects_hostile_header() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
let enc = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--argon-memory")
.arg("8")
.arg("--argon-passes")
.arg("1")
.arg("--allow-weak-kdf")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
assert!(enc.status.success());
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("--passphrase-env")
.arg("FCRY_TEST_PW")
.arg("--max-argon-memory-mib")
.arg("1")
.env("FCRY_TEST_PW", "correct horse battery staple")
.output()
.unwrap();
assert!(!dec.status.success(), "low decrypt cap should reject file");
assert!(
String::from_utf8_lossy(&dec.stderr).contains("decrypt cap"),
"expected cap error, got {}",
String::from_utf8_lossy(&dec.stderr)
);
}
#[test]
fn atomic_output_no_stale_tmp_on_failure() {
// A failed decrypt (wrong key) should not leave the output file behind.
@@ -404,15 +557,16 @@ fn atomic_output_no_stale_tmp_on_failure() {
fs::write(&plain, b"hello world").unwrap();
encrypt_file(&plain, &ct, None);
let wrong = "ffffffffffffffffffffffffffffffff";
let wrong = dir.path().join("wrong.key");
fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--raw-key")
.arg(wrong)
.arg("--key-file")
.arg(&wrong)
.output()
.unwrap();
assert!(!out.status.success());
@@ -422,6 +576,131 @@ fn atomic_output_no_stale_tmp_on_failure() {
assert!(!tmp.exists(), "temp file must be cleaned up");
}
#[test]
fn existing_output_refuses_without_force() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
fs::write(&ct, b"existing").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(!out.status.success(), "existing output should refuse");
assert_eq!(fs::read(&ct).unwrap(), b"existing");
}
#[test]
fn force_replaces_only_after_success() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
fs::write(&ct, b"existing").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--force")
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
out.status.success(),
"force encrypt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_ne!(fs::read(&ct).unwrap(), b"existing");
}
#[test]
fn in_place_replacement_roundtrips() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("data.bin");
let original = pseudo_random(41, 50_000);
fs::write(&path, &original).unwrap();
let enc = fcry()
.arg("-i")
.arg(&path)
.arg("-o")
.arg(&path)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
enc.status.success(),
"in-place encrypt failed: {}",
String::from_utf8_lossy(&enc.stderr)
);
assert_ne!(fs::read(&path).unwrap(), original);
let dec = fcry()
.arg("-d")
.arg("-i")
.arg(&path)
.arg("-o")
.arg(&path)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
dec.status.success(),
"in-place decrypt failed: {}",
String::from_utf8_lossy(&dec.stderr)
);
assert_eq!(fs::read(&path).unwrap(), original);
}
#[test]
fn old_predictable_temp_name_input_is_not_truncated() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("out.bin.tmp");
let output = dir.path().join("out.bin");
let original = pseudo_random(42, 1024);
fs::write(&input, &original).unwrap();
let out = fcry()
.arg("-i")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(
out.status.success(),
"encrypt failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(fs::read(&input).unwrap(), original);
assert!(output.exists());
}
#[cfg(unix)]
#[test]
fn output_file_mode_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
encrypt_file(&plain, &ct, None);
let mode = fs::metadata(&ct).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
// ---------------------------------------------------------------------------
// Multi-threaded pipeline + length-committed + random-access tests
// ---------------------------------------------------------------------------
@@ -433,12 +712,13 @@ fn encrypt_file_threads(
threads: usize,
) {
let mut cmd = fcry();
let key = key_file_near(ct);
cmd.arg("-i")
.arg(plain)
.arg("-o")
.arg(ct)
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key)
.arg("-j")
.arg(threads.to_string());
if let Some(cs) = chunk_size {
@@ -453,14 +733,15 @@ fn encrypt_file_threads(
}
fn decrypt_file_threads(ct: &std::path::Path, rt: &std::path::Path, threads: usize) {
let key = key_file_near(ct);
let out = fcry()
.arg("-d")
.arg("-i")
.arg(ct)
.arg("-o")
.arg(rt)
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key)
.arg("-j")
.arg(threads.to_string())
.output()
@@ -517,10 +798,12 @@ fn roundtrip_pipe_multi_threaded() {
// length when we don't know the input size), but encrypt/decrypt must still
// round-trip cleanly across the pipeline.
let data = pseudo_random(14, 200_000);
let dir = TempDir::new().unwrap();
let key = write_key_file(dir.path());
let mut enc = fcry()
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(&key)
.arg("-j")
.arg("4")
.stdin(Stdio::piped())
@@ -536,16 +819,17 @@ fn roundtrip_pipe_multi_threaded() {
String::from_utf8_lossy(&enc_out.stderr)
);
// flags byte at offset 6 must be 0 (no length committed for stdin input).
// flags byte at offset 6 must not set length commitment for stdin input.
assert_eq!(
enc_out.stdout[6], 0,
enc_out.stdout[6] & 0x01,
0,
"stdin-encrypted file unexpectedly committed length"
);
let mut dec = fcry()
.arg("-d")
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(&key)
.arg("-j")
.arg("4")
.stdin(Stdio::piped())
@@ -567,6 +851,97 @@ fn roundtrip_pipe_multi_threaded() {
assert_eq!(dec_out.stdout, data);
}
#[test]
fn stdin_chunk_size_zero_fails_but_empty_valid_chunk_succeeds() {
let dir = TempDir::new().unwrap();
let key = write_key_file(dir.path());
let mut bad = fcry()
.arg("--chunk-size")
.arg("0")
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
bad.stdin.as_mut().unwrap().write_all(b"x").unwrap();
let bad_out = bad.wait_with_output().unwrap();
assert!(!bad_out.status.success(), "chunk-size 0 should fail");
let mut good = fcry()
.arg("--chunk-size")
.arg("1")
.arg("--key-file")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(good.stdin.take());
let good_out = good.wait_with_output().unwrap();
assert!(
good_out.status.success(),
"empty stdin with valid chunk should succeed: {}",
String::from_utf8_lossy(&good_out.stderr)
);
}
#[test]
fn huge_thread_count_is_bounded() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, b"hello").unwrap();
let out = fcry()
.arg("-i")
.arg(&plain)
.arg("-o")
.arg(&ct)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.arg("-j")
.arg("1000000")
.output()
.unwrap();
assert!(
out.status.success(),
"huge -j should be capped, got {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stderr).contains("capped"));
}
#[test]
fn forged_huge_chunk_header_fails_before_allocation() {
let dir = TempDir::new().unwrap();
let forged = dir.path().join("forged.bin");
let mut bytes = Vec::new();
bytes.extend_from_slice(b"fcry");
bytes.push(3); // version
bytes.push(1); // alg
bytes.push(0x02); // key commitment flag
bytes.push(0); // reserved
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
fs::write(&forged, bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&forged)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.output()
.unwrap();
assert!(!out.status.success(), "huge chunk header should fail");
assert!(
String::from_utf8_lossy(&out.stderr).contains("chunk_size"),
"expected chunk_size error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn file_input_commits_length() {
// Encrypting from a regular file must auto-set FLAG_LENGTH_COMMITTED (bit 0
@@ -580,8 +955,39 @@ fn file_input_commits_length() {
let bytes = fs::read(&ct).unwrap();
// Magic(4) + version(1) + alg(1) + flags(1) = byte 6
assert_eq!(bytes[4], 2, "version should be 2");
assert_eq!(bytes[4], 3, "version should be 3");
assert_eq!(bytes[6] & 0x01, 0x01, "length-committed flag should be set");
assert_eq!(bytes[6] & 0x02, 0x02, "key-committed flag should be set");
}
#[test]
fn v3_downgrade_or_commitment_stripping_fails_authentication() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
let rt = dir.path().join("r.bin");
fs::write(&plain, pseudo_random(51, 1000)).unwrap();
encrypt_file(&plain, &ct, None);
let mut bytes = fs::read(&ct).unwrap();
bytes[4] = 2;
bytes[6] &= !0x02;
fs::write(&ct, bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("-o")
.arg(&rt)
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(
!out.status.success(),
"downgraded/stripped v3 header must fail authentication"
);
}
fn encrypt_random_access_fixture(
@@ -608,8 +1014,8 @@ fn random_access_decrypt(
.arg(ct)
.arg("-o")
.arg(out)
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(key_file_near(ct))
.arg("--offset")
.arg(offset.to_string())
.arg("--length")
@@ -671,10 +1077,11 @@ fn random_access_rejects_stdin_encrypted() {
let data = pseudo_random(18, 2000);
let dir = TempDir::new().unwrap();
let ct = dir.path().join("c.bin");
let key = write_key_file(dir.path());
let mut enc = fcry()
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(&key)
.arg("-o")
.arg(&ct)
.stdin(Stdio::piped())
@@ -693,14 +1100,13 @@ fn random_access_rejects_stdin_encrypted() {
}
#[test]
fn random_access_zero_length() {
fn random_access_rejects_zero_length() {
let dir = TempDir::new().unwrap();
let data = pseudo_random(19, 1000);
let ct = encrypt_random_access_fixture(dir.path(), &data, 256);
let out = dir.path().join("empty.bin");
let r = random_access_decrypt(&ct, &out, 500, 0);
assert!(r.status.success(), "zero-length slice should succeed");
assert_eq!(fs::read(&out).unwrap(), Vec::<u8>::new());
assert!(!r.status.success(), "zero-length slice should fail");
}
#[test]
@@ -723,6 +1129,33 @@ fn random_access_tampered_length_fails() {
);
}
#[test]
fn buffer_verify_stdout_emits_nothing_on_truncated_ciphertext() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("p.bin");
let ct = dir.path().join("c.bin");
fs::write(&plain, pseudo_random(61, 3 * 1024 * 1024)).unwrap();
encrypt_file(&plain, &ct, Some(64 * 1024));
let mut bytes = fs::read(&ct).unwrap();
bytes.truncate(bytes.len() - 32);
fs::write(&ct, bytes).unwrap();
let out = fcry()
.arg("-d")
.arg("-i")
.arg(&ct)
.arg("--buffer-verify")
.arg("--key-file")
.arg(key_file_near(&ct))
.output()
.unwrap();
assert!(!out.status.success(), "truncated decrypt should fail");
assert!(
out.stdout.is_empty(),
"buffer-verify must suppress partial stdout"
);
}
#[test]
fn rejects_zero_threads() {
// -j 0 is almost certainly a user mistake. Clap should reject it before
@@ -735,8 +1168,8 @@ fn rejects_zero_threads() {
.arg(&plain)
.arg("-o")
.arg(dir.path().join("c.bin"))
.arg("--raw-key")
.arg(KEY_STR)
.arg("--key-file")
.arg(write_key_file(dir.path()))
.arg("-j")
.arg("0")
.output()