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(
f: &mut std::fmt::Formatter<'_>,
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 {
f.write_str(&escaped)?;
written += escaped_len;
} else {
if written < max {
f.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 INTERNAL_MARKER_DISPLAY_MAX: usize = 64 - "Internal crypto error: ".len();
const _: () = assert!(INTERNAL_MARKER_DISPLAY_MAX >= 1);
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();
for (i, c) in text.chars().enumerate() {
if i >= UNTRUSTED_TEXT_DISPLAY_MAX {
out.push('…');
break;
}
let _ = write_sanitized_char(&mut out, c);
}
out
}
pub(crate) fn sanitize_path_for_display(path: &std::path::Path) -> String {
sanitize_for_display(&path.display().to_string())
}
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 folder missing")]
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 `{}`. Upgrade FerroCrypt.",
DisplayableTypeName(type_name)
)]
UnknownCriticalRecipient {
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 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,
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,
"Unknown required file feature (tag 0x{tag:04X}). Upgrade FerroCrypt."
),
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::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,
"Unsupported archive version (v{version}). Upgrade FerroCrypt."
)
}
}
}
}
#[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, "Older file format (v{version}). Use a previous release.")
}
Self::NewerFile { version } => {
write!(f, "Newer file format (v{version}). Upgrade FerroCrypt.")
}
Self::OlderKey { version } => {
write!(f, "Older key format (v{version}). Use a previous release.")
}
Self::NewerKey { version } => {
write!(f, "Newer key format (v{version}). Upgrade FerroCrypt.")
}
Self::OlderPublicKey { version } => {
write!(
f,
"Older public-key format (v{version}). Generate a new key pair."
)
}
Self::NewerPublicKey { version } => {
write!(
f,
"Newer public-key format (v{version}). Upgrade FerroCrypt."
)
}
}
}
}
#[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 ({n} KiB memory)")
}
Self::TimeCost(n) => write!(f, "File has invalid KDF settings (time cost {n})"),
}
}
}
#[derive(Debug)]
pub(crate) enum StreamError {
DecryptAead,
EncryptAead,
Truncated,
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::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::ExtraData => CryptoError::ExtraDataAfterPayload,
StreamError::ChunkCountExceeded => CryptoError::PayloadChunkCountExceeded,
StreamError::EncryptAead => {
CryptoError::InternalCryptoFailure("payload encryption failed")
}
StreamError::StateExhausted => {
CryptoError::InternalInvariant("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 folder missing"
);
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…`. Upgrade FerroCrypt."
);
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(),
"Unknown required file feature (tag 0x8001). Upgrade FerroCrypt."
);
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::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(),
"Unsupported archive version (v255). Upgrade FerroCrypt."
);
assert_eq!(
FormatDefect::RecipientCountOutOfRange { count: 5000 }.to_string(),
"Recipient count out of range (5000)"
);
assert_eq!(
UnsupportedVersion::NewerFile { version: 9 }.to_string(),
"Newer file format (v9). Upgrade FerroCrypt."
);
assert_eq!(
UnsupportedVersion::OlderFile { version: 1 }.to_string(),
"Older file format (v1). Use a previous release."
);
assert_eq!(
UnsupportedVersion::NewerKey { version: 9 }.to_string(),
"Newer key format (v9). Upgrade FerroCrypt."
);
assert_eq!(
UnsupportedVersion::OlderKey { version: 1 }.to_string(),
"Older key format (v1). Use a previous release."
);
assert_eq!(
UnsupportedVersion::OlderPublicKey { version: 1 }.to_string(),
"Older public-key format (v1). Generate a new key pair."
);
assert_eq!(
UnsupportedVersion::NewerPublicKey { version: 9 }.to_string(),
"Newer public-key format (v9). Upgrade FerroCrypt."
);
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 (42 KiB memory)"
);
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::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 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 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 + 1,
"64 kept chars plus the ellipsis"
);
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_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 user_facing_messages_fit_status_line_budget() {
const BUDGET: usize = 64;
fn check(label: &str, msg: &str) {
let chars = msg.chars().count();
assert!(
chars <= BUDGET,
"message over {BUDGET}-char budget ({chars} chars) [{label}]: {msg}",
);
}
check("InputPath", &CryptoError::InputPath.to_string());
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,
}
.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),
(
"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(
"StreamError::DecryptAead",
&StreamError::DecryptAead.to_string(),
);
check(
"StreamError::EncryptAead",
&StreamError::EncryptAead.to_string(),
);
check(
"StreamError::Truncated",
&StreamError::Truncated.to_string(),
);
check(
"StreamError::ExtraData",
&StreamError::ExtraData.to_string(),
);
check(
"StreamError::StateExhausted",
&StreamError::StateExhausted.to_string(),
);
check(
"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::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:?}"
);
}
}