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/Cargo.toml +28 -0
- package/README.md +235 -0
- package/build.rs +5 -0
- package/index.d.ts +287 -0
- package/package.json +37 -0
- package/src/config.rs +927 -0
- package/src/error.rs +309 -0
- package/src/lib.rs +428 -0
- package/src/report.rs +176 -0
- package/src/utils.rs +84 -0
package/src/config.rs
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
//! Node.js bindings for `SecurityConfig`.
|
|
2
|
+
|
|
3
|
+
use exarch_core::SecurityConfig as CoreConfig;
|
|
4
|
+
use napi::bindgen_prelude::Error;
|
|
5
|
+
use napi::bindgen_prelude::Result;
|
|
6
|
+
use napi_derive::napi;
|
|
7
|
+
|
|
8
|
+
/// Maximum length for file extension strings (e.g., ".tar.gz")
|
|
9
|
+
const MAX_EXTENSION_LENGTH: usize = 255;
|
|
10
|
+
|
|
11
|
+
/// Maximum length for path component strings
|
|
12
|
+
const MAX_COMPONENT_LENGTH: usize = 255;
|
|
13
|
+
|
|
14
|
+
/// Security configuration for archive extraction.
|
|
15
|
+
///
|
|
16
|
+
/// All security features default to deny (secure-by-default policy).
|
|
17
|
+
///
|
|
18
|
+
/// # Defaults
|
|
19
|
+
///
|
|
20
|
+
/// | Setting | Default Value |
|
|
21
|
+
/// |---------|--------------|
|
|
22
|
+
/// | `max_file_size` | 50 MB (52,428,800 bytes) |
|
|
23
|
+
/// | `max_total_size` | 500 MB (524,288,000 bytes) |
|
|
24
|
+
/// | `max_compression_ratio` | 100.0 |
|
|
25
|
+
/// | `max_file_count` | 10,000 |
|
|
26
|
+
/// | `max_path_depth` | 32 |
|
|
27
|
+
/// | `allow_symlinks` | false |
|
|
28
|
+
/// | `allow_hardlinks` | false |
|
|
29
|
+
/// | `allow_absolute_paths` | false |
|
|
30
|
+
/// | `allow_world_writable` | false |
|
|
31
|
+
/// | `preserve_permissions` | false |
|
|
32
|
+
/// | `allowed_extensions` | empty (all allowed) |
|
|
33
|
+
/// | `banned_path_components` | `.git`, `.ssh` |
|
|
34
|
+
#[napi]
|
|
35
|
+
#[derive(Debug, Clone)]
|
|
36
|
+
pub struct SecurityConfig {
|
|
37
|
+
inner: CoreConfig,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[napi]
|
|
41
|
+
impl SecurityConfig {
|
|
42
|
+
/// Creates a new `SecurityConfig` with secure defaults.
|
|
43
|
+
#[napi(constructor)]
|
|
44
|
+
pub fn new() -> Self {
|
|
45
|
+
Self {
|
|
46
|
+
inner: CoreConfig::default(),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Creates a `SecurityConfig` with secure defaults.
|
|
51
|
+
///
|
|
52
|
+
/// This is equivalent to calling `new SecurityConfig()`.
|
|
53
|
+
#[napi(factory)]
|
|
54
|
+
pub fn default() -> Self {
|
|
55
|
+
Self::new()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Creates a permissive configuration for trusted archives.
|
|
59
|
+
///
|
|
60
|
+
/// Enables: symlinks, hardlinks, absolute paths, world-writable files.
|
|
61
|
+
/// Use only for archives from trusted sources.
|
|
62
|
+
#[napi(factory)]
|
|
63
|
+
pub fn permissive() -> Self {
|
|
64
|
+
Self {
|
|
65
|
+
inner: CoreConfig::permissive(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Builder pattern methods - return Self for chaining
|
|
70
|
+
|
|
71
|
+
/// Sets the maximum file size in bytes.
|
|
72
|
+
///
|
|
73
|
+
/// # Errors
|
|
74
|
+
///
|
|
75
|
+
/// Returns error if size is negative.
|
|
76
|
+
#[napi]
|
|
77
|
+
pub fn max_file_size(&mut self, size: i64) -> Result<&Self> {
|
|
78
|
+
if size < 0 {
|
|
79
|
+
return Err(Error::from_reason("max file size cannot be negative"));
|
|
80
|
+
}
|
|
81
|
+
#[allow(clippy::cast_sign_loss)]
|
|
82
|
+
{
|
|
83
|
+
self.inner.max_file_size = size as u64;
|
|
84
|
+
}
|
|
85
|
+
Ok(self)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Sets the maximum total size in bytes.
|
|
89
|
+
///
|
|
90
|
+
/// # Errors
|
|
91
|
+
///
|
|
92
|
+
/// Returns error if size is negative.
|
|
93
|
+
#[napi]
|
|
94
|
+
pub fn max_total_size(&mut self, size: i64) -> Result<&Self> {
|
|
95
|
+
if size < 0 {
|
|
96
|
+
return Err(Error::from_reason("max total size cannot be negative"));
|
|
97
|
+
}
|
|
98
|
+
#[allow(clippy::cast_sign_loss)]
|
|
99
|
+
{
|
|
100
|
+
self.inner.max_total_size = size as u64;
|
|
101
|
+
}
|
|
102
|
+
Ok(self)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Sets the maximum compression ratio.
|
|
106
|
+
///
|
|
107
|
+
/// # Errors
|
|
108
|
+
///
|
|
109
|
+
/// Returns error if ratio is not a positive finite number.
|
|
110
|
+
#[napi]
|
|
111
|
+
pub fn max_compression_ratio(&mut self, ratio: f64) -> Result<&Self> {
|
|
112
|
+
if !ratio.is_finite() || ratio <= 0.0 {
|
|
113
|
+
return Err(Error::from_reason(
|
|
114
|
+
"compression ratio must be a positive finite number",
|
|
115
|
+
));
|
|
116
|
+
}
|
|
117
|
+
self.inner.max_compression_ratio = ratio;
|
|
118
|
+
Ok(self)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Sets the maximum file count.
|
|
122
|
+
#[napi]
|
|
123
|
+
pub fn max_file_count(&mut self, count: u32) -> &Self {
|
|
124
|
+
self.inner.max_file_count = count as usize;
|
|
125
|
+
self
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Sets the maximum path depth.
|
|
129
|
+
#[napi]
|
|
130
|
+
pub fn max_path_depth(&mut self, depth: u32) -> &Self {
|
|
131
|
+
self.inner.max_path_depth = depth as usize;
|
|
132
|
+
self
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// Allows or denies symlinks.
|
|
136
|
+
#[napi]
|
|
137
|
+
pub fn allow_symlinks(&mut self, allow: Option<bool>) -> &Self {
|
|
138
|
+
self.inner.allowed.symlinks = allow.unwrap_or(true);
|
|
139
|
+
self
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Allows or denies hardlinks.
|
|
143
|
+
#[napi]
|
|
144
|
+
pub fn allow_hardlinks(&mut self, allow: Option<bool>) -> &Self {
|
|
145
|
+
self.inner.allowed.hardlinks = allow.unwrap_or(true);
|
|
146
|
+
self
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Allows or denies absolute paths.
|
|
150
|
+
#[napi]
|
|
151
|
+
pub fn allow_absolute_paths(&mut self, allow: Option<bool>) -> &Self {
|
|
152
|
+
self.inner.allowed.absolute_paths = allow.unwrap_or(true);
|
|
153
|
+
self
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Allows or denies world-writable files.
|
|
157
|
+
#[napi]
|
|
158
|
+
pub fn allow_world_writable(&mut self, allow: Option<bool>) -> &Self {
|
|
159
|
+
self.inner.allowed.world_writable = allow.unwrap_or(true);
|
|
160
|
+
self
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Sets whether to preserve permissions from archive.
|
|
164
|
+
#[napi]
|
|
165
|
+
pub fn preserve_permissions(&mut self, preserve: Option<bool>) -> &Self {
|
|
166
|
+
self.inner.preserve_permissions = preserve.unwrap_or(true);
|
|
167
|
+
self
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Adds an allowed file extension.
|
|
171
|
+
///
|
|
172
|
+
/// # Errors
|
|
173
|
+
///
|
|
174
|
+
/// Returns error if extension exceeds maximum length or contains null
|
|
175
|
+
/// bytes.
|
|
176
|
+
#[napi]
|
|
177
|
+
pub fn add_allowed_extension(&mut self, ext: String) -> Result<&Self> {
|
|
178
|
+
if ext.contains('\0') {
|
|
179
|
+
return Err(Error::from_reason(
|
|
180
|
+
"extension contains null bytes - potential security issue",
|
|
181
|
+
));
|
|
182
|
+
}
|
|
183
|
+
if ext.len() > MAX_EXTENSION_LENGTH {
|
|
184
|
+
return Err(Error::from_reason(format!(
|
|
185
|
+
"extension exceeds maximum length of {MAX_EXTENSION_LENGTH} characters"
|
|
186
|
+
)));
|
|
187
|
+
}
|
|
188
|
+
self.inner.allowed_extensions.push(ext);
|
|
189
|
+
Ok(self)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Adds a banned path component.
|
|
193
|
+
///
|
|
194
|
+
/// # Errors
|
|
195
|
+
///
|
|
196
|
+
/// Returns error if component exceeds maximum length or contains null
|
|
197
|
+
/// bytes.
|
|
198
|
+
#[napi]
|
|
199
|
+
pub fn add_banned_component(&mut self, component: String) -> Result<&Self> {
|
|
200
|
+
if component.contains('\0') {
|
|
201
|
+
return Err(Error::from_reason(
|
|
202
|
+
"component contains null bytes - potential security issue",
|
|
203
|
+
));
|
|
204
|
+
}
|
|
205
|
+
if component.len() > MAX_COMPONENT_LENGTH {
|
|
206
|
+
return Err(Error::from_reason(format!(
|
|
207
|
+
"component exceeds maximum length of {MAX_COMPONENT_LENGTH} characters"
|
|
208
|
+
)));
|
|
209
|
+
}
|
|
210
|
+
self.inner.banned_path_components.push(component);
|
|
211
|
+
Ok(self)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Finalizes the configuration (for API consistency).
|
|
215
|
+
///
|
|
216
|
+
/// This method is provided for builder pattern consistency but is optional.
|
|
217
|
+
/// The configuration is always valid and can be used directly.
|
|
218
|
+
#[napi]
|
|
219
|
+
pub fn build(&self) -> &Self {
|
|
220
|
+
self
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validation methods
|
|
224
|
+
|
|
225
|
+
/// Checks if a path component is allowed.
|
|
226
|
+
#[napi]
|
|
227
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
228
|
+
pub fn is_path_component_allowed(&self, component: String) -> bool {
|
|
229
|
+
self.inner.is_path_component_allowed(&component)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Checks if a file extension is allowed.
|
|
233
|
+
#[napi]
|
|
234
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
235
|
+
pub fn is_extension_allowed(&self, extension: String) -> bool {
|
|
236
|
+
self.inner.is_extension_allowed(&extension)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Property getters
|
|
240
|
+
|
|
241
|
+
/// Maximum file size in bytes.
|
|
242
|
+
#[napi(getter)]
|
|
243
|
+
#[allow(clippy::cast_possible_wrap)]
|
|
244
|
+
pub fn get_max_file_size(&self) -> i64 {
|
|
245
|
+
self.inner.max_file_size as i64
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/// Maximum total size in bytes.
|
|
249
|
+
#[napi(getter)]
|
|
250
|
+
#[allow(clippy::cast_possible_wrap)]
|
|
251
|
+
pub fn get_max_total_size(&self) -> i64 {
|
|
252
|
+
self.inner.max_total_size as i64
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// Maximum compression ratio.
|
|
256
|
+
#[napi(getter)]
|
|
257
|
+
pub fn get_max_compression_ratio(&self) -> f64 {
|
|
258
|
+
self.inner.max_compression_ratio
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// Maximum file count.
|
|
262
|
+
#[napi(getter)]
|
|
263
|
+
#[allow(clippy::cast_possible_truncation)]
|
|
264
|
+
pub fn get_max_file_count(&self) -> u32 {
|
|
265
|
+
self.inner.max_file_count as u32
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Maximum path depth.
|
|
269
|
+
#[napi(getter)]
|
|
270
|
+
#[allow(clippy::cast_possible_truncation)]
|
|
271
|
+
pub fn get_max_path_depth(&self) -> u32 {
|
|
272
|
+
self.inner.max_path_depth as u32
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Whether to preserve permissions from archive.
|
|
276
|
+
#[napi(getter)]
|
|
277
|
+
pub fn get_preserve_permissions(&self) -> bool {
|
|
278
|
+
self.inner.preserve_permissions
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/// Whether symlinks are allowed.
|
|
282
|
+
#[napi(getter)]
|
|
283
|
+
pub fn get_allow_symlinks(&self) -> bool {
|
|
284
|
+
self.inner.allowed.symlinks
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// Whether hardlinks are allowed.
|
|
288
|
+
#[napi(getter)]
|
|
289
|
+
pub fn get_allow_hardlinks(&self) -> bool {
|
|
290
|
+
self.inner.allowed.hardlinks
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// Whether absolute paths are allowed.
|
|
294
|
+
#[napi(getter)]
|
|
295
|
+
pub fn get_allow_absolute_paths(&self) -> bool {
|
|
296
|
+
self.inner.allowed.absolute_paths
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// Whether world-writable files are allowed.
|
|
300
|
+
#[napi(getter)]
|
|
301
|
+
pub fn get_allow_world_writable(&self) -> bool {
|
|
302
|
+
self.inner.allowed.world_writable
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// List of allowed file extensions.
|
|
306
|
+
///
|
|
307
|
+
/// Note: This getter clones the underlying data. For performance-critical
|
|
308
|
+
/// code that only needs to count or check membership, use
|
|
309
|
+
/// `getAllowedExtensionsCount()` or `hasAllowedExtension()` instead.
|
|
310
|
+
#[napi(getter)]
|
|
311
|
+
pub fn get_allowed_extensions(&self) -> Vec<String> {
|
|
312
|
+
self.inner.allowed_extensions.clone()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// Returns the number of allowed extensions.
|
|
316
|
+
#[napi]
|
|
317
|
+
#[allow(clippy::cast_possible_truncation)]
|
|
318
|
+
pub fn get_allowed_extensions_count(&self) -> u32 {
|
|
319
|
+
self.inner.allowed_extensions.len() as u32
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Checks if a specific extension is in the allowed list.
|
|
323
|
+
#[napi]
|
|
324
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
325
|
+
pub fn has_allowed_extension(&self, ext: String) -> bool {
|
|
326
|
+
self.inner.allowed_extensions.contains(&ext)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// List of banned path components.
|
|
330
|
+
///
|
|
331
|
+
/// Note: This getter clones the underlying data. For performance-critical
|
|
332
|
+
/// code that only needs to count or check membership, use
|
|
333
|
+
/// `getBannedPathComponentsCount()` or `hasBannedPathComponent()` instead.
|
|
334
|
+
#[napi(getter)]
|
|
335
|
+
pub fn get_banned_path_components(&self) -> Vec<String> {
|
|
336
|
+
self.inner.banned_path_components.clone()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Returns the number of banned path components.
|
|
340
|
+
#[napi]
|
|
341
|
+
#[allow(clippy::cast_possible_truncation)]
|
|
342
|
+
pub fn get_banned_path_components_count(&self) -> u32 {
|
|
343
|
+
self.inner.banned_path_components.len() as u32
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/// Checks if a specific component is in the banned list.
|
|
347
|
+
#[napi]
|
|
348
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
349
|
+
pub fn has_banned_path_component(&self, component: String) -> bool {
|
|
350
|
+
self.inner.banned_path_components.contains(&component)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
impl SecurityConfig {
|
|
355
|
+
/// Returns a reference to the inner `CoreConfig`.
|
|
356
|
+
///
|
|
357
|
+
/// This is used internally to pass the configuration to the Rust extraction
|
|
358
|
+
/// API.
|
|
359
|
+
pub fn as_core(&self) -> &CoreConfig {
|
|
360
|
+
&self.inner
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#[cfg(test)]
|
|
365
|
+
#[allow(
|
|
366
|
+
clippy::unwrap_used,
|
|
367
|
+
clippy::expect_used,
|
|
368
|
+
clippy::float_cmp,
|
|
369
|
+
clippy::unreadable_literal,
|
|
370
|
+
clippy::manual_string_new,
|
|
371
|
+
clippy::uninlined_format_args
|
|
372
|
+
)]
|
|
373
|
+
mod tests {
|
|
374
|
+
use super::*;
|
|
375
|
+
|
|
376
|
+
#[test]
|
|
377
|
+
fn test_default_config() {
|
|
378
|
+
let config = SecurityConfig::new();
|
|
379
|
+
assert_eq!(config.get_max_file_size(), 50 * 1024 * 1024);
|
|
380
|
+
assert_eq!(config.get_max_total_size(), 500 * 1024 * 1024);
|
|
381
|
+
assert_eq!(config.get_max_file_count(), 10_000);
|
|
382
|
+
assert!(!config.get_preserve_permissions());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#[test]
|
|
386
|
+
fn test_default_static_method() {
|
|
387
|
+
let config = SecurityConfig::default();
|
|
388
|
+
assert_eq!(config.get_max_file_size(), 50 * 1024 * 1024);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#[test]
|
|
392
|
+
fn test_permissive_config() {
|
|
393
|
+
let config = SecurityConfig::permissive();
|
|
394
|
+
assert!(config.inner.allowed.symlinks);
|
|
395
|
+
assert!(config.inner.allowed.hardlinks);
|
|
396
|
+
assert!(config.inner.allowed.absolute_paths);
|
|
397
|
+
assert!(config.get_preserve_permissions());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#[test]
|
|
401
|
+
fn test_builder_pattern_method_chaining() {
|
|
402
|
+
let mut config = SecurityConfig::new();
|
|
403
|
+
config.max_file_size(100_000_000).unwrap();
|
|
404
|
+
config.max_total_size(1_000_000_000).unwrap();
|
|
405
|
+
config.max_file_count(50_000);
|
|
406
|
+
|
|
407
|
+
assert_eq!(config.get_max_file_size(), 100_000_000);
|
|
408
|
+
assert_eq!(config.get_max_total_size(), 1_000_000_000);
|
|
409
|
+
assert_eq!(config.get_max_file_count(), 50_000);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn test_builder_compression_ratio_valid() {
|
|
414
|
+
let mut config = SecurityConfig::new();
|
|
415
|
+
let result = config.max_compression_ratio(200.0);
|
|
416
|
+
assert!(result.is_ok());
|
|
417
|
+
assert_eq!(config.get_max_compression_ratio(), 200.0);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#[test]
|
|
421
|
+
fn test_builder_compression_ratio_rejects_nan() {
|
|
422
|
+
let mut config = SecurityConfig::new();
|
|
423
|
+
let result = config.max_compression_ratio(f64::NAN);
|
|
424
|
+
assert!(result.is_err());
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#[test]
|
|
428
|
+
fn test_builder_compression_ratio_rejects_infinity() {
|
|
429
|
+
let mut config = SecurityConfig::new();
|
|
430
|
+
let result = config.max_compression_ratio(f64::INFINITY);
|
|
431
|
+
assert!(result.is_err());
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#[test]
|
|
435
|
+
fn test_builder_compression_ratio_rejects_negative() {
|
|
436
|
+
let mut config = SecurityConfig::new();
|
|
437
|
+
let result = config.max_compression_ratio(-10.0);
|
|
438
|
+
assert!(result.is_err());
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[test]
|
|
442
|
+
fn test_builder_compression_ratio_rejects_zero() {
|
|
443
|
+
let mut config = SecurityConfig::new();
|
|
444
|
+
let result = config.max_compression_ratio(0.0);
|
|
445
|
+
assert!(result.is_err());
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#[test]
|
|
449
|
+
fn test_add_allowed_extension_valid() {
|
|
450
|
+
let mut config = SecurityConfig::new();
|
|
451
|
+
let result = config.add_allowed_extension(".txt".to_string());
|
|
452
|
+
assert!(result.is_ok());
|
|
453
|
+
assert!(
|
|
454
|
+
config
|
|
455
|
+
.get_allowed_extensions()
|
|
456
|
+
.contains(&".txt".to_string())
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#[test]
|
|
461
|
+
fn test_add_allowed_extension_rejects_null_bytes() {
|
|
462
|
+
let mut config = SecurityConfig::new();
|
|
463
|
+
let result = config.add_allowed_extension(".txt\0".to_string());
|
|
464
|
+
assert!(result.is_err());
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#[test]
|
|
468
|
+
fn test_add_allowed_extension_rejects_too_long() {
|
|
469
|
+
let mut config = SecurityConfig::new();
|
|
470
|
+
let long_ext = "x".repeat(MAX_EXTENSION_LENGTH + 1);
|
|
471
|
+
let result = config.add_allowed_extension(long_ext);
|
|
472
|
+
assert!(result.is_err());
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#[test]
|
|
476
|
+
fn test_add_banned_component_valid() {
|
|
477
|
+
let mut config = SecurityConfig::new();
|
|
478
|
+
let result = config.add_banned_component("node_modules".to_string());
|
|
479
|
+
assert!(result.is_ok());
|
|
480
|
+
assert!(
|
|
481
|
+
config
|
|
482
|
+
.get_banned_path_components()
|
|
483
|
+
.contains(&"node_modules".to_string())
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#[test]
|
|
488
|
+
fn test_add_banned_component_rejects_null_bytes() {
|
|
489
|
+
let mut config = SecurityConfig::new();
|
|
490
|
+
let result = config.add_banned_component("bad\0".to_string());
|
|
491
|
+
assert!(result.is_err());
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#[test]
|
|
495
|
+
fn test_add_banned_component_rejects_too_long() {
|
|
496
|
+
let mut config = SecurityConfig::new();
|
|
497
|
+
let long_component = "x".repeat(MAX_COMPONENT_LENGTH + 1);
|
|
498
|
+
let result = config.add_banned_component(long_component);
|
|
499
|
+
assert!(result.is_err());
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[test]
|
|
503
|
+
fn test_validation_methods() {
|
|
504
|
+
let config = SecurityConfig::new();
|
|
505
|
+
assert!(config.is_path_component_allowed("src".to_string()));
|
|
506
|
+
assert!(!config.is_path_component_allowed(".git".to_string()));
|
|
507
|
+
assert!(!config.is_path_component_allowed(".ssh".to_string()));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
#[test]
|
|
511
|
+
fn test_is_extension_allowed_empty_list() {
|
|
512
|
+
let config = SecurityConfig::new();
|
|
513
|
+
assert!(config.is_extension_allowed("txt".to_string()));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#[test]
|
|
517
|
+
fn test_as_core() {
|
|
518
|
+
let config = SecurityConfig::new();
|
|
519
|
+
let core_config = config.as_core();
|
|
520
|
+
assert_eq!(core_config.max_file_size, 50 * 1024 * 1024);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Integer boundary tests
|
|
524
|
+
#[test]
|
|
525
|
+
fn test_max_file_size_negative_value() {
|
|
526
|
+
let mut config = SecurityConfig::new();
|
|
527
|
+
let result = config.max_file_size(-1);
|
|
528
|
+
assert!(result.is_err(), "negative file size should be rejected");
|
|
529
|
+
assert!(
|
|
530
|
+
result.unwrap_err().to_string().contains("negative"),
|
|
531
|
+
"error should mention negative value"
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#[test]
|
|
536
|
+
fn test_max_file_size_i64_max() {
|
|
537
|
+
let mut config = SecurityConfig::new();
|
|
538
|
+
let result = config.max_file_size(i64::MAX);
|
|
539
|
+
assert!(result.is_ok(), "i64::MAX should be accepted");
|
|
540
|
+
assert_eq!(
|
|
541
|
+
config.get_max_file_size(),
|
|
542
|
+
i64::MAX,
|
|
543
|
+
"value should be stored correctly"
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#[test]
|
|
548
|
+
fn test_max_total_size_negative_value() {
|
|
549
|
+
let mut config = SecurityConfig::new();
|
|
550
|
+
let result = config.max_total_size(-1);
|
|
551
|
+
assert!(result.is_err(), "negative total size should be rejected");
|
|
552
|
+
assert!(
|
|
553
|
+
result.unwrap_err().to_string().contains("negative"),
|
|
554
|
+
"error should mention negative value"
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#[test]
|
|
559
|
+
fn test_max_total_size_i64_max() {
|
|
560
|
+
let mut config = SecurityConfig::new();
|
|
561
|
+
let result = config.max_total_size(i64::MAX);
|
|
562
|
+
assert!(result.is_ok(), "i64::MAX should be accepted");
|
|
563
|
+
assert_eq!(
|
|
564
|
+
config.get_max_total_size(),
|
|
565
|
+
i64::MAX,
|
|
566
|
+
"value should be stored correctly"
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn test_max_file_count_u32_max() {
|
|
572
|
+
let mut config = SecurityConfig::new();
|
|
573
|
+
config.max_file_count(u32::MAX);
|
|
574
|
+
assert_eq!(
|
|
575
|
+
config.get_max_file_count(),
|
|
576
|
+
u32::MAX,
|
|
577
|
+
"u32::MAX should be accepted"
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#[test]
|
|
582
|
+
fn test_max_path_depth_u32_max() {
|
|
583
|
+
let mut config = SecurityConfig::new();
|
|
584
|
+
config.max_path_depth(u32::MAX);
|
|
585
|
+
assert_eq!(
|
|
586
|
+
config.get_max_path_depth(),
|
|
587
|
+
u32::MAX,
|
|
588
|
+
"u32::MAX should be accepted"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#[test]
|
|
593
|
+
fn test_max_file_size_zero() {
|
|
594
|
+
let mut config = SecurityConfig::new();
|
|
595
|
+
let result = config.max_file_size(0);
|
|
596
|
+
assert!(result.is_ok(), "zero file size should be accepted");
|
|
597
|
+
assert_eq!(config.get_max_file_size(), 0);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Property getter tests
|
|
601
|
+
#[test]
|
|
602
|
+
fn test_property_getters_return_correct_values() {
|
|
603
|
+
let mut config = SecurityConfig::new();
|
|
604
|
+
config.max_file_size(100_000_000).unwrap();
|
|
605
|
+
config.max_total_size(1_000_000_000).unwrap();
|
|
606
|
+
config.max_compression_ratio(250.0).unwrap();
|
|
607
|
+
config.max_file_count(50_000);
|
|
608
|
+
config.max_path_depth(64);
|
|
609
|
+
config.preserve_permissions(Some(true));
|
|
610
|
+
|
|
611
|
+
assert_eq!(
|
|
612
|
+
config.get_max_file_size(),
|
|
613
|
+
100_000_000,
|
|
614
|
+
"max_file_size getter should return set value"
|
|
615
|
+
);
|
|
616
|
+
assert_eq!(
|
|
617
|
+
config.get_max_total_size(),
|
|
618
|
+
1_000_000_000,
|
|
619
|
+
"max_total_size getter should return set value"
|
|
620
|
+
);
|
|
621
|
+
assert_eq!(
|
|
622
|
+
config.get_max_compression_ratio(),
|
|
623
|
+
250.0,
|
|
624
|
+
"max_compression_ratio getter should return set value"
|
|
625
|
+
);
|
|
626
|
+
assert_eq!(
|
|
627
|
+
config.get_max_file_count(),
|
|
628
|
+
50_000,
|
|
629
|
+
"max_file_count getter should return set value"
|
|
630
|
+
);
|
|
631
|
+
assert_eq!(
|
|
632
|
+
config.get_max_path_depth(),
|
|
633
|
+
64,
|
|
634
|
+
"max_path_depth getter should return set value"
|
|
635
|
+
);
|
|
636
|
+
assert!(
|
|
637
|
+
config.get_preserve_permissions(),
|
|
638
|
+
"preserve_permissions getter should return set value"
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#[test]
|
|
643
|
+
fn test_allowed_extensions_getter_after_add() {
|
|
644
|
+
let mut config = SecurityConfig::new();
|
|
645
|
+
config.add_allowed_extension(".txt".to_string()).unwrap();
|
|
646
|
+
config.add_allowed_extension(".md".to_string()).unwrap();
|
|
647
|
+
|
|
648
|
+
let extensions = config.get_allowed_extensions();
|
|
649
|
+
assert_eq!(extensions.len(), 2, "should have 2 allowed extensions");
|
|
650
|
+
assert!(
|
|
651
|
+
extensions.contains(&".txt".to_string()),
|
|
652
|
+
"should contain .txt"
|
|
653
|
+
);
|
|
654
|
+
assert!(
|
|
655
|
+
extensions.contains(&".md".to_string()),
|
|
656
|
+
"should contain .md"
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
#[test]
|
|
661
|
+
fn test_banned_components_getter_after_add() {
|
|
662
|
+
let mut config = SecurityConfig::new();
|
|
663
|
+
// Default config already has 7 banned components
|
|
664
|
+
let initial_count = config.get_banned_path_components().len();
|
|
665
|
+
|
|
666
|
+
config
|
|
667
|
+
.add_banned_component("node_modules".to_string())
|
|
668
|
+
.unwrap();
|
|
669
|
+
config
|
|
670
|
+
.add_banned_component("test_component".to_string())
|
|
671
|
+
.unwrap();
|
|
672
|
+
|
|
673
|
+
let components = config.get_banned_path_components();
|
|
674
|
+
assert_eq!(
|
|
675
|
+
components.len(),
|
|
676
|
+
initial_count + 2,
|
|
677
|
+
"should have 2 additional banned components"
|
|
678
|
+
);
|
|
679
|
+
assert!(
|
|
680
|
+
components.contains(&"node_modules".to_string()),
|
|
681
|
+
"should contain node_modules"
|
|
682
|
+
);
|
|
683
|
+
assert!(
|
|
684
|
+
components.contains(&"test_component".to_string()),
|
|
685
|
+
"should contain test_component"
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Validation edge case tests
|
|
690
|
+
#[test]
|
|
691
|
+
fn test_is_path_component_allowed_empty_string() {
|
|
692
|
+
let config = SecurityConfig::new();
|
|
693
|
+
assert!(
|
|
694
|
+
config.is_path_component_allowed("".to_string()),
|
|
695
|
+
"empty string should be allowed by default"
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
#[test]
|
|
700
|
+
fn test_is_path_component_allowed_unicode() {
|
|
701
|
+
let config = SecurityConfig::new();
|
|
702
|
+
assert!(
|
|
703
|
+
config.is_path_component_allowed("日本語".to_string()),
|
|
704
|
+
"unicode should be allowed"
|
|
705
|
+
);
|
|
706
|
+
assert!(
|
|
707
|
+
config.is_path_component_allowed("файл".to_string()),
|
|
708
|
+
"cyrillic should be allowed"
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#[test]
|
|
713
|
+
fn test_is_path_component_allowed_with_spaces() {
|
|
714
|
+
let config = SecurityConfig::new();
|
|
715
|
+
assert!(
|
|
716
|
+
config.is_path_component_allowed("my file".to_string()),
|
|
717
|
+
"spaces should be allowed"
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
#[test]
|
|
722
|
+
fn test_is_path_component_allowed_special_chars() {
|
|
723
|
+
let config = SecurityConfig::new();
|
|
724
|
+
assert!(
|
|
725
|
+
config.is_path_component_allowed("file@special#chars".to_string()),
|
|
726
|
+
"special characters should be allowed"
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
#[test]
|
|
731
|
+
fn test_is_extension_allowed_with_allowed_list() {
|
|
732
|
+
let mut config = SecurityConfig::new();
|
|
733
|
+
config.add_allowed_extension(".txt".to_string()).unwrap();
|
|
734
|
+
config.add_allowed_extension(".md".to_string()).unwrap();
|
|
735
|
+
|
|
736
|
+
assert!(
|
|
737
|
+
config.is_extension_allowed(".txt".to_string()),
|
|
738
|
+
".txt should be in allowed list"
|
|
739
|
+
);
|
|
740
|
+
assert!(
|
|
741
|
+
config.is_extension_allowed(".md".to_string()),
|
|
742
|
+
".md should be in allowed list"
|
|
743
|
+
);
|
|
744
|
+
assert!(
|
|
745
|
+
!config.is_extension_allowed(".exe".to_string()),
|
|
746
|
+
".exe should not be in allowed list"
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#[test]
|
|
751
|
+
fn test_is_extension_allowed_empty_string() {
|
|
752
|
+
let config = SecurityConfig::new();
|
|
753
|
+
assert!(
|
|
754
|
+
config.is_extension_allowed("".to_string()),
|
|
755
|
+
"empty extension should be allowed when no allowed list"
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
#[test]
|
|
760
|
+
fn test_is_extension_allowed_case_sensitive() {
|
|
761
|
+
let mut config = SecurityConfig::new();
|
|
762
|
+
config.add_allowed_extension(".txt".to_string()).unwrap();
|
|
763
|
+
|
|
764
|
+
assert!(
|
|
765
|
+
config.is_extension_allowed(".txt".to_string()),
|
|
766
|
+
"exact case should match"
|
|
767
|
+
);
|
|
768
|
+
// NOTE: Core library uses case-insensitive matching for extensions
|
|
769
|
+
// This is intentional for cross-platform compatibility
|
|
770
|
+
assert!(
|
|
771
|
+
config.is_extension_allowed(".TXT".to_string()),
|
|
772
|
+
"case-insensitive matching is used"
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// build() method tests
|
|
777
|
+
#[test]
|
|
778
|
+
fn test_build_returns_self() {
|
|
779
|
+
let config = SecurityConfig::new();
|
|
780
|
+
let built = config.build();
|
|
781
|
+
assert_eq!(
|
|
782
|
+
built.get_max_file_size(),
|
|
783
|
+
config.get_max_file_size(),
|
|
784
|
+
"build() should return same reference"
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
#[test]
|
|
789
|
+
fn test_builder_pattern_with_build() {
|
|
790
|
+
let mut config = SecurityConfig::new();
|
|
791
|
+
config.max_file_size(100_000_000).unwrap();
|
|
792
|
+
config.max_total_size(1_000_000_000).unwrap();
|
|
793
|
+
let result = config.build();
|
|
794
|
+
|
|
795
|
+
assert_eq!(
|
|
796
|
+
result.get_max_file_size(),
|
|
797
|
+
100_000_000,
|
|
798
|
+
"build() should work in builder chain"
|
|
799
|
+
);
|
|
800
|
+
assert_eq!(result.get_max_total_size(), 1_000_000_000);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Float edge case tests
|
|
804
|
+
#[test]
|
|
805
|
+
fn test_builder_compression_ratio_rejects_negative_infinity() {
|
|
806
|
+
let mut config = SecurityConfig::new();
|
|
807
|
+
let result = config.max_compression_ratio(f64::NEG_INFINITY);
|
|
808
|
+
assert!(result.is_err(), "negative infinity should be rejected");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
#[test]
|
|
812
|
+
fn test_builder_compression_ratio_accepts_very_small_positive() {
|
|
813
|
+
let mut config = SecurityConfig::new();
|
|
814
|
+
let result = config.max_compression_ratio(0.000001);
|
|
815
|
+
assert!(
|
|
816
|
+
result.is_ok(),
|
|
817
|
+
"very small positive values should be accepted"
|
|
818
|
+
);
|
|
819
|
+
assert_eq!(config.get_max_compression_ratio(), 0.000001);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
#[test]
|
|
823
|
+
fn test_builder_compression_ratio_accepts_very_large() {
|
|
824
|
+
let mut config = SecurityConfig::new();
|
|
825
|
+
let result = config.max_compression_ratio(1_000_000.0);
|
|
826
|
+
assert!(result.is_ok(), "very large values should be accepted");
|
|
827
|
+
assert_eq!(config.get_max_compression_ratio(), 1_000_000.0);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Clone and Debug tests
|
|
831
|
+
#[test]
|
|
832
|
+
fn test_security_config_clone() {
|
|
833
|
+
let mut config = SecurityConfig::new();
|
|
834
|
+
let _ = config.max_file_size(100_000_000).unwrap();
|
|
835
|
+
|
|
836
|
+
let cloned = config.clone();
|
|
837
|
+
assert_eq!(
|
|
838
|
+
cloned.get_max_file_size(),
|
|
839
|
+
100_000_000,
|
|
840
|
+
"cloned config should have same values"
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
#[test]
|
|
845
|
+
fn test_security_config_debug() {
|
|
846
|
+
let config = SecurityConfig::new();
|
|
847
|
+
let debug_str = format!("{:?}", config);
|
|
848
|
+
assert!(!debug_str.is_empty(), "debug output should not be empty");
|
|
849
|
+
assert!(
|
|
850
|
+
debug_str.contains("SecurityConfig"),
|
|
851
|
+
"debug output should contain type name"
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Tests for non-cloning getters
|
|
856
|
+
#[test]
|
|
857
|
+
fn test_get_allowed_extensions_count() {
|
|
858
|
+
let mut config = SecurityConfig::new();
|
|
859
|
+
assert_eq!(
|
|
860
|
+
config.get_allowed_extensions_count(),
|
|
861
|
+
0,
|
|
862
|
+
"initial count should be 0"
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
config.add_allowed_extension(".txt".to_string()).unwrap();
|
|
866
|
+
config.add_allowed_extension(".md".to_string()).unwrap();
|
|
867
|
+
|
|
868
|
+
assert_eq!(
|
|
869
|
+
config.get_allowed_extensions_count(),
|
|
870
|
+
2,
|
|
871
|
+
"count should reflect added extensions"
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
#[test]
|
|
876
|
+
fn test_has_allowed_extension() {
|
|
877
|
+
let mut config = SecurityConfig::new();
|
|
878
|
+
config.add_allowed_extension(".txt".to_string()).unwrap();
|
|
879
|
+
|
|
880
|
+
assert!(
|
|
881
|
+
config.has_allowed_extension(".txt".to_string()),
|
|
882
|
+
"should find .txt"
|
|
883
|
+
);
|
|
884
|
+
assert!(
|
|
885
|
+
!config.has_allowed_extension(".md".to_string()),
|
|
886
|
+
"should not find .md"
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
#[test]
|
|
891
|
+
fn test_get_banned_path_components_count() {
|
|
892
|
+
let mut config = SecurityConfig::new();
|
|
893
|
+
// Default config already has 7 banned components
|
|
894
|
+
let initial_count = config.get_banned_path_components_count();
|
|
895
|
+
|
|
896
|
+
config
|
|
897
|
+
.add_banned_component("node_modules".to_string())
|
|
898
|
+
.unwrap();
|
|
899
|
+
config
|
|
900
|
+
.add_banned_component("custom_dir".to_string())
|
|
901
|
+
.unwrap();
|
|
902
|
+
|
|
903
|
+
assert_eq!(
|
|
904
|
+
config.get_banned_path_components_count(),
|
|
905
|
+
initial_count + 2,
|
|
906
|
+
"count should reflect added components"
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
#[test]
|
|
911
|
+
fn test_has_banned_path_component() {
|
|
912
|
+
let mut config = SecurityConfig::new();
|
|
913
|
+
config
|
|
914
|
+
.add_banned_component("node_modules".to_string())
|
|
915
|
+
.unwrap();
|
|
916
|
+
|
|
917
|
+
assert!(
|
|
918
|
+
config.has_banned_path_component("node_modules".to_string()),
|
|
919
|
+
"should find node_modules"
|
|
920
|
+
);
|
|
921
|
+
// .git is in default banned components
|
|
922
|
+
assert!(
|
|
923
|
+
config.has_banned_path_component(".git".to_string()),
|
|
924
|
+
".git should be in default banned components"
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
}
|