1use std::collections::HashMap;
9use std::path::PathBuf;
10
11use crate::ir::ArgumentSource;
12use crate::parser::ParsedFile;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SanitizerCategory {
17 Path,
18 Network,
19 Redaction,
20 TypeCoercion,
21}
22
23impl SanitizerCategory {
24 pub fn as_str(self) -> &'static str {
25 match self {
26 Self::Path => "path",
27 Self::Network => "network",
28 Self::Redaction => "redaction",
29 Self::TypeCoercion => "type",
30 }
31 }
32}
33
34use crate::ir::SinkClass;
35
36static PATH_SANITIZER_NAMES: &[&str] = &[
38 "validatePath",
39 "sanitizePath",
40 "normalizePath",
41 "resolvePath",
42 "canonicalizePath",
43 "realpath",
44 "path.resolve",
45 "path.normalize",
46 "resolve",
47 "normalize",
48 "os.path.realpath",
49 "os.path.abspath",
50 "os.path.normpath",
51 "abspath",
52 "normpath",
53];
54
55static NETWORK_SANITIZER_NAMES: &[&str] = &[
58 "validateUrl",
59 "validateURL",
60 "validateUri",
61 "validateURI",
62 "validateAllowedUrl",
63 "validateAllowedURL",
64 "validateAllowedUri",
65 "validateAllowedURI",
66 "allowlistUrl",
67 "allowlistURL",
68 "allowlistUri",
69 "allowlistURI",
70 "ensureAllowedUrl",
71 "ensureAllowedURL",
72 "ensureAllowedUri",
73 "ensureAllowedURI",
74 "assertAllowedUrl",
75 "assertAllowedURL",
76 "assertAllowedUri",
77 "assertAllowedURI",
78];
79
80static TYPE_COERCION_SANITIZER_NAMES: &[&str] =
82 &["parseInt", "parseFloat", "Number", "int", "float", "str"];
83
84static REDACTION_SANITIZER_NAMES: &[&str] = &[
87 "redactSecret",
88 "redactSecrets",
89 "redactToken",
90 "redactCredentials",
91 "maskSecret",
92 "maskToken",
93 "maskCredentials",
94 "scrubSecret",
95 "scrubToken",
96 "scrubCredentials",
97];
98
99fn exact_or_method_match(name: &str, names: &[&str]) -> bool {
100 if names.contains(&name) {
101 return true;
102 }
103
104 name.rsplit('.')
105 .next()
106 .is_some_and(|method| names.contains(&method))
107}
108
109fn compact_lower(name: &str) -> String {
110 name.chars()
111 .filter(|ch| *ch != '_' && *ch != '-')
112 .flat_map(char::to_lowercase)
113 .collect()
114}
115
116pub fn sanitizer_category(name: &str) -> Option<SanitizerCategory> {
118 if let Some((prefix, _)) = name.split_once(':') {
119 return match prefix {
120 "path" => Some(SanitizerCategory::Path),
121 "network" => Some(SanitizerCategory::Network),
122 "redaction" => Some(SanitizerCategory::Redaction),
123 "type" => Some(SanitizerCategory::TypeCoercion),
124 _ => None,
125 };
126 }
127
128 if exact_or_method_match(name, REDACTION_SANITIZER_NAMES) {
129 return Some(SanitizerCategory::Redaction);
130 }
131
132 if exact_or_method_match(name, PATH_SANITIZER_NAMES) {
133 return Some(SanitizerCategory::Path);
134 }
135
136 if exact_or_method_match(name, NETWORK_SANITIZER_NAMES) {
137 return Some(SanitizerCategory::Network);
138 }
139
140 if exact_or_method_match(name, TYPE_COERCION_SANITIZER_NAMES) {
141 return Some(SanitizerCategory::TypeCoercion);
142 }
143
144 let lower = compact_lower(name);
145
146 if (lower.starts_with("validate") || lower.starts_with("sanitize")) && lower.contains("path") {
147 return Some(SanitizerCategory::Path);
148 }
149
150 if (lower.starts_with("validate")
151 || lower.starts_with("allowlist")
152 || lower.starts_with("ensureallowed")
153 || lower.starts_with("assertallowed"))
154 && (lower.contains("url")
155 || lower.contains("uri")
156 || lower.contains("host")
157 || lower.contains("domain"))
158 {
159 return Some(SanitizerCategory::Network);
160 }
161
162 None
163}
164
165pub fn is_sanitizer(name: &str) -> bool {
169 matches!(
170 sanitizer_category(name),
171 Some(
172 SanitizerCategory::Path | SanitizerCategory::Network | SanitizerCategory::TypeCoercion
173 )
174 )
175}
176
177pub fn is_redaction_sanitizer(name: &str) -> bool {
178 matches!(sanitizer_category(name), Some(SanitizerCategory::Redaction))
179}
180
181pub fn sanitizer_label(name: &str) -> Option<String> {
182 sanitizer_category(name).map(|category| format!("{}:{name}", category.as_str()))
183}
184
185pub(crate) fn sanitizer_allows_sink(sanitizer: &str, sink: SinkClass) -> bool {
193 if let Some(downgraded_sink) = cross_file_sink(sanitizer) {
195 return downgraded_sink == sink;
196 }
197
198 matches!(
199 (sanitizer_category(sanitizer), sink),
200 (Some(SanitizerCategory::Path), SinkClass::FilePath)
201 | (Some(SanitizerCategory::Network), SinkClass::NetworkUrl)
202 )
203}
204
205fn arg_safe_for_sink(arg: &ArgumentSource, sink: SinkClass) -> bool {
206 !arg.is_tainted_for_sink(sink)
207}
208
209const CROSS_FILE_SANITIZER_PREFIX: &str = "crossfile";
214
215fn cross_file_sanitizer_label(sink: SinkClass, func_name: &str) -> String {
216 let sink_tag = match sink {
217 SinkClass::Command => "command",
218 SinkClass::FilePath => "filepath",
219 SinkClass::NetworkUrl => "networkurl",
220 SinkClass::DynamicExec => "dynamicexec",
221 };
222 format!("{CROSS_FILE_SANITIZER_PREFIX}:{sink_tag}:caller passes sanitized value to {func_name}")
223}
224
225fn cross_file_sink(sanitizer: &str) -> Option<SinkClass> {
226 let rest = sanitizer
227 .strip_prefix(CROSS_FILE_SANITIZER_PREFIX)?
228 .strip_prefix(':')?;
229 let tag = rest.split(':').next()?;
230 match tag {
231 "command" => Some(SinkClass::Command),
232 "filepath" => Some(SinkClass::FilePath),
233 "networkurl" => Some(SinkClass::NetworkUrl),
234 "dynamicexec" => Some(SinkClass::DynamicExec),
235 _ => None,
236 }
237}
238
239fn all_call_sites_safe_for_sink(
240 sites: &[Vec<ArgumentSource>],
241 param_idx: usize,
242 sink: SinkClass,
243) -> bool {
244 sites.iter().all(|args| {
245 args.get(param_idx)
246 .is_some_and(|arg| arg_safe_for_sink(arg, sink))
247 })
248}
249
250#[derive(Debug)]
252pub struct CrossFileResult {
253 pub downgraded_count: usize,
255 pub sanitized_functions: Vec<String>,
257}
258
259pub fn apply_cross_file_sanitization(
268 parsed_files: &mut [(PathBuf, ParsedFile)],
269) -> CrossFileResult {
270 let mut downgraded_count = 0;
271 let mut sanitized_functions = Vec::new();
272
273 let mut func_defs: HashMap<String, Vec<(usize, Vec<String>, bool)>> = HashMap::new();
276 for (idx, (_, parsed)) in parsed_files.iter().enumerate() {
277 for def in &parsed.function_defs {
278 func_defs.entry(def.name.clone()).or_default().push((
279 idx,
280 def.params.clone(),
281 def.is_exported,
282 ));
283 }
284 }
285
286 let mut call_sites: HashMap<String, Vec<Vec<ArgumentSource>>> = HashMap::new();
289 for (_, parsed) in parsed_files.iter() {
290 for cs in &parsed.call_sites {
291 call_sites
292 .entry(cs.callee.clone())
293 .or_default()
294 .push(cs.arguments.clone());
295 }
296 }
297
298 let mut params_to_downgrade: Vec<(usize, String, String, SinkClass)> = Vec::new();
302
303 for (func_name, defs) in &func_defs {
304 let sites = match call_sites.get(func_name) {
305 Some(s) if !s.is_empty() => s,
306 _ => {
307 continue;
309 }
310 };
311
312 for (file_idx, params, _is_exported) in defs {
313 for (param_idx, param_name) in params.iter().enumerate() {
315 for sink in [
316 SinkClass::Command,
317 SinkClass::FilePath,
318 SinkClass::NetworkUrl,
319 SinkClass::DynamicExec,
320 ] {
321 if all_call_sites_safe_for_sink(sites, param_idx, sink) {
322 params_to_downgrade.push((
323 *file_idx,
324 param_name.clone(),
325 func_name.clone(),
326 sink,
327 ));
328 }
329 }
330 }
331 }
332 }
333
334 for (file_idx, param_name, func_name, sink) in ¶ms_to_downgrade {
336 let (_, parsed) = &mut parsed_files[*file_idx];
337 let sanitizer_label = cross_file_sanitizer_label(*sink, func_name);
342
343 let sanitized = ArgumentSource::Sanitized {
344 sanitizer: sanitizer_label.clone(),
345 };
346 let mut local_downgraded = 0;
347
348 match sink {
349 SinkClass::Command => {
350 for cmd in &mut parsed.commands {
351 if matches!(&cmd.command_arg, ArgumentSource::Parameter { name } if name == param_name)
352 {
353 cmd.command_arg = sanitized.clone();
354 downgraded_count += 1;
355 local_downgraded += 1;
356 }
357 }
358 }
359 SinkClass::FilePath => {
360 for op in &mut parsed.file_operations {
361 if matches!(&op.path_arg, ArgumentSource::Parameter { name } if name == param_name)
362 {
363 op.path_arg = sanitized.clone();
364 downgraded_count += 1;
365 local_downgraded += 1;
366 }
367 }
368 }
369 SinkClass::NetworkUrl => {
370 for op in &mut parsed.network_operations {
371 if matches!(&op.url_arg, ArgumentSource::Parameter { name } if name == param_name)
372 {
373 op.url_arg = sanitized.clone();
374 downgraded_count += 1;
375 local_downgraded += 1;
376 }
377 }
378 }
379 SinkClass::DynamicExec => {
380 for op in &mut parsed.dynamic_exec {
381 if matches!(&op.code_arg, ArgumentSource::Parameter { name } if name == param_name)
382 {
383 op.code_arg = sanitized.clone();
384 downgraded_count += 1;
385 local_downgraded += 1;
386 }
387 }
388 }
389 }
390
391 if local_downgraded > 0 && !sanitized_functions.contains(func_name) {
392 sanitized_functions.push(func_name.clone());
393 }
394 }
395
396 CrossFileResult {
397 downgraded_count,
398 sanitized_functions,
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::adapter::auto_detect_and_load;
406 use crate::ir::execution_surface::{FileOpType, FileOperation};
407 use crate::ir::SourceLocation;
408 use crate::parser::{CallSite, FunctionDef};
409 use crate::rules::{Finding, RuleEngine};
410
411 fn loc(file: &str, line: usize) -> SourceLocation {
412 SourceLocation {
413 file: PathBuf::from(file),
414 line,
415 column: 0,
416 end_line: None,
417 end_column: None,
418 }
419 }
420
421 fn fixture_findings(name: &str) -> Vec<Finding> {
422 let fixture_path = PathBuf::from("tests/fixtures/mcp_servers").join(name);
423 let engine = RuleEngine::new();
424
425 auto_detect_and_load(&fixture_path, false)
426 .unwrap_or_else(|err| panic!("failed to load fixture {name}: {err}"))
427 .iter()
428 .flat_map(|target| engine.run(target))
429 .collect()
430 }
431
432 #[test]
433 fn sanitizer_names_recognized() {
434 assert!(is_sanitizer("validatePath"));
435 assert!(is_sanitizer("path.resolve"));
436 assert!(is_sanitizer("os.path.realpath"));
437 assert!(!is_sanitizer("URL.parse"));
438 assert!(is_sanitizer("parseInt"));
439 assert!(!is_sanitizer("urlparse"));
440 assert!(!is_sanitizer("sanitizeSecret"));
441 assert!(is_sanitizer("validateUrl"));
442 assert!(!is_sanitizer("processData"));
443 assert!(!is_sanitizer("readFile"));
444 }
445
446 #[test]
447 fn custom_validate_path_recognized() {
448 assert!(is_sanitizer("validate_path"));
449 assert!(is_sanitizer("validateUrl"));
450 assert!(is_sanitizer("sanitizeCustomPath"));
451 }
452
453 #[test]
454 fn redaction_helpers_recognized() {
455 assert!(is_redaction_sanitizer("redactSecret"));
456 assert!(is_redaction_sanitizer("redactSecrets"));
457 assert!(is_redaction_sanitizer("redactToken"));
458 assert!(is_redaction_sanitizer("redactCredentials"));
459 assert!(is_redaction_sanitizer("maskSecret"));
460 assert!(is_redaction_sanitizer("maskToken"));
461 assert!(is_redaction_sanitizer("maskCredentials"));
462 assert!(is_redaction_sanitizer("scrubSecret"));
463 assert!(is_redaction_sanitizer("scrubToken"));
464 assert!(is_redaction_sanitizer("scrubCredentials"));
465 assert!(!is_sanitizer("redactSecret"));
466 }
467
468 #[test]
469 fn cross_file_downgrade() {
470 let mut file_a = ParsedFile::default();
472 file_a.call_sites.push(CallSite {
473 callee: "readFileContent".into(),
474 arguments: vec![ArgumentSource::Sanitized {
475 sanitizer: "validatePath".into(),
476 }],
477 caller: Some("handleRead".into()),
478 location: loc("index.ts", 5),
479 });
480
481 let mut file_b = ParsedFile::default();
483 file_b.function_defs.push(FunctionDef {
484 name: "readFileContent".into(),
485 params: vec!["filePath".into()],
486 is_exported: true,
487 location: loc("lib.ts", 1),
488 });
489 file_b.file_operations.push(FileOperation {
490 path_arg: ArgumentSource::Parameter {
491 name: "filePath".into(),
492 },
493 operation: FileOpType::Read,
494 location: loc("lib.ts", 3),
495 });
496
497 let mut files = vec![
498 (PathBuf::from("index.ts"), file_a),
499 (PathBuf::from("lib.ts"), file_b),
500 ];
501
502 let result = apply_cross_file_sanitization(&mut files);
503
504 assert_eq!(result.downgraded_count, 1);
505 assert_eq!(result.sanitized_functions, vec!["readFileContent"]);
506
507 let lib_ops = &files[1].1.file_operations;
509 assert!(!lib_ops[0].path_arg.is_tainted());
510 assert!(matches!(
511 &lib_ops[0].path_arg,
512 ArgumentSource::Sanitized { .. }
513 ));
514 }
515
516 #[test]
517 fn redaction_sanitizers_do_not_downgrade_file_paths() {
518 let mut file_a = ParsedFile::default();
519 file_a.call_sites.push(CallSite {
520 callee: "logRedactedValues".into(),
521 arguments: vec![
522 ArgumentSource::Sanitized {
523 sanitizer: "redactSecret".into(),
524 },
525 ArgumentSource::Sanitized {
526 sanitizer: "maskToken".into(),
527 },
528 ArgumentSource::Sanitized {
529 sanitizer: "scrubCredentials".into(),
530 },
531 ],
532 caller: Some("handleLog".into()),
533 location: loc("index.ts", 8),
534 });
535
536 let mut file_b = ParsedFile::default();
537 file_b.function_defs.push(FunctionDef {
538 name: "logRedactedValues".into(),
539 params: vec!["secret".into(), "token".into(), "credentials".into()],
540 is_exported: true,
541 location: loc("logger.ts", 1),
542 });
543 file_b.file_operations.push(FileOperation {
544 path_arg: ArgumentSource::Parameter {
545 name: "secret".into(),
546 },
547 operation: FileOpType::Write,
548 location: loc("logger.ts", 3),
549 });
550 file_b.file_operations.push(FileOperation {
551 path_arg: ArgumentSource::Parameter {
552 name: "token".into(),
553 },
554 operation: FileOpType::Write,
555 location: loc("logger.ts", 4),
556 });
557 file_b.file_operations.push(FileOperation {
558 path_arg: ArgumentSource::Parameter {
559 name: "credentials".into(),
560 },
561 operation: FileOpType::Write,
562 location: loc("logger.ts", 5),
563 });
564
565 let mut files = vec![
566 (PathBuf::from("index.ts"), file_a),
567 (PathBuf::from("logger.ts"), file_b),
568 ];
569
570 let result = apply_cross_file_sanitization(&mut files);
571
572 assert_eq!(result.downgraded_count, 0);
573 assert!(result.sanitized_functions.is_empty());
574 for op in &files[1].1.file_operations {
575 assert!(
576 op.path_arg.is_tainted(),
577 "redaction-sanitized argument must not downgrade file paths"
578 );
579 }
580 }
581
582 #[test]
583 fn url_parse_does_not_downgrade_network_sink() {
584 let mut file_a = ParsedFile::default();
585 file_a.call_sites.push(CallSite {
586 callee: "fetchRemote".into(),
587 arguments: vec![ArgumentSource::Sanitized {
588 sanitizer: "URL.parse".into(),
589 }],
590 caller: Some("handler".into()),
591 location: loc("index.ts", 5),
592 });
593
594 let mut file_b = ParsedFile::default();
595 file_b.function_defs.push(FunctionDef {
596 name: "fetchRemote".into(),
597 params: vec!["url".into()],
598 is_exported: true,
599 location: loc("net.ts", 1),
600 });
601 file_b
602 .network_operations
603 .push(crate::ir::execution_surface::NetworkOperation {
604 function: "fetch".into(),
605 url_arg: ArgumentSource::Parameter { name: "url".into() },
606 method: Some("GET".into()),
607 sends_data: false,
608 location: loc("net.ts", 3),
609 });
610
611 let mut files = vec![
612 (PathBuf::from("index.ts"), file_a),
613 (PathBuf::from("net.ts"), file_b),
614 ];
615
616 let result = apply_cross_file_sanitization(&mut files);
617
618 assert_eq!(result.downgraded_count, 0);
619 assert!(files[1].1.network_operations[0].url_arg.is_tainted());
620 }
621
622 #[test]
623 fn url_parse_ssrf_fixture_still_flags_ssrf() {
624 let findings = fixture_findings("vuln_url_parse_ssrf");
625
626 assert!(
627 findings
628 .iter()
629 .any(|finding| finding.rule_id == "SHIELD-003"),
630 "URL.parse fixture should still trigger SSRF: {findings:?}"
631 );
632 }
633
634 #[test]
635 fn redacted_file_access_fixture_still_flags_arbitrary_file_access() {
636 let findings = fixture_findings("vuln_redacted_file_access");
637
638 assert!(
639 findings
640 .iter()
641 .any(|finding| finding.rule_id == "SHIELD-004"),
642 "redacted file path fixture should still trigger arbitrary file access: {findings:?}"
643 );
644 }
645
646 #[test]
647 fn wrong_category_sanitizer_does_not_suppress_file_sink() {
648 let findings = fixture_findings("vuln_wrong_category_sanitizer");
651
652 assert!(
653 findings
654 .iter()
655 .any(|finding| finding.rule_id == "SHIELD-004"),
656 "a network validator on a file-path sink must still trigger arbitrary file access: {findings:?}"
657 );
658 }
659
660 #[test]
661 fn type_coercion_does_not_suppress_eval_sink() {
662 let findings = fixture_findings("vuln_coercion_eval");
666
667 assert!(
668 findings
669 .iter()
670 .any(|finding| finding.rule_id == "SHIELD-011"),
671 "type coercion on an eval sink must still trigger dynamic exec: {findings:?}"
672 );
673 }
674
675 #[test]
676 fn type_coercion_is_not_a_command_sanitizer() {
677 let coerced = ArgumentSource::Sanitized {
681 sanitizer: "type:str".into(),
682 };
683 assert!(
684 !arg_safe_for_sink(&coerced, SinkClass::Command),
685 "type coercion must not sanitize a command sink"
686 );
687 assert!(
688 !arg_safe_for_sink(&coerced, SinkClass::DynamicExec),
689 "type coercion must not sanitize a dynamic-exec sink"
690 );
691 }
692
693 #[test]
694 fn argument_source_is_tainted_for_sink_respects_category() {
695 let net = ArgumentSource::Sanitized {
698 sanitizer: "network:validateUrl".into(),
699 };
700 assert!(!net.is_tainted_for_sink(SinkClass::NetworkUrl));
701 assert!(net.is_tainted_for_sink(SinkClass::FilePath));
702
703 let path = ArgumentSource::Sanitized {
704 sanitizer: "path:validatePath".into(),
705 };
706 assert!(!path.is_tainted_for_sink(SinkClass::FilePath));
707 assert!(path.is_tainted_for_sink(SinkClass::NetworkUrl));
708 }
709
710 #[test]
711 fn no_downgrade_when_unsanitized_caller_exists() {
712 let mut file_a = ParsedFile::default();
714 file_a.call_sites.push(CallSite {
715 callee: "readFile".into(),
716 arguments: vec![ArgumentSource::Sanitized {
717 sanitizer: "validatePath".into(),
718 }],
719 caller: Some("safeHandler".into()),
720 location: loc("safe.ts", 5),
721 });
722 file_a.call_sites.push(CallSite {
723 callee: "readFile".into(),
724 arguments: vec![ArgumentSource::Parameter {
725 name: "userInput".into(),
726 }],
727 caller: Some("unsafeHandler".into()),
728 location: loc("safe.ts", 10),
729 });
730
731 let mut file_b = ParsedFile::default();
732 file_b.function_defs.push(FunctionDef {
733 name: "readFile".into(),
734 params: vec!["path".into()],
735 is_exported: true,
736 location: loc("lib.ts", 1),
737 });
738 file_b.file_operations.push(FileOperation {
739 path_arg: ArgumentSource::Parameter {
740 name: "path".into(),
741 },
742 operation: FileOpType::Read,
743 location: loc("lib.ts", 3),
744 });
745
746 let mut files = vec![
747 (PathBuf::from("safe.ts"), file_a),
748 (PathBuf::from("lib.ts"), file_b),
749 ];
750
751 let result = apply_cross_file_sanitization(&mut files);
752
753 assert_eq!(result.downgraded_count, 0);
754 assert!(files[1].1.file_operations[0].path_arg.is_tainted());
756 }
757
758 #[test]
759 fn no_downgrade_for_exported_with_no_callers() {
760 let mut file_a = ParsedFile::default();
761 file_a.function_defs.push(FunctionDef {
762 name: "dangerousFunc".into(),
763 params: vec!["input".into()],
764 is_exported: true,
765 location: loc("lib.ts", 1),
766 });
767 file_a.file_operations.push(FileOperation {
768 path_arg: ArgumentSource::Parameter {
769 name: "input".into(),
770 },
771 operation: FileOpType::Write,
772 location: loc("lib.ts", 3),
773 });
774
775 let mut files = vec![(PathBuf::from("lib.ts"), file_a)];
776
777 let result = apply_cross_file_sanitization(&mut files);
778
779 assert_eq!(result.downgraded_count, 0);
780 assert!(files[0].1.file_operations[0].path_arg.is_tainted());
781 }
782
783 #[test]
784 fn downgrade_only_matching_params() {
785 let mut file_a = ParsedFile::default();
787 file_a.call_sites.push(CallSite {
788 callee: "copyFile".into(),
789 arguments: vec![
790 ArgumentSource::Sanitized {
791 sanitizer: "validatePath".into(),
792 },
793 ArgumentSource::Parameter {
794 name: "rawDest".into(),
795 },
796 ],
797 caller: Some("handler".into()),
798 location: loc("index.ts", 5),
799 });
800
801 let mut file_b = ParsedFile::default();
802 file_b.function_defs.push(FunctionDef {
803 name: "copyFile".into(),
804 params: vec!["src".into(), "dest".into()],
805 is_exported: true,
806 location: loc("lib.ts", 1),
807 });
808 file_b.file_operations.push(FileOperation {
810 path_arg: ArgumentSource::Parameter { name: "src".into() },
811 operation: FileOpType::Read,
812 location: loc("lib.ts", 3),
813 });
814 file_b.file_operations.push(FileOperation {
815 path_arg: ArgumentSource::Parameter {
816 name: "dest".into(),
817 },
818 operation: FileOpType::Write,
819 location: loc("lib.ts", 4),
820 });
821
822 let mut files = vec![
823 (PathBuf::from("index.ts"), file_a),
824 (PathBuf::from("lib.ts"), file_b),
825 ];
826
827 let result = apply_cross_file_sanitization(&mut files);
828
829 assert_eq!(result.downgraded_count, 1); assert!(!files[1].1.file_operations[0].path_arg.is_tainted()); assert!(files[1].1.file_operations[1].path_arg.is_tainted()); }
833}