use std::path::Path;
use super::types::ArchiveError;
#[cfg(debug_assertions)]
#[must_use]
pub fn sanitize_path_for_error(path: &Path) -> String {
path.display().to_string()
}
#[cfg(not(debug_assertions))]
#[must_use]
pub fn sanitize_path_for_error(path: &Path) -> String {
path.file_name().map_or_else(
|| "<unknown>".to_string(),
|n| n.to_string_lossy().into_owned(),
)
}
#[must_use]
pub fn format_entry_path_for_error(path: &Path) -> String {
path.display().to_string()
}
#[cfg(debug_assertions)]
#[must_use]
pub fn sanitize_io_error_for_error(e: &std::io::Error) -> String {
e.to_string()
}
#[cfg(not(debug_assertions))]
#[must_use]
pub fn sanitize_io_error_for_error(e: &std::io::Error) -> String {
e.get_ref()
.and_then(|inner| inner.downcast_ref::<super::IoContext>())
.map_or_else(|| e.kind().to_string(), |ctx| ctx.context.to_string())
}
impl ArchiveError {
#[must_use]
pub fn redacted_path(&self) -> Option<String> {
match self {
Self::PathTraversal { path }
| Self::SymlinkEscape { path }
| Self::HardlinkEscape { path }
| Self::InvalidPermissions { path, .. } => Some(format_entry_path_for_error(path)),
Self::SourceNotFound { path }
| Self::SourceNotAccessible { path }
| Self::OutputExists { path }
| Self::UnknownFormat { path } => Some(sanitize_path_for_error(path)),
Self::PartialExtraction { source, .. } => source.redacted_path(),
Self::Io(_)
| Self::InvalidArchive(_)
| Self::ZipBomb { .. }
| Self::QuotaExceeded { .. }
| Self::SecurityViolation { .. }
| Self::InvalidCompressionLevel { .. }
| Self::InvalidConfiguration { .. } => None,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::ExtractionReport;
use std::path::PathBuf;
#[test]
#[cfg(debug_assertions)]
fn test_sanitize_path_for_error_keeps_full_path_in_debug() {
let path = PathBuf::from("/srv/secret/app/x.txt");
assert_eq!(sanitize_path_for_error(&path), "/srv/secret/app/x.txt");
}
#[test]
#[cfg(not(debug_assertions))]
fn test_sanitize_path_for_error_strips_directory_in_release() {
let path = PathBuf::from("/srv/secret/app/x.txt");
assert_eq!(sanitize_path_for_error(&path), "x.txt");
}
#[test]
fn test_format_entry_path_for_error_never_redacts() {
let path = PathBuf::from("../../etc/passwd");
assert_eq!(format_entry_path_for_error(&path), "../../etc/passwd");
}
#[test]
#[cfg(debug_assertions)]
fn test_sanitize_io_error_for_error_keeps_message_in_debug() {
let err = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"directory is not writable: /srv/secret/app/private-output",
);
assert_eq!(
sanitize_io_error_for_error(&err),
"directory is not writable: /srv/secret/app/private-output"
);
}
#[test]
#[cfg(not(debug_assertions))]
fn test_sanitize_io_error_for_error_redacts_message_in_release() {
let err = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"directory is not writable: /srv/secret/app/private-output",
);
let msg = sanitize_io_error_for_error(&err);
assert!(!msg.contains("/srv/secret/app"));
assert!(msg.contains("permission denied"));
}
#[test]
#[cfg(not(debug_assertions))]
fn test_sanitize_io_error_for_error_surfaces_io_context_in_release() {
let err = std::io::Error::other(crate::IoContext::new(
"failed to read entry metadata",
"/srv/secret/app/x.txt: permission denied",
));
let msg = sanitize_io_error_for_error(&err);
assert_eq!(msg, "failed to read entry metadata");
}
#[test]
#[cfg(debug_assertions)]
fn test_sanitize_io_error_for_error_keeps_io_context_detail_in_debug() {
let err = std::io::Error::other(crate::IoContext::new(
"failed to read entry metadata",
"/srv/secret/app/x.txt: permission denied",
));
assert_eq!(
sanitize_io_error_for_error(&err),
"failed to read entry metadata: /srv/secret/app/x.txt: permission denied"
);
}
#[test]
fn test_never_redacted_variants_keep_full_path() {
let attacker_path = PathBuf::from("../../etc/passwd");
let never_redacted = [
ArchiveError::PathTraversal {
path: attacker_path.clone(),
},
ArchiveError::SymlinkEscape {
path: attacker_path.clone(),
},
ArchiveError::HardlinkEscape {
path: attacker_path.clone(),
},
ArchiveError::InvalidPermissions {
path: attacker_path,
mode: 0o777,
},
];
for err in never_redacted {
assert_eq!(
err.redacted_path().as_deref(),
Some("../../etc/passwd"),
"expected full path to survive redaction for {err:?}"
);
}
}
#[test]
fn test_host_path_variants_follow_profile_policy() {
let host_path = PathBuf::from("/srv/secret/app/x.txt");
let host_derived = [
ArchiveError::SourceNotFound {
path: host_path.clone(),
},
ArchiveError::SourceNotAccessible {
path: host_path.clone(),
},
ArchiveError::OutputExists {
path: host_path.clone(),
},
ArchiveError::UnknownFormat { path: host_path },
];
for err in host_derived {
let redacted = err.redacted_path().expect("variant carries a path");
assert!(
redacted.ends_with("x.txt"),
"expected filename to survive redaction for {err:?}, got {redacted:?}"
);
#[cfg(not(debug_assertions))]
assert!(
!redacted.contains("/srv/secret"),
"expected host directory to be redacted for {err:?}, got {redacted:?}"
);
}
}
#[test]
fn test_variants_without_path_return_none() {
let no_path = [
ArchiveError::ZipBomb {
compressed: 100,
uncompressed: 100_000,
ratio: 1000.0,
},
ArchiveError::InvalidArchive("bad header".to_string()),
ArchiveError::SecurityViolation {
reason: "test".to_string(),
},
ArchiveError::InvalidCompressionLevel { level: 0 },
ArchiveError::InvalidConfiguration {
reason: "test".to_string(),
},
ArchiveError::Io(std::io::Error::other("test")),
];
for err in no_path {
assert_eq!(err.redacted_path(), None, "expected no path for {err:?}");
}
}
#[test]
fn test_partial_extraction_delegates_to_source() {
let err = ArchiveError::PartialExtraction {
source: Box::new(ArchiveError::PathTraversal {
path: PathBuf::from("../../etc/passwd"),
}),
report: ExtractionReport::new(),
};
assert_eq!(err.redacted_path().as_deref(), Some("../../etc/passwd"));
}
}