use std::fs;
use std::path::{Path, PathBuf};
use ferrocrypt::secrecy::SecretString;
use ferrocrypt::{
CryptoError, Decryptor, Encryptor, FormatDefect, HeaderReadLimits, InvalidKdfParams, KdfLimit,
KdfParams, KeyPairGenerator, PrivateKey, PublicKey, probe_recipient_mode,
probe_recipient_mode_with_limits,
};
use ferrocrypt_test_support::{
TEST_FAST_KDF_MEM_COST, fast_keypair_generator, fast_passphrase_encryptor,
};
fn generate_key_pair(
output_dir: impl AsRef<Path>,
passphrase: SecretString,
on_event: impl Fn(&ferrocrypt::ProgressEvent),
) -> Result<ferrocrypt::KeyGenOutcome, CryptoError> {
fast_keypair_generator(passphrase).write(output_dir, on_event)
}
const PASSPHRASE: &str = "api-test-passphrase";
const TEST_WORKSPACE: &str = "tests/workspace_api";
#[ctor::dtor]
fn cleanup() {
if Path::new(TEST_WORKSPACE).exists() {
let _ = fs::remove_dir_all(TEST_WORKSPACE);
}
}
fn fresh_workspace(name: &str) -> PathBuf {
let dir = Path::new(TEST_WORKSPACE).join(name);
if dir.exists() {
fs::remove_dir_all(&dir).expect("clean api workspace");
}
fs::create_dir_all(&dir).expect("create api workspace");
dir
}
fn pass() -> SecretString {
SecretString::from(PASSPHRASE.to_string())
}
#[test]
fn encryptor_passphrase_round_trip() {
let work = fresh_workspace("passphrase_round_trip");
let input = work.join("data.txt");
fs::write(&input, b"hello passphrase api").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let outcome = fast_passphrase_encryptor(pass())
.write(&input, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let decrypted = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::Passphrase(d) => d.decrypt(pass(), &restore, |_| {}).expect("decrypt"),
Decryptor::PrivateKey(_) => panic!("expected passphrase decryptor"),
_ => unreachable!("Decryptor is non_exhaustive; v1 has only Passphrase + PrivateKey"),
};
let restored_bytes = fs::read(decrypted.output_path).unwrap();
assert_eq!(restored_bytes, b"hello passphrase api");
}
#[test]
fn encryptor_recipient_round_trip() {
let work = fresh_workspace("recipient_round_trip");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let input = work.join("data.txt");
fs::write(&input, b"hello recipient api").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let outcome = Encryptor::with_public_key(PublicKey::from_key_file(&kg.public_key_path))
.write(&input, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let decrypted = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::PrivateKey(d) => d
.decrypt(
PrivateKey::from_key_file(&kg.private_key_path),
pass(),
&restore,
|_| {},
)
.expect("decrypt"),
Decryptor::Passphrase(_) => panic!("expected private-key decryptor"),
_ => unreachable!("Decryptor is non_exhaustive; v1 has only Passphrase + PrivateKey"),
};
let restored_bytes = fs::read(decrypted.output_path).unwrap();
assert_eq!(restored_bytes, b"hello recipient api");
}
#[test]
fn encryptor_with_recipients_each_can_decrypt() {
let work = fresh_workspace("multi_recipients");
let keys_a = work.join("keys_a");
let keys_b = work.join("keys_b");
fs::create_dir_all(&keys_a).unwrap();
fs::create_dir_all(&keys_b).unwrap();
let kg_a = generate_key_pair(&keys_a, pass(), |_| {}).expect("keygen alice");
let kg_b = generate_key_pair(&keys_b, pass(), |_| {}).expect("keygen bob");
let input = work.join("data.txt");
fs::write(&input, b"multi recipient payload").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let outcome = Encryptor::with_public_keys([
PublicKey::from_key_file(&kg_a.public_key_path),
PublicKey::from_key_file(&kg_b.public_key_path),
])
.expect("with_public_keys")
.write(&input, &out_dir, |_| {})
.expect("encrypt");
for (label, kg) in [("alice", &kg_a), ("bob", &kg_b)] {
let restore = work.join(format!("restored-{label}"));
fs::create_dir_all(&restore).unwrap();
let decrypted = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::PrivateKey(d) => d
.decrypt(
PrivateKey::from_key_file(&kg.private_key_path),
pass(),
&restore,
|_| {},
)
.expect("decrypt"),
Decryptor::Passphrase(_) => panic!("expected private-key decryptor"),
_ => unreachable!("Decryptor is non_exhaustive"),
};
let restored_bytes = fs::read(decrypted.output_path).unwrap();
assert_eq!(
restored_bytes, b"multi recipient payload",
"{label} restored bytes drifted"
);
}
}
#[test]
fn encryptor_with_recipients_rejects_empty() {
let err = Encryptor::with_public_keys(std::iter::empty::<PublicKey>()).unwrap_err();
assert!(
matches!(err, CryptoError::EmptyRecipientList),
"expected EmptyRecipientList, got {err:?}"
);
}
#[test]
fn save_as_overrides_default_filename() {
let work = fresh_workspace("save_as");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let custom = work.join("custom-name.fcr");
let outcome = fast_passphrase_encryptor(pass())
.save_as(&custom)
.write(&input, &work, |_| {})
.expect("encrypt");
assert_eq!(outcome.output_path, custom, "save_as path not honored");
assert!(custom.exists(), "custom path missing on disk");
}
#[test]
fn decryptor_open_rejects_directory() {
let work = fresh_workspace("open_dir");
let err = Decryptor::open(&work).unwrap_err();
match err {
CryptoError::InvalidInput(msg) => {
assert!(msg.contains("directory"), "unexpected message: {msg:?}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn decryptor_open_rejects_non_fcr_file() {
let work = fresh_workspace("open_bad_magic");
let path = work.join("plain.txt");
fs::write(&path, b"this is not a FerroCrypt file").unwrap();
let err = Decryptor::open(&path).unwrap_err();
match err {
CryptoError::InvalidFormat(FormatDefect::BadMagic) => {}
other => panic!("expected InvalidFormat(BadMagic), got {other:?}"),
}
}
#[test]
fn decryptor_open_rejects_missing_input() {
let err = Decryptor::open("/nonexistent/never/exists.fcr").unwrap_err();
assert!(
matches!(err, CryptoError::InputPath),
"expected InputPath, got {err:?}"
);
}
#[test]
fn encryptor_passphrase_rejects_empty_before_input_check() {
let work = fresh_workspace("empty_pass_before_input");
let missing = work.join("does-not-exist.txt");
let err = fast_passphrase_encryptor(SecretString::from(String::new()))
.write(&missing, &work, |_| {})
.unwrap_err();
match err {
CryptoError::InvalidInput(msg) => assert!(
msg.contains("Passphrase"),
"expected Passphrase rejection, got {msg:?}"
),
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[test]
fn encryptor_debug_does_not_leak_passphrase() {
const SECRET: &str = "totally-secret-passphrase-9F2";
let encryptor = fast_passphrase_encryptor(SecretString::from(SECRET.to_string()));
let rendered = format!("{encryptor:?}");
assert!(
!rendered.contains(SECRET),
"Encryptor leaked passphrase into Debug output: {rendered}"
);
}
#[test]
fn probe_recipient_mode_round_trips_via_encryptor() {
let work = fresh_workspace("probe_round_trip");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let outcome = fast_passphrase_encryptor(pass())
.write(&input, &work, |_| {})
.expect("encrypt");
assert!(
probe_recipient_mode(&outcome.output_path)
.unwrap()
.is_some(),
"encrypt output must classify as a known FerroCrypt mode"
);
}
#[test]
fn decrypt_outcome_carries_authenticated_passphrase_mode() {
let work = fresh_workspace("outcome_mode_passphrase");
let input = work.join("data.txt");
fs::write(&input, b"plaintext").unwrap();
let encrypted = fast_passphrase_encryptor(pass())
.write(&input, &work, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let outcome = match Decryptor::open(&encrypted.output_path).expect("open") {
Decryptor::Passphrase(d) => d.decrypt(pass(), &restore, |_| {}).expect("decrypt"),
other => panic!("expected passphrase decryptor, got {other:?}"),
};
assert!(
outcome.recipient_mode.is_passphrase(),
"passphrase decrypt must report passphrase mode, got {}",
outcome.recipient_mode
);
assert!(!outcome.recipient_mode.is_public_key());
}
#[test]
fn decrypt_outcome_carries_authenticated_public_key_mode() {
let work = fresh_workspace("outcome_mode_public_key");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let input = work.join("data.txt");
fs::write(&input, b"plaintext").unwrap();
let encrypted = Encryptor::with_public_key(PublicKey::from_key_file(&kg.public_key_path))
.write(&input, &work, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let outcome = match Decryptor::open(&encrypted.output_path).expect("open") {
Decryptor::PrivateKey(d) => d
.decrypt(
PrivateKey::from_key_file(&kg.private_key_path),
pass(),
&restore,
|_| {},
)
.expect("decrypt"),
other => panic!("expected private-key decryptor, got {other:?}"),
};
assert!(
outcome.recipient_mode.is_public_key(),
"public-key decrypt must report public-key mode, got {}",
outcome.recipient_mode
);
assert!(!outcome.recipient_mode.is_passphrase());
}
#[test]
fn passphrase_decryptor_archive_limits_constrains_extraction() {
use ferrocrypt::ArchiveLimits;
let work = fresh_workspace("passphrase_archive_limits");
let dir = work.join("input");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.txt"), b"a").unwrap();
fs::write(dir.join("b.txt"), b"b").unwrap();
fs::write(dir.join("c.txt"), b"c").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let outcome = fast_passphrase_encryptor(pass())
.write(&dir, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let tight = ArchiveLimits::default().with_max_entry_count(1);
let result = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::Passphrase(d) => d.archive_limits(tight).decrypt(pass(), &restore, |_| {}),
_ => panic!("expected passphrase decryptor"),
};
match result {
Err(CryptoError::InvalidInput(msg)) => {
assert!(
msg.contains("entry-count cap exceeded"),
"expected entry-count cap error, got: {msg}"
);
}
other => panic!("expected InvalidInput cap error, got {other:?}"),
}
}
#[test]
fn recipient_decryptor_archive_limits_constrains_extraction() {
use ferrocrypt::ArchiveLimits;
let work = fresh_workspace("recipient_archive_limits");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let dir = work.join("input");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.txt"), b"a").unwrap();
fs::write(dir.join("b.txt"), b"b").unwrap();
fs::write(dir.join("c.txt"), b"c").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let outcome = Encryptor::with_public_key(PublicKey::from_key_file(&kg.public_key_path))
.write(&dir, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let tight = ArchiveLimits::default().with_max_entry_count(1);
let result = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::PrivateKey(d) => d.archive_limits(tight).decrypt(
PrivateKey::from_key_file(&kg.private_key_path),
pass(),
&restore,
|_| {},
),
_ => panic!("expected private-key decryptor"),
};
match result {
Err(CryptoError::InvalidInput(msg)) => {
assert!(
msg.contains("entry-count cap exceeded"),
"expected entry-count cap error, got: {msg}"
);
}
other => panic!("expected InvalidInput cap error, got {other:?}"),
}
}
#[test]
fn archive_limits_writer_raised_default_reader_rejects_path_depth() {
use ferrocrypt::ArchiveLimits;
let work = fresh_workspace("archive_limits_asymmetric_path_depth");
let input = work.join("input");
let mut deepest = input.clone();
for _ in 0..64 {
deepest = deepest.join("a");
}
fs::create_dir_all(&deepest).unwrap();
fs::write(deepest.join("leaf.txt"), b"deep payload").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let raised = ArchiveLimits::default().with_max_path_depth(80);
let outcome = fast_passphrase_encryptor(pass())
.archive_limits(raised)
.write(&input, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let default_decrypt = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::Passphrase(d) => d.decrypt(pass(), &restore, |_| {}),
_ => panic!("expected passphrase decryptor"),
};
match default_decrypt {
Err(CryptoError::InvalidInput(msg)) => assert!(
msg.contains("path depth cap"),
"expected path-depth cap rejection from default reader, got: {msg}"
),
other => panic!("expected default reader to reject deep file, got {other:?}"),
}
let restore2 = work.join("restored2");
fs::create_dir_all(&restore2).unwrap();
let raised_decrypt = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::Passphrase(d) => d
.archive_limits(raised)
.decrypt(pass(), &restore2, |_| {})
.expect("raised reader must accept the file the default reader refused"),
_ => panic!("expected passphrase decryptor"),
};
assert!(raised_decrypt.output_path.is_dir());
let mut leaf = raised_decrypt.output_path.clone();
for _ in 0..64 {
leaf = leaf.join("a");
}
leaf = leaf.join("leaf.txt");
assert_eq!(fs::read(&leaf).unwrap(), b"deep payload");
}
#[test]
fn archive_limits_raised_on_both_sides_round_trips() {
use ferrocrypt::ArchiveLimits;
let work = fresh_workspace("archive_limits_raised");
let dir = work.join("input");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.txt"), b"alpha").unwrap();
fs::write(dir.join("b.txt"), b"beta").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let raised = ArchiveLimits::default()
.with_max_entry_count(8)
.with_max_path_depth(8);
let outcome = fast_passphrase_encryptor(pass())
.archive_limits(raised)
.write(&dir, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let outcome_decrypt = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::Passphrase(d) => d
.archive_limits(raised)
.decrypt(pass(), &restore, |_| {})
.expect("decrypt"),
_ => panic!("expected passphrase decryptor"),
};
let extracted = outcome_decrypt.output_path;
assert!(extracted.is_dir());
assert_eq!(fs::read(extracted.join("a.txt")).unwrap(), b"alpha");
assert_eq!(fs::read(extracted.join("b.txt")).unwrap(), b"beta");
}
#[test]
fn decryptor_open_with_limits_accepts_recipient_count_above_default() {
let work = fresh_workspace("recipient_count_above_default");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let input = work.join("data.txt");
fs::write(&input, b"raised count payload").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
const RECIPIENT_COUNT: usize = 80;
let recipients: Vec<PublicKey> = (0..RECIPIENT_COUNT)
.map(|_| PublicKey::from_key_file(&kg.public_key_path))
.collect();
let writer_limits = HeaderReadLimits::default().max_recipient_count(128);
let outcome = Encryptor::with_public_keys(recipients)
.expect("with_public_keys")
.header_read_limits(writer_limits)
.write(&input, &out_dir, |_| {})
.expect("encrypt");
match Decryptor::open(&outcome.output_path) {
Err(CryptoError::RecipientCountCapExceeded { count, local_cap }) => {
assert_eq!(count, RECIPIENT_COUNT as u16);
assert!(
local_cap < count,
"default local_cap should be below file count"
);
}
other => panic!("expected RecipientCountCapExceeded with default cap, got {other:?}"),
}
let raised = HeaderReadLimits::default().max_recipient_count(128);
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let decrypted = match Decryptor::open_with_limits(&outcome.output_path, raised)
.expect("open_with_limits")
{
Decryptor::PrivateKey(d) => d
.decrypt(
PrivateKey::from_key_file(&kg.private_key_path),
pass(),
&restore,
|_| {},
)
.expect("decrypt"),
_ => panic!("expected private-key decryptor"),
};
assert_eq!(
fs::read(decrypted.output_path).unwrap(),
b"raised count payload"
);
}
#[test]
fn probe_recipient_mode_with_limits_accepts_above_default() {
use ferrocrypt::UnauthenticatedRecipientMode;
let work = fresh_workspace("probe_with_limits");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let recipients: Vec<PublicKey> = (0..80)
.map(|_| PublicKey::from_key_file(&kg.public_key_path))
.collect();
let writer_limits = HeaderReadLimits::default().max_recipient_count(128);
let outcome = Encryptor::with_public_keys(recipients)
.expect("with_public_keys")
.header_read_limits(writer_limits)
.write(&input, &out_dir, |_| {})
.expect("encrypt");
match probe_recipient_mode(&outcome.output_path) {
Err(CryptoError::RecipientCountCapExceeded { .. }) => {}
other => panic!("expected RecipientCountCapExceeded with default probe, got {other:?}"),
}
let raised = HeaderReadLimits::default().max_recipient_count(128);
match probe_recipient_mode_with_limits(&outcome.output_path, raised) {
Ok(Some(UnauthenticatedRecipientMode::PublicKey)) => {}
other => panic!("expected Ok(Some(PublicKey)) under raised cap, got {other:?}"),
}
}
#[test]
fn encryptor_with_recipients_above_default_rejects_without_opt_in() {
let work = fresh_workspace("recipients_above_default_rejects");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let cap = HeaderReadLimits::RECIPIENT_COUNT_DEFAULT;
let recipients: Vec<PublicKey> = (0..(cap as usize + 1))
.map(|_| PublicKey::from_key_file(&kg.public_key_path))
.collect();
let result = Encryptor::with_public_keys(recipients)
.expect("with_public_keys")
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::RecipientCountCapExceeded { count, local_cap }) => {
assert_eq!(count, cap + 1);
assert_eq!(local_cap, cap);
}
other => panic!("expected RecipientCountCapExceeded, got {other:?}"),
}
let at_cap: Vec<PublicKey> = (0..cap)
.map(|_| PublicKey::from_key_file(&kg.public_key_path))
.collect();
let at_cap_out = out_dir.join("at_cap");
fs::create_dir_all(&at_cap_out).unwrap();
let outcome = Encryptor::with_public_keys(at_cap)
.expect("with_public_keys at cap")
.write(&input, &at_cap_out, |_| {})
.expect("encrypt at exactly the default cap must succeed");
assert!(outcome.output_path.exists());
}
#[test]
fn encryptor_passphrase_header_limits_reject_tight_recipient_count() {
let work = fresh_workspace("passphrase_header_count_tight");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let tight = HeaderReadLimits::default().max_recipient_count(0);
let result = fast_passphrase_encryptor(pass())
.header_read_limits(tight)
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::RecipientCountCapExceeded {
count: 1,
local_cap: 0,
}) => {}
other => panic!("expected RecipientCountCapExceeded(1, 0), got {other:?}"),
}
}
#[test]
fn encryptor_passphrase_header_limits_reject_tight_body_len() {
let work = fresh_workspace("passphrase_header_body_tight");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let tight = HeaderReadLimits::default().max_recipient_body_len(1);
let result = fast_passphrase_encryptor(pass())
.header_read_limits(tight)
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::RecipientBodyCapExceeded {
body_len,
local_cap,
}) => {
assert!(body_len > local_cap);
assert_eq!(local_cap, 1);
}
other => panic!("expected RecipientBodyCapExceeded, got {other:?}"),
}
}
#[test]
fn encryptor_recipient_header_limits_reject_tight_header_len() {
let work = fresh_workspace("recipient_header_len_tight");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let kg = generate_key_pair(&keys, pass(), |_| {}).expect("keygen");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let tight = HeaderReadLimits::default().max_header_len(32);
let result = Encryptor::with_public_key(PublicKey::from_key_file(&kg.public_key_path))
.header_read_limits(tight)
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::HeaderLenCapExceeded {
header_len,
local_cap: 32,
}) => {
assert!(header_len > 32);
}
other => panic!("expected HeaderLenCapExceeded, got {other:?}"),
}
}
#[test]
fn encryptor_kdf_params_rejects_structural_time_cost() {
let work = fresh_workspace("kdf_structural_time_rejects");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let invalid = KdfParams {
mem_cost: TEST_FAST_KDF_MEM_COST,
time_cost: 13,
lanes: 4,
};
let result = Encryptor::with_passphrase(pass())
.kdf_params(invalid)
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::InvalidKdfParams(InvalidKdfParams::TimeCost(13))) => {}
other => panic!("expected InvalidKdfParams::TimeCost(13), got {other:?}"),
}
}
#[test]
fn encryptor_kdf_params_rejects_structural_mem_cost_even_with_raised_limit() {
let work = fresh_workspace("kdf_structural_mem_rejects");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let invalid_mem = 3 * 1024 * 1024;
let invalid = KdfParams {
mem_cost: invalid_mem,
time_cost: 4,
lanes: 4,
};
let result = Encryptor::with_passphrase(pass())
.kdf_params(invalid)
.kdf_limit(KdfLimit::new(4 * 1024 * 1024))
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::InvalidKdfParams(InvalidKdfParams::MemoryCost(n))) => {
assert_eq!(n, invalid_mem);
}
other => panic!("expected InvalidKdfParams::MemoryCost, got {other:?}"),
}
}
#[test]
fn keypair_generator_kdf_params_rejects_structural_lanes() {
let work = fresh_workspace("keypair_kdf_structural_lanes_rejects");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let invalid = KdfParams {
mem_cost: TEST_FAST_KDF_MEM_COST,
time_cost: 1,
lanes: 9,
};
let result = KeyPairGenerator::with_passphrase(pass())
.kdf_params(invalid)
.write(&keys, |_| {});
match result {
Err(CryptoError::InvalidKdfParams(InvalidKdfParams::Parallelism(9))) => {}
other => panic!("expected InvalidKdfParams::Parallelism(9), got {other:?}"),
}
}
#[test]
fn encryptor_kdf_params_above_default_rejects_with_default_kdf_limit() {
let work = fresh_workspace("kdf_above_default_rejects");
let input = work.join("data.txt");
fs::write(&input, b"x").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let default_limit = KdfLimit::default();
let oversized_params = KdfParams {
mem_cost: default_limit.max_mem_cost_kib + 1,
time_cost: 4,
lanes: 4,
};
let result = Encryptor::with_passphrase(pass())
.kdf_params(oversized_params)
.write(&input, &out_dir, |_| {});
match result {
Err(CryptoError::KdfResourceCapExceeded {
mem_cost_kib,
local_cap_kib,
}) => {
assert_eq!(mem_cost_kib, oversized_params.mem_cost);
assert_eq!(local_cap_kib, default_limit.max_mem_cost_kib);
}
other => panic!("expected KdfResourceCapExceeded, got {other:?}"),
}
}
#[test]
fn encryptor_kdf_params_at_kdf_limit_succeeds() {
let work = fresh_workspace("kdf_at_limit_succeeds");
let input = work.join("data.txt");
fs::write(&input, b"hello").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let exact = KdfLimit::new(TEST_FAST_KDF_MEM_COST);
let outcome = fast_passphrase_encryptor(pass())
.kdf_limit(exact)
.write(&input, &out_dir, |_| {})
.expect("encrypt with kdf_limit at boundary");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let decrypted = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::Passphrase(d) => d
.kdf_limit(exact)
.decrypt(pass(), &restore, |_| {})
.expect("decrypt"),
_ => panic!("expected passphrase decryptor"),
};
assert_eq!(fs::read(decrypted.output_path).unwrap(), b"hello");
}
#[test]
fn keypair_generator_kdf_params_above_default_rejects_with_default_kdf_limit() {
let work = fresh_workspace("keypair_kdf_above_default_rejects");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let default_limit = KdfLimit::default();
let oversized_params = KdfParams {
mem_cost: default_limit.max_mem_cost_kib + 1,
time_cost: 4,
lanes: 4,
};
let result = KeyPairGenerator::with_passphrase(pass())
.kdf_params(oversized_params)
.write(&keys, |_| {});
match result {
Err(CryptoError::KdfResourceCapExceeded {
mem_cost_kib,
local_cap_kib,
}) => {
assert_eq!(mem_cost_kib, oversized_params.mem_cost);
assert_eq!(local_cap_kib, default_limit.max_mem_cost_kib);
}
other => panic!("expected KdfResourceCapExceeded, got {other:?}"),
}
}
#[test]
fn keypair_generator_kdf_params_at_kdf_limit_succeeds() {
let work = fresh_workspace("keypair_kdf_at_limit_succeeds");
let keys = work.join("keys");
fs::create_dir_all(&keys).unwrap();
let exact = KdfLimit::new(TEST_FAST_KDF_MEM_COST);
let kg = fast_keypair_generator(pass())
.kdf_limit(exact)
.write(&keys, |_| {})
.expect("keygen with kdf_limit at boundary");
let input = work.join("data.txt");
fs::write(&input, b"x25519 round-trip via raised kdf").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let outcome = Encryptor::with_public_key(PublicKey::from_key_file(&kg.public_key_path))
.write(&input, &out_dir, |_| {})
.expect("encrypt");
let restore = work.join("restored");
fs::create_dir_all(&restore).unwrap();
let decrypted = match Decryptor::open(&outcome.output_path).expect("open") {
Decryptor::PrivateKey(d) => d
.kdf_limit(exact)
.decrypt(
PrivateKey::from_key_file(&kg.private_key_path),
pass(),
&restore,
|_| {},
)
.expect("decrypt"),
_ => panic!("expected private-key decryptor"),
};
assert_eq!(
fs::read(decrypted.output_path).unwrap(),
b"x25519 round-trip via raised kdf"
);
}
#[test]
fn encryptor_passphrase_rejects_existing_output_before_kdf() {
let work = fresh_workspace("rejects_existing_output_before_kdf");
let input = work.join("data.txt");
fs::write(&input, b"output-conflict preflight").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
fast_passphrase_encryptor(pass())
.write(&input, &out_dir, |_| {})
.expect("first encrypt");
let kdf_event_count = std::cell::Cell::new(0u32);
let result = fast_passphrase_encryptor(pass()).write(&input, &out_dir, |evt| {
if matches!(evt, ferrocrypt::ProgressEvent::DerivingPassphraseWrapKey) {
kdf_event_count.set(kdf_event_count.get() + 1);
}
});
match result {
Err(CryptoError::InvalidInput(msg)) => {
assert!(
msg.starts_with("Output already exists:"),
"unexpected message: {msg}"
);
}
other => panic!("expected InvalidInput(Output already exists), got {other:?}"),
}
assert_eq!(
kdf_event_count.get(),
0,
"Argon2id ran before the output-conflict preflight"
);
}
#[cfg(unix)]
#[test]
fn encryptor_passphrase_rejects_dangling_symlink_at_output_before_kdf() {
use std::os::unix::fs::symlink;
let work = fresh_workspace("rejects_dangling_symlink_before_kdf");
let input = work.join("data.txt");
fs::write(&input, b"dangling-symlink preflight").unwrap();
let out_dir = work.join("out");
fs::create_dir_all(&out_dir).unwrap();
let dangling = out_dir.join("data.fcr");
symlink(out_dir.join("absent-target"), &dangling).unwrap();
assert!(!dangling.exists(), "sanity: target really is missing");
let kdf_event_count = std::cell::Cell::new(0u32);
let result = fast_passphrase_encryptor(pass()).write(&input, &out_dir, |evt| {
if matches!(evt, ferrocrypt::ProgressEvent::DerivingPassphraseWrapKey) {
kdf_event_count.set(kdf_event_count.get() + 1);
}
});
match result {
Err(CryptoError::InvalidInput(msg)) => {
assert!(
msg.starts_with("Output already exists:"),
"unexpected message: {msg}"
);
}
other => panic!("expected InvalidInput(Output already exists), got {other:?}"),
}
assert_eq!(
kdf_event_count.get(),
0,
"Argon2id ran before the dangling-symlink preflight"
);
}