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/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
+ }