use super::redaction::sanitize_io_error_for_error;
use super::types::ArchiveError;
use super::types::PolicySetting;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FfiErrorMessage {
pub code: &'static str,
pub description: String,
pub context: Option<String>,
pub relaxable_by: Option<PolicySetting>,
}
impl FfiErrorMessage {
const fn plain(code: &'static str, description: String) -> Self {
Self {
code,
description,
context: None,
relaxable_by: None,
}
}
}
impl ArchiveError {
#[must_use]
pub fn to_ffi_message(&self) -> FfiErrorMessage {
let path = self
.redacted_path()
.unwrap_or_else(|| "<unknown>".to_string());
match self {
Self::PathTraversal { .. } => {
FfiErrorMessage::plain("PATH_TRAVERSAL", format!("path traversal detected: {path}"))
}
Self::SymlinkEscape { .. } => FfiErrorMessage::plain(
"SYMLINK_ESCAPE",
format!("symlink target outside extraction directory: {path}"),
),
Self::HardlinkEscape { .. } => FfiErrorMessage::plain(
"HARDLINK_ESCAPE",
format!("hardlink target outside extraction directory: {path}"),
),
Self::ZipBomb(details) => FfiErrorMessage {
code: "ZIP_BOMB",
description: self.to_string(),
context: Some(format!(
"{} compression ratio: {}",
details.scope(),
details.ratio()
)),
relaxable_by: None,
},
Self::ZipInconsistency(_) => {
FfiErrorMessage::plain("ZIP_INCONSISTENCY", self.to_string())
}
Self::QuotaExceeded { resource } => {
FfiErrorMessage::plain("QUOTA_EXCEEDED", resource.to_string())
}
Self::SecurityViolation {
reason,
relaxable_by,
..
} => FfiErrorMessage {
code: "SECURITY_VIOLATION",
description: format!("operation denied by security policy: {reason}"),
context: None,
relaxable_by: *relaxable_by,
},
Self::InvalidArchive(reason) => {
FfiErrorMessage::plain("INVALID_ARCHIVE", format!("invalid archive: {reason}"))
}
Self::Io(io_err) => FfiErrorMessage {
code: "IO_ERROR",
description: sanitize_io_error_for_error(io_err),
context: Some(io_err.kind().to_string()),
relaxable_by: None,
},
Self::InvalidPermissions { mode, .. } => FfiErrorMessage::plain(
"INVALID_PERMISSIONS",
format!("invalid permissions for {path}: {mode:#o}"),
),
Self::SourceNotFound { .. } => {
FfiErrorMessage::plain("SOURCE_NOT_FOUND", format!("source path not found: {path}"))
}
Self::SourceNotAccessible { .. } => FfiErrorMessage::plain(
"SOURCE_NOT_ACCESSIBLE",
format!("source path is not accessible: {path}"),
),
Self::OutputExists { .. } => FfiErrorMessage::plain(
"OUTPUT_EXISTS",
format!("output file already exists: {path}"),
),
Self::InvalidCompressionLevel { level } => FfiErrorMessage::plain(
"INVALID_COMPRESSION_LEVEL",
format!("invalid compression level {level}, must be 1-9"),
),
Self::UnknownFormat { .. } => FfiErrorMessage::plain(
"UNKNOWN_FORMAT",
format!("cannot determine archive format from: {path}"),
),
Self::InvalidConfiguration { reason } => FfiErrorMessage::plain(
"INVALID_CONFIGURATION",
format!("invalid configuration: {reason}"),
),
Self::PartialExtraction { source, .. } => source.to_ffi_message(),
}
}
#[must_use]
pub fn error_code(&self) -> &'static str {
match self {
Self::PathTraversal { .. } => "PATH_TRAVERSAL",
Self::SymlinkEscape { .. } => "SYMLINK_ESCAPE",
Self::HardlinkEscape { .. } => "HARDLINK_ESCAPE",
Self::ZipBomb(_) => "ZIP_BOMB",
Self::ZipInconsistency(_) => "ZIP_INCONSISTENCY",
Self::QuotaExceeded { .. } => "QUOTA_EXCEEDED",
Self::SecurityViolation { .. } => "SECURITY_VIOLATION",
Self::InvalidArchive(_) => "INVALID_ARCHIVE",
Self::Io(_) => "IO_ERROR",
Self::InvalidPermissions { .. } => "INVALID_PERMISSIONS",
Self::SourceNotFound { .. } => "SOURCE_NOT_FOUND",
Self::SourceNotAccessible { .. } => "SOURCE_NOT_ACCESSIBLE",
Self::OutputExists { .. } => "OUTPUT_EXISTS",
Self::InvalidCompressionLevel { .. } => "INVALID_COMPRESSION_LEVEL",
Self::UnknownFormat { .. } => "UNKNOWN_FORMAT",
Self::InvalidConfiguration { .. } => "INVALID_CONFIGURATION",
Self::PartialExtraction { source, .. } => source.error_code(),
}
}
}
#[cfg(test)]
mod tests {
use super::super::types::ZipBombDetails;
use super::super::types::ZipBombScope;
use super::*;
use std::path::PathBuf;
#[test]
fn test_attacker_path_never_redacted() {
let error = ArchiveError::PathTraversal {
path: PathBuf::from("../../etc/passwd"),
};
let msg = error.to_ffi_message();
assert!(msg.description.contains("../../etc/passwd"));
}
#[test]
#[cfg(not(debug_assertions))]
fn test_host_path_redacted_in_release() {
let error = ArchiveError::SourceNotFound {
path: PathBuf::from("/srv/secret/app/x.txt"),
};
let msg = error.to_ffi_message();
assert!(msg.description.contains("x.txt"));
assert!(!msg.description.contains("/srv/secret"));
}
#[test]
#[cfg(not(debug_assertions))]
fn test_io_error_redacted_in_release() {
let error = ArchiveError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"directory is not writable: /srv/secret/app/private-output",
));
let msg = error.to_ffi_message();
assert!(!msg.description.contains("/srv/secret"));
}
#[test]
fn test_error_codes_match() {
let test_cases = vec![
(
ArchiveError::PathTraversal {
path: PathBuf::from("test"),
},
"PATH_TRAVERSAL",
),
(
ArchiveError::SymlinkEscape {
path: PathBuf::from("test"),
},
"SYMLINK_ESCAPE",
),
(
ArchiveError::ZipBomb(ZipBombDetails::new(ZipBombScope::Entry, 100, 10000)),
"ZIP_BOMB",
),
];
for (error, expected_code) in test_cases {
assert_eq!(error.error_code(), expected_code);
assert_eq!(error.to_ffi_message().code, expected_code);
}
}
#[test]
fn test_all_error_variants_have_codes() {
use super::super::types::QuotaResource;
let errors = vec![
ArchiveError::PathTraversal {
path: PathBuf::from("test"),
},
ArchiveError::SymlinkEscape {
path: PathBuf::from("test"),
},
ArchiveError::HardlinkEscape {
path: PathBuf::from("test"),
},
ArchiveError::ZipBomb(ZipBombDetails::new(ZipBombScope::Entry, 100, 10000)),
ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow,
},
ArchiveError::violation("test".into()),
ArchiveError::UnknownFormat {
path: PathBuf::from("test.rar"),
},
ArchiveError::InvalidArchive("test".into()),
ArchiveError::Io(std::io::Error::other("test")),
ArchiveError::InvalidPermissions {
path: PathBuf::from("test"),
mode: 0o777,
},
ArchiveError::SourceNotFound {
path: PathBuf::from("test"),
},
ArchiveError::SourceNotAccessible {
path: PathBuf::from("test"),
},
ArchiveError::OutputExists {
path: PathBuf::from("test"),
},
ArchiveError::InvalidCompressionLevel { level: 10 },
ArchiveError::UnknownFormat {
path: PathBuf::from("test"),
},
ArchiveError::InvalidConfiguration {
reason: "test".into(),
},
];
for error in errors {
let code = error.error_code();
assert!(!code.is_empty(), "Error code should not be empty");
let msg = error.to_ffi_message();
assert_eq!(msg.code, code);
assert!(!msg.description.is_empty());
}
}
}