exarch_core/error/redaction.rs
1//! Shared path and I/O-error redaction algorithms for FFI-facing error
2//! messages.
3//!
4//! `exarch-python` and `exarch-node` both render [`ArchiveError`] into
5//! host-language exceptions and must decide, per error variant, how much
6//! path information to expose. Getting this wrong in either direction is a
7//! problem: leaking a host filesystem path can disclose internal directory
8//! structure to a user reading a release-build error message (see #453),
9//! while over-redacting an archive-relative path that the *attacker*
10//! already authored (e.g. a `PathTraversal` entry) destroys the defender's
11//! ability to identify which archive entry triggered the violation without
12//! actually hiding anything (see #462).
13//!
14//! This module owns the two redaction algorithms — [`sanitize_path_for_error`]
15//! for genuinely host-derived paths and [`format_entry_path_for_error`] for
16//! archive-relative, attacker-authored paths — plus
17//! [`sanitize_io_error_for_error`] for I/O error messages (see #463, #464).
18//! Both bindings call these algorithms directly, per variant, in their own
19//! `convert_error`; [`ArchiveError::to_ffi_message`] uses them via
20//! [`ArchiveError::redacted_path`]. The mapping from variant to algorithm is
21//! therefore still applied independently in three places — here (via
22//! `redacted_path`), in `exarch-python::convert_error`, and in
23//! `exarch-node::convert_error` — only the two algorithms themselves are
24//! single-sourced.
25
26use std::path::Path;
27
28use super::types::ArchiveError;
29
30/// Formats a host-derived filesystem path for inclusion in an FFI error
31/// message.
32///
33/// In debug builds, returns the full path for detailed diagnostics. In
34/// release builds, returns only the filename to avoid leaking internal
35/// directory structures to potential attackers.
36///
37/// Use this for variants whose path was derived from the host filesystem
38/// (e.g. [`ArchiveError::SourceNotFound`]), not for archive-relative,
39/// attacker-authored paths — see [`format_entry_path_for_error`] for those.
40///
41/// # Examples
42///
43/// ```
44/// use exarch_core::sanitize_path_for_error;
45/// use std::path::Path;
46///
47/// // Debug builds keep the full path; release builds keep only the
48/// // filename — either way the filename itself is always present.
49/// let redacted = sanitize_path_for_error(Path::new("/srv/secret/app/x.txt"));
50/// assert!(redacted.ends_with("x.txt"));
51/// ```
52#[cfg(debug_assertions)]
53#[must_use]
54pub fn sanitize_path_for_error(path: &Path) -> String {
55 path.display().to_string()
56}
57
58/// Formats a host-derived filesystem path for inclusion in an FFI error
59/// message.
60///
61/// In debug builds, returns the full path for detailed diagnostics. In
62/// release builds, returns only the filename to avoid leaking internal
63/// directory structures to potential attackers.
64///
65/// Use this for variants whose path was derived from the host filesystem
66/// (e.g. [`ArchiveError::SourceNotFound`]), not for archive-relative,
67/// attacker-authored paths — see [`format_entry_path_for_error`] for those.
68///
69/// # Examples
70///
71/// ```
72/// use exarch_core::sanitize_path_for_error;
73/// use std::path::Path;
74///
75/// // Debug builds keep the full path; release builds keep only the
76/// // filename — either way the filename itself is always present.
77/// let redacted = sanitize_path_for_error(Path::new("/srv/secret/app/x.txt"));
78/// assert!(redacted.ends_with("x.txt"));
79/// ```
80#[cfg(not(debug_assertions))]
81#[must_use]
82pub fn sanitize_path_for_error(path: &Path) -> String {
83 path.file_name().map_or_else(
84 || "<unknown>".to_string(),
85 |n| n.to_string_lossy().into_owned(),
86 )
87}
88
89/// Formats an archive-relative, attacker-authored path for inclusion in an
90/// FFI error message.
91///
92/// Unlike [`sanitize_path_for_error`], this never redacts: it always
93/// returns the full path, in both debug and release builds. It exists for
94/// variants like [`ArchiveError::PathTraversal`],
95/// [`ArchiveError::SymlinkEscape`], and [`ArchiveError::HardlinkEscape`], whose
96/// `path` is an entry path the attacker crafted inside the archive, not a host
97/// filesystem path — see #462. The attacker already knows the path they
98/// authored, so redacting it discloses nothing to them while destroying the
99/// defender's ability to identify the offending entry in a redacted
100/// release-build log.
101///
102/// # Examples
103///
104/// ```
105/// use exarch_core::format_entry_path_for_error;
106/// use std::path::Path;
107///
108/// let path = Path::new("../../etc/passwd");
109/// assert_eq!(
110/// format_entry_path_for_error(path),
111/// path.display().to_string()
112/// );
113/// ```
114#[must_use]
115pub fn format_entry_path_for_error(path: &Path) -> String {
116 path.display().to_string()
117}
118
119/// Sanitizes I/O error messages for FFI error reporting.
120///
121/// In debug builds, returns the full `Display` output (which may embed a
122/// host path, e.g. from `DestDir`'s validation messages). In release
123/// builds, returns only the [`std::io::ErrorKind`] description, since the
124/// free-form message text has no structured path field to redact.
125///
126/// Exception: errors carrying an [`IoContext`](super::IoContext) — used at
127/// [`std::io::Error::other`] call sites whose [`std::io::ErrorKind::Other`]
128/// description would otherwise redact to the uninformative "other error"
129/// (see #464) — surface [`IoContext::context`](super::IoContext::context) in
130/// release builds instead. That is safe because `context` is always a
131/// `&'static str` fixed at the call site, never built from path or archive
132/// entry data.
133///
134/// # Examples
135///
136/// ```
137/// use exarch_core::sanitize_io_error_for_error;
138///
139/// let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
140/// let msg = sanitize_io_error_for_error(&err);
141/// assert!(!msg.is_empty());
142/// ```
143#[cfg(debug_assertions)]
144#[must_use]
145pub fn sanitize_io_error_for_error(e: &std::io::Error) -> String {
146 e.to_string()
147}
148
149/// Sanitizes I/O error messages for FFI error reporting.
150///
151/// In debug builds, returns the full `Display` output (which may embed a
152/// host path, e.g. from `DestDir`'s validation messages). In release
153/// builds, returns only the [`std::io::ErrorKind`] description, since the
154/// free-form message text has no structured path field to redact.
155///
156/// Exception: errors carrying an [`IoContext`](super::IoContext) — used at
157/// [`std::io::Error::other`] call sites whose [`std::io::ErrorKind::Other`]
158/// description would otherwise redact to the uninformative "other error"
159/// (see #464) — surface [`IoContext::context`](super::IoContext::context) in
160/// release builds instead. That is safe because `context` is always a
161/// `&'static str` fixed at the call site, never built from path or archive
162/// entry data.
163///
164/// # Examples
165///
166/// ```
167/// use exarch_core::sanitize_io_error_for_error;
168///
169/// let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
170/// let msg = sanitize_io_error_for_error(&err);
171/// assert!(!msg.is_empty());
172/// ```
173#[cfg(not(debug_assertions))]
174#[must_use]
175pub fn sanitize_io_error_for_error(e: &std::io::Error) -> String {
176 e.get_ref()
177 .and_then(|inner| inner.downcast_ref::<super::IoContext>())
178 .map_or_else(|| e.kind().to_string(), |ctx| ctx.context.to_string())
179}
180
181impl ArchiveError {
182 /// Formats this error's path field for an FFI-facing error message,
183 /// applying the shared redaction policy, or returns `None` if this
184 /// variant carries no path.
185 ///
186 /// Maps each [`ArchiveError`] variant to the correct redaction
187 /// algorithm — [`format_entry_path_for_error`] (never redacted) for
188 /// archive-relative, attacker-authored paths, or
189 /// [`sanitize_path_for_error`] (redacted to filename-only in release
190 /// builds) for genuinely host-derived paths (see #462). Used by
191 /// [`Self::to_ffi_message`]. `exarch-python` and `exarch-node` apply
192 /// this same variant-to-algorithm mapping independently in their own
193 /// `convert_error` — calling [`format_entry_path_for_error`] and
194 /// [`sanitize_path_for_error`] directly, per match arm, rather than
195 /// through this method — so the mapping itself is duplicated across
196 /// core, python, and node; only the two algorithms are single-sourced
197 /// (see #463). `test_never_redacted_variants_keep_full_path` and
198 /// `test_host_path_variants_follow_profile_policy` below guard this
199 /// method's mapping; the bindings' own tests guard theirs.
200 ///
201 /// [`Self::InvalidPermissions`] carries an archive entry path (see
202 /// `check_permissions` in `inspection::verify`), not a host path, so it
203 /// is grouped with the never-redacted variants for the same reason as
204 /// `PathTraversal`/`SymlinkEscape`/`HardlinkEscape`.
205 ///
206 /// # Examples
207 ///
208 /// ```
209 /// use exarch_core::ArchiveError;
210 /// use std::path::PathBuf;
211 ///
212 /// let err = ArchiveError::PathTraversal {
213 /// path: PathBuf::from("../../etc/passwd"),
214 /// };
215 /// assert_eq!(err.redacted_path().as_deref(), Some("../../etc/passwd"));
216 ///
217 /// let err = ArchiveError::InvalidCompressionLevel { level: 0 };
218 /// assert_eq!(err.redacted_path(), None);
219 /// ```
220 #[must_use]
221 pub fn redacted_path(&self) -> Option<String> {
222 match self {
223 Self::PathTraversal { path }
224 | Self::SymlinkEscape { path }
225 | Self::HardlinkEscape { path }
226 | Self::InvalidPermissions { path, .. } => Some(format_entry_path_for_error(path)),
227
228 Self::SourceNotFound { path }
229 | Self::SourceNotAccessible { path }
230 | Self::OutputExists { path }
231 | Self::UnknownFormat { path } => Some(sanitize_path_for_error(path)),
232
233 Self::PartialExtraction { source, .. } => source.redacted_path(),
234
235 Self::Io(_)
236 | Self::InvalidArchive(_)
237 | Self::ZipBomb { .. }
238 | Self::QuotaExceeded { .. }
239 | Self::SecurityViolation { .. }
240 | Self::InvalidCompressionLevel { .. }
241 | Self::InvalidConfiguration { .. } => None,
242 }
243 }
244}
245
246#[cfg(test)]
247#[allow(clippy::unwrap_used, clippy::expect_used)]
248mod tests {
249 use super::*;
250 use crate::ExtractionReport;
251 use std::path::PathBuf;
252
253 #[test]
254 #[cfg(debug_assertions)]
255 fn test_sanitize_path_for_error_keeps_full_path_in_debug() {
256 let path = PathBuf::from("/srv/secret/app/x.txt");
257 assert_eq!(sanitize_path_for_error(&path), "/srv/secret/app/x.txt");
258 }
259
260 #[test]
261 #[cfg(not(debug_assertions))]
262 fn test_sanitize_path_for_error_strips_directory_in_release() {
263 let path = PathBuf::from("/srv/secret/app/x.txt");
264 assert_eq!(sanitize_path_for_error(&path), "x.txt");
265 }
266
267 #[test]
268 fn test_format_entry_path_for_error_never_redacts() {
269 let path = PathBuf::from("../../etc/passwd");
270 assert_eq!(format_entry_path_for_error(&path), "../../etc/passwd");
271 }
272
273 #[test]
274 #[cfg(debug_assertions)]
275 fn test_sanitize_io_error_for_error_keeps_message_in_debug() {
276 let err = std::io::Error::new(
277 std::io::ErrorKind::PermissionDenied,
278 "directory is not writable: /srv/secret/app/private-output",
279 );
280 assert_eq!(
281 sanitize_io_error_for_error(&err),
282 "directory is not writable: /srv/secret/app/private-output"
283 );
284 }
285
286 #[test]
287 #[cfg(not(debug_assertions))]
288 fn test_sanitize_io_error_for_error_redacts_message_in_release() {
289 let err = std::io::Error::new(
290 std::io::ErrorKind::PermissionDenied,
291 "directory is not writable: /srv/secret/app/private-output",
292 );
293 let msg = sanitize_io_error_for_error(&err);
294 assert!(!msg.contains("/srv/secret/app"));
295 assert!(msg.contains("permission denied"));
296 }
297
298 /// Regression test for #464: `ErrorKind::Other` would otherwise redact to
299 /// the uninformative "other error", so an `IoContext` payload surfaces its
300 /// static `context` instead — without leaking the dynamic detail.
301 #[test]
302 #[cfg(not(debug_assertions))]
303 fn test_sanitize_io_error_for_error_surfaces_io_context_in_release() {
304 let err = std::io::Error::other(crate::IoContext::new(
305 "failed to read entry metadata",
306 "/srv/secret/app/x.txt: permission denied",
307 ));
308 let msg = sanitize_io_error_for_error(&err);
309 assert_eq!(msg, "failed to read entry metadata");
310 }
311
312 #[test]
313 #[cfg(debug_assertions)]
314 fn test_sanitize_io_error_for_error_keeps_io_context_detail_in_debug() {
315 let err = std::io::Error::other(crate::IoContext::new(
316 "failed to read entry metadata",
317 "/srv/secret/app/x.txt: permission denied",
318 ));
319 assert_eq!(
320 sanitize_io_error_for_error(&err),
321 "failed to read entry metadata: /srv/secret/app/x.txt: permission denied"
322 );
323 }
324
325 /// Regression test for #462: every `ArchiveError` variant carrying an
326 /// archive-relative, attacker-authored path must keep the full path in
327 /// `redacted_path`, in both debug and release builds.
328 #[test]
329 fn test_never_redacted_variants_keep_full_path() {
330 let attacker_path = PathBuf::from("../../etc/passwd");
331 let never_redacted = [
332 ArchiveError::PathTraversal {
333 path: attacker_path.clone(),
334 },
335 ArchiveError::SymlinkEscape {
336 path: attacker_path.clone(),
337 },
338 ArchiveError::HardlinkEscape {
339 path: attacker_path.clone(),
340 },
341 ArchiveError::InvalidPermissions {
342 path: attacker_path,
343 mode: 0o777,
344 },
345 ];
346
347 for err in never_redacted {
348 assert_eq!(
349 err.redacted_path().as_deref(),
350 Some("../../etc/passwd"),
351 "expected full path to survive redaction for {err:?}"
352 );
353 }
354 }
355
356 /// Regression test for #453/#462: every `ArchiveError` variant carrying
357 /// a genuinely host-derived path is redacted to filename-only in
358 /// release builds (and kept in full in debug builds).
359 #[test]
360 fn test_host_path_variants_follow_profile_policy() {
361 let host_path = PathBuf::from("/srv/secret/app/x.txt");
362 let host_derived = [
363 ArchiveError::SourceNotFound {
364 path: host_path.clone(),
365 },
366 ArchiveError::SourceNotAccessible {
367 path: host_path.clone(),
368 },
369 ArchiveError::OutputExists {
370 path: host_path.clone(),
371 },
372 ArchiveError::UnknownFormat { path: host_path },
373 ];
374
375 for err in host_derived {
376 let redacted = err.redacted_path().expect("variant carries a path");
377 assert!(
378 redacted.ends_with("x.txt"),
379 "expected filename to survive redaction for {err:?}, got {redacted:?}"
380 );
381 #[cfg(not(debug_assertions))]
382 assert!(
383 !redacted.contains("/srv/secret"),
384 "expected host directory to be redacted for {err:?}, got {redacted:?}"
385 );
386 }
387 }
388
389 #[test]
390 fn test_variants_without_path_return_none() {
391 let no_path = [
392 ArchiveError::ZipBomb {
393 compressed: 100,
394 uncompressed: 100_000,
395 ratio: 1000.0,
396 },
397 ArchiveError::InvalidArchive("bad header".to_string()),
398 ArchiveError::SecurityViolation {
399 reason: "test".to_string(),
400 },
401 ArchiveError::InvalidCompressionLevel { level: 0 },
402 ArchiveError::InvalidConfiguration {
403 reason: "test".to_string(),
404 },
405 ArchiveError::Io(std::io::Error::other("test")),
406 ];
407
408 for err in no_path {
409 assert_eq!(err.redacted_path(), None, "expected no path for {err:?}");
410 }
411 }
412
413 /// Regression test for #251/#210: `PartialExtraction` must delegate to
414 /// its `source`'s redaction policy rather than exposing a path itself.
415 #[test]
416 fn test_partial_extraction_delegates_to_source() {
417 let err = ArchiveError::PartialExtraction {
418 source: Box::new(ArchiveError::PathTraversal {
419 path: PathBuf::from("../../etc/passwd"),
420 }),
421 report: ExtractionReport::new(),
422 };
423 assert_eq!(err.redacted_path().as_deref(), Some("../../etc/passwd"));
424 }
425}