use thiserror::Error;
use crate::UnauthenticatedRecipientMode;
use crate::recipient::argon2id;
use crate::recipient::policy::MixingPolicy;
const TYPE_NAME_DISPLAY_MAX: usize = 13;
const _: () = assert!(TYPE_NAME_DISPLAY_MAX >= 1);
fn write_truncated_sanitized<W: std::fmt::Write>(
w: &mut W,
s: &str,
max: usize,
) -> std::fmt::Result {
let mut written = 0;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
let mut escaped = String::new();
write_sanitized_char(&mut escaped, ch)?;
let escaped_len = escaped.chars().count();
let ellipsis_reserve = usize::from(chars.peek().is_some());
if written + escaped_len + ellipsis_reserve <= max {
w.write_str(&escaped)?;
written += escaped_len;
} else {
if written < max {
w.write_str("…")?;
}
return Ok(());
}
}
Ok(())
}
struct DisplayableTypeName<'a>(&'a str);
impl std::fmt::Display for DisplayableTypeName<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write_truncated_sanitized(f, self.0, TYPE_NAME_DISPLAY_MAX)
}
}
const MALFORMED_ARCHIVE_REASON_DISPLAY_MAX: usize = 64 - "Malformed archive: ".len();
const _: () = assert!(MALFORMED_ARCHIVE_REASON_DISPLAY_MAX >= 1);
const fn display_fragment_text_is_valid(fragment: &str) -> bool {
let bytes = fragment.as_bytes();
if bytes.is_empty() {
return false;
}
if matches!(bytes[bytes.len() - 1], b'.' | b'!' | b'?') {
return false;
}
let mut index = 0;
while index < bytes.len() {
if bytes[index] < b' ' || bytes[index] > b'~' {
return false;
}
index += 1;
}
true
}
const fn display_fragment_is_valid(fragment: &str, max: usize) -> bool {
fragment.len() <= max && display_fragment_text_is_valid(fragment)
}
pub(crate) const fn malformed_archive_reason_is_valid(reason: &str) -> bool {
display_fragment_is_valid(reason, MALFORMED_ARCHIVE_REASON_DISPLAY_MAX)
}
pub(crate) const fn archive_path_reason_is_valid(reason: &str) -> bool {
display_fragment_text_is_valid(reason)
}
const INTERNAL_MARKER_DISPLAY_MAX: usize = 64 - "Internal crypto error: ".len();
const _: () = assert!(INTERNAL_MARKER_DISPLAY_MAX >= 1);
pub(crate) const fn internal_marker_is_valid(marker: &str) -> bool {
display_fragment_is_valid(marker, INTERNAL_MARKER_DISPLAY_MAX)
}
macro_rules! internal_invariant {
($marker:literal) => {{
const _: () = assert!($crate::error::internal_marker_is_valid($marker));
$crate::error::CryptoError::InternalInvariant($marker)
}};
}
macro_rules! internal_crypto_failure {
($marker:literal) => {{
const _: () = assert!($crate::error::internal_marker_is_valid($marker));
$crate::error::CryptoError::InternalCryptoFailure($marker)
}};
}
pub(crate) use internal_crypto_failure;
pub(crate) use internal_invariant;
struct DisplayableMarker<'a>(&'a str);
impl std::fmt::Display for DisplayableMarker<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write_truncated_sanitized(f, self.0, INTERNAL_MARKER_DISPLAY_MAX)
}
}
fn recipient_unwrap_message(type_name: &str) -> &'static str {
if type_name == argon2id::TYPE_NAME {
"Decryption failed: wrong passphrase or modified file"
} else {
"Decryption failed: no matching recipient or modified file"
}
}
const HEADER_CORRUPTED_MESSAGE: &str = "Decryption failed: file header was modified or corrupted";
const UNTRUSTED_TEXT_DISPLAY_MAX: usize = 64;
fn write_sanitized_char<W: std::fmt::Write>(w: &mut W, c: char) -> std::fmt::Result {
if c.is_ascii_graphic() || c == ' ' {
w.write_char(c)
} else {
write!(w, "{}", c.escape_default())
}
}
pub(crate) fn sanitize_for_display(text: &str) -> String {
let mut out = String::new();
let _ = write_truncated_sanitized(&mut out, text, UNTRUSTED_TEXT_DISPLAY_MAX);
out
}
pub(crate) fn sanitize_path_for_display(path: &std::path::Path) -> String {
sanitize_for_display(&path.display().to_string())
}
fn is_bidi_control(c: char) -> bool {
matches!(
c,
'\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
| '\u{200E}'
| '\u{200F}'
| '\u{061C}'
)
}
pub(crate) fn sanitize_prefix_for_display(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if c.is_control() || is_bidi_control(c) {
out.extend(c.escape_default());
} else {
out.push(c);
}
}
out
}
struct PathSuffix<'a>(&'a Option<String>);
impl std::fmt::Display for PathSuffix<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(path) => write!(f, ": {path}"),
None => Ok(()),
}
}
}
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum CryptoError {
#[error(transparent)]
Io(std::io::Error),
#[error("Input file or directory not found")]
InputPath,
#[error("{0}")]
InvalidInput(String),
#[error("{0}")]
InvalidFormat(FormatDefect),
#[error("{0}")]
UnsupportedVersion(UnsupportedVersion),
#[error("{0}")]
InvalidKdfParams(InvalidKdfParams),
#[error("Passphrase memory over limit ({mem_cost_kib} KiB, limit {local_cap_kib})")]
KdfResourceCapExceeded {
mem_cost_kib: u32,
local_cap_kib: u32,
},
#[error("Passphrase time over limit ({time_cost}, limit {local_cap})")]
KdfTimeCostCapExceeded {
time_cost: u32,
local_cap: u32,
},
#[error("Passphrase parallelism over limit ({lanes}, limit {local_cap})")]
KdfLanesCapExceeded {
lanes: u32,
local_cap: u32,
},
#[error("Passphrase memory too low ({mem_cost_kib} KiB, needs {floor_kib} KiB)")]
KdfBelowWriteFloor {
mem_cost_kib: u32,
floor_kib: u32,
},
#[error("Header too large ({header_len} bytes, limit {local_cap})")]
HeaderLenCapExceeded {
header_len: u32,
local_cap: u32,
},
#[error("Too many recipients ({count} entries, limit {local_cap})")]
RecipientCountCapExceeded {
count: u16,
local_cap: u16,
},
#[error("Recipient data too large ({body_len} bytes, limit {local_cap})")]
RecipientBodyCapExceeded {
body_len: u32,
local_cap: u32,
},
#[error("Recipient string too long ({input_chars} chars, limit {local_cap})")]
RecipientStringCapExceeded {
input_chars: u32,
local_cap: u32,
},
#[error("Private key data too large ({wrapped_secret_len} bytes, limit {local_cap})")]
PrivateKeyWrappedSecretCapExceeded {
wrapped_secret_len: u32,
local_cap: u32,
},
#[error("Private key unlock failed: wrong passphrase or modified key file")]
KeyFileUnlockFailed,
#[error("{}", HEADER_CORRUPTED_MESSAGE)]
HeaderTampered,
#[error("{}", HEADER_CORRUPTED_MESSAGE)]
HeaderMacFailedAfterUnwrap {
type_name: String,
},
#[error("{}", recipient_unwrap_message(type_name))]
RecipientUnwrapFailed {
type_name: String,
},
#[error("Unsupported recipient `{}`", DisplayableTypeName(type_name))]
UnknownCriticalRecipient {
type_name: String,
},
#[error("Unsupported key type `{}`", DisplayableTypeName(type_name))]
UnsupportedKeyType {
type_name: String,
},
#[error("Decryption failed: no supported recipient")]
NoSupportedRecipient,
#[error("File is {found} encrypted; use {}", found.credential_name())]
DecryptorModeMismatch {
expected: UnauthenticatedRecipientMode,
found: UnauthenticatedRecipientMode,
},
#[error("Recipient list cannot be empty")]
EmptyRecipientList,
#[error(
"Recipient `{}` mixed with another recipient",
DisplayableTypeName(type_name)
)]
IncompatibleRecipients {
type_name: String,
policy: MixingPolicy,
},
#[error("Decryption failed: file data was modified or corrupted")]
PayloadTampered,
#[error("Encrypted file is truncated")]
PayloadTruncated,
#[error("Encrypted file has unexpected trailing data")]
ExtraDataAfterPayload,
#[error("Encrypted file exceeds supported data size")]
PayloadChunkCountExceeded,
#[error("Malformed archive: {reason}")]
MalformedArchive {
reason: &'static str,
},
#[error("Unsafe archive path ({reason}): {path}")]
UnsafeArchivePath {
path: String,
reason: &'static str,
},
#[error("Invalid archive tree ({reason}): {path}")]
InvalidArchiveTree {
path: String,
reason: &'static str,
},
#[error("Too many archive entries ({entry_count}, limit {local_cap})")]
ArchiveEntryCountCapExceeded {
entry_count: u32,
local_cap: u32,
},
#[error("Archive is too large (limit {local_cap} bytes)")]
ArchiveTotalBytesCapExceeded {
total_bytes: u64,
local_cap: u64,
},
#[error("Archive manifest is too large (limit {local_cap} bytes)")]
ArchiveManifestLenCapExceeded {
manifest_len: u64,
local_cap: u32,
},
#[error(
"Archive path too long ({path_bytes} bytes, limit {local_cap}){}",
PathSuffix(path)
)]
ArchivePathBytesCapExceeded {
path_bytes: u32,
local_cap: u32,
path: Option<String>,
},
#[error("Archive path too deep ({depth} components, limit {local_cap}): {path}")]
ArchivePathDepthCapExceeded {
depth: u32,
local_cap: u32,
path: String,
},
#[error("Archive extension is too large (limit {local_cap} bytes)")]
ArchiveExtLenCapExceeded {
ext_len: u64,
local_cap: u32,
},
#[error(
"Archive entry extension is too large (limit {local_cap} bytes){}",
PathSuffix(path)
)]
ArchiveEntryExtLenCapExceeded {
ext_len: u64,
local_cap: u32,
path: Option<String>,
},
#[error("Archive entry extensions are too large (limit {local_cap} bytes)")]
ArchiveTotalEntryExtCapExceeded {
total_ext_bytes: u64,
local_cap: u64,
},
#[error("Internal error: {}", DisplayableMarker(.0))]
InternalInvariant(&'static str),
#[error("Internal crypto error: {}", DisplayableMarker(.0))]
InternalCryptoFailure(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FormatDefect {
Truncated,
BadMagic,
ExtTooLarge {
len: u32,
},
MalformedTlv,
UnknownCriticalTag {
tag: u16,
},
NotAKeyFile,
WrongKeyFileType,
MalformedPublicKey,
WrongKind {
kind: u8,
},
MalformedHeader,
MalformedPayloadStream,
OversizedHeader {
header_len: u32,
},
RecipientCountOutOfRange {
count: u16,
},
MalformedTypeName,
MalformedRecipientEntry,
RecipientFlagsReserved,
MalformedPrivateKey,
UnsupportedArchiveVersion {
version: u8,
},
}
impl std::fmt::Display for FormatDefect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Truncated => f.write_str("File is truncated or corrupted"),
Self::BadMagic => f.write_str("Not a FerroCrypt file"),
Self::ExtTooLarge { len } => {
write!(f, "Extension region is too large ({len} bytes)")
}
Self::MalformedTlv => f.write_str("Extension region is malformed"),
Self::UnknownCriticalTag { tag } => {
write!(
f,
"Newer FerroCrypt is needed for file feature tag 0x{tag:04X}"
)
}
Self::NotAKeyFile => f.write_str("Not a FerroCrypt key file"),
Self::WrongKeyFileType => f.write_str("Wrong key file kind (public vs private)"),
Self::MalformedPublicKey => f.write_str("Public key is malformed"),
Self::WrongKind { kind } => {
write!(f, "Wrong file kind: 0x{kind:02X}")
}
Self::MalformedHeader => f.write_str("File header is malformed"),
Self::MalformedPayloadStream => f.write_str("Encrypted payload stream is malformed"),
Self::OversizedHeader { header_len } => {
write!(f, "File header is too large ({header_len} bytes)")
}
Self::RecipientCountOutOfRange { count } => {
write!(f, "Recipient count out of range ({count})")
}
Self::MalformedTypeName => f.write_str("Recipient type name is malformed"),
Self::MalformedRecipientEntry => f.write_str("Recipient entry is malformed"),
Self::RecipientFlagsReserved => f.write_str("Recipient entry uses reserved flag bits"),
Self::MalformedPrivateKey => f.write_str("Private key is malformed"),
Self::UnsupportedArchiveVersion { version } => {
write!(
f,
"Newer FerroCrypt is needed for FCA archive version byte 0x{version:02X}"
)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnsupportedVersion {
OlderFile {
version: u8,
},
NewerFile {
version: u8,
},
OlderKey {
version: u8,
},
NewerKey {
version: u8,
},
OlderPublicKey {
version: u8,
},
NewerPublicKey {
version: u8,
},
}
impl std::fmt::Display for UnsupportedVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OlderFile { version } => {
write!(f, "Unsupported older .fcr version byte 0x{version:02X}")
}
Self::NewerFile { version } => {
write!(
f,
"Newer FerroCrypt is needed for .fcr version byte 0x{version:02X}"
)
}
Self::OlderKey { version } => {
write!(
f,
"Unsupported older private-key version byte 0x{version:02X}"
)
}
Self::NewerKey { version } => {
write!(
f,
"Newer FerroCrypt is needed for private-key version byte 0x{version:02X}"
)
}
Self::OlderPublicKey { version } => {
write!(
f,
"Unsupported older public-key version byte 0x{version:02X}"
)
}
Self::NewerPublicKey { version } => {
write!(
f,
"Newer FerroCrypt is needed for public-key version byte 0x{version:02X}"
)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidKdfParams {
Parallelism(u32),
MemoryCost(u32),
TimeCost(u32),
}
impl std::fmt::Display for InvalidKdfParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parallelism(n) => {
write!(f, "File has invalid KDF settings (parallelism {n})")
}
Self::MemoryCost(n) => {
write!(f, "File has invalid KDF settings (memory {n} KiB)")
}
Self::TimeCost(n) => write!(f, "File has invalid KDF settings (time cost {n})"),
}
}
}
#[derive(Debug)]
pub(crate) enum StreamError {
DecryptAead,
EncryptAead,
Truncated,
EmptyFinalChunk,
ExtraData,
StateExhausted,
ChunkCountExceeded,
}
impl std::fmt::Display for StreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
StreamError::DecryptAead => "Payload authentication failed",
StreamError::EncryptAead => "payload encryption failed",
StreamError::Truncated => "Encrypted stream truncated",
StreamError::EmptyFinalChunk => "Encrypted stream has an invalid empty final chunk",
StreamError::ExtraData => "Encrypted stream has trailing data",
StreamError::StateExhausted => "stream state already finalized",
StreamError::ChunkCountExceeded => "Encrypted stream exceeds supported data size",
};
f.write_str(msg)
}
}
impl std::error::Error for StreamError {}
impl From<std::io::Error> for CryptoError {
fn from(e: std::io::Error) -> Self {
if let Some(stream_err) = e
.get_ref()
.and_then(|inner| inner.downcast_ref::<StreamError>())
{
return match stream_err {
StreamError::DecryptAead => CryptoError::PayloadTampered,
StreamError::Truncated => CryptoError::PayloadTruncated,
StreamError::EmptyFinalChunk => {
CryptoError::InvalidFormat(FormatDefect::MalformedPayloadStream)
}
StreamError::ExtraData => CryptoError::ExtraDataAfterPayload,
StreamError::ChunkCountExceeded => CryptoError::PayloadChunkCountExceeded,
StreamError::EncryptAead => {
internal_crypto_failure!("payload encryption failed")
}
StreamError::StateExhausted => {
internal_invariant!("stream state already finalized")
}
};
}
CryptoError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recipient::policy::NativeMixingRule;
#[test]
fn typed_decryption_errors_display_exact_strings() {
assert_eq!(
CryptoError::InputPath.to_string(),
"Input file or directory not found"
);
assert_eq!(
CryptoError::KeyFileUnlockFailed.to_string(),
"Private key unlock failed: wrong passphrase or modified key file"
);
assert_eq!(
CryptoError::HeaderTampered.to_string(),
"Decryption failed: file header was modified or corrupted"
);
assert_eq!(
CryptoError::HeaderMacFailedAfterUnwrap {
type_name: "x25519".to_owned()
}
.to_string(),
"Decryption failed: file header was modified or corrupted"
);
assert_eq!(
CryptoError::UnknownCriticalRecipient {
type_name: "mlkem768x25519".to_owned()
}
.to_string(),
"Unsupported recipient `mlkem768x255…`"
);
assert_eq!(
CryptoError::NoSupportedRecipient.to_string(),
"Decryption failed: no supported recipient"
);
assert_eq!(
CryptoError::DecryptorModeMismatch {
expected: UnauthenticatedRecipientMode::Passphrase,
found: UnauthenticatedRecipientMode::PublicKey,
}
.to_string(),
"File is public-key encrypted; use a private key"
);
assert_eq!(
CryptoError::DecryptorModeMismatch {
expected: UnauthenticatedRecipientMode::PublicKey,
found: UnauthenticatedRecipientMode::Passphrase,
}
.to_string(),
"File is passphrase encrypted; use a passphrase"
);
assert_eq!(
CryptoError::EmptyRecipientList.to_string(),
"Recipient list cannot be empty"
);
assert_eq!(
CryptoError::IncompatibleRecipients {
type_name: "argon2id".to_owned(),
policy: MixingPolicy::Exclusive,
}
.to_string(),
"Recipient `argon2id` mixed with another recipient"
);
assert_eq!(
CryptoError::IncompatibleRecipients {
type_name: "mlkem768x25519".to_owned(),
policy: MixingPolicy::Exclusive,
}
.to_string(),
"Recipient `mlkem768x255…` mixed with another recipient"
);
assert_eq!(
CryptoError::IncompatibleRecipients {
type_name: "x25519-mlkem768".to_owned(),
policy: MixingPolicy::Custom {
compatibility_class: NativeMixingRule::POST_QUANTUM_CLASS,
},
}
.to_string(),
"Recipient `x25519-mlkem…` mixed with another recipient"
);
assert_eq!(
CryptoError::PayloadTampered.to_string(),
"Decryption failed: file data was modified or corrupted"
);
assert_eq!(
CryptoError::PayloadTruncated.to_string(),
"Encrypted file is truncated"
);
assert_eq!(
CryptoError::ExtraDataAfterPayload.to_string(),
"Encrypted file has unexpected trailing data"
);
assert_eq!(
CryptoError::PayloadChunkCountExceeded.to_string(),
"Encrypted file exceeds supported data size"
);
assert_eq!(
CryptoError::RecipientUnwrapFailed {
type_name: "x25519".to_owned()
}
.to_string(),
"Decryption failed: no matching recipient or modified file"
);
assert_eq!(
CryptoError::RecipientUnwrapFailed {
type_name: "argon2id".to_owned()
}
.to_string(),
"Decryption failed: wrong passphrase or modified file"
);
assert_eq!(
CryptoError::RecipientBodyCapExceeded {
body_len: 10_000,
local_cap: 8_192
}
.to_string(),
"Recipient data too large (10000 bytes, limit 8192)"
);
assert_eq!(
CryptoError::RecipientStringCapExceeded {
input_chars: 5_000,
local_cap: 1_024,
}
.to_string(),
"Recipient string too long (5000 chars, limit 1024)"
);
assert_eq!(
CryptoError::HeaderLenCapExceeded {
header_len: 2_000_000,
local_cap: 1_048_576,
}
.to_string(),
"Header too large (2000000 bytes, limit 1048576)"
);
assert_eq!(
CryptoError::RecipientCountCapExceeded {
count: 100,
local_cap: 64,
}
.to_string(),
"Too many recipients (100 entries, limit 64)"
);
assert_eq!(
CryptoError::KdfResourceCapExceeded {
mem_cost_kib: 1_048_576,
local_cap_kib: 524_288,
}
.to_string(),
"Passphrase memory over limit (1048576 KiB, limit 524288)"
);
assert_eq!(
CryptoError::KdfTimeCostCapExceeded {
time_cost: 8,
local_cap: 6,
}
.to_string(),
"Passphrase time over limit (8, limit 6)"
);
assert_eq!(
CryptoError::KdfLanesCapExceeded {
lanes: 4,
local_cap: 2,
}
.to_string(),
"Passphrase parallelism over limit (4, limit 2)"
);
assert_eq!(
CryptoError::KdfBelowWriteFloor {
mem_cost_kib: 8_192,
floor_kib: 19_456,
}
.to_string(),
"Passphrase memory too low (8192 KiB, needs 19456 KiB)"
);
}
#[test]
fn typed_format_variants_display_exact_strings() {
assert_eq!(
FormatDefect::Truncated.to_string(),
"File is truncated or corrupted"
);
assert_eq!(FormatDefect::BadMagic.to_string(), "Not a FerroCrypt file");
assert_eq!(
FormatDefect::ExtTooLarge { len: 65_537 }.to_string(),
"Extension region is too large (65537 bytes)"
);
assert_eq!(
FormatDefect::MalformedTlv.to_string(),
"Extension region is malformed"
);
assert_eq!(
FormatDefect::UnknownCriticalTag { tag: 0x8001 }.to_string(),
"Newer FerroCrypt is needed for file feature tag 0x8001"
);
assert_eq!(
FormatDefect::NotAKeyFile.to_string(),
"Not a FerroCrypt key file"
);
assert_eq!(
FormatDefect::WrongKeyFileType.to_string(),
"Wrong key file kind (public vs private)"
);
assert_eq!(
FormatDefect::MalformedPublicKey.to_string(),
"Public key is malformed"
);
assert_eq!(
FormatDefect::WrongKind { kind: 0x99 }.to_string(),
"Wrong file kind: 0x99"
);
assert_eq!(
FormatDefect::MalformedHeader.to_string(),
"File header is malformed"
);
assert_eq!(
FormatDefect::MalformedPayloadStream.to_string(),
"Encrypted payload stream is malformed"
);
assert_eq!(
FormatDefect::OversizedHeader {
header_len: 16_777_217
}
.to_string(),
"File header is too large (16777217 bytes)"
);
assert_eq!(
FormatDefect::MalformedTypeName.to_string(),
"Recipient type name is malformed"
);
assert_eq!(
FormatDefect::MalformedRecipientEntry.to_string(),
"Recipient entry is malformed"
);
assert_eq!(
FormatDefect::RecipientFlagsReserved.to_string(),
"Recipient entry uses reserved flag bits"
);
assert_eq!(
FormatDefect::MalformedPrivateKey.to_string(),
"Private key is malformed"
);
assert_eq!(
FormatDefect::UnsupportedArchiveVersion { version: 0xFF }.to_string(),
"Newer FerroCrypt is needed for FCA archive version byte 0xFF"
);
assert_eq!(
FormatDefect::RecipientCountOutOfRange { count: 5000 }.to_string(),
"Recipient count out of range (5000)"
);
assert_eq!(
UnsupportedVersion::NewerFile { version: 9 }.to_string(),
"Newer FerroCrypt is needed for .fcr version byte 0x09"
);
assert_eq!(
UnsupportedVersion::OlderFile { version: 1 }.to_string(),
"Unsupported older .fcr version byte 0x01"
);
assert_eq!(
UnsupportedVersion::NewerKey { version: 9 }.to_string(),
"Newer FerroCrypt is needed for private-key version byte 0x09"
);
assert_eq!(
UnsupportedVersion::OlderKey { version: 1 }.to_string(),
"Unsupported older private-key version byte 0x01"
);
assert_eq!(
UnsupportedVersion::OlderPublicKey { version: 1 }.to_string(),
"Unsupported older public-key version byte 0x01"
);
assert_eq!(
UnsupportedVersion::NewerPublicKey { version: 9 }.to_string(),
"Newer FerroCrypt is needed for public-key version byte 0x09"
);
assert_eq!(
InvalidKdfParams::Parallelism(9999).to_string(),
"File has invalid KDF settings (parallelism 9999)"
);
assert_eq!(
InvalidKdfParams::MemoryCost(42).to_string(),
"File has invalid KDF settings (memory 42 KiB)"
);
assert_eq!(
InvalidKdfParams::TimeCost(7).to_string(),
"File has invalid KDF settings (time cost 7)"
);
assert_eq!(
StreamError::DecryptAead.to_string(),
"Payload authentication failed"
);
assert_eq!(
StreamError::EncryptAead.to_string(),
"payload encryption failed"
);
assert_eq!(
StreamError::Truncated.to_string(),
"Encrypted stream truncated"
);
assert_eq!(
StreamError::EmptyFinalChunk.to_string(),
"Encrypted stream has an invalid empty final chunk"
);
assert_eq!(
StreamError::ExtraData.to_string(),
"Encrypted stream has trailing data"
);
assert_eq!(
StreamError::StateExhausted.to_string(),
"stream state already finalized"
);
assert_eq!(
StreamError::ChunkCountExceeded.to_string(),
"Encrypted stream exceeds supported data size"
);
}
#[test]
fn archive_errors_display_exact_strings() {
assert_eq!(
CryptoError::MalformedArchive {
reason: "bad magic"
}
.to_string(),
"Malformed archive: bad magic"
);
assert_eq!(
CryptoError::UnsafeArchivePath {
path: "../etc/passwd".to_owned(),
reason: "forbidden component",
}
.to_string(),
"Unsafe archive path (forbidden component): ../etc/passwd"
);
assert_eq!(
CryptoError::InvalidArchiveTree {
path: "root/a.txt".to_owned(),
reason: "duplicate entry",
}
.to_string(),
"Invalid archive tree (duplicate entry): root/a.txt"
);
assert_eq!(
CryptoError::ArchiveEntryCountCapExceeded {
entry_count: 250_001,
local_cap: 250_000,
}
.to_string(),
"Too many archive entries (250001, limit 250000)"
);
assert_eq!(
CryptoError::ArchiveTotalBytesCapExceeded {
total_bytes: 100,
local_cap: 99,
}
.to_string(),
"Archive is too large (limit 99 bytes)"
);
assert_eq!(
CryptoError::ArchiveManifestLenCapExceeded {
manifest_len: 100,
local_cap: 99,
}
.to_string(),
"Archive manifest is too large (limit 99 bytes)"
);
assert_eq!(
CryptoError::ArchivePathBytesCapExceeded {
path_bytes: 100,
local_cap: 99,
path: None,
}
.to_string(),
"Archive path too long (100 bytes, limit 99)"
);
assert_eq!(
CryptoError::ArchivePathBytesCapExceeded {
path_bytes: 100,
local_cap: 99,
path: Some("root/long".to_owned()),
}
.to_string(),
"Archive path too long (100 bytes, limit 99): root/long"
);
assert_eq!(
CryptoError::ArchivePathDepthCapExceeded {
depth: 65,
local_cap: 64,
path: "a/b".to_owned(),
}
.to_string(),
"Archive path too deep (65 components, limit 64): a/b"
);
assert_eq!(
CryptoError::ArchiveExtLenCapExceeded {
ext_len: 100,
local_cap: 99,
}
.to_string(),
"Archive extension is too large (limit 99 bytes)"
);
assert_eq!(
CryptoError::ArchiveEntryExtLenCapExceeded {
ext_len: 100,
local_cap: 99,
path: None,
}
.to_string(),
"Archive entry extension is too large (limit 99 bytes)"
);
assert_eq!(
CryptoError::ArchiveTotalEntryExtCapExceeded {
total_ext_bytes: 100,
local_cap: 99,
}
.to_string(),
"Archive entry extensions are too large (limit 99 bytes)"
);
assert_eq!(
CryptoError::PrivateKeyWrappedSecretCapExceeded {
wrapped_secret_len: 5000,
local_cap: 4096,
}
.to_string(),
"Private key data too large (5000 bytes, limit 4096)"
);
assert_eq!(
CryptoError::InternalInvariant("envelope ciphertext size mismatch").to_string(),
"Internal error: envelope ciphertext size mismatch"
);
assert_eq!(
CryptoError::InternalCryptoFailure("payload encryption failed").to_string(),
"Internal crypto error: payload encryption failed"
);
}
#[test]
fn crate_internal_markers_follow_display_policy() {
const BOUNDARY_MARKER: &str = "encrypt writer already finished or failed";
assert_eq!(BOUNDARY_MARKER.len(), INTERNAL_MARKER_DISPLAY_MAX);
assert!(internal_marker_is_valid(BOUNDARY_MARKER));
assert!(!internal_marker_is_valid(""));
assert!(!internal_marker_is_valid(
"manifest entry missing source path after content streaming"
));
assert!(!internal_marker_is_valid("non-ASCII marker: é"));
assert!(!internal_marker_is_valid("payload encryption failed."));
assert_eq!(
internal_invariant!("encrypt writer already finished or failed").to_string(),
"Internal error: encrypt writer already finished or failed"
);
assert_eq!(
internal_crypto_failure!("payload encryption failed").to_string(),
"Internal crypto error: payload encryption failed"
);
}
#[test]
fn malformed_archive_reasons_follow_display_policy() {
const BOUNDARY_REASON: &str = "declared total does not match the entry sizes";
assert_eq!(BOUNDARY_REASON.len(), MALFORMED_ARCHIVE_REASON_DISPLAY_MAX);
assert!(malformed_archive_reason_is_valid(BOUNDARY_REASON));
assert_eq!(
CryptoError::MalformedArchive {
reason: BOUNDARY_REASON,
}
.to_string()
.chars()
.count(),
64
);
assert!(!malformed_archive_reason_is_valid(""));
let overlong = "x".repeat(MALFORMED_ARCHIVE_REASON_DISPLAY_MAX + 1);
assert!(!malformed_archive_reason_is_valid(&overlong));
assert!(!malformed_archive_reason_is_valid(
"entry path is not valid UTF-\u{ff11}"
));
assert!(!malformed_archive_reason_is_valid("entry path is empty."));
assert!(!malformed_archive_reason_is_valid("entry path\nis empty"));
}
#[test]
fn sanitize_for_display_escapes_and_truncates() {
assert_eq!(
sanitize_for_display("plain path/file.txt"),
"plain path/file.txt"
);
assert_eq!(
sanitize_for_display("a\x1b]0;pwned\x07b"),
"a\\u{1b}]0;pwned\\u{7}b"
);
assert_eq!(sanitize_for_display("nul\0byte"), "nul\\u{0}byte");
assert_eq!(
sanitize_for_display("bidi\u{202e}gpj.txt"),
"bidi\\u{202e}gpj.txt"
);
assert_eq!(sanitize_for_display("caf\u{e9}"), "caf\\u{e9}");
let long: String = "x".repeat(UNTRUSTED_TEXT_DISPLAY_MAX + 10);
let rendered = sanitize_for_display(&long);
assert_eq!(
rendered.chars().count(),
UNTRUSTED_TEXT_DISPLAY_MAX,
"63 kept chars plus the ellipsis fill the rendered budget"
);
assert!(rendered.ends_with('…'));
let exact: String = "y".repeat(UNTRUSTED_TEXT_DISPLAY_MAX);
assert_eq!(
sanitize_for_display(&exact),
exact,
"at-cap input is untouched"
);
}
#[test]
fn sanitize_for_display_bounds_rendered_chars_for_escaped_input() {
let hostile: String = "\u{202e}".repeat(UNTRUSTED_TEXT_DISPLAY_MAX);
let rendered = sanitize_for_display(&hostile);
assert!(
rendered.chars().count() <= UNTRUSTED_TEXT_DISPLAY_MAX,
"rendered budget exceeded: {} chars",
rendered.chars().count()
);
assert!(rendered.ends_with('…'));
assert!(rendered.starts_with("\\u{202e}"));
assert!(
!rendered.contains('\u{202e}'),
"raw bidi override leaked: {rendered:?}"
);
}
#[test]
fn sanitize_path_for_display_escapes_hostile_name() {
let path = std::path::Path::new("dir/evil\u{1b}]0;pwned\u{7}.txt");
let rendered = sanitize_path_for_display(path);
assert!(
!rendered.chars().any(char::is_control),
"raw control character leaked: {rendered:?}"
);
assert_eq!(rendered, "dir/evil\\u{1b}]0;pwned\\u{7}.txt");
}
#[test]
fn type_name_in_error_is_escaped_not_emitted_raw() {
let msg = CryptoError::IncompatibleRecipients {
type_name: "\u{1b}\u{202e}".to_owned(),
policy: MixingPolicy::Exclusive,
}
.to_string();
assert_eq!(msg, "Recipient `\\u{1b}…` mixed with another recipient");
assert!(!msg.contains('\u{1b}'), "raw ESC must not appear: {msg:?}");
assert!(msg.chars().count() <= 64, "message over budget: {msg}");
}
#[test]
fn type_name_escape_truncation_keeps_status_budget() {
let msg = CryptoError::UnknownCriticalRecipient {
type_name: "\u{202e}".repeat(20),
}
.to_string();
assert!(
!msg.contains('\u{202e}'),
"raw bidi char must not appear: {msg:?}"
);
assert!(msg.contains('…'), "hostile name should be truncated: {msg}");
assert!(msg.chars().count() <= 64, "message over budget: {msg}");
}
#[test]
fn unsupported_key_type_message_names_type() {
let msg = CryptoError::UnsupportedKeyType {
type_name: "mlkem768".to_owned(),
}
.to_string();
assert_eq!(msg, "Unsupported key type `mlkem768`");
}
#[test]
fn user_facing_messages_fit_status_line_budget() {
const BUDGET: usize = 64;
fn check_width(label: &str, msg: &str) {
let chars = msg.chars().count();
assert!(
chars <= BUDGET,
"message over {BUDGET}-char budget ({chars} chars) [{label}]: {msg}",
);
}
fn check(label: &str, msg: &str) {
assert!(
msg.chars().next().is_some_and(|c| c.is_ascii_uppercase()),
"message does not start with a capital [{label}]: {msg}",
);
assert!(
!matches!(msg.chars().last(), Some('.' | '!' | '?')),
"message has terminal punctuation [{label}]: {msg}",
);
check_width(label, msg);
}
check("InputPath", &CryptoError::InputPath.to_string());
const PROPER_NOUN_STARTS: &[&str] = &["Windows-"];
let mut archive_reasons = std::collections::HashSet::new();
let mut check_fragment = |reason: &'static str| {
assert!(
archive_reasons.insert(reason),
"duplicate archive reason: {reason}"
);
assert!(
reason.starts_with(|c: char| c.is_ascii_lowercase())
|| PROPER_NOUN_STARTS.iter().any(|p| reason.starts_with(p)),
"archive reason is not a lowercase fragment: {reason}",
);
};
for &reason in crate::archive::reasons::MALFORMED_ALL {
check_fragment(reason);
let rendered = CryptoError::MalformedArchive { reason }.to_string();
assert_eq!(
rendered,
format!("Malformed archive: {reason}"),
"crate-owned malformed-archive reason was altered for display",
);
check(reason, &rendered);
}
for &reason in crate::archive::reasons::UNSAFE_PATH_ALL {
check_fragment(reason);
assert_eq!(
CryptoError::UnsafeArchivePath {
path: "root/a.txt".to_owned(),
reason,
}
.to_string(),
format!("Unsafe archive path ({reason}): root/a.txt"),
"crate-owned unsafe-path reason was altered for display",
);
}
for &reason in crate::archive::reasons::INVALID_TREE_ALL {
check_fragment(reason);
assert_eq!(
CryptoError::InvalidArchiveTree {
path: "root/a.txt".to_owned(),
reason,
}
.to_string(),
format!("Invalid archive tree ({reason}): root/a.txt"),
"crate-owned invalid-tree reason was altered for display",
);
}
check(
"KeyFileUnlockFailed",
&CryptoError::KeyFileUnlockFailed.to_string(),
);
check("HeaderTampered", &CryptoError::HeaderTampered.to_string());
check(
"HeaderMacFailedAfterUnwrap",
&CryptoError::HeaderMacFailedAfterUnwrap {
type_name: "x25519".to_owned(),
}
.to_string(),
);
check(
"NoSupportedRecipient",
&CryptoError::NoSupportedRecipient.to_string(),
);
check(
"DecryptorModeMismatch(passphrase, public-key)",
&CryptoError::DecryptorModeMismatch {
expected: UnauthenticatedRecipientMode::Passphrase,
found: UnauthenticatedRecipientMode::PublicKey,
}
.to_string(),
);
check(
"DecryptorModeMismatch(public-key, passphrase)",
&CryptoError::DecryptorModeMismatch {
expected: UnauthenticatedRecipientMode::PublicKey,
found: UnauthenticatedRecipientMode::Passphrase,
}
.to_string(),
);
check(
"EmptyRecipientList",
&CryptoError::EmptyRecipientList.to_string(),
);
check(
"IncompatibleRecipients(argon2id, Exclusive)",
&CryptoError::IncompatibleRecipients {
type_name: "argon2id".to_owned(),
policy: MixingPolicy::Exclusive,
}
.to_string(),
);
check(
"IncompatibleRecipients(truncated, Exclusive)",
&CryptoError::IncompatibleRecipients {
type_name: "mlkem768x25519".to_owned(),
policy: MixingPolicy::Exclusive,
}
.to_string(),
);
check(
"IncompatibleRecipients(argon2id, PublicKeyMixable)",
&CryptoError::IncompatibleRecipients {
type_name: "argon2id".to_owned(),
policy: MixingPolicy::PublicKeyMixable,
}
.to_string(),
);
check(
"IncompatibleRecipients(truncated, Custom)",
&CryptoError::IncompatibleRecipients {
type_name: "x25519-mlkem768".to_owned(),
policy: MixingPolicy::Custom {
compatibility_class: NativeMixingRule::POST_QUANTUM_CLASS,
},
}
.to_string(),
);
check("PayloadTampered", &CryptoError::PayloadTampered.to_string());
check(
"RecipientUnwrapFailed(passphrase)",
&CryptoError::RecipientUnwrapFailed {
type_name: "argon2id".to_owned(),
}
.to_string(),
);
check(
"RecipientUnwrapFailed(public-key)",
&CryptoError::RecipientUnwrapFailed {
type_name: "x25519".to_owned(),
}
.to_string(),
);
check(
"PayloadTruncated",
&CryptoError::PayloadTruncated.to_string(),
);
check(
"ExtraDataAfterPayload",
&CryptoError::ExtraDataAfterPayload.to_string(),
);
check(
"PayloadChunkCountExceeded",
&CryptoError::PayloadChunkCountExceeded.to_string(),
);
check(
"KdfResourceCapExceeded(max)",
&CryptoError::KdfResourceCapExceeded {
mem_cost_kib: u32::MAX,
local_cap_kib: u32::MAX,
}
.to_string(),
);
check(
"KdfBelowWriteFloor(max)",
&CryptoError::KdfBelowWriteFloor {
mem_cost_kib: u32::MAX,
floor_kib: u32::MAX,
}
.to_string(),
);
check(
"KdfTimeCostCapExceeded(max)",
&CryptoError::KdfTimeCostCapExceeded {
time_cost: u32::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"KdfLanesCapExceeded(max)",
&CryptoError::KdfLanesCapExceeded {
lanes: u32::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"HeaderLenCapExceeded(max)",
&CryptoError::HeaderLenCapExceeded {
header_len: u32::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"RecipientCountCapExceeded(max)",
&CryptoError::RecipientCountCapExceeded {
count: u16::MAX,
local_cap: u16::MAX,
}
.to_string(),
);
check(
"RecipientBodyCapExceeded(max)",
&CryptoError::RecipientBodyCapExceeded {
body_len: u32::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"RecipientStringCapExceeded(max)",
&CryptoError::RecipientStringCapExceeded {
input_chars: u32::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"ArchiveEntryCountCapExceeded(max)",
&CryptoError::ArchiveEntryCountCapExceeded {
entry_count: u32::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"ArchiveTotalBytesCapExceeded(max)",
&CryptoError::ArchiveTotalBytesCapExceeded {
total_bytes: u64::MAX,
local_cap: u64::MAX,
}
.to_string(),
);
check(
"ArchiveManifestLenCapExceeded(max)",
&CryptoError::ArchiveManifestLenCapExceeded {
manifest_len: u64::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"ArchivePathBytesCapExceeded(max, no path)",
&CryptoError::ArchivePathBytesCapExceeded {
path_bytes: u32::MAX,
local_cap: u32::MAX,
path: None,
}
.to_string(),
);
check(
"ArchiveExtLenCapExceeded(max)",
&CryptoError::ArchiveExtLenCapExceeded {
ext_len: u64::MAX,
local_cap: u32::MAX,
}
.to_string(),
);
check(
"ArchiveEntryExtLenCapExceeded(max, no path)",
&CryptoError::ArchiveEntryExtLenCapExceeded {
ext_len: u64::MAX,
local_cap: u32::MAX,
path: None,
}
.to_string(),
);
let max_name = "x".repeat(u8::MAX as usize);
check(
"UnknownCriticalRecipient(max-name)",
&CryptoError::UnknownCriticalRecipient {
type_name: max_name.clone(),
}
.to_string(),
);
check(
"UnsupportedKeyType(max-name)",
&CryptoError::UnsupportedKeyType {
type_name: max_name,
}
.to_string(),
);
check(
"InternalInvariant(long marker)",
&CryptoError::InternalInvariant(
"Manifest entry missing source_path during content streaming",
)
.to_string(),
);
check(
"InternalCryptoFailure(long marker)",
&CryptoError::InternalCryptoFailure(
"Argon2id key derivation failed inside an overlong internal marker",
)
.to_string(),
);
let defects: &[(&str, FormatDefect)] = &[
("Truncated", FormatDefect::Truncated),
("BadMagic", FormatDefect::BadMagic),
("ExtTooLarge", FormatDefect::ExtTooLarge { len: u32::MAX }),
("MalformedTlv", FormatDefect::MalformedTlv),
(
"UnknownCriticalTag",
FormatDefect::UnknownCriticalTag { tag: u16::MAX },
),
("NotAKeyFile", FormatDefect::NotAKeyFile),
("WrongKeyFileType", FormatDefect::WrongKeyFileType),
("MalformedPublicKey", FormatDefect::MalformedPublicKey),
("WrongKind", FormatDefect::WrongKind { kind: u8::MAX }),
("MalformedHeader", FormatDefect::MalformedHeader),
(
"MalformedPayloadStream",
FormatDefect::MalformedPayloadStream,
),
(
"OversizedHeader(max)",
FormatDefect::OversizedHeader {
header_len: u32::MAX,
},
),
(
"RecipientCountOutOfRange(max)",
FormatDefect::RecipientCountOutOfRange { count: u16::MAX },
),
("MalformedTypeName", FormatDefect::MalformedTypeName),
(
"MalformedRecipientEntry",
FormatDefect::MalformedRecipientEntry,
),
(
"RecipientFlagsReserved",
FormatDefect::RecipientFlagsReserved,
),
("MalformedPrivateKey", FormatDefect::MalformedPrivateKey),
(
"UnsupportedArchiveVersion(max)",
FormatDefect::UnsupportedArchiveVersion { version: u8::MAX },
),
];
for (label, d) in defects {
check(label, &d.to_string());
}
let versions: &[(&str, UnsupportedVersion)] = &[
(
"OlderFile(max)",
UnsupportedVersion::OlderFile { version: u8::MAX },
),
(
"NewerFile(max)",
UnsupportedVersion::NewerFile { version: u8::MAX },
),
(
"OlderKey(max)",
UnsupportedVersion::OlderKey { version: u8::MAX },
),
(
"NewerKey(max)",
UnsupportedVersion::NewerKey { version: u8::MAX },
),
(
"OlderPublicKey(max)",
UnsupportedVersion::OlderPublicKey { version: u8::MAX },
),
(
"NewerPublicKey(max)",
UnsupportedVersion::NewerPublicKey { version: u8::MAX },
),
];
for (label, v) in versions {
check(label, &v.to_string());
}
let kdf: &[(&str, InvalidKdfParams)] = &[
("Parallelism(max)", InvalidKdfParams::Parallelism(u32::MAX)),
("MemoryCost(max)", InvalidKdfParams::MemoryCost(u32::MAX)),
("TimeCost(max)", InvalidKdfParams::TimeCost(u32::MAX)),
];
for (label, p) in kdf {
check(label, &p.to_string());
}
check_width(
"StreamError::DecryptAead",
&StreamError::DecryptAead.to_string(),
);
check_width(
"StreamError::EncryptAead",
&StreamError::EncryptAead.to_string(),
);
check_width(
"StreamError::Truncated",
&StreamError::Truncated.to_string(),
);
check_width(
"StreamError::EmptyFinalChunk",
&StreamError::EmptyFinalChunk.to_string(),
);
check_width(
"StreamError::ExtraData",
&StreamError::ExtraData.to_string(),
);
check_width(
"StreamError::StateExhausted",
&StreamError::StateExhausted.to_string(),
);
check_width(
"StreamError::ChunkCountExceeded",
&StreamError::ChunkCountExceeded.to_string(),
);
}
#[test]
fn stream_error_markers_map_to_typed_variants() {
fn from_marker(marker: StreamError) -> CryptoError {
std::io::Error::other(marker).into()
}
assert!(matches!(
from_marker(StreamError::DecryptAead),
CryptoError::PayloadTampered
));
assert!(matches!(
from_marker(StreamError::Truncated),
CryptoError::PayloadTruncated
));
assert!(matches!(
from_marker(StreamError::EmptyFinalChunk),
CryptoError::InvalidFormat(FormatDefect::MalformedPayloadStream)
));
assert!(matches!(
from_marker(StreamError::ExtraData),
CryptoError::ExtraDataAfterPayload
));
assert!(matches!(
from_marker(StreamError::ChunkCountExceeded),
CryptoError::PayloadChunkCountExceeded
));
match from_marker(StreamError::EncryptAead) {
CryptoError::InternalCryptoFailure(msg) => {
assert_eq!(msg, "payload encryption failed");
assert_eq!(msg, StreamError::EncryptAead.to_string());
}
other => panic!("expected InternalCryptoFailure, got {other:?}"),
}
match from_marker(StreamError::StateExhausted) {
CryptoError::InternalInvariant(msg) => {
assert_eq!(msg, "stream state already finalized");
assert_eq!(msg, StreamError::StateExhausted.to_string());
}
other => panic!("expected InternalInvariant, got {other:?}"),
}
let plain: CryptoError = std::io::Error::other("bare message").into();
assert!(
matches!(plain, CryptoError::Io(_)),
"unmarked io::Error must map to CryptoError::Io, got {plain:?}"
);
}
}