Skip to main content

exarch_core/security/
boundary.rs

1//! Raw path string and config entry validation shared by FFI bindings.
2//!
3//! Both `exarch-python` and `exarch-node` accept archive/output paths as
4//! plain strings from their host language before any `Path`, `DestDir`, or
5//! `SecurityConfig` exists to run them through [`super::path::validate_path`].
6//! They also accept caller-supplied `SecurityConfig` entries — allowed
7//! extensions, banned path components — as plain strings before
8//! [`crate::config::SecurityConfig::validate`] exists to check them. This
9//! module centralizes both pre-flight checks so the two bindings cannot
10//! drift on what counts as a well-formed input string.
11
12use crate::error::ArchiveError;
13use crate::error::Result;
14
15/// Maximum length, in bytes, of a raw path string accepted at the FFI
16/// boundary.
17///
18/// Linux and macOS define `PATH_MAX` as 4096 bytes. Bindings enforce the
19/// same ceiling on every platform so behavior does not vary by OS and
20/// pathological inputs are rejected before reaching the archive pipeline.
21pub const MAX_PATH_LENGTH: usize = 4096;
22
23/// Validates a raw, caller-supplied path string before it enters the
24/// archive pipeline.
25///
26/// This is the shared boundary check for `exarch-python` and `exarch-node`:
27/// reject overlong strings and null bytes before doing anything else with
28/// them. It intentionally does not perform traversal, symlink, or
29/// destination-boundary checks — those require a `Path`, a `DestDir`, and a
30/// `SecurityConfig`, and are handled later by
31/// [`super::path::validate_path`].
32///
33/// # Check order
34///
35/// Length is checked before scanning for a null byte: `.len()` is an O(1)
36/// lookup, so an oversized input is rejected without ever running the
37/// O(n) null-byte scan below it — this serves the length check's own
38/// `DoS`-prevention purpose.
39///
40/// # Null-byte detection
41///
42/// Uses [`str::contains`], which short-circuits on the first null byte.
43/// This check validates the *format* of caller-supplied input (is this a
44/// well-formed path string?) rather than comparing one secret against
45/// another, so there is no timing side channel to defend against and
46/// short-circuiting is safe.
47///
48/// # Errors
49///
50/// Returns [`ArchiveError::SecurityViolation`] if `path` contains a null
51/// byte or exceeds [`MAX_PATH_LENGTH`] bytes.
52///
53/// # Examples
54///
55/// ```
56/// use exarch_core::validate_raw_path_str;
57///
58/// assert!(validate_raw_path_str("archive.tar.gz").is_ok());
59/// assert!(validate_raw_path_str("bad\0path").is_err());
60/// assert!(validate_raw_path_str(&"x".repeat(5000)).is_err());
61/// ```
62pub fn validate_raw_path_str(path: &str) -> Result<()> {
63    if path.len() > MAX_PATH_LENGTH {
64        return Err(ArchiveError::SecurityViolation {
65            reason: format!(
66                "path exceeds maximum length of {MAX_PATH_LENGTH} bytes (got {} bytes)",
67                path.len()
68            ),
69        });
70    }
71
72    if path.contains('\0') {
73        return Err(ArchiveError::SecurityViolation {
74            reason: "path contains null bytes - potential security issue".to_string(),
75        });
76    }
77
78    Ok(())
79}
80
81/// Maximum length, in bytes, of a single `SecurityConfig` entry — an
82/// allowed extension or a banned path component.
83pub const MAX_CONFIG_ENTRY_LENGTH: usize = 255;
84
85/// Validates a single caller-supplied `SecurityConfig` entry string.
86///
87/// This is the shared boundary check for `exarch-python` and `exarch-node`
88/// entry points that build `allowed_extensions` and `banned_path_components`
89/// (`add_allowed_extension`, `add_banned_component`, and their `with_*`
90/// equivalents), and is also called per-entry from
91/// [`crate::config::SecurityConfig::validate`] as the backstop every entry
92/// point funnels through. `field` names the caller's field for the error
93/// message (e.g. `"extension"`, `"banned path component"`).
94///
95/// # Check order
96///
97/// 1. Length, checked before scanning for a null byte, for the same
98///    O(1)-before-O(n) reason as [`validate_raw_path_str`].
99/// 2. Null byte.
100/// 3. Emptiness — unlike [`validate_raw_path_str`], which accepts `""`, an
101///    empty entry is rejected here: in `banned_path_components` it is inert,
102///    and in `allowed_extensions` it silently flips the config from "allow all"
103///    to an allowlist that matches nothing.
104///
105/// # Errors
106///
107/// Returns [`ArchiveError::InvalidConfiguration`] if `value` exceeds
108/// [`MAX_CONFIG_ENTRY_LENGTH`] bytes, contains a null byte, or is empty.
109///
110/// # Examples
111///
112/// ```
113/// use exarch_core::validate_config_entry;
114///
115/// assert!(validate_config_entry("txt", "extension").is_ok());
116/// assert!(validate_config_entry("", "extension").is_err());
117/// assert!(validate_config_entry("bad\0ext", "extension").is_err());
118/// assert!(validate_config_entry(&"x".repeat(256), "extension").is_err());
119/// ```
120pub fn validate_config_entry(value: &str, field: &str) -> Result<()> {
121    if value.len() > MAX_CONFIG_ENTRY_LENGTH {
122        return Err(ArchiveError::InvalidConfiguration {
123            reason: format!(
124                "{field} exceeds maximum length of {MAX_CONFIG_ENTRY_LENGTH} bytes (got {} bytes)",
125                value.len()
126            ),
127        });
128    }
129
130    if value.contains('\0') {
131        return Err(ArchiveError::InvalidConfiguration {
132            reason: format!("{field} contains null bytes - potential security issue"),
133        });
134    }
135
136    if value.is_empty() {
137        return Err(ArchiveError::InvalidConfiguration {
138            reason: format!("{field} must not be empty"),
139        });
140    }
141
142    Ok(())
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used, clippy::expect_used)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_validate_raw_path_str_accepts_normal() {
152        assert!(validate_raw_path_str("/tmp/test.tar.gz").is_ok());
153        assert!(validate_raw_path_str("relative/path.tar").is_ok());
154        assert!(validate_raw_path_str("").is_ok());
155    }
156
157    #[test]
158    fn test_validate_raw_path_str_rejects_null_bytes() {
159        let err = validate_raw_path_str("/tmp/test\0malicious").expect_err("null byte");
160        match err {
161            ArchiveError::SecurityViolation { reason } => {
162                assert_eq!(
163                    reason,
164                    "path contains null bytes - potential security issue"
165                );
166            }
167            other => panic!("expected SecurityViolation, got: {other:?}"),
168        }
169    }
170
171    #[test]
172    fn test_validate_raw_path_str_rejects_too_long() {
173        let long_path = "x".repeat(MAX_PATH_LENGTH + 1);
174        let err = validate_raw_path_str(&long_path).expect_err("too long");
175        match err {
176            ArchiveError::SecurityViolation { reason } => {
177                assert_eq!(
178                    reason,
179                    format!(
180                        "path exceeds maximum length of {MAX_PATH_LENGTH} bytes (got {} bytes)",
181                        long_path.len()
182                    )
183                );
184            }
185            other => panic!("expected SecurityViolation, got: {other:?}"),
186        }
187    }
188
189    #[test]
190    fn test_validate_raw_path_str_checks_length_before_null_byte() {
191        let long_path_with_null = format!("{}\0", "x".repeat(MAX_PATH_LENGTH));
192        let err = validate_raw_path_str(&long_path_with_null).expect_err("too long");
193        match err {
194            ArchiveError::SecurityViolation { reason } => {
195                assert!(
196                    reason.contains("maximum length"),
197                    "length check should run first: {reason}"
198                );
199            }
200            other => panic!("expected SecurityViolation, got: {other:?}"),
201        }
202    }
203
204    #[test]
205    fn test_validate_raw_path_str_accepts_max_length() {
206        let max_path = "x".repeat(MAX_PATH_LENGTH);
207        assert!(validate_raw_path_str(&max_path).is_ok());
208    }
209
210    #[test]
211    fn test_validate_config_entry_accepts_normal() {
212        assert!(validate_config_entry("txt", "extension").is_ok());
213        assert!(validate_config_entry(".git", "banned path component").is_ok());
214    }
215
216    #[test]
217    fn test_validate_config_entry_rejects_empty() {
218        let err = validate_config_entry("", "extension").expect_err("empty");
219        match err {
220            ArchiveError::InvalidConfiguration { reason } => {
221                assert_eq!(reason, "extension must not be empty");
222            }
223            other => panic!("expected InvalidConfiguration, got: {other:?}"),
224        }
225    }
226
227    #[test]
228    fn test_validate_config_entry_rejects_null_bytes() {
229        let err = validate_config_entry("bad\0ext", "extension").expect_err("null byte");
230        match err {
231            ArchiveError::InvalidConfiguration { reason } => {
232                assert_eq!(
233                    reason,
234                    "extension contains null bytes - potential security issue"
235                );
236            }
237            other => panic!("expected InvalidConfiguration, got: {other:?}"),
238        }
239    }
240
241    #[test]
242    fn test_validate_config_entry_accepts_max_length() {
243        let max_entry = "x".repeat(MAX_CONFIG_ENTRY_LENGTH);
244        assert!(validate_config_entry(&max_entry, "extension").is_ok());
245    }
246
247    #[test]
248    fn test_validate_config_entry_rejects_too_long() {
249        let long_entry = "x".repeat(MAX_CONFIG_ENTRY_LENGTH + 1);
250        let err = validate_config_entry(&long_entry, "extension").expect_err("too long");
251        match err {
252            ArchiveError::InvalidConfiguration { reason } => {
253                assert_eq!(
254                    reason,
255                    format!(
256                        "extension exceeds maximum length of {MAX_CONFIG_ENTRY_LENGTH} bytes (got {} bytes)",
257                        long_entry.len()
258                    )
259                );
260            }
261            other => panic!("expected InvalidConfiguration, got: {other:?}"),
262        }
263    }
264
265    #[test]
266    fn test_validate_config_entry_rejects_multibyte_over_length() {
267        // "日" is 3 bytes in UTF-8; 100 repeats is 300 bytes but only 100 chars.
268        let multibyte_entry = "日".repeat(100);
269        assert!(multibyte_entry.chars().count() < MAX_CONFIG_ENTRY_LENGTH);
270        let err =
271            validate_config_entry(&multibyte_entry, "extension").expect_err("too long in bytes");
272        match err {
273            ArchiveError::InvalidConfiguration { reason } => {
274                assert!(
275                    reason.contains("bytes"),
276                    "message must use byte-length wording: {reason}"
277                );
278            }
279            other => panic!("expected InvalidConfiguration, got: {other:?}"),
280        }
281    }
282
283    #[test]
284    fn test_validate_config_entry_checks_length_before_null_byte() {
285        let long_entry_with_null = format!("{}\0", "x".repeat(MAX_CONFIG_ENTRY_LENGTH));
286        let err = validate_config_entry(&long_entry_with_null, "extension").expect_err("too long");
287        match err {
288            ArchiveError::InvalidConfiguration { reason } => {
289                assert!(
290                    reason.contains("maximum length"),
291                    "length check should run first: {reason}"
292                );
293            }
294            other => panic!("expected InvalidConfiguration, got: {other:?}"),
295        }
296    }
297}