Skip to main content

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}