ferrocrypt 0.3.0-rc.2

Recipient-oriented file and directory encryption: passphrase (Argon2id) and X25519 public-key recipients, XChaCha20-Poly1305 STREAM payloads, HKDF-SHA3-256 / HMAC-SHA3-256 key derivation and authentication.
Documentation
//! Replays the committed `testvectors/suite/` edge-case corpus.
//!
//! Reads `testvectors/suite/manifest.tsv` and runs every row through the
//! public API exactly as an independent implementation would: decrypt
//! each `.fcr` with the listed credential, or read each `public.key`
//! text fixture, and require the listed outcome. `ok` rows must decrypt
//! to `plaintext.txt` byte-for-byte; `error` rows must fail with both
//! the listed typed error class and the exact user-facing message.
//!
//! The corpus is generated by the ignored `suite_vector_gen` unit test
//! in the library crate, which needs crate internals to craft the
//! must-reject bytes. This test deliberately uses only public API and
//! only committed files, so a failure means either the reader's
//! behaviour drifted or the committed corpus no longer tells the truth.

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);
}

/// One parsed `manifest.tsv` row.
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()
}

/// Checks that `e` is the typed variant the manifest's `error_class`
/// column names. The class labels are the manifest's stable vocabulary;
/// extend this mapping when the corpus grows a new class.
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}"),
    }
}

/// Runs one manifest row's attempt. `Ok(())` means the action fully
/// succeeded (for `decrypt`, the output landed in `out`).
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),
    }
}

/// Replays every manifest row and collects mismatches, so one run
/// reports every divergence instead of stopping at the first.
#[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() {
        // Per-process subtree, so a concurrent `cargo test` invocation
        // of this binary cannot delete files this run is using.
        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")
    );
}

/// Every committed case file must be exercised by at least one manifest
/// row, and every manifest row must point at a committed file — a
/// fixture without a row is dead weight, and a row without a fixture is
/// a broken promise.
#[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();
        // Skip filesystem litter such as .DS_Store; it is not corpus content.
        if name.starts_with('.') {
            continue;
        }
        let name = format!("cases/{name}");
        assert!(
            listed.contains(name.as_str()),
            "committed case file has no manifest row: {name}"
        );
    }
}