exarch-rs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs ADDED
@@ -0,0 +1,428 @@
1
+ //! Node.js bindings for exarch-core.
2
+ //!
3
+ //! This crate provides a JavaScript/TypeScript API for secure archive
4
+ //! extraction with built-in protection against path traversal, zip bombs,
5
+ //! symlink attacks, and other common vulnerabilities.
6
+ //!
7
+ //! # Installation
8
+ //!
9
+ //! ```bash
10
+ //! npm install @exarch/node
11
+ //! ```
12
+ //!
13
+ //! # Quick Start
14
+ //!
15
+ //! ```javascript
16
+ //! const { extractArchive, SecurityConfig } = require('@exarch/node');
17
+ //!
18
+ //! // Use secure defaults
19
+ //! const report = await extractArchive('archive.tar.gz', '/tmp/output');
20
+ //! console.log(`Extracted ${report.filesExtracted} files`);
21
+ //!
22
+ //! // Customize security settings
23
+ //! const config = new SecurityConfig()
24
+ //! .maxFileSize(100 * 1024 * 1024)
25
+ //! .allowSymlinks(true);
26
+ //! const report = await extractArchive('archive.tar.gz', '/tmp/output', config);
27
+ //! ```
28
+ //!
29
+ //! # Security
30
+ //!
31
+ //! This library uses a secure-by-default approach. All potentially dangerous
32
+ //! features are disabled by default and must be explicitly enabled. See
33
+ //! `SecurityConfig` for configuration options.
34
+ //!
35
+ //! # Repository
36
+ //!
37
+ //! <https://github.com/rabax/exarch>
38
+ //!
39
+ //! # License
40
+ //!
41
+ //! MIT OR Apache-2.0
42
+
43
+ use napi::bindgen_prelude::*;
44
+ use napi_derive::napi;
45
+
46
+ mod config;
47
+ mod error;
48
+ mod report;
49
+ mod utils;
50
+
51
+ use config::SecurityConfig;
52
+ use error::convert_error;
53
+ use report::ExtractionReport;
54
+ use utils::validate_path;
55
+
56
+ /// Extract an archive to the specified directory (async).
57
+ ///
58
+ /// This function provides secure archive extraction with configurable
59
+ /// security policies. By default, it uses a restrictive security
60
+ /// configuration that blocks symlinks, hardlinks, absolute paths, and
61
+ /// enforces resource quotas.
62
+ ///
63
+ /// # Security Considerations
64
+ ///
65
+ /// ## Thread Safety and TOCTOU
66
+ ///
67
+ /// The extraction runs on a libuv thread pool worker thread. This creates
68
+ /// a Time-Of-Check-Time-Of-Use (TOCTOU) race condition where the archive
69
+ /// file could be modified between validation and extraction. This is an
70
+ /// accepted tradeoff for async performance. For untrusted archives, ensure
71
+ /// exclusive access to the archive file during extraction.
72
+ ///
73
+ /// ## Input Validation
74
+ ///
75
+ /// - Paths containing null bytes are rejected (security)
76
+ /// - Paths exceeding 4096 bytes are rejected (`DoS` prevention)
77
+ /// - All validation happens at the Node.js boundary before calling core library
78
+ ///
79
+ /// # Arguments
80
+ ///
81
+ /// * `archive_path` - Path to the archive file
82
+ /// * `output_dir` - Directory where files will be extracted
83
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
84
+ ///
85
+ /// # Returns
86
+ ///
87
+ /// Promise resolving to `ExtractionReport` with extraction statistics
88
+ ///
89
+ /// # Errors
90
+ ///
91
+ /// Returns error for security violations or I/O errors. Error messages are
92
+ /// prefixed with error codes for discrimination in JavaScript:
93
+ ///
94
+ /// - `PATH_TRAVERSAL`: Path traversal attempt detected
95
+ /// - `SYMLINK_ESCAPE`: Symlink points outside extraction directory
96
+ /// - `HARDLINK_ESCAPE`: Hardlink target outside extraction directory
97
+ /// - `ZIP_BOMB`: Potential zip bomb detected
98
+ /// - `INVALID_PERMISSIONS`: File permissions are invalid or unsafe
99
+ /// - `QUOTA_EXCEEDED`: Resource quota exceeded
100
+ /// - `SECURITY_VIOLATION`: Security policy violation
101
+ /// - `UNSUPPORTED_FORMAT`: Archive format not supported
102
+ /// - `INVALID_ARCHIVE`: Archive is corrupted
103
+ /// - `IO_ERROR`: I/O operation failed
104
+ ///
105
+ /// # Examples
106
+ ///
107
+ /// ```javascript
108
+ /// // Use secure defaults
109
+ /// const report = await extractArchive('archive.tar.gz', '/tmp/output');
110
+ /// console.log(`Extracted ${report.filesExtracted} files`);
111
+ ///
112
+ /// // Customize security settings
113
+ /// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
114
+ /// const report = await extractArchive('archive.tar.gz', '/tmp/output', config);
115
+ /// ```
116
+ #[napi]
117
+ #[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
118
+ pub async fn extract_archive(
119
+ archive_path: String,
120
+ output_dir: String,
121
+ config: Option<&SecurityConfig>,
122
+ ) -> Result<ExtractionReport> {
123
+ // Validate paths at boundary
124
+ // NOTE: Defense-in-depth - paths are validated here and again in core
125
+ // library. This boundary validation catches issues early and provides
126
+ // better error messages for Node.js users.
127
+ validate_path(&archive_path)?;
128
+ validate_path(&output_dir)?;
129
+
130
+ // Get config reference or use default
131
+ let default_config = exarch_core::SecurityConfig::default();
132
+ let config_ref = config.map_or(&default_config, |c| c.as_core());
133
+
134
+ // Use Arc to share config across thread boundary without cloning
135
+ let config_arc = std::sync::Arc::new(config_ref.clone());
136
+
137
+ // Run extraction on tokio thread pool
138
+ //
139
+ // NAPI-RS with tokio_rt feature uses tokio runtime for async operations.
140
+ // spawn_blocking is required because archive extraction does blocking I/O.
141
+ // This moves the work to tokio's blocking thread pool rather than
142
+ // blocking the Node.js event loop.
143
+ //
144
+ // NOTE: TOCTOU race condition - archive contents can change between
145
+ // validation and extraction. This is an accepted limitation for async I/O.
146
+ // For maximum security with untrusted archives, use extractArchiveSync()
147
+ // or ensure exclusive file access (e.g., flock) during extraction.
148
+ let report = tokio::task::spawn_blocking(move || {
149
+ exarch_core::extract_archive(&archive_path, &output_dir, &config_arc)
150
+ })
151
+ .await
152
+ .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
153
+ .map_err(convert_error)?;
154
+
155
+ Ok(ExtractionReport::from(report))
156
+ }
157
+
158
+ /// Extract an archive to the specified directory (sync).
159
+ ///
160
+ /// Synchronous version of `extractArchive`. Blocks the event loop until
161
+ /// extraction completes. Prefer the async version for most use cases.
162
+ ///
163
+ /// # Arguments
164
+ ///
165
+ /// * `archive_path` - Path to the archive file
166
+ /// * `output_dir` - Directory where files will be extracted
167
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
168
+ ///
169
+ /// # Returns
170
+ ///
171
+ /// `ExtractionReport` with extraction statistics
172
+ ///
173
+ /// # Errors
174
+ ///
175
+ /// Returns error for security violations or I/O errors. See `extract_archive`
176
+ /// for error code documentation.
177
+ ///
178
+ /// # Examples
179
+ ///
180
+ /// ```javascript
181
+ /// // Use secure defaults
182
+ /// const report = extractArchiveSync('archive.tar.gz', '/tmp/output');
183
+ /// console.log(`Extracted ${report.filesExtracted} files`);
184
+ ///
185
+ /// // Customize security settings
186
+ /// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
187
+ /// const report = extractArchiveSync('archive.tar.gz', '/tmp/output', config);
188
+ /// ```
189
+ #[napi]
190
+ #[allow(clippy::needless_pass_by_value)]
191
+ pub fn extract_archive_sync(
192
+ archive_path: String,
193
+ output_dir: String,
194
+ config: Option<&SecurityConfig>,
195
+ ) -> Result<ExtractionReport> {
196
+ // Validate paths at boundary
197
+ // NOTE: Defense-in-depth - paths are validated here and again in core
198
+ // library. This boundary validation catches issues early and provides
199
+ // better error messages for Node.js users.
200
+ validate_path(&archive_path)?;
201
+ validate_path(&output_dir)?;
202
+
203
+ // Get config reference or use default
204
+ let default_config = exarch_core::SecurityConfig::default();
205
+ let config_ref = config.map_or(&default_config, |c| c.as_core());
206
+
207
+ // Run extraction synchronously
208
+ let report = exarch_core::extract_archive(&archive_path, &output_dir, config_ref)
209
+ .map_err(convert_error)?;
210
+
211
+ Ok(ExtractionReport::from(report))
212
+ }
213
+
214
+ #[cfg(test)]
215
+ #[allow(
216
+ clippy::unwrap_used,
217
+ clippy::expect_used,
218
+ clippy::uninlined_format_args,
219
+ clippy::manual_string_new
220
+ )]
221
+ mod tests {
222
+ use super::*;
223
+
224
+ #[test]
225
+ fn test_module_exports_functions() {
226
+ // This test just ensures the module compiles and exports the expected
227
+ // functions. Runtime tests would require actual archive files.
228
+ }
229
+
230
+ // CR-004: Path validation tests
231
+ #[tokio::test]
232
+ async fn test_extract_archive_rejects_null_byte_in_archive_path() {
233
+ let result = extract_archive(
234
+ "/tmp/test\0malicious.tar".to_string(),
235
+ "/tmp/output".to_string(),
236
+ None,
237
+ )
238
+ .await;
239
+
240
+ assert!(result.is_err(), "should reject null bytes in archive path");
241
+ assert!(
242
+ result.unwrap_err().to_string().contains("null bytes"),
243
+ "error message should mention null bytes"
244
+ );
245
+ }
246
+
247
+ #[tokio::test]
248
+ async fn test_extract_archive_rejects_null_byte_in_output_dir() {
249
+ let result = extract_archive(
250
+ "/tmp/test.tar".to_string(),
251
+ "/tmp/output\0malicious".to_string(),
252
+ None,
253
+ )
254
+ .await;
255
+
256
+ assert!(result.is_err(), "should reject null bytes in output path");
257
+ assert!(
258
+ result.unwrap_err().to_string().contains("null bytes"),
259
+ "error message should mention null bytes"
260
+ );
261
+ }
262
+
263
+ #[tokio::test]
264
+ async fn test_extract_archive_rejects_excessively_long_archive_path() {
265
+ let long_path = "x".repeat(5000);
266
+ let result = extract_archive(long_path, "/tmp/output".to_string(), None).await;
267
+
268
+ assert!(result.is_err(), "should reject excessively long paths");
269
+ assert!(
270
+ result.unwrap_err().to_string().contains("maximum length"),
271
+ "error message should mention length limit"
272
+ );
273
+ }
274
+
275
+ #[tokio::test]
276
+ async fn test_extract_archive_rejects_excessively_long_output_dir() {
277
+ let long_path = "x".repeat(5000);
278
+ let result = extract_archive("/tmp/test.tar".to_string(), long_path, None).await;
279
+
280
+ assert!(result.is_err(), "should reject excessively long paths");
281
+ assert!(
282
+ result.unwrap_err().to_string().contains("maximum length"),
283
+ "error message should mention length limit"
284
+ );
285
+ }
286
+
287
+ #[tokio::test]
288
+ async fn test_extract_archive_accepts_empty_paths() {
289
+ // Empty paths should be accepted at boundary validation
290
+ // Core library will handle actual path validation
291
+ let result = extract_archive("".to_string(), "".to_string(), None).await;
292
+
293
+ // If it fails, ensure it's not a boundary path validation error
294
+ // (empty paths pass boundary validation; core handles semantic validation)
295
+ if let Err(e) = result {
296
+ let err_msg = e.to_string();
297
+ assert!(
298
+ !err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
299
+ "should not fail on boundary path validation, got: {}",
300
+ err_msg
301
+ );
302
+ }
303
+ // If it succeeds, boundary validation passed (which is what we're
304
+ // testing)
305
+ }
306
+
307
+ #[test]
308
+ fn test_extract_archive_sync_rejects_null_byte_in_archive_path() {
309
+ let result = extract_archive_sync(
310
+ "/tmp/test\0malicious.tar".to_string(),
311
+ "/tmp/output".to_string(),
312
+ None,
313
+ );
314
+
315
+ assert!(result.is_err(), "should reject null bytes in archive path");
316
+ assert!(
317
+ result.unwrap_err().to_string().contains("null bytes"),
318
+ "error message should mention null bytes"
319
+ );
320
+ }
321
+
322
+ #[test]
323
+ fn test_extract_archive_sync_rejects_null_byte_in_output_dir() {
324
+ let result = extract_archive_sync(
325
+ "/tmp/test.tar".to_string(),
326
+ "/tmp/output\0malicious".to_string(),
327
+ None,
328
+ );
329
+
330
+ assert!(result.is_err(), "should reject null bytes in output path");
331
+ assert!(
332
+ result.unwrap_err().to_string().contains("null bytes"),
333
+ "error message should mention null bytes"
334
+ );
335
+ }
336
+
337
+ #[test]
338
+ fn test_extract_archive_sync_rejects_excessively_long_archive_path() {
339
+ let long_path = "x".repeat(5000);
340
+ let result = extract_archive_sync(long_path, "/tmp/output".to_string(), None);
341
+
342
+ assert!(result.is_err(), "should reject excessively long paths");
343
+ assert!(
344
+ result.unwrap_err().to_string().contains("maximum length"),
345
+ "error message should mention length limit"
346
+ );
347
+ }
348
+
349
+ #[test]
350
+ fn test_extract_archive_sync_rejects_excessively_long_output_dir() {
351
+ let long_path = "x".repeat(5000);
352
+ let result = extract_archive_sync("/tmp/test.tar".to_string(), long_path, None);
353
+
354
+ assert!(result.is_err(), "should reject excessively long paths");
355
+ assert!(
356
+ result.unwrap_err().to_string().contains("maximum length"),
357
+ "error message should mention length limit"
358
+ );
359
+ }
360
+
361
+ #[test]
362
+ fn test_extract_archive_sync_accepts_valid_paths() {
363
+ // Test that valid paths pass boundary validation
364
+ // The actual extraction may succeed (empty archive) or fail (file not found)
365
+ // but should NOT fail due to path validation
366
+ let result = extract_archive_sync(
367
+ "/tmp/valid_test_path.tar".to_string(),
368
+ "/tmp/valid_output_path".to_string(),
369
+ None,
370
+ );
371
+
372
+ // If it fails, ensure it's not a path validation error
373
+ if let Err(e) = result {
374
+ let err_msg = e.to_string();
375
+ assert!(
376
+ !err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
377
+ "should not fail on path validation, got: {}",
378
+ err_msg
379
+ );
380
+ }
381
+ // If it succeeds, path validation passed (which is what we're testing)
382
+ }
383
+
384
+ #[test]
385
+ fn test_extract_archive_sync_accepts_relative_paths() {
386
+ // Test that valid relative paths pass boundary validation
387
+ let result = extract_archive_sync(
388
+ "relative_test.tar".to_string(),
389
+ "relative_output".to_string(),
390
+ None,
391
+ );
392
+
393
+ // If it fails, ensure it's not a path validation error
394
+ if let Err(e) = result {
395
+ let err_msg = e.to_string();
396
+ assert!(
397
+ !err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
398
+ "should not fail on path validation for relative paths, got: {}",
399
+ err_msg
400
+ );
401
+ }
402
+ // If it succeeds, path validation passed (which is what we're testing)
403
+ }
404
+
405
+ #[test]
406
+ fn test_extract_archive_sync_with_custom_config() {
407
+ let mut config = SecurityConfig::new();
408
+ config.max_file_size(1_000_000).unwrap();
409
+
410
+ // Test that valid paths pass boundary validation with custom config
411
+ let result = extract_archive_sync(
412
+ "custom_test.tar".to_string(),
413
+ "custom_output".to_string(),
414
+ Some(&config),
415
+ );
416
+
417
+ // If it fails, ensure it's not a path validation error
418
+ if let Err(e) = result {
419
+ let err_msg = e.to_string();
420
+ assert!(
421
+ !err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
422
+ "should not fail on path validation, got: {}",
423
+ err_msg
424
+ );
425
+ }
426
+ // If it succeeds, path validation passed (which is what we're testing)
427
+ }
428
+ }
package/src/report.rs ADDED
@@ -0,0 +1,176 @@
1
+ //! Node.js bindings for `ExtractionReport`.
2
+
3
+ use exarch_core::ExtractionReport as CoreReport;
4
+ use napi_derive::napi;
5
+
6
+ /// Report of an archive extraction operation.
7
+ ///
8
+ /// Contains statistics and metadata about the extraction process.
9
+ #[napi(object)]
10
+ #[derive(Debug, Clone)]
11
+ pub struct ExtractionReport {
12
+ /// Number of files successfully extracted.
13
+ pub files_extracted: u32,
14
+ /// Number of directories created.
15
+ pub directories_created: u32,
16
+ /// Number of symlinks created.
17
+ pub symlinks_created: u32,
18
+ /// Total bytes written to disk.
19
+ pub bytes_written: i64,
20
+ /// Extraction duration in milliseconds.
21
+ pub duration_ms: i64,
22
+ /// Number of files skipped due to security checks.
23
+ pub files_skipped: u32,
24
+ /// List of warning messages.
25
+ pub warnings: Vec<String>,
26
+ }
27
+
28
+ impl From<CoreReport> for ExtractionReport {
29
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
30
+ fn from(report: CoreReport) -> Self {
31
+ // Use saturating conversions to prevent silent wraparound on overflow
32
+ // This ensures audit trails remain accurate even for very large extractions
33
+ Self {
34
+ files_extracted: report.files_extracted.min(u32::MAX as usize) as u32,
35
+ directories_created: report.directories_created.min(u32::MAX as usize) as u32,
36
+ symlinks_created: report.symlinks_created.min(u32::MAX as usize) as u32,
37
+ bytes_written: report.bytes_written.min(i64::MAX as u64) as i64,
38
+ duration_ms: report.duration.as_millis().min(i64::MAX as u128) as i64,
39
+ files_skipped: report.files_skipped.min(u32::MAX as usize) as u32,
40
+ warnings: report.warnings,
41
+ }
42
+ }
43
+ }
44
+
45
+ #[cfg(test)]
46
+ #[allow(clippy::unwrap_used, clippy::expect_used)]
47
+ mod tests {
48
+ use super::*;
49
+ use std::time::Duration;
50
+
51
+ #[test]
52
+ fn test_extraction_report_conversion() {
53
+ let mut core_report = CoreReport::new();
54
+ core_report.files_extracted = 10;
55
+ core_report.directories_created = 5;
56
+ core_report.symlinks_created = 2;
57
+ core_report.bytes_written = 1024;
58
+ core_report.duration = Duration::from_millis(500);
59
+ core_report.files_skipped = 1;
60
+ core_report.add_warning("Test warning".to_string());
61
+
62
+ let report = ExtractionReport::from(core_report);
63
+
64
+ assert_eq!(report.files_extracted, 10);
65
+ assert_eq!(report.directories_created, 5);
66
+ assert_eq!(report.symlinks_created, 2);
67
+ assert_eq!(report.bytes_written, 1024);
68
+ assert_eq!(report.duration_ms, 500);
69
+ assert_eq!(report.files_skipped, 1);
70
+ assert_eq!(report.warnings.len(), 1);
71
+ assert_eq!(report.warnings[0], "Test warning");
72
+ }
73
+
74
+ #[test]
75
+ fn test_extraction_report_zero_values() {
76
+ let core_report = CoreReport::new();
77
+ let report = ExtractionReport::from(core_report);
78
+
79
+ assert_eq!(report.files_extracted, 0);
80
+ assert_eq!(report.directories_created, 0);
81
+ assert_eq!(report.symlinks_created, 0);
82
+ assert_eq!(report.bytes_written, 0);
83
+ assert_eq!(report.files_skipped, 0);
84
+ assert_eq!(report.warnings.len(), 0);
85
+ }
86
+
87
+ #[test]
88
+ fn test_extraction_report_large_values() {
89
+ let mut core_report = CoreReport::new();
90
+ core_report.files_extracted = 100_000;
91
+ core_report.directories_created = 50_000;
92
+ core_report.bytes_written = 10_000_000_000; // 10 GB
93
+ core_report.duration = Duration::from_secs(3600); // 1 hour
94
+
95
+ let report = ExtractionReport::from(core_report);
96
+
97
+ assert_eq!(report.files_extracted, 100_000);
98
+ assert_eq!(report.bytes_written, 10_000_000_000);
99
+ assert_eq!(report.duration_ms, 3_600_000);
100
+ }
101
+
102
+ #[test]
103
+ fn test_extraction_report_multiple_warnings() {
104
+ let mut core_report = CoreReport::new();
105
+ core_report.add_warning("Warning 1".to_string());
106
+ core_report.add_warning("Warning 2".to_string());
107
+ core_report.add_warning("Warning 3".to_string());
108
+
109
+ let report = ExtractionReport::from(core_report);
110
+
111
+ assert_eq!(report.warnings.len(), 3);
112
+ assert_eq!(report.warnings[0], "Warning 1");
113
+ assert_eq!(report.warnings[1], "Warning 2");
114
+ assert_eq!(report.warnings[2], "Warning 3");
115
+ }
116
+
117
+ #[test]
118
+ fn test_extraction_report_duration_hours() {
119
+ let mut core_report = CoreReport::new();
120
+ core_report.duration = Duration::from_secs(7200); // 2 hours
121
+
122
+ let report = ExtractionReport::from(core_report);
123
+
124
+ assert_eq!(
125
+ report.duration_ms, 7_200_000,
126
+ "2 hours should be 7,200,000 milliseconds"
127
+ );
128
+ }
129
+
130
+ #[test]
131
+ fn test_extraction_report_duration_zero() {
132
+ let mut core_report = CoreReport::new();
133
+ core_report.duration = Duration::from_secs(0);
134
+
135
+ let report = ExtractionReport::from(core_report);
136
+
137
+ assert_eq!(report.duration_ms, 0, "zero duration should be 0 ms");
138
+ }
139
+
140
+ #[test]
141
+ fn test_extraction_report_duration_microseconds() {
142
+ let mut core_report = CoreReport::new();
143
+ core_report.duration = Duration::from_micros(1500); // 1.5 ms
144
+
145
+ let report = ExtractionReport::from(core_report);
146
+
147
+ assert_eq!(
148
+ report.duration_ms, 1,
149
+ "1500 microseconds should round to 1 millisecond"
150
+ );
151
+ }
152
+
153
+ #[test]
154
+ fn test_extraction_report_warnings_order_preserved() {
155
+ let mut core_report = CoreReport::new();
156
+ core_report.add_warning("First warning".to_string());
157
+ core_report.add_warning("Second warning".to_string());
158
+ core_report.add_warning("Third warning".to_string());
159
+
160
+ let report = ExtractionReport::from(core_report);
161
+
162
+ assert_eq!(report.warnings.len(), 3, "should have 3 warnings");
163
+ assert_eq!(
164
+ report.warnings[0], "First warning",
165
+ "first warning should be at index 0"
166
+ );
167
+ assert_eq!(
168
+ report.warnings[1], "Second warning",
169
+ "second warning should be at index 1"
170
+ );
171
+ assert_eq!(
172
+ report.warnings[2], "Third warning",
173
+ "third warning should be at index 2"
174
+ );
175
+ }
176
+ }
package/src/utils.rs ADDED
@@ -0,0 +1,84 @@
1
+ //! Utility functions for Node.js bindings.
2
+
3
+ use napi::bindgen_prelude::*;
4
+
5
+ /// Maximum path length in bytes (Linux/macOS `PATH_MAX` is typically 4096)
6
+ const MAX_PATH_LENGTH: usize = 4096;
7
+
8
+ /// Validates a path string for security issues.
9
+ ///
10
+ /// Rejects:
11
+ /// - Paths containing null bytes (potential injection attacks)
12
+ /// - Paths exceeding `MAX_PATH_LENGTH` bytes (`DoS` prevention)
13
+ ///
14
+ /// # Errors
15
+ ///
16
+ /// Returns error if path contains null bytes or exceeds maximum length.
17
+ pub fn validate_path(path: &str) -> Result<()> {
18
+ // Use constant-time null byte check to prevent timing side-channel attacks
19
+ // The fold operation processes every byte regardless of when null is found
20
+ let has_null = path.bytes().fold(false, |acc, b| acc | (b == 0));
21
+
22
+ if has_null {
23
+ return Err(Error::from_reason(
24
+ "path contains null bytes - potential security issue",
25
+ ));
26
+ }
27
+
28
+ if path.len() > MAX_PATH_LENGTH {
29
+ // Pre-allocate string to avoid multiple allocations
30
+ use std::fmt::Write;
31
+ let mut msg = String::with_capacity(80);
32
+ // Writing to a String never fails
33
+ let _ = write!(
34
+ &mut msg,
35
+ "path exceeds maximum length of {MAX_PATH_LENGTH} bytes (got {} bytes)",
36
+ path.len()
37
+ );
38
+ return Err(Error::from_reason(msg));
39
+ }
40
+
41
+ Ok(())
42
+ }
43
+
44
+ #[cfg(test)]
45
+ #[allow(clippy::unwrap_used, clippy::expect_used)]
46
+ mod tests {
47
+ use super::*;
48
+
49
+ #[test]
50
+ fn test_validate_path_accepts_normal() {
51
+ assert!(
52
+ validate_path("/tmp/test.tar.gz").is_ok(),
53
+ "absolute paths should be accepted"
54
+ );
55
+ assert!(
56
+ validate_path("relative/path.tar").is_ok(),
57
+ "relative paths should be accepted"
58
+ );
59
+ // Empty path is valid - callers may provide empty strings for defaults
60
+ // or optional parameters. Core library handles empty path validation.
61
+ assert!(validate_path("").is_ok(), "empty paths should be accepted");
62
+ }
63
+
64
+ #[test]
65
+ fn test_validate_path_rejects_null_bytes() {
66
+ let result = validate_path("/tmp/test\0malicious");
67
+ assert!(result.is_err());
68
+ assert!(result.unwrap_err().to_string().contains("null bytes"));
69
+ }
70
+
71
+ #[test]
72
+ fn test_validate_path_rejects_too_long() {
73
+ let long_path = "x".repeat(MAX_PATH_LENGTH + 1);
74
+ let result = validate_path(&long_path);
75
+ assert!(result.is_err());
76
+ assert!(result.unwrap_err().to_string().contains("maximum length"));
77
+ }
78
+
79
+ #[test]
80
+ fn test_validate_path_accepts_max_length() {
81
+ let max_path = "x".repeat(MAX_PATH_LENGTH);
82
+ assert!(validate_path(&max_path).is_ok());
83
+ }
84
+ }