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 `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/// `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::violation(format!(
65            "path exceeds maximum length of {MAX_PATH_LENGTH} bytes (got {} bytes)",
66            path.len()
67        )));
68    }
69
70    if path.contains('\0') {
71        return Err(ArchiveError::violation(
72            "path contains null bytes - potential security issue".to_string(),
73        ));
74    }
75
76    Ok(())
77}
78
79/// Maximum length, in bytes, of a single `SecurityConfig` entry — an
80/// allowed extension or a banned path component.
81pub const MAX_CONFIG_ENTRY_LENGTH: usize = 255;
82
83/// Validates a single caller-supplied `SecurityConfig` entry string.
84///
85/// This is the shared boundary check for `exarch-python` and `exarch-node`
86/// entry points that build `allowed_extensions` and `banned_path_components`
87/// (`add_allowed_extension`, `add_banned_component`, and their `with_*`
88/// equivalents), and is also called per-entry from
89/// [`crate::config::SecurityConfig::validate`] as the backstop every entry
90/// point funnels through. `field` names the caller's field for the error
91/// message (e.g. `"extension"`, `"banned path component"`).
92///
93/// # Check order
94///
95/// 1. Length, checked before scanning for a null byte, for the same
96///    O(1)-before-O(n) reason as [`validate_raw_path_str`].
97/// 2. Null byte.
98/// 3. Emptiness — unlike [`validate_raw_path_str`], which accepts `""`, an
99///    empty entry is rejected here: in `banned_path_components` it is inert,
100///    and in `allowed_extensions` it silently flips the config from "allow all"
101///    to an allowlist that matches nothing.
102///
103/// # Errors
104///
105/// Returns [`ArchiveError::InvalidConfiguration`] if `value` exceeds
106/// [`MAX_CONFIG_ENTRY_LENGTH`] bytes, contains a null byte, or is empty.
107///
108/// # Examples
109///
110/// ```
111/// use exarch_core::validate_config_entry;
112///
113/// assert!(validate_config_entry("txt", "extension").is_ok());
114/// assert!(validate_config_entry("", "extension").is_err());
115/// assert!(validate_config_entry("bad\0ext", "extension").is_err());
116/// assert!(validate_config_entry(&"x".repeat(256), "extension").is_err());
117/// ```
118pub fn validate_config_entry(value: &str, field: &str) -> Result<()> {
119    if value.len() > MAX_CONFIG_ENTRY_LENGTH {
120        return Err(ArchiveError::InvalidConfiguration {
121            reason: format!(
122                "{field} exceeds maximum length of {MAX_CONFIG_ENTRY_LENGTH} bytes (got {} bytes)",
123                value.len()
124            ),
125        });
126    }
127
128    if value.contains('\0') {
129        return Err(ArchiveError::InvalidConfiguration {
130            reason: format!("{field} contains null bytes - potential security issue"),
131        });
132    }
133
134    if value.is_empty() {
135        return Err(ArchiveError::InvalidConfiguration {
136            reason: format!("{field} must not be empty"),
137        });
138    }
139
140    Ok(())
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used, clippy::expect_used)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_validate_raw_path_str_accepts_normal() {
150        assert!(validate_raw_path_str("/tmp/test.tar.gz").is_ok());
151        assert!(validate_raw_path_str("relative/path.tar").is_ok());
152        assert!(validate_raw_path_str("").is_ok());
153    }
154
155    #[test]
156    fn test_validate_raw_path_str_rejects_null_bytes() {
157        let err = validate_raw_path_str("/tmp/test\0malicious").expect_err("null byte");
158        match err {
159            ArchiveError::SecurityViolation { reason, .. } => {
160                assert_eq!(
161                    reason,
162                    "path contains null bytes - potential security issue"
163                );
164            }
165            other => panic!("expected SecurityViolation, got: {other:?}"),
166        }
167    }
168
169    #[test]
170    fn test_validate_raw_path_str_rejects_too_long() {
171        let long_path = "x".repeat(MAX_PATH_LENGTH + 1);
172        let err = validate_raw_path_str(&long_path).expect_err("too long");
173        match err {
174            ArchiveError::SecurityViolation { reason, .. } => {
175                assert_eq!(
176                    reason,
177                    format!(
178                        "path exceeds maximum length of {MAX_PATH_LENGTH} bytes (got {} bytes)",
179                        long_path.len()
180                    )
181                );
182            }
183            other => panic!("expected SecurityViolation, got: {other:?}"),
184        }
185    }
186
187    #[test]
188    fn test_validate_raw_path_str_checks_length_before_null_byte() {
189        let long_path_with_null = format!("{}\0", "x".repeat(MAX_PATH_LENGTH));
190        let err = validate_raw_path_str(&long_path_with_null).expect_err("too long");
191        match err {
192            ArchiveError::SecurityViolation { reason, .. } => {
193                assert!(
194                    reason.contains("maximum length"),
195                    "length check should run first: {reason}"
196                );
197            }
198            other => panic!("expected SecurityViolation, got: {other:?}"),
199        }
200    }
201
202    #[test]
203    fn test_validate_raw_path_str_accepts_max_length() {
204        let max_path = "x".repeat(MAX_PATH_LENGTH);
205        assert!(validate_raw_path_str(&max_path).is_ok());
206    }
207
208    #[test]
209    fn test_validate_config_entry_accepts_normal() {
210        assert!(validate_config_entry("txt", "extension").is_ok());
211        assert!(validate_config_entry(".git", "banned path component").is_ok());
212    }
213
214    #[test]
215    fn test_validate_config_entry_rejects_empty() {
216        let err = validate_config_entry("", "extension").expect_err("empty");
217        match err {
218            ArchiveError::InvalidConfiguration { reason } => {
219                assert_eq!(reason, "extension must not be empty");
220            }
221            other => panic!("expected InvalidConfiguration, got: {other:?}"),
222        }
223    }
224
225    #[test]
226    fn test_validate_config_entry_rejects_null_bytes() {
227        let err = validate_config_entry("bad\0ext", "extension").expect_err("null byte");
228        match err {
229            ArchiveError::InvalidConfiguration { reason } => {
230                assert_eq!(
231                    reason,
232                    "extension contains null bytes - potential security issue"
233                );
234            }
235            other => panic!("expected InvalidConfiguration, got: {other:?}"),
236        }
237    }
238
239    #[test]
240    fn test_validate_config_entry_accepts_max_length() {
241        let max_entry = "x".repeat(MAX_CONFIG_ENTRY_LENGTH);
242        assert!(validate_config_entry(&max_entry, "extension").is_ok());
243    }
244
245    #[test]
246    fn test_validate_config_entry_rejects_too_long() {
247        let long_entry = "x".repeat(MAX_CONFIG_ENTRY_LENGTH + 1);
248        let err = validate_config_entry(&long_entry, "extension").expect_err("too long");
249        match err {
250            ArchiveError::InvalidConfiguration { reason } => {
251                assert_eq!(
252                    reason,
253                    format!(
254                        "extension exceeds maximum length of {MAX_CONFIG_ENTRY_LENGTH} bytes (got {} bytes)",
255                        long_entry.len()
256                    )
257                );
258            }
259            other => panic!("expected InvalidConfiguration, got: {other:?}"),
260        }
261    }
262
263    #[test]
264    fn test_validate_config_entry_rejects_multibyte_over_length() {
265        // "日" is 3 bytes in UTF-8; 100 repeats is 300 bytes but only 100
266        // chars.
267        let multibyte_entry = "日".repeat(100);
268        assert!(multibyte_entry.chars().count() < MAX_CONFIG_ENTRY_LENGTH);
269        let err =
270            validate_config_entry(&multibyte_entry, "extension").expect_err("too long in bytes");
271        match err {
272            ArchiveError::InvalidConfiguration { reason } => {
273                assert!(
274                    reason.contains("bytes"),
275                    "message must use byte-length wording: {reason}"
276                );
277            }
278            other => panic!("expected InvalidConfiguration, got: {other:?}"),
279        }
280    }
281
282    #[test]
283    fn test_validate_config_entry_checks_length_before_null_byte() {
284        let long_entry_with_null = format!("{}\0", "x".repeat(MAX_CONFIG_ENTRY_LENGTH));
285        let err = validate_config_entry(&long_entry_with_null, "extension").expect_err("too long");
286        match err {
287            ArchiveError::InvalidConfiguration { reason } => {
288                assert!(
289                    reason.contains("maximum length"),
290                    "length check should run first: {reason}"
291                );
292            }
293            other => panic!("expected InvalidConfiguration, got: {other:?}"),
294        }
295    }
296}