Skip to main content

hearth_graph/resolve/
js.rs

1//! JavaScript and TypeScript resolution through `oxc_resolver`.
2
3#[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/// Configuration for JavaScript and TypeScript module resolution.
52#[derive(Debug, Clone)]
53pub struct JsResolveOptions {
54    /// An optional manually selected tsconfig-format file, such as
55    /// `tsconfig.json` or `jsconfig.json`.
56    pub tsconfig: Option<PathBuf>,
57    /// Conditions accepted while resolving package `exports`.
58    pub condition_names: Vec<String>,
59    /// File extensions probed in priority order.
60    pub extensions: Vec<String>,
61}
62
63impl Default for JsResolveOptions {
64    fn default() -> Self {
65        Self {
66            tsconfig: None,
67            // These family conditions are split between the import and require
68            // resolvers by `resolver_options`.
69            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
85/// Build a JavaScript resolver backed by the operating system filesystem.
86pub fn js_resolver(options: JsResolveOptions) -> Box<dyn Resolve> {
87    build_js_resolver(SecureOsFileSystem::new(), options)
88}
89
90/// Build a JavaScript resolver backed by an injected filesystem.
91pub 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        // Aliased specifiers (`#dep`) say nothing about the installed package,
651        // so a missing manifest name falls back to the directory name under
652        // the last node_modules component before the specifier text.
653        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    // O_NOFOLLOW binds final-component symlink rejection to this open, while
732    // O_NONBLOCK prevents a hostile FIFO from blocking before fstat rejects it.
733    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    // Open the named object rather than traversing a reparse point. Backup
742    // semantics lets the same handle-based validation reject directories too.
743    // Identification QoS prevents a named-pipe server from impersonating this
744    // process before the opened handle can be classified and rejected.
745    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    // SAFETY: the handle remains owned by `file` for the duration of this call.
779    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            // oxc_resolver interprets every package-manifest read error as
846            // "missing" and may fall back to index.js. Returning a malformed
847            // marker makes its JSON parser propagate a configuration failure
848            // instead of silently accepting a rejected existing manifest.
849            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}