use std::fs;
use std::path::{Path, PathBuf};
use ferrocrypt::secrecy::SecretString;
use ferrocrypt::{
CryptoError, Decryptor, FormatDefect, InvalidKdfParams, PrivateKey, PublicKey,
UnsupportedVersion, validate_private_key_file,
};
const TEST_WORKSPACE: &str = "tests/workspace_testvector_suite";
fn suite_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("testvectors/suite")
}
#[ctor::dtor]
fn cleanup() {
ferrocrypt_test_support::remove_per_process_workspace(TEST_WORKSPACE);
}
struct Row {
file: String,
action: String,
credential: String,
expect: String,
error_class: String,
error_message: String,
}
fn parse_manifest(suite: &Path) -> Vec<Row> {
let text = fs::read_to_string(suite.join("manifest.tsv")).expect("read manifest.tsv");
text.lines()
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| {
let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(fields.len(), 6, "manifest row must have 6 fields: {line}");
Row {
file: fields[0].to_string(),
action: fields[1].to_string(),
credential: fields[2].to_string(),
expect: fields[3].to_string(),
error_class: fields[4].to_string(),
error_message: fields[5].to_string(),
}
})
.collect()
}
fn error_matches(class: &str, e: &CryptoError) -> bool {
match class {
"InvalidFormat(BadMagic)" => {
matches!(e, CryptoError::InvalidFormat(FormatDefect::BadMagic))
}
"InvalidFormat(WrongKind)" => {
matches!(
e,
CryptoError::InvalidFormat(FormatDefect::WrongKind { .. })
)
}
"InvalidFormat(MalformedHeader)" => {
matches!(e, CryptoError::InvalidFormat(FormatDefect::MalformedHeader))
}
"InvalidFormat(OversizedHeader)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::OversizedHeader { .. })
),
"InvalidFormat(Truncated)" => {
matches!(e, CryptoError::InvalidFormat(FormatDefect::Truncated))
}
"InvalidFormat(MalformedTlv)" => {
matches!(e, CryptoError::InvalidFormat(FormatDefect::MalformedTlv))
}
"InvalidFormat(UnknownCriticalTag)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::UnknownCriticalTag { .. })
),
"InvalidFormat(MalformedRecipientEntry)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)
),
"InvalidFormat(RecipientFlagsReserved)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::RecipientFlagsReserved)
),
"InvalidFormat(MalformedPublicKey)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)
),
"InvalidFormat(NotAKeyFile)" => {
matches!(e, CryptoError::InvalidFormat(FormatDefect::NotAKeyFile))
}
"InvalidFormat(WrongKeyFileType)" => {
matches!(
e,
CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType)
)
}
"InvalidFormat(MalformedPrivateKey)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)
),
"UnsupportedVersion(NewerKey)" => matches!(
e,
CryptoError::UnsupportedVersion(UnsupportedVersion::NewerKey { .. })
),
"KeyFileUnlockFailed" => matches!(e, CryptoError::KeyFileUnlockFailed),
"MalformedArchive" => matches!(e, CryptoError::MalformedArchive { .. }),
"InvalidFormat(UnsupportedArchiveVersion)" => matches!(
e,
CryptoError::InvalidFormat(FormatDefect::UnsupportedArchiveVersion { .. })
),
"UnsupportedVersion(NewerFile)" => matches!(
e,
CryptoError::UnsupportedVersion(UnsupportedVersion::NewerFile { .. })
),
"InvalidKdfParams(MemoryCost)" => matches!(
e,
CryptoError::InvalidKdfParams(InvalidKdfParams::MemoryCost(_))
),
"InvalidKdfParams(Parallelism)" => matches!(
e,
CryptoError::InvalidKdfParams(InvalidKdfParams::Parallelism(_))
),
"InvalidKdfParams(TimeCost)" => matches!(
e,
CryptoError::InvalidKdfParams(InvalidKdfParams::TimeCost(_))
),
"PayloadTruncated" => matches!(e, CryptoError::PayloadTruncated),
"PayloadTampered" => matches!(e, CryptoError::PayloadTampered),
"HeaderTampered" => matches!(e, CryptoError::HeaderTampered),
"HeaderMacFailedAfterUnwrap(x25519)" => matches!(
e,
CryptoError::HeaderMacFailedAfterUnwrap { type_name } if type_name == "x25519"
),
"RecipientUnwrapFailed(argon2id)" => matches!(
e,
CryptoError::RecipientUnwrapFailed { type_name } if type_name == "argon2id"
),
"RecipientUnwrapFailed(x25519)" => matches!(
e,
CryptoError::RecipientUnwrapFailed { type_name } if type_name == "x25519"
),
"UnknownCriticalRecipient(mlkem768)" => matches!(
e,
CryptoError::UnknownCriticalRecipient { type_name } if type_name == "mlkem768"
),
"NoSupportedRecipient" => matches!(e, CryptoError::NoSupportedRecipient),
"IncompatibleRecipients(argon2id)" => matches!(
e,
CryptoError::IncompatibleRecipients { type_name, .. } if type_name == "argon2id"
),
"InvalidInput" => matches!(e, CryptoError::InvalidInput(_)),
other => panic!("manifest names an error class this test does not map: {other}"),
}
}
fn attempt(suite: &Path, row: &Row, out: &Path) -> Result<(), CryptoError> {
match row.action.as_str() {
"decrypt" => {
let decryptor = Decryptor::open(suite.join(&row.file))?;
match decryptor {
Decryptor::Passphrase(d) => {
let literal = row
.credential
.strip_prefix("passphrase:")
.unwrap_or_else(|| {
panic!(
"{}: passphrase file needs a passphrase credential, got `{}`",
row.file, row.credential
)
});
d.decrypt(SecretString::from(literal.to_string()), out, |_| {})?;
}
Decryptor::PrivateKey(d) => {
let rest = row
.credential
.strip_prefix("private-key:")
.unwrap_or_else(|| {
panic!(
"{}: recipient file needs a private-key credential, got `{}`",
row.file, row.credential
)
});
let (key_path, unlock) = rest
.split_once(",unlock=")
.expect("private-key credential carries an unlock passphrase");
d.decrypt(
PrivateKey::from_key_file(suite.join(key_path)),
SecretString::from(unlock.to_string()),
out,
|_| {},
)?;
}
other => panic!("{}: unexpected decryptor variant {other:?}", row.file),
}
Ok(())
}
"read-public-key" => PublicKey::from_key_file(suite.join(&row.file)).validate(),
"validate-private-key" => validate_private_key_file(suite.join(&row.file)),
other => panic!("{}: unknown manifest action `{other}`", row.file),
}
}
#[test]
fn suite_manifest_holds() {
let suite = suite_dir();
let rows = parse_manifest(&suite);
assert!(!rows.is_empty(), "manifest.tsv has no rows");
let expected_plaintext = fs::read(suite.join("plaintext.txt")).expect("read plaintext.txt");
let mut failures = Vec::new();
for (index, row) in rows.iter().enumerate() {
let out = ferrocrypt_test_support::per_process_workspace(TEST_WORKSPACE)
.join(format!("row-{index}"));
if out.exists() {
fs::remove_dir_all(&out).expect("clean row output dir");
}
fs::create_dir_all(&out).expect("create row output dir");
let outcome = attempt(&suite, row, &out);
match (row.expect.as_str(), outcome) {
("ok", Ok(())) => {
let produced = fs::read(out.join("plaintext.txt"));
match produced {
Ok(bytes) if bytes == expected_plaintext => {}
Ok(_) => failures.push(format!("{}: decrypted plaintext differs", row.file)),
Err(e) => failures.push(format!(
"{}: decrypt succeeded but plaintext.txt is missing: {e}",
row.file
)),
}
}
("ok", Err(e)) => {
failures.push(format!("{}: expected success, got `{e}` ({e:?})", row.file));
}
("error", Err(e)) => {
if !error_matches(&row.error_class, &e) {
failures.push(format!(
"{}: expected class {}, got {e:?}",
row.file, row.error_class
));
}
if e.to_string() != row.error_message {
failures.push(format!(
"{}: expected message `{}`, got `{e}`",
row.file, row.error_message
));
}
}
("error", Ok(())) => {
failures.push(format!(
"{}: expected {} to fail, but it succeeded",
row.file, row.error_class
));
}
(other, _) => panic!("{}: unknown expect value `{other}`", row.file),
}
}
assert!(
failures.is_empty(),
"suite corpus diverged:\n{}",
failures.join("\n")
);
}
#[test]
fn manifest_covers_every_case_file() {
let suite = suite_dir();
let rows = parse_manifest(&suite);
for row in &rows {
assert!(
suite.join(&row.file).is_file(),
"manifest row points at a missing file: {}",
row.file
);
}
let listed: std::collections::HashSet<&str> = rows.iter().map(|r| r.file.as_str()).collect();
for entry in fs::read_dir(suite.join("cases")).expect("read cases/") {
let file_name = entry.expect("cases/ entry").file_name();
let name = file_name.to_string_lossy();
if name.starts_with('.') {
continue;
}
let name = format!("cases/{name}");
assert!(
listed.contains(name.as_str()),
"committed case file has no manifest row: {name}"
);
}
}