use std::io;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PasswordDetectionMethod {
EarlyHeaderValidation,
CrcMismatch,
DecompressionFailure,
}
impl std::fmt::Display for PasswordDetectionMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EarlyHeaderValidation => write!(f, "early header validation"),
Self::CrcMismatch => write!(f, "CRC mismatch after decompression"),
Self::DecompressionFailure => write!(f, "decompression failure"),
}
}
}
struct WrongPasswordDisplay<'a> {
entry_index: Option<usize>,
entry_name: Option<&'a str>,
}
impl std::fmt::Display for WrongPasswordDisplay<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Wrong password")?;
match (self.entry_index, self.entry_name) {
(Some(idx), Some(name)) => write!(f, " for entry {} ({})", idx, name),
(Some(idx), None) => write!(f, " for entry {}", idx),
(None, Some(name)) => write!(f, " for entry '{}'", name),
(None, None) => Ok(()),
}
}
}
struct CrcMismatchDisplay<'a> {
entry_index: usize,
entry_name: Option<&'a str>,
expected: u32,
actual: u32,
}
impl std::fmt::Display for CrcMismatchDisplay<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "CRC mismatch for entry {}", self.entry_index)?;
if let Some(name) = self.entry_name {
write!(f, " ({})", name)?;
}
write!(f, ": expected {:#x}, got {:#x}", self.expected, self.actual)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Invalid 7z format: {0}")]
InvalidFormat(String),
#[error("Corrupt header at offset {offset:#x}: {reason}")]
CorruptHeader {
offset: u64,
reason: String,
},
#[error("Unsupported method: {method_id:#x}")]
UnsupportedMethod {
method_id: u64,
},
#[error("Unsupported feature: {feature}")]
UnsupportedFeature {
feature: &'static str,
},
#[error("{}", WrongPasswordDisplay { entry_index: *.entry_index, entry_name: entry_name.as_deref() })]
WrongPassword {
entry_index: Option<usize>,
entry_name: Option<String>,
detection_method: PasswordDetectionMethod,
},
#[error("Operation cancelled")]
Cancelled,
#[error("Cryptographic error: {0}")]
CryptoError(String),
#[error("{}", CrcMismatchDisplay { entry_index: *entry_index, entry_name: entry_name.as_deref(), expected: *expected, actual: *actual })]
CrcMismatch {
entry_index: usize,
entry_name: Option<String>,
expected: u32,
actual: u32,
},
#[error("Path traversal detected in entry {entry_index}: {path}")]
PathTraversal {
entry_index: usize,
path: String,
},
#[error("Symbolic link rejected at entry {entry_index}: {path}")]
SymlinkRejected {
entry_index: usize,
path: String,
},
#[error(
"Symbolic link target escapes extraction directory at entry {entry_index}: {path} -> {target}"
)]
SymlinkTargetEscape {
entry_index: usize,
path: String,
target: String,
},
#[error("Resource limit exceeded: {0}")]
ResourceLimitExceeded(String),
#[error("Invalid archive path: {0}")]
InvalidArchivePath(String),
#[error(
"Volume {volume} missing: expected at '{path}' (multi-volume archives require all parts in the same directory)"
)]
VolumeMissing {
volume: u32,
path: String,
#[source]
source: io::Error,
},
#[error("Volume {volume} corrupted: {details}")]
VolumeCorrupted {
volume: u32,
details: String,
},
#[error("Incomplete archive: expected {expected} volumes, found {found}")]
IncompleteArchive {
expected: u32,
found: u32,
},
#[error("Entry not found: {path}")]
EntryNotFound {
path: String,
},
#[error("Entry already exists: {path}")]
EntryExists {
path: String,
},
#[cfg(feature = "regex")]
#[error("Invalid regex pattern '{pattern}': {reason}")]
InvalidRegex {
pattern: String,
reason: String,
},
#[error("invalid compression level {level}: must be 0-9")]
InvalidCompressionLevel {
level: u32,
},
#[error("password required for encrypted archive")]
PasswordRequired,
}
impl Error {
pub fn is_security_error(&self) -> bool {
matches!(
self,
Error::PathTraversal { .. }
| Error::SymlinkRejected { .. }
| Error::SymlinkTargetEscape { .. }
)
}
pub fn is_recoverable(&self) -> bool {
match self {
Error::WrongPassword { .. } => true,
Error::PasswordRequired => true,
Error::Cancelled => true,
Error::VolumeMissing { .. } => true,
Error::Io(e) => matches!(
e.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
),
_ => false,
}
}
pub fn is_corruption(&self) -> bool {
matches!(
self,
Error::CrcMismatch { .. } | Error::CorruptHeader { .. }
)
}
pub fn is_encryption_error(&self) -> bool {
matches!(
self,
Error::WrongPassword { .. } | Error::CryptoError(_) | Error::PasswordRequired
)
}
pub fn is_unsupported(&self) -> bool {
matches!(
self,
Error::UnsupportedMethod { .. } | Error::UnsupportedFeature { .. }
)
}
pub fn entry_index(&self) -> Option<usize> {
match self {
Error::WrongPassword { entry_index, .. } => *entry_index,
Error::CrcMismatch { entry_index, .. } => Some(*entry_index),
Error::PathTraversal { entry_index, .. } => Some(*entry_index),
Error::SymlinkRejected { entry_index, .. } => Some(*entry_index),
Error::SymlinkTargetEscape { entry_index, .. } => Some(*entry_index),
_ => None,
}
}
pub fn entry_name(&self) -> Option<&str> {
match self {
Error::WrongPassword { entry_name, .. } => entry_name.as_deref(),
Error::CrcMismatch { entry_name, .. } => entry_name.as_deref(),
Error::PathTraversal { path, .. } => Some(path.as_str()),
Error::SymlinkRejected { path, .. } => Some(path.as_str()),
Error::SymlinkTargetEscape { path, .. } => Some(path.as_str()),
Error::VolumeMissing { path, .. } => Some(path.as_str()),
_ => None,
}
}
pub fn method_id(&self) -> Option<u64> {
match self {
Error::UnsupportedMethod { method_id } => Some(*method_id),
_ => None,
}
}
pub fn wrong_password(
entry_index: Option<usize>,
entry_name: Option<String>,
detection_method: PasswordDetectionMethod,
) -> Self {
Error::WrongPassword {
entry_index,
entry_name,
detection_method,
}
}
pub fn crc_mismatch(
entry_index: usize,
entry_name: Option<String>,
expected: u32,
actual: u32,
) -> Self {
Error::CrcMismatch {
entry_index,
entry_name,
expected,
actual,
}
}
pub fn corrupt_header(offset: u64, reason: impl Into<String>) -> Self {
Error::CorruptHeader {
offset,
reason: reason.into(),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_io_error_from() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let err: Error = io_err.into();
assert!(matches!(err, Error::Io(_)));
assert!(err.to_string().contains("I/O error"));
}
#[test]
fn test_invalid_format() {
let err = Error::InvalidFormat("missing signature".into());
assert_eq!(err.to_string(), "Invalid 7z format: missing signature");
}
#[test]
fn test_corrupt_header() {
let err = Error::CorruptHeader {
offset: 0x1234,
reason: "unexpected end of header".into(),
};
assert!(err.to_string().contains("0x1234"));
assert!(err.to_string().contains("unexpected end of header"));
}
#[test]
fn test_unsupported_method() {
let err = Error::UnsupportedMethod {
method_id: 0x030101,
};
assert!(err.to_string().contains("0x30101"));
}
#[test]
fn test_unsupported_feature() {
let err = Error::UnsupportedFeature {
feature: "solid blocks",
};
assert!(err.to_string().contains("solid blocks"));
}
#[test]
fn test_wrong_password() {
let err = Error::WrongPassword {
entry_index: None,
entry_name: None,
detection_method: PasswordDetectionMethod::CrcMismatch,
};
assert!(err.to_string().contains("Wrong password"));
let err = Error::WrongPassword {
entry_index: Some(5),
entry_name: None,
detection_method: PasswordDetectionMethod::EarlyHeaderValidation,
};
assert!(err.to_string().contains("entry 5"));
let err = Error::WrongPassword {
entry_index: Some(3),
entry_name: Some("file.txt".into()),
detection_method: PasswordDetectionMethod::DecompressionFailure,
};
assert!(err.to_string().contains("file.txt"));
assert!(err.to_string().contains("entry 3"));
}
#[test]
fn test_cancelled() {
let err = Error::Cancelled;
assert!(err.to_string().contains("cancelled"));
assert!(err.is_recoverable());
}
#[test]
fn test_crypto_error() {
let err = Error::CryptoError("invalid key size".into());
assert!(err.to_string().contains("invalid key size"));
}
#[test]
fn test_crc_mismatch() {
let err = Error::CrcMismatch {
entry_index: 5,
entry_name: None,
expected: 0xDEADBEEF,
actual: 0xCAFEBABE,
};
let msg = err.to_string();
assert!(msg.contains("entry 5"));
assert!(msg.contains("0xdeadbeef"));
assert!(msg.contains("0xcafebabe"));
let err = Error::CrcMismatch {
entry_index: 5,
entry_name: Some("path/to/file.txt".into()),
expected: 0xDEADBEEF,
actual: 0xCAFEBABE,
};
let msg = err.to_string();
assert!(msg.contains("entry 5"));
assert!(msg.contains("path/to/file.txt"));
assert!(msg.contains("0xdeadbeef"));
assert!(msg.contains("0xcafebabe"));
}
#[test]
fn test_path_traversal() {
let err = Error::PathTraversal {
entry_index: 3,
path: "../etc/passwd".into(),
};
let msg = err.to_string();
assert!(msg.contains("entry 3"));
assert!(msg.contains("../etc/passwd"));
}
#[test]
fn test_volume_missing_with_source() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let err = Error::VolumeMissing {
volume: 2,
path: "test.7z.002".into(),
source: io_err,
};
let msg = err.to_string();
assert!(msg.contains("Volume 2"), "Should show volume number");
assert!(msg.contains("test.7z.002"), "Should show path");
assert!(
std::error::Error::source(&err).is_some(),
"Source chain should be preserved"
);
}
#[test]
fn test_volume_corrupted() {
let err = Error::VolumeCorrupted {
volume: 3,
details: "truncated at offset 0x1000".into(),
};
let msg = err.to_string();
assert!(msg.contains("3"), "Should show volume number");
assert!(msg.contains("corrupted"), "Should mention corruption");
assert!(msg.contains("truncated"), "Should include details");
}
#[test]
fn test_incomplete_archive() {
let err = Error::IncompleteArchive {
expected: 5,
found: 3,
};
let msg = err.to_string();
assert!(msg.contains("5"), "Should show expected count");
assert!(msg.contains("3"), "Should show found count");
assert!(msg.contains("Incomplete"), "Should indicate incomplete");
}
#[cfg(feature = "regex")]
#[test]
fn test_invalid_regex() {
let err = Error::InvalidRegex {
pattern: "[invalid".into(),
reason: "unclosed bracket".into(),
};
let msg = err.to_string();
assert!(msg.contains("[invalid"), "Should show pattern");
assert!(msg.contains("unclosed bracket"), "Should show reason");
}
#[test]
fn test_resource_limit_exceeded() {
let err = Error::ResourceLimitExceeded("file too large".into());
assert!(err.to_string().contains("file too large"));
}
#[test]
fn test_invalid_archive_path() {
let err = Error::InvalidArchivePath("contains NUL byte".into());
assert!(err.to_string().contains("contains NUL byte"));
}
#[test]
fn test_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Error>();
}
#[test]
fn test_is_encryption_error() {
let err = Error::WrongPassword {
entry_index: None,
entry_name: None,
detection_method: PasswordDetectionMethod::CrcMismatch,
};
assert!(err.is_encryption_error());
let err = Error::CryptoError("test".into());
assert!(err.is_encryption_error());
let err = Error::Io(io::Error::new(io::ErrorKind::NotFound, "test"));
assert!(!err.is_encryption_error());
}
#[test]
fn test_is_unsupported() {
let err = Error::UnsupportedMethod { method_id: 0x1234 };
assert!(err.is_unsupported());
let err = Error::UnsupportedFeature { feature: "test" };
assert!(err.is_unsupported());
let err = Error::InvalidFormat("test".into());
assert!(!err.is_unsupported());
}
#[test]
fn test_entry_index() {
let err = Error::CrcMismatch {
entry_index: 5,
entry_name: None,
expected: 0,
actual: 1,
};
assert_eq!(err.entry_index(), Some(5));
let err = Error::PathTraversal {
entry_index: 3,
path: "test".into(),
};
assert_eq!(err.entry_index(), Some(3));
let err = Error::WrongPassword {
entry_index: Some(7),
entry_name: None,
detection_method: PasswordDetectionMethod::CrcMismatch,
};
assert_eq!(err.entry_index(), Some(7));
let err = Error::InvalidFormat("test".into());
assert_eq!(err.entry_index(), None);
}
#[test]
fn test_entry_name() {
let err = Error::WrongPassword {
entry_index: None,
entry_name: Some("file.txt".into()),
detection_method: PasswordDetectionMethod::CrcMismatch,
};
assert_eq!(err.entry_name(), Some("file.txt"));
let err = Error::CrcMismatch {
entry_index: 0,
entry_name: Some("data/file.bin".into()),
expected: 0,
actual: 1,
};
assert_eq!(err.entry_name(), Some("data/file.bin"));
let err = Error::PathTraversal {
entry_index: 0,
path: "../etc/passwd".into(),
};
assert_eq!(err.entry_name(), Some("../etc/passwd"));
let err = Error::InvalidFormat("test".into());
assert_eq!(err.entry_name(), None);
}
#[test]
fn test_method_id() {
let err = Error::UnsupportedMethod {
method_id: 0x030101,
};
assert_eq!(err.method_id(), Some(0x030101));
let err = Error::InvalidFormat("test".into());
assert_eq!(err.method_id(), None);
}
#[test]
fn test_convenience_constructors() {
let err = Error::wrong_password(
Some(5),
Some("file.txt".into()),
PasswordDetectionMethod::EarlyHeaderValidation,
);
assert!(err.is_encryption_error());
assert_eq!(err.entry_index(), Some(5));
assert_eq!(err.entry_name(), Some("file.txt"));
let err = Error::crc_mismatch(3, Some("test.txt".into()), 0xDEAD, 0xBEEF);
assert!(err.is_corruption());
assert_eq!(err.entry_index(), Some(3));
assert_eq!(err.entry_name(), Some("test.txt"));
let err = Error::corrupt_header(0x1000, "truncated");
assert!(err.is_corruption());
assert!(err.to_string().contains("0x1000"));
assert!(err.to_string().contains("truncated"));
}
#[test]
fn test_is_recoverable_transient_io_errors() {
let err = Error::Io(io::Error::new(io::ErrorKind::WouldBlock, "would block"));
assert!(err.is_recoverable(), "WouldBlock should be recoverable");
let err = Error::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted"));
assert!(err.is_recoverable(), "Interrupted should be recoverable");
let err = Error::Io(io::Error::new(io::ErrorKind::TimedOut, "timed out"));
assert!(err.is_recoverable(), "TimedOut should be recoverable");
}
#[test]
fn test_is_recoverable_non_transient_io_errors() {
let err = Error::Io(io::Error::new(io::ErrorKind::NotFound, "not found"));
assert!(!err.is_recoverable(), "NotFound should not be recoverable");
let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
assert!(
!err.is_recoverable(),
"PermissionDenied should not be recoverable"
);
let err = Error::Io(io::Error::new(io::ErrorKind::InvalidData, "invalid"));
assert!(
!err.is_recoverable(),
"InvalidData should not be recoverable"
);
let err = Error::Io(io::Error::new(io::ErrorKind::UnexpectedEof, "eof"));
assert!(
!err.is_recoverable(),
"UnexpectedEof should not be recoverable"
);
}
#[test]
fn test_is_recoverable_volume_missing() {
let err = Error::VolumeMissing {
volume: 2,
path: "archive.7z.002".into(),
source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
};
assert!(
err.is_recoverable(),
"VolumeMissing should be recoverable (user can provide the file)"
);
}
#[test]
fn test_is_recoverable_resource_limit_not_recoverable() {
let err = Error::ResourceLimitExceeded("size limit".into());
assert!(
!err.is_recoverable(),
"ResourceLimitExceeded should NOT be recoverable"
);
}
#[test]
fn test_is_recoverable_other_errors_not_recoverable() {
let err = Error::InvalidFormat("bad format".into());
assert!(!err.is_recoverable());
let err = Error::CrcMismatch {
entry_index: 0,
entry_name: None,
expected: 0xDEAD,
actual: 0xBEEF,
};
assert!(!err.is_recoverable());
let err = Error::UnsupportedMethod { method_id: 0x999 };
assert!(!err.is_recoverable());
}
#[test]
fn test_entry_not_found() {
let err = Error::EntryNotFound {
path: "test/file.txt".into(),
};
assert_eq!(err.to_string(), "Entry not found: test/file.txt");
assert!(!err.is_recoverable());
assert!(!err.is_security_error());
}
#[test]
fn test_entry_exists() {
let err = Error::EntryExists {
path: "existing/file.txt".into(),
};
assert_eq!(err.to_string(), "Entry already exists: existing/file.txt");
assert!(!err.is_recoverable());
assert!(!err.is_security_error());
}
#[test]
fn test_invalid_compression_level() {
let err = Error::InvalidCompressionLevel { level: 15 };
assert_eq!(err.to_string(), "invalid compression level 15: must be 0-9");
assert!(!err.is_recoverable());
assert!(!err.is_security_error());
let err = Error::InvalidCompressionLevel { level: 10 };
assert!(err.to_string().contains("10"));
let err = Error::InvalidCompressionLevel { level: u32::MAX };
assert!(err.to_string().contains(&u32::MAX.to_string()));
}
#[test]
fn test_symlink_error_types() {
let rejected_err = Error::SymlinkRejected {
entry_index: 5,
path: "malicious_link".into(),
};
assert!(rejected_err.is_security_error());
assert_eq!(rejected_err.entry_index(), Some(5));
assert_eq!(rejected_err.entry_name(), Some("malicious_link"));
let msg = rejected_err.to_string();
assert!(msg.contains("Symbolic link rejected"));
assert!(msg.contains("entry 5"));
assert!(msg.contains("malicious_link"));
let escape_err = Error::SymlinkTargetEscape {
entry_index: 7,
path: "sneaky_link".into(),
target: "../../../etc/passwd".into(),
};
assert!(escape_err.is_security_error());
assert_eq!(escape_err.entry_index(), Some(7));
assert_eq!(escape_err.entry_name(), Some("sneaky_link"));
let msg = escape_err.to_string();
assert!(msg.contains("Symbolic link target escapes"));
assert!(msg.contains("entry 7"));
assert!(msg.contains("sneaky_link"));
assert!(msg.contains("../../../etc/passwd"));
}
#[test]
fn test_symlink_absolute_path_error() {
let err = Error::SymlinkTargetEscape {
entry_index: 0,
path: "link_to_passwd".into(),
target: "/etc/passwd".into(),
};
assert!(err.is_security_error());
let msg = err.to_string();
assert!(msg.contains("/etc/passwd"));
}
#[test]
fn test_symlink_windows_absolute_path_error() {
let err = Error::SymlinkTargetEscape {
entry_index: 0,
path: "link_to_system".into(),
target: "C:\\Windows\\System32".into(),
};
assert!(err.is_security_error());
let msg = err.to_string();
assert!(msg.contains("C:\\Windows\\System32"));
}
#[test]
fn test_symlink_errors_not_recoverable() {
let rejected = Error::SymlinkRejected {
entry_index: 0,
path: "link".into(),
};
assert!(!rejected.is_recoverable());
let escape = Error::SymlinkTargetEscape {
entry_index: 0,
path: "link".into(),
target: "../escape".into(),
};
assert!(!escape.is_recoverable());
}
#[test]
fn test_password_detection_method_display() {
assert!(
PasswordDetectionMethod::EarlyHeaderValidation
.to_string()
.contains("header")
);
assert!(
PasswordDetectionMethod::CrcMismatch
.to_string()
.contains("CRC")
);
assert!(
PasswordDetectionMethod::DecompressionFailure
.to_string()
.contains("decompression")
);
}
#[test]
fn test_wrong_password_error_has_full_context() {
let err = Error::WrongPassword {
entry_index: Some(5),
entry_name: Some("secret.txt".into()),
detection_method: PasswordDetectionMethod::CrcMismatch,
};
assert!(err.is_encryption_error());
assert!(err.is_recoverable());
assert!(!err.is_security_error());
assert_eq!(err.entry_index(), Some(5));
assert_eq!(err.entry_name(), Some("secret.txt"));
let msg = err.to_string();
assert!(msg.contains("Wrong password"));
assert!(msg.contains("entry 5"));
assert!(msg.contains("secret.txt"));
}
#[test]
fn test_password_required_error() {
let err = Error::PasswordRequired;
assert!(err.is_encryption_error());
assert!(err.is_recoverable());
assert!(!err.is_security_error());
let msg = err.to_string();
assert!(msg.contains("password required"));
assert!(msg.contains("encrypted"));
}
}