1#[cfg(unix)]
4use std::os::unix::fs::OpenOptionsExt;
5#[cfg(windows)]
6use std::os::windows::{
7 fs::{MetadataExt, OpenOptionsExt},
8 io::AsRawHandle,
9};
10#[cfg(debug_assertions)]
11use std::sync::atomic::{AtomicU32, Ordering};
12use std::{
13 collections::{HashMap, HashSet, VecDeque},
14 env, io,
15 io::Read,
16 path::{Component, Path, PathBuf},
17 sync::Arc,
18};
19
20use compact_str::CompactString;
21use oxc_resolver::{
22 FileSystem, FileSystemOs, ResolveContext, ResolveError, ResolveOptions, ResolverGeneric,
23 TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
24};
25use parking_lot::Mutex;
26use serde_json::Value;
27#[cfg(windows)]
28use windows_sys::Win32::Storage::FileSystem::{
29 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, GetFileType, SECURITY_IDENTIFICATION,
30};
31
32use super::{
33 FailedKind, ResolutionCompleteness, ResolutionOutcome, Resolve, Resolved, UnresolvedReason,
34};
35use crate::imports::{ImportKind, RawImport};
36
37const MAX_TSCONFIG_BYTES: usize = 1024 * 1024;
38const MAX_RESOLVER_FILE_BYTES: u64 = 1024 * 1024;
39const REJECTED_PACKAGE_MANIFEST: &str = "{\"__hearth_rejected_package_manifest__\":";
40const MAX_TSCONFIG_EXTENDS_ENTRIES: usize = 32;
41const MAX_TSCONFIG_EXTENDS_VISITS: usize = 256;
42const MAX_RESOLUTION_MEMO_ENTRIES: usize = 65_536;
43
44#[cfg(any(windows, test))]
45const WINDOWS_FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010;
46#[cfg(any(windows, test))]
47const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
48#[cfg(any(windows, test))]
49const WINDOWS_FILE_TYPE_DISK: u32 = 1;
50
51#[derive(Debug, Clone)]
53pub struct JsResolveOptions {
54 pub tsconfig: Option<PathBuf>,
57 pub condition_names: Vec<String>,
59 pub extensions: Vec<String>,
61}
62
63impl Default for JsResolveOptions {
64 fn default() -> Self {
65 Self {
66 tsconfig: None,
67 condition_names: vec!["import".into(), "require".into()],
70 extensions: vec![
71 ".ts".into(),
72 ".tsx".into(),
73 ".mts".into(),
74 ".cts".into(),
75 ".js".into(),
76 ".jsx".into(),
77 ".mjs".into(),
78 ".cjs".into(),
79 ".vue".into(),
80 ],
81 }
82 }
83}
84
85pub fn js_resolver(options: JsResolveOptions) -> Box<dyn Resolve> {
87 build_js_resolver(SecureOsFileSystem::new(), options)
88}
89
90pub fn js_resolver_with_fs<FS: FileSystem + 'static>(
92 fs: FS,
93 options: JsResolveOptions,
94) -> Box<dyn Resolve> {
95 build_js_resolver(fs, options)
96}
97
98fn build_js_resolver<FS: FileSystem + 'static>(
99 fs: FS,
100 options: JsResolveOptions,
101) -> Box<dyn Resolve> {
102 let (import_options, require_options, configured_tsconfig) = resolver_options(options);
103 let file_system = SharedFileSystem::from_file_system(fs);
104 let import_resolver =
105 ResolverGeneric::new_with_file_system(file_system.clone(), import_options);
106 let require_resolver = import_resolver.clone_with_options(require_options);
107 Box::new(JsResolver {
108 import_resolver,
109 require_resolver,
110 file_system,
111 configured_tsconfig,
112 dependency_memo: Mutex::new(HashMap::new()),
113 #[cfg(debug_assertions)]
114 in_flight: AtomicU32::new(0),
115 })
116}
117
118struct JsResolver {
119 import_resolver: ResolverGeneric<SharedFileSystem>,
120 require_resolver: ResolverGeneric<SharedFileSystem>,
121 file_system: SharedFileSystem,
122 configured_tsconfig: Option<PathBuf>,
123 dependency_memo: Mutex<HashMap<ResolutionMemoKey, Vec<CompactString>>>,
124 #[cfg(debug_assertions)]
125 in_flight: AtomicU32,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Hash)]
129struct ResolutionMemoKey {
130 from_dir: PathBuf,
131 specifier: CompactString,
132 kind: ImportKind,
133}
134
135impl Resolve for JsResolver {
136 fn resolve(&self, from_file: &str, import: &RawImport) -> ResolutionOutcome {
137 #[cfg(debug_assertions)]
138 let _in_flight = InFlightResolve::enter(&self.in_flight);
139
140 if matches!(import.kind, ImportKind::RustUse | ImportKind::RustMod) {
141 return unresolved(UnresolvedReason::Unsupported, Vec::new(), Vec::new());
142 }
143
144 let from_path = Path::new(from_file);
145 if !from_path.is_absolute() {
146 return unresolved(
147 failed(FailedKind::InvalidSpecifier, "from_file must be absolute"),
148 Vec::new(),
149 Vec::new(),
150 );
151 }
152
153 let Some(parent) = from_path.parent() else {
154 return unresolved(
155 failed(
156 FailedKind::InvalidSpecifier,
157 "from_file must have a parent directory",
158 ),
159 Vec::new(),
160 Vec::new(),
161 );
162 };
163 let memo_key = ResolutionMemoKey {
164 from_dir: parent.to_path_buf(),
165 specifier: import.specifier.clone(),
166 kind: import.kind,
167 };
168 let resolver = self.resolver_for(import.kind);
169
170 let mut dependency_paths: Vec<PathBuf> = self.configured_tsconfig.iter().cloned().collect();
171 let mut notes = Vec::new();
172 let mut tsconfig_tracking_truncated = false;
173 let discovered = match &self.configured_tsconfig {
174 Some(configured) => resolver.find_tsconfig(configured),
175 None => resolver.find_tsconfig(from_path),
176 };
177 let tsconfig = match discovered {
178 Ok(tsconfig) => tsconfig,
179 Err(error) => {
180 if let Some(configured_tsconfig) = &self.configured_tsconfig {
181 let tracking = self.track_tsconfig_chain(configured_tsconfig);
182 tsconfig_tracking_truncated |= tracking.truncated;
183 dependency_paths.extend(tracking.dependencies);
184 notes.extend(tracking.notes);
185 }
186 dependency_paths.extend(error_dependency_paths(&error));
187 let mut outcome = unresolved(
188 classify_error(error),
189 collect_dependencies(ResolveContext::default(), dependency_paths),
190 notes,
191 );
192 if tsconfig_tracking_truncated {
193 outcome.completeness = ResolutionCompleteness::Partial;
194 }
195 return self.replay_dependencies(memo_key, outcome);
196 }
197 };
198 if let Some(tsconfig) = &tsconfig {
199 let tracking = self.track_tsconfig_chain(tsconfig.path());
200 tsconfig_tracking_truncated |= tracking.truncated;
201 dependency_paths.extend(tracking.dependencies);
202 notes.extend(tracking.notes);
203 }
204
205 let mut context = ResolveContext::default();
206 let resolution = resolver.resolve_with_context(
207 parent,
208 import.specifier.as_str(),
209 tsconfig.as_deref(),
210 &mut context,
211 );
212
213 let mut outcome = match resolution {
214 Ok(resolution) => {
215 let package_json = resolution.package_json();
216 dependency_paths
217 .extend(package_json.map(|package_json| package_json.path().to_path_buf()));
218 ResolutionOutcome {
219 resolved: classify_resolution(
220 import.specifier.as_str(),
221 resolution.path(),
222 package_json.and_then(|package_json| package_json.name()),
223 ),
224 dependencies: collect_dependencies(context, dependency_paths),
225 notes,
226 completeness: ResolutionCompleteness::Complete,
227 }
228 }
229 Err(error) => {
230 dependency_paths.extend(error_dependency_paths(&error));
231 unresolved(
232 classify_error(error),
233 collect_dependencies(context, dependency_paths),
234 notes,
235 )
236 }
237 };
238 if tsconfig_tracking_truncated {
239 outcome.completeness = ResolutionCompleteness::Partial;
240 }
241 self.replay_dependencies(memo_key, outcome)
242 }
243
244 fn clear_cache(&self) {
245 #[cfg(debug_assertions)]
246 debug_assert_eq!(
247 self.in_flight.load(Ordering::Acquire),
248 0,
249 "clear_cache must not overlap an in-flight resolve"
250 );
251 self.import_resolver.clear_cache();
252 self.require_resolver.clear_cache();
253 self.dependency_memo.lock().clear();
254 }
255}
256
257impl JsResolver {
258 fn resolver_for(&self, kind: ImportKind) -> &ResolverGeneric<SharedFileSystem> {
259 match kind {
260 ImportKind::CommonJs | ImportKind::TsImportRequire => &self.require_resolver,
261 _ => &self.import_resolver,
262 }
263 }
264
265 fn replay_dependencies(
266 &self,
267 key: ResolutionMemoKey,
268 mut outcome: ResolutionOutcome,
269 ) -> ResolutionOutcome {
270 let mut memo = self.dependency_memo.lock();
271 if let Some(dependencies) = memo.get(&key) {
272 outcome.dependencies.extend(dependencies.iter().cloned());
273 }
274 normalize_dependencies(&mut outcome.dependencies);
275 if memo.len() >= MAX_RESOLUTION_MEMO_ENTRIES && !memo.contains_key(&key) {
276 memo.clear();
277 }
278 memo.insert(key, outcome.dependencies.clone());
279 outcome
280 }
281
282 fn track_tsconfig_chain(&self, leaf: &Path) -> TsconfigTracking {
283 let extends_resolver = self
284 .import_resolver
285 .clone_with_options(tsconfig_extends_options());
286 let mut tracking = TsconfigTracking::default();
287 let mut pending = VecDeque::from([(absolute_path(leaf), Vec::new())]);
288 let mut visited = HashSet::new();
289
290 while let Some((config_path, mut ancestry)) = pending.pop_front() {
291 if ancestry.contains(&config_path) {
292 tracking.notes.push(
293 format!(
294 "tsconfig extends cycle while tracking dependencies: {}",
295 config_path.display()
296 )
297 .into(),
298 );
299 continue;
300 }
301 if visited.contains(&config_path) {
302 continue;
303 }
304 if visited.len() == MAX_TSCONFIG_EXTENDS_VISITS {
305 tracking.truncated = true;
306 tracking.notes.push(
307 format!(
308 "tsconfig extends visit budget of {MAX_TSCONFIG_EXTENDS_VISITS} configs \
309 exhausted while tracking dependencies; {} configs remain pending",
310 pending.len() + 1
311 )
312 .into(),
313 );
314 break;
315 }
316 visited.insert(config_path.clone());
317 ancestry.push(config_path.clone());
318 tracking.dependencies.push(config_path.clone());
319
320 let mut source = match self.file_system.read_to_string(&config_path) {
321 Ok(source) => source,
322 Err(error) => {
323 tracking.notes.push(
324 format!(
325 "could not read tsconfig extends from {}: {error}",
326 config_path.display()
327 )
328 .into(),
329 );
330 continue;
331 }
332 };
333 if source.len() > MAX_TSCONFIG_BYTES {
334 tracking.truncated = true;
335 tracking.notes.push(
336 format!(
337 "tsconfig extends file {} exceeds the size limit of \
338 {MAX_TSCONFIG_BYTES} bytes ({} bytes)",
339 config_path.display(),
340 source.len()
341 )
342 .into(),
343 );
344 continue;
345 }
346 if let Err(error) = json_strip_comments::strip(&mut source) {
347 tracking.notes.push(
348 format!(
349 "could not strip JSONC syntax from tsconfig extends in {}: {error}",
350 config_path.display()
351 )
352 .into(),
353 );
354 continue;
355 }
356 let value: Value = match serde_json::from_str(&source) {
357 Ok(value) => value,
358 Err(error) => {
359 tracking.notes.push(
360 format!(
361 "could not parse tsconfig extends from {}: {error}",
362 config_path.display()
363 )
364 .into(),
365 );
366 continue;
367 }
368 };
369 let specifiers = match extends_specifiers(&value) {
370 Ok(specifiers) => specifiers,
371 Err(detail) => {
372 tracking.notes.push(
373 format!(
374 "invalid tsconfig extends in {}: {detail}",
375 config_path.display()
376 )
377 .into(),
378 );
379 continue;
380 }
381 };
382 let mut specifiers = specifiers;
383 if specifiers.len() > MAX_TSCONFIG_EXTENDS_ENTRIES {
384 tracking.truncated = true;
385 tracking.notes.push(
386 format!(
387 "tsconfig extends entry limit of {MAX_TSCONFIG_EXTENDS_ENTRIES} exceeded \
388 in {}; only the first {MAX_TSCONFIG_EXTENDS_ENTRIES} of {} entries were \
389 tracked",
390 config_path.display(),
391 specifiers.len()
392 )
393 .into(),
394 );
395 specifiers.truncate(MAX_TSCONFIG_EXTENDS_ENTRIES);
396 }
397 if specifiers.is_empty() {
398 continue;
399 }
400 let Some(directory) = config_path.parent() else {
401 tracking.notes.push(
402 format!(
403 "tsconfig has no parent directory while tracking extends: {}",
404 config_path.display()
405 )
406 .into(),
407 );
408 continue;
409 };
410 for specifier in specifiers {
411 let package_style = is_package_style_extends(&specifier);
412 let target_path =
413 (!package_style).then(|| extends_target_path(directory, &specifier));
414 let absolute_specifier = target_path.as_deref().map(Path::to_string_lossy);
415 let resolution_specifier =
416 absolute_specifier.as_deref().unwrap_or(specifier.as_str());
417 let mut context = ResolveContext::default();
418 let resolution = extends_resolver.resolve_with_context(
419 directory,
420 resolution_specifier,
421 None,
422 &mut context,
423 );
424 if !package_style {
425 tracking.dependencies.extend(context.file_dependencies);
426 tracking.dependencies.extend(context.missing_dependencies);
427 }
428
429 match resolution {
430 Ok(resolution) => {
431 tracking.dependencies.extend(
432 resolution
433 .package_json()
434 .map(|package_json| package_json.path().to_path_buf()),
435 );
436 pending.push_back((absolute_path(resolution.path()), ancestry.clone()));
437 }
438 Err(error) => {
439 tracking.dependencies.extend(target_path);
440 let kind = if package_style { "package-style " } else { "" };
441 tracking.notes.push(
442 format!(
443 "{kind}tsconfig extends {specifier:?} from {} could not be resolved: {error}",
444 config_path.display()
445 )
446 .into(),
447 );
448 }
449 }
450 }
451 }
452
453 tracking
454 }
455}
456
457fn resolver_options(
458 options: JsResolveOptions,
459) -> (ResolveOptions, ResolveOptions, Option<PathBuf>) {
460 let JsResolveOptions {
461 tsconfig,
462 condition_names,
463 extensions,
464 } = options;
465 let configured_tsconfig = tsconfig.map(|path| absolute_path(&path));
466 let tsconfig = configured_tsconfig.clone().map(|config_file| {
467 TsconfigDiscovery::Manual(TsconfigOptions {
468 config_file,
469 references: TsconfigReferences::Disabled,
470 })
471 });
472 let common_conditions: Vec<String> = condition_names
473 .into_iter()
474 .filter(|condition| condition != "import" && condition != "require")
475 .collect();
476 let import_options = ResolveOptions {
477 tsconfig: tsconfig.clone(),
478 condition_names: family_conditions("import", &common_conditions),
479 extensions: extensions.clone(),
480 ..ResolveOptions::default()
481 };
482 let require_options = ResolveOptions {
483 tsconfig,
484 condition_names: family_conditions("require", &common_conditions),
485 extensions,
486 ..ResolveOptions::default()
487 };
488 (import_options, require_options, configured_tsconfig)
489}
490
491fn family_conditions(family: &str, common: &[String]) -> Vec<String> {
492 std::iter::once(family.to_owned())
493 .chain(common.iter().cloned())
494 .collect()
495}
496
497fn tsconfig_extends_options() -> ResolveOptions {
498 ResolveOptions {
499 tsconfig: None,
500 condition_names: vec!["node".into(), "import".into()],
501 extensions: vec![".json".into()],
502 main_files: vec!["tsconfig".into()],
503 ..ResolveOptions::default()
504 }
505}
506
507fn collect_dependencies(context: ResolveContext, additional: Vec<PathBuf>) -> Vec<CompactString> {
508 let mut dependencies: Vec<CompactString> = context
509 .file_dependencies
510 .into_iter()
511 .chain(context.missing_dependencies)
512 .chain(additional)
513 .map(|path| absolute_path(&path))
514 .map(|path| path_string(&path))
515 .collect();
516 dependencies.sort_unstable();
517 dependencies.dedup();
518 dependencies
519}
520
521fn extends_specifiers(value: &Value) -> Result<Vec<String>, &'static str> {
522 match value.get("extends") {
523 None => Ok(Vec::new()),
524 Some(Value::String(specifier)) => Ok(vec![specifier.clone()]),
525 Some(Value::Array(specifiers)) => specifiers
526 .iter()
527 .map(|specifier| {
528 specifier
529 .as_str()
530 .map(str::to_owned)
531 .ok_or("extends array entries must be strings")
532 })
533 .collect(),
534 Some(_) => Err("extends must be a string or an array of strings"),
535 }
536}
537
538fn is_package_style_extends(specifier: &str) -> bool {
539 !Path::new(specifier).is_absolute() && !specifier.starts_with('.')
540}
541
542fn extends_target_path(directory: &Path, specifier: &str) -> PathBuf {
543 let target = Path::new(specifier);
544 if target.is_absolute() {
545 target.to_path_buf()
546 } else {
547 normalize_path(&absolute_path(&directory.join(target)))
548 }
549}
550
551fn normalize_path(path: &Path) -> PathBuf {
552 let mut normalized = PathBuf::new();
553 for component in path.components() {
554 match component {
555 Component::CurDir => {}
556 Component::ParentDir => {
557 normalized.pop();
558 }
559 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
560 normalized.push(component.as_os_str());
561 }
562 }
563 }
564 normalized
565}
566
567fn classify_error(error: ResolveError) -> UnresolvedReason {
568 if is_not_found_error(&error) {
569 UnresolvedReason::NotFound
570 } else {
571 let kind = match &error {
572 ResolveError::TsconfigNotFound(_)
573 | ResolveError::TsconfigSelfReference(_)
574 | ResolveError::TsconfigCircularExtend(_)
575 | ResolveError::TsconfigLoadFailed { .. }
576 | ResolveError::Json(_)
577 | ResolveError::InvalidPackageTarget(_, _, _)
578 | ResolveError::InvalidPackageConfig(_)
579 | ResolveError::InvalidPackageConfigDefault(_)
580 | ResolveError::InvalidPackageConfigDirectory(_) => FailedKind::Config,
581 ResolveError::IOError(_) => FailedKind::Io,
582 ResolveError::PathNotSupported(_)
583 | ResolveError::Specifier(_)
584 | ResolveError::InvalidModuleSpecifier(_, _) => FailedKind::InvalidSpecifier,
585 _ => FailedKind::Other,
586 };
587 failed(kind, error.to_string())
588 }
589}
590
591fn is_not_found_error(error: &ResolveError) -> bool {
592 matches!(
593 error,
594 ResolveError::NotFound(_)
595 | ResolveError::MatchedAliasNotFound(_, _)
596 | ResolveError::ExtensionAlias(_, _, _)
597 )
598}
599
600fn error_dependency_paths(error: &ResolveError) -> Vec<PathBuf> {
601 match error {
602 ResolveError::TsconfigLoadFailed { path, source } => {
603 let mut paths = vec![path.clone()];
604 paths.extend(error_dependency_paths(source));
605 paths
606 }
607 ResolveError::TsconfigCircularExtend(paths) => paths.paths().to_vec(),
608 ResolveError::Json(error) => vec![error.path.clone()],
609 ResolveError::InvalidModuleSpecifier(_, path)
610 | ResolveError::InvalidPackageTarget(_, _, path)
611 | ResolveError::InvalidPackageConfig(path)
612 | ResolveError::InvalidPackageConfigDefault(path)
613 | ResolveError::InvalidPackageConfigDirectory(path)
614 | ResolveError::PackageImportNotDefined(_, path) => vec![path.clone()],
615 ResolveError::PackagePathNotExported {
616 package_json_path, ..
617 } => vec![package_json_path.clone()],
618 _ => Vec::new(),
619 }
620}
621
622fn absolute_path(path: &Path) -> PathBuf {
623 if path.is_absolute() {
624 path.to_path_buf()
625 } else {
626 env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
627 }
628}
629
630fn path_string(path: &Path) -> CompactString {
631 CompactString::from(path.to_string_lossy().as_ref())
632}
633
634fn is_node_modules_path(path: &Path) -> bool {
635 path.components()
636 .any(|component| matches!(component, Component::Normal(name) if name == "node_modules"))
637}
638
639fn is_path_specifier(specifier: &str) -> bool {
640 specifier.starts_with("./")
641 || specifier.starts_with("../")
642 || Path::new(specifier).is_absolute()
643}
644
645fn classify_resolution(specifier: &str, path: &Path, manifest_name: Option<&str>) -> Resolved {
646 let path = absolute_path(path);
647 if is_path_specifier(specifier) || !is_node_modules_path(&path) {
648 Resolved::Path(path_string(&path))
649 } else {
650 let name = manifest_name
654 .map(CompactString::from)
655 .or_else(|| package_name_from_path(&path))
656 .unwrap_or_else(|| package_name(specifier));
657 Resolved::External(name)
658 }
659}
660
661fn package_name_from_path(path: &Path) -> Option<CompactString> {
662 let components: Vec<&str> = path
663 .components()
664 .filter_map(|component| match component {
665 Component::Normal(name) => name.to_str(),
666 _ => None,
667 })
668 .collect();
669 let base = components
670 .iter()
671 .rposition(|name| *name == "node_modules")?;
672 let first = components.get(base + 1)?;
673 if first.starts_with('@') {
674 let second = components.get(base + 2)?;
675 Some(CompactString::from(format!("{first}/{second}")))
676 } else {
677 Some(CompactString::from(*first))
678 }
679}
680
681fn package_name(specifier: &str) -> CompactString {
682 let segment_count = usize::from(specifier.starts_with('@')) + 1;
683 CompactString::from(
684 specifier
685 .split('/')
686 .take(segment_count)
687 .collect::<Vec<_>>()
688 .join("/"),
689 )
690}
691
692fn unresolved(
693 reason: UnresolvedReason,
694 dependencies: Vec<CompactString>,
695 notes: Vec<CompactString>,
696) -> ResolutionOutcome {
697 let completeness = if matches!(&reason, UnresolvedReason::Failed { .. }) {
698 ResolutionCompleteness::Partial
699 } else {
700 ResolutionCompleteness::Complete
701 };
702 ResolutionOutcome {
703 resolved: Resolved::Unresolved(reason),
704 dependencies,
705 notes,
706 completeness,
707 }
708}
709
710fn failed(kind: FailedKind, detail: impl Into<CompactString>) -> UnresolvedReason {
711 UnresolvedReason::Failed {
712 kind,
713 detail: detail.into(),
714 }
715}
716
717fn normalize_dependencies(dependencies: &mut Vec<CompactString>) {
718 dependencies.sort_unstable();
719 dependencies.dedup();
720}
721
722#[derive(Default)]
723struct TsconfigTracking {
724 dependencies: Vec<PathBuf>,
725 notes: Vec<CompactString>,
726 truncated: bool,
727}
728
729#[cfg(unix)]
730fn open_resolver_file(path: &Path) -> io::Result<std::fs::File> {
731 std::fs::OpenOptions::new()
734 .read(true)
735 .custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC | libc::O_NOFOLLOW)
736 .open(path)
737}
738
739#[cfg(windows)]
740fn open_resolver_file(path: &Path) -> io::Result<std::fs::File> {
741 std::fs::OpenOptions::new()
746 .read(true)
747 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
748 .security_qos_flags(SECURITY_IDENTIFICATION)
749 .open(path)
750}
751
752#[cfg(not(any(unix, windows)))]
753fn open_resolver_file(_path: &Path) -> io::Result<std::fs::File> {
754 Err(io::Error::new(
755 io::ErrorKind::Unsupported,
756 "secure resolver config reads are unsupported on this target",
757 ))
758}
759
760fn invalid_resolver_file() -> io::Error {
761 io::Error::new(
762 io::ErrorKind::InvalidData,
763 "resolver config must be a regular file no larger than 1 MiB",
764 )
765}
766
767#[cfg(unix)]
768fn opened_resolver_file_metadata(file: &std::fs::File) -> io::Result<std::fs::Metadata> {
769 let metadata = file.metadata()?;
770 if !metadata.is_file() {
771 return Err(invalid_resolver_file());
772 }
773 Ok(metadata)
774}
775
776#[cfg(windows)]
777fn opened_resolver_file_metadata(file: &std::fs::File) -> io::Result<std::fs::Metadata> {
778 let file_type = unsafe { GetFileType(file.as_raw_handle()) };
780 if file_type != WINDOWS_FILE_TYPE_DISK {
781 return Err(invalid_resolver_file());
782 }
783
784 let metadata = file.metadata()?;
785 if !windows_handle_is_regular(file_type, metadata.file_attributes()) {
786 return Err(invalid_resolver_file());
787 }
788 Ok(metadata)
789}
790
791#[cfg(not(any(unix, windows)))]
792fn opened_resolver_file_metadata(_file: &std::fs::File) -> io::Result<std::fs::Metadata> {
793 Err(io::Error::new(
794 io::ErrorKind::Unsupported,
795 "secure resolver config reads are unsupported on this target",
796 ))
797}
798
799#[cfg(any(windows, test))]
800fn windows_handle_is_regular(file_type: u32, attributes: u32) -> bool {
801 file_type == WINDOWS_FILE_TYPE_DISK
802 && attributes & (WINDOWS_FILE_ATTRIBUTE_DIRECTORY | WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT)
803 == 0
804}
805
806fn resolver_file_capacity(file_len: u64) -> io::Result<usize> {
807 if file_len > MAX_RESOLVER_FILE_BYTES {
808 return Err(invalid_resolver_file());
809 }
810 Ok(file_len as usize)
811}
812
813fn read_resolver_contents(reader: &mut impl Read, initial_capacity: usize) -> io::Result<Vec<u8>> {
814 let mut bytes = Vec::with_capacity(initial_capacity);
815 reader
816 .take(MAX_RESOLVER_FILE_BYTES + 1)
817 .read_to_end(&mut bytes)?;
818 if bytes.len() as u64 > MAX_RESOLVER_FILE_BYTES {
819 return Err(io::Error::new(
820 io::ErrorKind::InvalidData,
821 "resolver config exceeds 1 MiB",
822 ));
823 }
824 Ok(bytes)
825}
826
827fn read_resolver_file(path: &Path) -> io::Result<Vec<u8>> {
828 let mut file = open_resolver_file(path)?;
829 let metadata = opened_resolver_file_metadata(&file)?;
830 let capacity = resolver_file_capacity(metadata.len())?;
831 read_resolver_contents(&mut file, capacity)
832}
833
834fn rejected_package_manifest(path: &Path, error: &io::Error) -> bool {
835 path.file_name().is_some_and(|name| name == "package.json")
836 && error.kind() != io::ErrorKind::NotFound
837}
838
839fn fail_closed_package_manifest_bytes(
840 path: &Path,
841 result: io::Result<Vec<u8>>,
842) -> io::Result<Vec<u8>> {
843 match result {
844 Err(error) if rejected_package_manifest(path, &error) => {
845 Ok(REJECTED_PACKAGE_MANIFEST.as_bytes().to_vec())
850 }
851 result => result,
852 }
853}
854
855fn fail_closed_package_manifest_string(
856 path: &Path,
857 result: io::Result<String>,
858) -> io::Result<String> {
859 match result {
860 Err(error) if rejected_package_manifest(path, &error) => {
861 Ok(REJECTED_PACKAGE_MANIFEST.to_owned())
862 }
863 result => result,
864 }
865}
866
867#[derive(Clone)]
868struct SecureOsFileSystem(FileSystemOs);
869
870impl FileSystem for SecureOsFileSystem {
871 fn new() -> Self {
872 Self(FileSystemOs::new())
873 }
874
875 fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
876 read_resolver_file(path)
877 }
878
879 fn read_to_string(&self, path: &Path) -> io::Result<String> {
880 let bytes = self.read(path)?;
881 String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
882 }
883
884 fn metadata(&self, path: &Path) -> io::Result<oxc_resolver::FileMetadata> {
885 self.0.metadata(path)
886 }
887
888 fn symlink_metadata(&self, path: &Path) -> io::Result<oxc_resolver::FileMetadata> {
889 self.0.symlink_metadata(path)
890 }
891
892 fn read_link(&self, path: &Path) -> Result<PathBuf, ResolveError> {
893 self.0.read_link(path)
894 }
895
896 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
897 self.0.canonicalize(path)
898 }
899}
900
901#[derive(Clone)]
902struct SharedFileSystem(Arc<dyn FileSystem>);
903
904impl SharedFileSystem {
905 fn from_file_system(file_system: impl FileSystem + 'static) -> Self {
906 Self(Arc::new(file_system))
907 }
908}
909
910impl FileSystem for SharedFileSystem {
911 fn new() -> Self {
912 Self::from_file_system(SecureOsFileSystem::new())
913 }
914
915 fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
916 fail_closed_package_manifest_bytes(path, self.0.read(path))
917 }
918
919 fn read_to_string(&self, path: &Path) -> io::Result<String> {
920 fail_closed_package_manifest_string(path, self.0.read_to_string(path))
921 }
922
923 fn metadata(&self, path: &Path) -> io::Result<oxc_resolver::FileMetadata> {
924 self.0.metadata(path)
925 }
926
927 fn symlink_metadata(&self, path: &Path) -> io::Result<oxc_resolver::FileMetadata> {
928 self.0.symlink_metadata(path)
929 }
930
931 fn read_link(&self, path: &Path) -> Result<PathBuf, ResolveError> {
932 self.0.read_link(path)
933 }
934
935 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
936 self.0.canonicalize(path)
937 }
938}
939
940#[cfg(debug_assertions)]
941struct InFlightResolve<'a> {
942 counter: &'a AtomicU32,
943}
944
945#[cfg(debug_assertions)]
946impl<'a> InFlightResolve<'a> {
947 fn enter(counter: &'a AtomicU32) -> Self {
948 let previous = counter.fetch_add(1, Ordering::AcqRel);
949 debug_assert_ne!(previous, u32::MAX, "in-flight resolve counter overflowed");
950 Self { counter }
951 }
952}
953
954#[cfg(debug_assertions)]
955impl Drop for InFlightResolve<'_> {
956 fn drop(&mut self) {
957 let previous = self.counter.fetch_sub(1, Ordering::AcqRel);
958 debug_assert!(previous > 0, "in-flight resolve counter underflowed");
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use std::io::Cursor;
965
966 use super::*;
967
968 #[cfg(any(unix, windows))]
969 #[test]
970 fn secure_resolver_read_accepts_a_regular_file_at_the_limit() {
971 let directory = tempfile::tempdir().unwrap();
972 let path = directory.path().join("config.json");
973 std::fs::write(&path, vec![b'x'; MAX_RESOLVER_FILE_BYTES as usize]).unwrap();
974
975 let contents = read_resolver_file(&path).unwrap();
976
977 assert_eq!(contents.len() as u64, MAX_RESOLVER_FILE_BYTES);
978 }
979
980 #[cfg(any(unix, windows))]
981 #[test]
982 fn secure_resolver_read_rejects_an_oversized_file() {
983 let directory = tempfile::tempdir().unwrap();
984 let path = directory.path().join("config.json");
985 std::fs::write(&path, vec![b'x'; MAX_RESOLVER_FILE_BYTES as usize + 1]).unwrap();
986
987 let error = read_resolver_file(&path).unwrap_err();
988
989 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
990 }
991
992 #[test]
993 fn bounded_read_rejects_growth_after_opened_metadata_was_checked() {
994 let mut contents = Cursor::new(vec![b'x'; MAX_RESOLVER_FILE_BYTES as usize + 1]);
995
996 let error =
997 read_resolver_contents(&mut contents, MAX_RESOLVER_FILE_BYTES as usize).unwrap_err();
998
999 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1000 }
1001
1002 #[cfg(any(unix, windows))]
1003 #[test]
1004 fn secure_resolver_read_rejects_a_directory() {
1005 let directory = tempfile::tempdir().unwrap();
1006
1007 let error = read_resolver_file(directory.path()).unwrap_err();
1008
1009 assert!(matches!(
1010 error.kind(),
1011 io::ErrorKind::InvalidData | io::ErrorKind::PermissionDenied
1012 ));
1013 }
1014
1015 #[cfg(unix)]
1016 #[test]
1017 fn secure_resolver_read_rejects_a_final_symlink() {
1018 use std::os::unix::fs::symlink;
1019
1020 let directory = tempfile::tempdir().unwrap();
1021 let target = directory.path().join("target.json");
1022 let link = directory.path().join("link.json");
1023 std::fs::write(&target, b"{}").unwrap();
1024 symlink(&target, &link).unwrap();
1025
1026 assert!(read_resolver_file(&link).is_err());
1027 }
1028
1029 #[test]
1030 fn windows_handle_classification_rejects_devices_directories_and_reparse_points() {
1031 const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x0000_0020;
1032 const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080;
1033 const FILE_TYPE_UNKNOWN: u32 = 0;
1034 const FILE_TYPE_CHAR: u32 = 2;
1035 const FILE_TYPE_PIPE: u32 = 3;
1036
1037 assert!(windows_handle_is_regular(
1038 WINDOWS_FILE_TYPE_DISK,
1039 FILE_ATTRIBUTE_NORMAL
1040 ));
1041 assert!(windows_handle_is_regular(
1042 WINDOWS_FILE_TYPE_DISK,
1043 FILE_ATTRIBUTE_ARCHIVE
1044 ));
1045 assert!(!windows_handle_is_regular(
1046 FILE_TYPE_UNKNOWN,
1047 FILE_ATTRIBUTE_NORMAL
1048 ));
1049 assert!(!windows_handle_is_regular(
1050 FILE_TYPE_CHAR,
1051 FILE_ATTRIBUTE_NORMAL
1052 ));
1053 assert!(!windows_handle_is_regular(
1054 FILE_TYPE_PIPE,
1055 FILE_ATTRIBUTE_NORMAL
1056 ));
1057 assert!(!windows_handle_is_regular(
1058 WINDOWS_FILE_TYPE_DISK,
1059 WINDOWS_FILE_ATTRIBUTE_DIRECTORY
1060 ));
1061 assert!(!windows_handle_is_regular(
1062 WINDOWS_FILE_TYPE_DISK,
1063 WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT
1064 ));
1065 }
1066
1067 #[cfg(not(any(unix, windows)))]
1068 #[test]
1069 fn secure_resolver_reads_fail_closed_on_unsupported_targets() {
1070 let error = read_resolver_file(Path::new("config.json")).unwrap_err();
1071
1072 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
1073 }
1074}