Skip to main content

webui_discovery/
npm.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! npm package resolution for external component discovery.
5//!
6//! Resolves npm packages from `node_modules/` using Node.js-style upward
7//! traversal. Plugins own component naming and layout; the manifest helpers
8//! support metadata-based discovery such as FAST.
9
10use anyhow::{bail, Context, Result};
11use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor};
12use std::fmt;
13use std::fs;
14use std::path::{Component, Path, PathBuf};
15
16use super::cache::{CacheKey, DiscoveryCache};
17use super::{prepared, DiscoveredComponent, DiscoveryPlugin, PreparedPackage};
18
19/// Validated npm package context presented to a discovery plugin.
20#[derive(Debug, Clone, Copy)]
21pub struct PackageContext<'a> {
22    /// npm package name.
23    pub name: &'a str,
24    /// Canonical package root.
25    pub root: &'a Path,
26    /// Parsed `package.json`, available to every package discovery plugin.
27    pub manifest: &'a serde_json::Value,
28}
29
30pub(crate) struct ComponentDeclaration {
31    pub(crate) tag_name: String,
32    pub(crate) name: Option<String>,
33    pub(crate) module_specifier: Option<String>,
34}
35
36#[derive(Debug)]
37pub(crate) struct ResolvedPackageModule {
38    pub(crate) name: String,
39    pub(crate) root: PathBuf,
40    pub(crate) relative_path: PathBuf,
41    pub(crate) manifest: serde_json::Value,
42    pub(crate) ordered_manifest: OrderedJson,
43}
44
45/// Maximum file size for package.json and custom elements manifests (10 MB).
46pub(crate) const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
47
48/// Package fields that conventionally point to a browser/module entry.
49const SCRIPT_ENTRY_FIELDS: &[&str] = &["main", "module", "browser"];
50
51/// Resource-only exports do not imply registration scripts for metadata-based plugins.
52const SCRIPTLESS_ASSET_EXPORTS: &[&str] = &[
53    "./template-webui.html",
54    "./styles.css",
55    "./component-asset.js",
56];
57
58const ACTIVE_EXPORT_CONDITIONS: &[&str] = &["browser", "import", "default"];
59
60enum ExportTarget {
61    Target(String),
62    Blocked,
63    NoMatch,
64    Invalid,
65}
66
67#[derive(Debug)]
68pub(crate) enum OrderedJson {
69    String(String),
70    Object(Vec<(String, OrderedJson)>),
71    Null,
72    Other,
73}
74
75struct OrderedJsonSeed;
76
77impl<'de> DeserializeSeed<'de> for OrderedJsonSeed {
78    type Value = OrderedJson;
79
80    fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
81    where
82        D: serde::Deserializer<'de>,
83    {
84        deserializer.deserialize_any(OrderedJsonVisitor)
85    }
86}
87
88struct OrderedJsonVisitor;
89
90impl<'de> Visitor<'de> for OrderedJsonVisitor {
91    type Value = OrderedJson;
92
93    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        formatter.write_str("a JSON value")
95    }
96
97    fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
98        Ok(OrderedJson::String(value.to_string()))
99    }
100
101    fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
102        Ok(OrderedJson::String(value))
103    }
104
105    fn visit_none<E>(self) -> std::result::Result<Self::Value, E> {
106        Ok(OrderedJson::Null)
107    }
108
109    fn visit_unit<E>(self) -> std::result::Result<Self::Value, E> {
110        Ok(OrderedJson::Null)
111    }
112
113    fn visit_bool<E>(self, _value: bool) -> std::result::Result<Self::Value, E> {
114        Ok(OrderedJson::Other)
115    }
116
117    fn visit_i64<E>(self, _value: i64) -> std::result::Result<Self::Value, E> {
118        Ok(OrderedJson::Other)
119    }
120
121    fn visit_u64<E>(self, _value: u64) -> std::result::Result<Self::Value, E> {
122        Ok(OrderedJson::Other)
123    }
124
125    fn visit_f64<E>(self, _value: f64) -> std::result::Result<Self::Value, E> {
126        Ok(OrderedJson::Other)
127    }
128
129    fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error>
130    where
131        A: SeqAccess<'de>,
132    {
133        while sequence.next_element_seed(OrderedJsonSeed)?.is_some() {}
134        Ok(OrderedJson::Other)
135    }
136
137    fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
138    where
139        A: MapAccess<'de>,
140    {
141        let mut entries = Vec::new();
142        while let Some(key) = map.next_key::<String>()? {
143            entries.push((key, map.next_value_seed(OrderedJsonSeed)?));
144        }
145        Ok(OrderedJson::Object(entries))
146    }
147}
148
149impl OrderedJson {
150    fn get(&self, key: &str) -> Option<&Self> {
151        match self {
152            Self::Object(entries) => entries
153                .iter()
154                .find_map(|(candidate, value)| (candidate == key).then_some(value)),
155            _ => None,
156        }
157    }
158}
159
160fn parse_ordered_json(content: &str, path: &Path) -> Result<OrderedJson> {
161    let mut deserializer = serde_json::Deserializer::from_str(content);
162    let value = OrderedJsonSeed
163        .deserialize(&mut deserializer)
164        .with_context(|| format!("Failed to parse {}", path.display()))?;
165    deserializer
166        .end()
167        .with_context(|| format!("Failed to parse {}", path.display()))?;
168    Ok(value)
169}
170
171/// Check if a package name is a bare scope (e.g., `@reactive-ui` without a sub-package).
172fn is_bare_scope(name: &str) -> bool {
173    name.starts_with('@') && !name.contains('/')
174}
175
176fn validate_package_source(name: &str) -> Result<()> {
177    let valid = match name.strip_prefix('@') {
178        Some(scoped) => match scoped.split_once('/') {
179            Some((scope, package)) => is_package_segment(scope) && is_package_segment(package),
180            None => is_package_segment(scoped),
181        },
182        None => is_package_segment(name),
183    };
184    if !valid {
185        return Err(invalid_package_source(name));
186    }
187    Ok(())
188}
189
190fn is_package_segment(segment: &str) -> bool {
191    !segment.is_empty()
192        && !segment.starts_with(['.', '_'])
193        && segment
194            .bytes()
195            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
196}
197
198#[cold]
199#[inline(never)]
200fn invalid_package_source(name: &str) -> anyhow::Error {
201    anyhow::anyhow!(
202        "Invalid npm component source '{name}'. Use a package name, @scope, or @scope/package \
203         with an optional trailing /*. For local directories, use an explicit path such as \
204         ./components instead of a package subpath."
205    )
206}
207
208/// Validate that a relative path from package.json does not escape the package directory.
209///
210/// Rejects absolute paths, root-relative paths, and any `..` components
211/// to prevent path traversal attacks.
212fn validate_relative_path(rel_path: &str, field_name: &str) -> Result<()> {
213    let path = Path::new(rel_path);
214    if path.is_absolute() || rel_path.starts_with('/') || rel_path.starts_with('\\') {
215        bail!("Absolute path not allowed in {}: {}", field_name, rel_path);
216    }
217    if path.components().any(|c| matches!(c, Component::ParentDir)) {
218        bail!(
219            "Parent directory traversal (..) not allowed in {}: {}",
220            field_name,
221            rel_path
222        );
223    }
224    Ok(())
225}
226
227/// Read a file with a size limit to prevent denial-of-service via oversized manifests.
228fn read_to_string_limited(path: &Path, max_size: u64) -> Result<String> {
229    let metadata =
230        fs::metadata(path).with_context(|| format!("Failed to stat {}", path.display()))?;
231    if metadata.len() > max_size {
232        bail!(
233            "File too large ({} bytes, max {}): {}",
234            metadata.len(),
235            max_size,
236            path.display()
237        );
238    }
239    fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
240}
241
242/// Resolve an npm package or scope to discovered components.
243pub fn resolve(
244    name: &str,
245    search_dir: &Path,
246    plugin: &dyn DiscoveryPlugin,
247    cache: &mut DiscoveryCache,
248) -> Result<Vec<DiscoveredComponent>> {
249    let name = name.strip_suffix("/*").unwrap_or(name);
250    validate_package_source(name)?;
251    // Walk up from the build's app directory first, then fall back to the
252    // process working directory. The fallback covers callers whose app
253    // directory lives outside the project (e.g. a system-temp scratch dir),
254    // where the project's `node_modules` is only reachable from the cwd the
255    // command was invoked in.
256    let fallback = std::env::current_dir().unwrap_or_else(|_| search_dir.to_path_buf());
257    let node_modules = find_package_node_modules(name, search_dir, &fallback)?;
258    if is_bare_scope(name) {
259        resolve_scoped(name, &node_modules, plugin, cache)
260    } else {
261        resolve_single(name, &node_modules, plugin, cache, false)
262    }
263}
264
265fn find_package_node_modules(name: &str, primary: &Path, fallback: &Path) -> Result<PathBuf> {
266    for start in [primary, fallback] {
267        if let Some(node_modules) = find_package_node_modules_from(name, start)? {
268            return Ok(node_modules);
269        }
270    }
271    bail!(
272        "Package or scope '{name}' not found in node_modules (searched upward from {} and {}). \
273         Install the required packages in the project before building.",
274        primary.display(),
275        fallback.display()
276    );
277}
278
279fn find_package_node_modules_from(name: &str, start: &Path) -> Result<Option<PathBuf>> {
280    for directory in start.ancestors() {
281        let node_modules = directory.join("node_modules");
282        let candidate = node_modules.join(name);
283        match fs::symlink_metadata(&candidate) {
284            Ok(_) => {
285                return Ok(Some(node_modules));
286            }
287            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
288            Err(error) => {
289                return Err(error)
290                    .with_context(|| format!("Failed to inspect package {}", candidate.display()));
291            }
292        }
293    }
294    Ok(None)
295}
296
297/// Enumerate all sub-packages under a scoped directory (e.g., `@reactive-ui/*`).
298fn resolve_scoped(
299    scope: &str,
300    node_modules: &Path,
301    plugin: &dyn DiscoveryPlugin,
302    cache: &mut DiscoveryCache,
303) -> Result<Vec<DiscoveredComponent>> {
304    let scope_dir = node_modules.join(scope);
305    if !scope_dir.is_dir() {
306        bail!(
307            "Scoped package directory not found: {}",
308            scope_dir.display()
309        );
310    }
311
312    let mut entries = fs::read_dir(&scope_dir)
313        .with_context(|| format!("Failed to read scope directory: {}", scope_dir.display()))?
314        .collect::<std::io::Result<Vec<_>>>()?;
315    entries.sort_by_cached_key(fs::DirEntry::file_name);
316    let mut all = Vec::new();
317    for entry in entries {
318        let path = entry.path();
319        if !path.is_dir() {
320            continue;
321        }
322        let sub_name = format!("{}/{}", scope, entry.file_name().to_string_lossy());
323        all.extend(
324            resolve_single(&sub_name, node_modules, plugin, cache, true)
325                .with_context(|| format!("Failed to discover scope member '{sub_name}'"))?,
326        );
327    }
328
329    Ok(all)
330}
331
332/// Resolve a single npm package to discovered components.
333fn resolve_single(
334    name: &str,
335    node_modules: &Path,
336    plugin: &dyn DiscoveryPlugin,
337    cache: &mut DiscoveryCache,
338    scope_member: bool,
339) -> Result<Vec<DiscoveredComponent>> {
340    let pkg_dir = node_modules.join(name);
341
342    // Resolve symlinks (pnpm, npm workspaces, yarn)
343    let pkg_dir = fs::canonicalize(&pkg_dir).with_context(|| {
344        format!(
345            "Package not found or broken symlink: {} (looked in {})",
346            name,
347            node_modules.display()
348        )
349    })?;
350
351    if !pkg_dir.is_dir() {
352        bail!("Package path is not a directory: {}", pkg_dir.display());
353    }
354
355    let pkg_json_path = pkg_dir.join("package.json");
356    if !pkg_json_path.exists() {
357        bail!("No package.json found at {}", pkg_json_path.display());
358    }
359
360    let content = read_to_string_limited(&pkg_json_path, MAX_MANIFEST_SIZE)?;
361    let pkg_json = serde_json::from_str(&content)
362        .with_context(|| format!("Failed to parse {}", pkg_json_path.display()))?;
363    let package = PackageContext {
364        name,
365        root: &pkg_dir,
366        manifest: &pkg_json,
367    };
368    if scope_member && !plugin.supports_package(package)? {
369        return Ok(Vec::new());
370    }
371    let inputs = match plugin.prepare_package(package)? {
372        PreparedPackage::Files(inputs) => inputs,
373        PreparedPackage::Uncached(components) => return Ok(components),
374    };
375    let fingerprint = prepared::fingerprint(&content, &inputs)?;
376    let cache_key = CacheKey {
377        namespace: plugin.cache_namespace(),
378        source: name,
379        package_json: &pkg_json_path,
380    };
381    if let Some(cached) = cache.get(&cache_key, fingerprint)? {
382        return Ok(cached);
383    }
384    let loaded = prepared::load(name, &content, inputs)?;
385    cache.put(&cache_key, &loaded)?;
386    Ok(loaded.into_components())
387}
388
389pub(crate) fn package_component_declarations(
390    package: PackageContext<'_>,
391) -> Result<Vec<ComponentDeclaration>> {
392    let path = custom_elements_manifest_path(package)?;
393    parse_custom_elements_manifest(&path)
394}
395
396pub(crate) fn package_export_path(
397    package: PackageContext<'_>,
398    key: &str,
399) -> Result<Option<PathBuf>> {
400    let package_json = package.root.join("package.json");
401    let content = read_to_string_limited(&package_json, MAX_MANIFEST_SIZE)?;
402    let ordered_manifest = parse_ordered_json(&content, &package_json)?;
403    package_export_path_from_metadata(package.name, package.root, &ordered_manifest, key)
404}
405
406pub(crate) fn package_export_path_from_metadata(
407    package_name: &str,
408    package_root: &Path,
409    manifest: &OrderedJson,
410    key: &str,
411) -> Result<Option<PathBuf>> {
412    let Some(exports) = manifest.get("exports") else {
413        return Ok(None);
414    };
415    let target = match resolve_export_target(exports, key) {
416        ExportTarget::Target(target) => target,
417        ExportTarget::Blocked | ExportTarget::NoMatch => return Ok(None),
418        ExportTarget::Invalid => {
419            bail!(
420                "Package '{package_name}' export '{key}' must be a relative file path string \
421                 or a supported conditional object"
422            );
423        }
424    };
425    validate_package_export_target(&target, key)?;
426    let relative = Path::new(&target);
427    validate_package_path_boundary(package_root, relative, key).with_context(|| {
428        format!("Package '{package_name}' export '{key}' leaves the package boundary")
429    })?;
430    Ok(Some(package_root.join(relative)))
431}
432
433pub(crate) fn resolve_bare_module_specifier(
434    specifier: &str,
435    from: &Path,
436) -> Result<Option<ResolvedPackageModule>> {
437    let Some((package_name, subpath)) = bare_package_specifier(specifier)? else {
438        return Ok(None);
439    };
440    let Some(node_modules) = find_package_node_modules_from(package_name, from)? else {
441        if package_name.starts_with('@') {
442            bail!(
443                "Package '{package_name}' referenced by CEM module specifier '{specifier}' \
444                 was not found in node_modules"
445            );
446        }
447        return Ok(None);
448    };
449    let root = fs::canonicalize(node_modules.join(package_name)).with_context(|| {
450        format!(
451            "Package not found or broken symlink: {} (looked in {})",
452            package_name,
453            node_modules.display()
454        )
455    })?;
456    if !root.is_dir() {
457        bail!("Package path is not a directory: {}", root.display());
458    }
459    let package_json = root.join("package.json");
460    let content = read_to_string_limited(&package_json, MAX_MANIFEST_SIZE)?;
461    let manifest: serde_json::Value = serde_json::from_str(&content)
462        .with_context(|| format!("Failed to parse {}", package_json.display()))?;
463    let ordered_manifest = parse_ordered_json(&content, &package_json)?;
464    let relative_path =
465        resolve_package_module_path(package_name, &root, subpath, &manifest, &ordered_manifest)
466            .with_context(|| format!("Failed to resolve CEM module specifier '{specifier}'"))?;
467    Ok(Some(ResolvedPackageModule {
468        name: package_name.to_string(),
469        root,
470        relative_path,
471        manifest,
472        ordered_manifest,
473    }))
474}
475
476pub(crate) fn bare_module_package_name(specifier: &str) -> Result<Option<&str>> {
477    bare_package_specifier(specifier).map(|parsed| parsed.map(|(package_name, _)| package_name))
478}
479
480pub(crate) fn resolve_self_module_specifier(
481    package_name: &str,
482    package_root: &Path,
483    specifier: &str,
484    manifest: &serde_json::Value,
485) -> Result<PathBuf> {
486    let Some((specifier_package, subpath)) = bare_package_specifier(specifier)? else {
487        bail!("CEM module specifier is not a bare package reference: {specifier}");
488    };
489    if specifier_package != package_name {
490        bail!(
491            "CEM module specifier '{specifier}' does not reference containing package '{package_name}'"
492        );
493    }
494    let package_json = package_root.join("package.json");
495    let content = read_to_string_limited(&package_json, MAX_MANIFEST_SIZE)?;
496    let ordered_manifest = parse_ordered_json(&content, &package_json)?;
497    resolve_package_module_path(
498        package_name,
499        package_root,
500        subpath,
501        manifest,
502        &ordered_manifest,
503    )
504}
505
506fn bare_package_specifier(specifier: &str) -> Result<Option<(&str, Option<&str>)>> {
507    if specifier.starts_with("./")
508        || specifier.starts_with("../")
509        || specifier.starts_with('/')
510        || specifier.starts_with('\\')
511    {
512        return Ok(None);
513    }
514    validate_relative_path(specifier, "customElements modules[].path")?;
515
516    if specifier.starts_with('@') {
517        let Some(scope_end) = specifier.find('/') else {
518            bail!("Invalid scoped CEM module specifier: {specifier}");
519        };
520        let remainder = &specifier[scope_end + 1..];
521        let (package, subpath) = remainder
522            .split_once('/')
523            .map_or((remainder, None), |(package, subpath)| {
524                (package, Some(subpath))
525            });
526        let package_name = &specifier[..scope_end + 1 + package.len()];
527        validate_package_source(package_name)?;
528        validate_package_subpath(subpath, specifier)?;
529        return Ok(Some((package_name, subpath)));
530    }
531
532    let (package_name, subpath) = specifier
533        .split_once('/')
534        .map_or((specifier, None), |(package, subpath)| {
535            (package, Some(subpath))
536        });
537    if !is_package_segment(package_name) {
538        return Ok(None);
539    }
540    validate_package_subpath(subpath, specifier)?;
541    Ok(Some((package_name, subpath)))
542}
543
544fn validate_package_subpath(subpath: Option<&str>, specifier: &str) -> Result<()> {
545    if subpath.is_some_and(|path| {
546        path.is_empty()
547            || path.starts_with('/')
548            || path.ends_with('/')
549            || path.contains('\\')
550            || contains_encoded_separator(path)
551            || path
552                .split('/')
553                .any(|segment| segment.is_empty() || matches!(segment, "." | ".." | "node_modules"))
554    }) {
555        bail!("Invalid CEM module specifier: {specifier}");
556    }
557    Ok(())
558}
559
560fn resolve_package_module_path(
561    package_name: &str,
562    package_root: &Path,
563    subpath: Option<&str>,
564    manifest: &serde_json::Value,
565    ordered_manifest: &OrderedJson,
566) -> Result<PathBuf> {
567    let export_key = subpath.map_or_else(|| ".".to_string(), |path| format!("./{path}"));
568    let (target, is_export) = if let Some(exports) = ordered_manifest.get("exports") {
569        let target = match resolve_export_target(exports, &export_key) {
570            ExportTarget::Target(target) => target,
571            ExportTarget::Blocked => {
572                bail!("Package '{package_name}' blocks export '{export_key}'")
573            }
574            ExportTarget::NoMatch => {
575                bail!("Package '{package_name}' does not export '{export_key}'")
576            }
577            ExportTarget::Invalid => {
578                bail!("Package '{package_name}' has an invalid export '{export_key}'")
579            }
580        };
581        (target, true)
582    } else if let Some(subpath) = subpath {
583        (subpath.to_string(), false)
584    } else {
585        (
586            ["module", "main", "browser"]
587                .into_iter()
588                .find_map(|field| manifest.get(field).and_then(serde_json::Value::as_str))
589                .unwrap_or("index.js")
590                .to_string(),
591            false,
592        )
593    };
594    if is_export {
595        validate_package_export_target(&target, &export_key)?;
596    } else {
597        validate_relative_path(&target, "package module path")?;
598    }
599    let relative = PathBuf::from(target);
600    validate_package_path_boundary(package_root, &relative, "package module path")?;
601    Ok(relative)
602}
603
604fn resolve_export_target(exports: &OrderedJson, key: &str) -> ExportTarget {
605    match exports {
606        OrderedJson::Object(entries)
607            if entries
608                .iter()
609                .any(|(candidate, _)| candidate.starts_with('.')) =>
610        {
611            if let Some(value) = exports.get(key) {
612                resolve_export_value(value)
613            } else {
614                resolve_pattern_export_target(entries, key)
615            }
616        }
617        _ if key == "." => resolve_export_value(exports),
618        _ => ExportTarget::NoMatch,
619    }
620}
621
622fn resolve_export_value(value: &OrderedJson) -> ExportTarget {
623    match value {
624        OrderedJson::String(target) => ExportTarget::Target(target.clone()),
625        OrderedJson::Null => ExportTarget::Blocked,
626        OrderedJson::Object(entries) => {
627            for (condition, target) in entries {
628                if ACTIVE_EXPORT_CONDITIONS.contains(&condition.as_str()) {
629                    match resolve_export_value(target) {
630                        ExportTarget::NoMatch => {}
631                        resolved => return resolved,
632                    }
633                }
634            }
635            ExportTarget::NoMatch
636        }
637        _ => ExportTarget::Invalid,
638    }
639}
640
641fn resolve_pattern_export_target(exports: &[(String, OrderedJson)], key: &str) -> ExportTarget {
642    let mut selected = None;
643    for (pattern, value) in exports {
644        let Some((prefix, suffix)) = pattern.split_once('*') else {
645            continue;
646        };
647        if suffix.contains('*')
648            || key.len() < prefix.len() + suffix.len()
649            || !key.starts_with(prefix)
650            || !key.ends_with(suffix)
651        {
652            continue;
653        }
654        let matched = &key[prefix.len()..key.len() - suffix.len()];
655        let score = (prefix.len(), suffix.len());
656        if selected
657            .as_ref()
658            .is_none_or(|(best_score, _, _)| score > *best_score)
659        {
660            selected = Some((score, value, matched));
661        }
662    }
663    let Some((_, value, matched)) = selected else {
664        return ExportTarget::NoMatch;
665    };
666    match resolve_export_value(value) {
667        ExportTarget::Target(target) => ExportTarget::Target(target.replace('*', matched)),
668        resolved => resolved,
669    }
670}
671
672fn validate_package_export_target(target: &str, key: &str) -> Result<()> {
673    let Some(relative) = target.strip_prefix("./") else {
674        bail!("Package export '{key}' target must start with './': {target}");
675    };
676    if relative.is_empty()
677        || target.contains('\\')
678        || contains_encoded_separator(target)
679        || relative
680            .split('/')
681            .any(|segment| segment.is_empty() || matches!(segment, "." | ".." | "node_modules"))
682    {
683        bail!("Invalid package export '{key}' target: {target}");
684    }
685    validate_relative_path(target, key)
686}
687
688fn contains_encoded_separator(value: &str) -> bool {
689    value.as_bytes().windows(3).any(|bytes| {
690        bytes[0] == b'%'
691            && ((bytes[1] == b'2' && bytes[2].eq_ignore_ascii_case(&b'f'))
692                || (bytes[1] == b'5' && bytes[2].eq_ignore_ascii_case(&b'c')))
693    })
694}
695
696pub(crate) fn validate_package_asset_path(
697    root: &Path,
698    path: &Path,
699    field_name: &str,
700) -> Result<()> {
701    let relative = path.strip_prefix(root).with_context(|| {
702        format!(
703            "{field_name} is outside package root {}: {}",
704            root.display(),
705            path.display()
706        )
707    })?;
708    validate_package_path_boundary(root, relative, field_name)
709}
710
711pub(crate) fn package_asset_metadata(root: &Path, path: &Path) -> Result<Option<fs::Metadata>> {
712    let metadata = match fs::symlink_metadata(path) {
713        Ok(metadata) => metadata,
714        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
715        Err(error) => {
716            return Err(error).with_context(|| {
717                format!("Cannot inspect component catalog asset {}", path.display())
718            });
719        }
720    };
721    if metadata.file_type().is_symlink() {
722        validate_package_asset_path(root, path, "component catalog asset")?;
723        return fs::metadata(path)
724            .map(Some)
725            .with_context(|| format!("Cannot inspect component catalog asset {}", path.display()));
726    }
727    Ok(Some(metadata))
728}
729
730fn validate_package_path_boundary(root: &Path, relative: &Path, field_name: &str) -> Result<()> {
731    let canonical_root = fs::canonicalize(root)
732        .with_context(|| format!("Failed to resolve package root {}", root.display()))?;
733    let target = root.join(relative);
734    let mut existing = target.as_path();
735    loop {
736        match fs::symlink_metadata(existing) {
737            Ok(_) => {
738                let canonical = fs::canonicalize(existing).with_context(|| {
739                    format!("Failed to resolve {field_name}: {}", existing.display())
740                })?;
741                if !canonical.starts_with(&canonical_root) {
742                    bail!(
743                        "{field_name} resolves outside package root {}: {}",
744                        root.display(),
745                        target.display()
746                    );
747                }
748                return Ok(());
749            }
750            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
751            Err(error) => {
752                return Err(error).with_context(|| {
753                    format!("Failed to inspect {field_name}: {}", existing.display())
754                });
755            }
756        }
757        existing = existing.parent().with_context(|| {
758            format!(
759                "Could not validate {field_name} within package root {}",
760                root.display()
761            )
762        })?;
763    }
764}
765
766pub(crate) fn custom_elements_manifest_path(package: PackageContext<'_>) -> Result<PathBuf> {
767    let package_json = package.root.join("package.json");
768    let relative = package
769        .manifest
770        .get("customElements")
771        .and_then(serde_json::Value::as_str)
772        .with_context(|| format!("No 'customElements' field in {}", package_json.display()))?;
773    validate_relative_path(relative, "customElements")?;
774    let path = package.root.join(relative);
775    validate_package_asset_path(package.root, &path, "customElements").with_context(|| {
776        format!(
777            "Custom elements manifest leaves package '{}': {}",
778            package.name,
779            path.display()
780        )
781    })?;
782    Ok(path)
783}
784
785pub(crate) fn read_required_file(path: &Path, kind: &str) -> Result<String> {
786    read_to_string_limited(path, MAX_MANIFEST_SIZE)
787        .with_context(|| format!("Failed to read {kind}: {}", path.display()))
788}
789
790pub(crate) fn read_optional_file(path: Option<&Path>, kind: &str) -> Result<Option<String>> {
791    match path {
792        Some(path) if path.is_file() => read_required_file(path, kind).map(Some),
793        _ => Ok(None),
794    }
795}
796
797pub(crate) fn package_has_authored_script(pkg_json: &serde_json::Value) -> bool {
798    for field in SCRIPT_ENTRY_FIELDS {
799        match pkg_json.get(*field) {
800            Some(serde_json::Value::String(path)) if is_script_path(path) => return true,
801            Some(serde_json::Value::Object(map)) if map.values().any(export_value_has_script) => {
802                return true;
803            }
804            _ => {}
805        }
806    }
807
808    let Some(exports) = pkg_json.get("exports") else {
809        return false;
810    };
811    match exports {
812        serde_json::Value::String(path) => is_script_path(path),
813        serde_json::Value::Array(values) => values.iter().any(export_value_has_script),
814        serde_json::Value::Object(map) => {
815            if let Some(root) = map.get(".") {
816                return export_value_has_script(root);
817            }
818            map.iter()
819                .any(|(key, value)| !is_resource_export(key) && export_value_has_script(value))
820        }
821        _ => false,
822    }
823}
824
825fn export_value_has_script(value: &serde_json::Value) -> bool {
826    match value {
827        serde_json::Value::String(path) => is_script_path(path),
828        serde_json::Value::Array(values) => values.iter().any(export_value_has_script),
829        serde_json::Value::Object(map) => map.values().any(export_value_has_script),
830        _ => false,
831    }
832}
833
834fn is_resource_export(key: &str) -> bool {
835    SCRIPTLESS_ASSET_EXPORTS.contains(&key)
836}
837
838fn is_script_path(path: &str) -> bool {
839    let path = path.split_once('?').map_or(path, |(prefix, _)| prefix);
840    path.ends_with(".js")
841        || path.ends_with(".mjs")
842        || path.ends_with(".cjs")
843        || path.ends_with(".ts")
844}
845
846// Parse `modules[].{path,declarations[].{name,tagName}}` from a Custom Elements
847// Manifest.
848fn parse_custom_elements_manifest(path: &Path) -> Result<Vec<ComponentDeclaration>> {
849    let content = read_to_string_limited(path, MAX_MANIFEST_SIZE)
850        .with_context(|| format!("Custom elements manifest: {}", path.display()))?;
851    let manifest: serde_json::Value = serde_json::from_str(&content).with_context(|| {
852        format!(
853            "Failed to parse custom elements manifest: {}",
854            path.display()
855        )
856    })?;
857
858    let mut seen = std::collections::HashSet::new();
859    let mut declarations = Vec::new();
860
861    if let Some(modules) = manifest.get("modules").and_then(|v| v.as_array()) {
862        for module in modules {
863            let module_path = module.get("path").and_then(|value| value.as_str());
864            if let Some(path) = module_path {
865                validate_relative_path(path, "customElements modules[].path")?;
866            }
867            if let Some(module_declarations) = module.get("declarations").and_then(|v| v.as_array())
868            {
869                for declaration in module_declarations {
870                    let Some(tag_name) =
871                        declaration.get("tagName").and_then(|value| value.as_str())
872                    else {
873                        continue;
874                    };
875                    if seen.insert(tag_name) {
876                        declarations.push(ComponentDeclaration {
877                            tag_name: tag_name.to_string(),
878                            name: declaration
879                                .get("name")
880                                .and_then(|value| value.as_str())
881                                .map(str::to_string),
882                            module_specifier: module_path.map(str::to_string),
883                        });
884                    }
885                }
886            }
887        }
888    }
889
890    Ok(declarations)
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use crate::{FastDiscoveryPlugin, WebUIDiscoveryPlugin};
897    use std::fs;
898    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
899    use tempfile::TempDir;
900
901    #[test]
902    fn default_discovery_reads_package_but_not_fast_metadata() -> Result<()> {
903        let root = tempfile::tempdir()?;
904        let package = root.path().join("node_modules/native-package");
905        fs::create_dir_all(package.join("components"))?;
906        fs::write(
907            package.join("package.json"),
908            r#"{"customElements":"custom-elements.json"}"#,
909        )?;
910        fs::write(package.join("custom-elements.json"), [0xff])?;
911        fs::write(
912            package.join("components/native-card.html"),
913            "<span>Native</span>",
914        )?;
915        fs::write(
916            package.join("components/native-card.css"),
917            "span { color: blue; }",
918        )?;
919        let result = crate::discover_source("native-package", root.path())?;
920        assert_eq!(result.components.len(), 1);
921        assert_eq!(result.components[0].tag_name, "native-card");
922        assert!(!result.components[0].is_client_owned);
923        assert!(crate::discover_source_with_plugin(
924            "native-package",
925            root.path(),
926            &FastDiscoveryPlugin::new()
927        )
928        .is_err());
929        fs::write(package.join("package.json"), [0xff])?;
930        assert!(crate::discover_source("native-package", root.path()).is_err());
931        Ok(())
932    }
933
934    struct MetadataPlugin {
935        calls: AtomicUsize,
936    }
937
938    impl DiscoveryPlugin for MetadataPlugin {
939        fn cache_namespace(&self) -> &'static str {
940            "shared-metadata-test"
941        }
942
943        fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
944            WebUIDiscoveryPlugin::new().discover_local(root)
945        }
946
947        fn prepare_package(&self, package: PackageContext<'_>) -> Result<PreparedPackage> {
948            assert!(package.manifest["revision"].is_number());
949            self.calls.fetch_add(1, Ordering::Relaxed);
950            self.discover_local(package.root)
951                .map(PreparedPackage::Uncached)
952        }
953    }
954
955    #[test]
956    fn package_metadata_is_available_but_arbitrary_plugin_outputs_are_not_cached() -> Result<()> {
957        let root = tempfile::tempdir()?;
958        let package = root.path().join("node_modules/native-package");
959        fs::create_dir_all(&package)?;
960        fs::write(package.join("package.json"), r#"{"revision":1}"#)?;
961        fs::write(package.join("native-card.html"), "<span>Native</span>")?;
962        let plugin = MetadataPlugin {
963            calls: AtomicUsize::new(0),
964        };
965        for _ in 0..2 {
966            crate::discover_source_with_plugin("native-package", root.path(), &plugin)?;
967        }
968        assert_eq!(plugin.calls.load(Ordering::Relaxed), 2);
969        fs::write(package.join("package.json"), r#"{"revision":2}"#)?;
970        crate::discover_source_with_plugin("native-package", root.path(), &plugin)?;
971        assert_eq!(plugin.calls.load(Ordering::Relaxed), 3);
972        fs::write(package.join("package.json"), [0xff])?;
973        assert!(
974            crate::discover_source_with_plugin("native-package", root.path(), &plugin).is_err()
975        );
976        assert_eq!(plugin.calls.load(Ordering::Relaxed), 3);
977        Ok(())
978    }
979
980    struct TransientCatalog {
981        directory: &'static str,
982        first: AtomicBool,
983    }
984
985    impl DiscoveryPlugin for TransientCatalog {
986        fn cache_namespace(&self) -> &'static str {
987            "transient-catalog"
988        }
989
990        fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
991            FastDiscoveryPlugin.discover_local(root)
992        }
993
994        fn prepare_package(&self, package: PackageContext<'_>) -> Result<PreparedPackage> {
995            let prepared = FastDiscoveryPlugin.prepare_package(package)?;
996            if self.first.swap(false, Ordering::Relaxed) {
997                let directory = package.root.join(self.directory);
998                fs::create_dir_all(&directory)?;
999                fs::write(directory.join("transient-card.html"), "<p>Transient</p>")?;
1000            }
1001            Ok(prepared)
1002        }
1003    }
1004
1005    #[test]
1006    fn transient_catalog_membership_cannot_enter_a_prepared_or_cached_result() -> Result<()> {
1007        for (existing, directory) in [
1008            (Some("components"), "components"),
1009            (Some("components/empty"), "components/empty"),
1010            (None, "components"),
1011        ] {
1012            let root = tempfile::tempdir()?;
1013            let package = root.path().join("node_modules/fixture-pkg");
1014            fs::create_dir_all(&package)?;
1015            fs::write(
1016                package.join("package.json"),
1017                r#"{"customElements":"custom-elements.json"}"#,
1018            )?;
1019            fs::write(
1020                package.join("custom-elements.json"),
1021                r#"{"modules":[{"path":"./stable.js","declarations":[{"name":"Stable","tagName":"stable-card"}]}]}"#,
1022            )?;
1023            fs::write(
1024                package.join("stable.template-webui.html"),
1025                "<template>Stable</template>",
1026            )?;
1027            if let Some(directory) = existing {
1028                fs::create_dir_all(package.join(directory))?;
1029            }
1030            #[cfg(unix)]
1031            let guard = existing.map_or_else(|| package.clone(), |path| package.join(path));
1032            #[cfg(unix)]
1033            let modified = fs::metadata(&guard)?.modified()?;
1034            let plugin = TransientCatalog {
1035                directory,
1036                first: AtomicBool::new(true),
1037            };
1038            let first = crate::discover_source_with_plugin("fixture-pkg", root.path(), &plugin)?;
1039            assert_eq!(first.components.len(), 1);
1040            assert_eq!(first.components[0].tag_name, "stable-card");
1041            fs::remove_file(package.join(directory).join("transient-card.html"))?;
1042            if existing.is_none() {
1043                fs::remove_dir(package.join(directory))?;
1044            }
1045            #[cfg(unix)]
1046            fs::File::open(guard)?.set_modified(modified)?;
1047            let second = crate::discover_source_with_plugin("fixture-pkg", root.path(), &plugin)?;
1048            assert_eq!(second.components.len(), 1);
1049            assert_eq!(second.components[0].tag_name, "stable-card");
1050        }
1051        Ok(())
1052    }
1053
1054    fn ordered_json(source: &str) -> OrderedJson {
1055        parse_ordered_json(source, Path::new("package.json")).unwrap()
1056    }
1057
1058    fn resolve_test_package_module_path(
1059        root: &Path,
1060        subpath: Option<&str>,
1061        source: &str,
1062    ) -> Result<PathBuf> {
1063        let manifest = serde_json::from_str(source).unwrap();
1064        let ordered_manifest = ordered_json(source);
1065        resolve_package_module_path(
1066            "external-module",
1067            root,
1068            subpath,
1069            &manifest,
1070            &ordered_manifest,
1071        )
1072    }
1073
1074    fn create_npm_package(dir: &Path, name: &str, tag_name: &str, html: &str, css: Option<&str>) {
1075        let pkg_dir = dir.join(name);
1076        fs::create_dir_all(&pkg_dir).unwrap();
1077
1078        // Create template
1079        fs::write(pkg_dir.join(format!("{tag_name}.html")), html).unwrap();
1080
1081        // Create styles (optional)
1082        if let Some(css_content) = css {
1083            fs::write(pkg_dir.join(format!("{tag_name}.css")), css_content).unwrap();
1084        }
1085
1086        let pkg_json = serde_json::json!({
1087            "name": name,
1088            "version": "1.0.0",
1089            "webui": { "components": ["./"] }
1090        });
1091        fs::write(
1092            pkg_dir.join("package.json"),
1093            serde_json::to_string_pretty(&pkg_json).unwrap(),
1094        )
1095        .unwrap();
1096    }
1097
1098    #[test]
1099    fn test_find_node_modules_in_cwd() {
1100        let tmp = TempDir::new().unwrap();
1101        let nm = tmp.path().join("node_modules");
1102        fs::create_dir_all(nm.join("fixture-pkg")).unwrap();
1103
1104        let result = find_package_node_modules("fixture-pkg", tmp.path(), tmp.path());
1105        assert!(result.is_ok());
1106        assert_eq!(
1107            result.unwrap().canonicalize().unwrap(),
1108            nm.canonicalize().unwrap()
1109        );
1110    }
1111
1112    #[test]
1113    fn test_find_node_modules_walks_up() {
1114        let tmp = TempDir::new().unwrap();
1115        let nm = tmp.path().join("node_modules");
1116        fs::create_dir_all(nm.join("fixture-pkg")).unwrap();
1117        let sub = tmp.path().join("packages").join("my-app");
1118        fs::create_dir_all(&sub).unwrap();
1119
1120        let result = find_package_node_modules("fixture-pkg", &sub, &sub);
1121        assert!(result.is_ok());
1122        assert_eq!(
1123            result.unwrap().canonicalize().unwrap(),
1124            nm.canonicalize().unwrap()
1125        );
1126    }
1127
1128    #[test]
1129    fn test_find_node_modules_not_found() {
1130        let tmp = TempDir::new().unwrap();
1131        let result = find_package_node_modules("fixture-pkg", tmp.path(), tmp.path());
1132        assert!(result.is_err());
1133    }
1134
1135    #[test]
1136    fn test_find_node_modules_fallback_used_when_primary_empty() {
1137        let primary = TempDir::new().unwrap();
1138        let project = TempDir::new().unwrap();
1139        let nm = project.path().join("node_modules");
1140        fs::create_dir_all(nm.join("fixture-pkg")).unwrap();
1141
1142        let found =
1143            find_package_node_modules("fixture-pkg", primary.path(), project.path()).unwrap();
1144        assert_eq!(found, nm);
1145    }
1146
1147    #[test]
1148    fn test_find_node_modules_fallback_prefers_primary() {
1149        let primary = TempDir::new().unwrap();
1150        let nm_primary = primary.path().join("node_modules");
1151        fs::create_dir_all(nm_primary.join("fixture-pkg")).unwrap();
1152        let project = TempDir::new().unwrap();
1153        fs::create_dir_all(project.path().join("node_modules/fixture-pkg")).unwrap();
1154
1155        let found =
1156            find_package_node_modules("fixture-pkg", primary.path(), project.path()).unwrap();
1157        assert_eq!(found, nm_primary);
1158    }
1159
1160    #[test]
1161    fn test_find_node_modules_fallback_errors_when_neither_has_it() {
1162        let primary = TempDir::new().unwrap();
1163        let fallback = TempDir::new().unwrap();
1164        assert!(find_package_node_modules("fixture-pkg", primary.path(), fallback.path()).is_err());
1165    }
1166
1167    #[test]
1168    fn test_bare_module_resolution_rechecks_nearer_ancestors() {
1169        let tmp = TempDir::new().unwrap();
1170        let app = tmp.path().join("packages/app");
1171        fs::create_dir_all(&app).unwrap();
1172        for name in ["plain-module", "@fixture/scoped-module"] {
1173            for directory in [
1174                tmp.path().to_path_buf(),
1175                tmp.path().join("packages"),
1176                app.clone(),
1177            ] {
1178                let package = directory.join("node_modules").join(name);
1179                fs::create_dir_all(&package).unwrap();
1180                fs::write(
1181                    package.join("package.json"),
1182                    r#"{"exports":{"./component.js":"./component.js"}}"#,
1183                )
1184                .unwrap();
1185                let resolved = resolve_bare_module_specifier(&format!("{name}/component.js"), &app)
1186                    .unwrap()
1187                    .unwrap();
1188                assert_eq!(resolved.root, package.canonicalize().unwrap());
1189            }
1190        }
1191    }
1192
1193    #[test]
1194    fn test_is_bare_scope() {
1195        assert!(is_bare_scope("@reactive-ui"));
1196        assert!(is_bare_scope("@scope"));
1197        assert!(!is_bare_scope("@scope/button"));
1198        assert!(!is_bare_scope("my-widget"));
1199    }
1200
1201    #[test]
1202    fn test_resolve_single_package() {
1203        let tmp = TempDir::new().unwrap();
1204        let nm = tmp.path().join("node_modules");
1205        fs::create_dir(&nm).unwrap();
1206
1207        create_npm_package(
1208            &nm,
1209            "my-widget",
1210            "my-widget",
1211            "<div><slot></slot></div>",
1212            Some(".widget { color: blue; }"),
1213        );
1214
1215        let mut cache = DiscoveryCache::open().unwrap();
1216        let result = resolve(
1217            "my-widget",
1218            tmp.path(),
1219            &WebUIDiscoveryPlugin::new(),
1220            &mut cache,
1221        );
1222        assert!(result.is_ok());
1223
1224        let components = result.unwrap();
1225        assert_eq!(components.len(), 1);
1226        assert_eq!(components[0].tag_name, "my-widget");
1227        assert_eq!(components[0].html_content, "<div><slot></slot></div>");
1228        assert_eq!(
1229            components[0].css_content.as_deref(),
1230            Some(".widget { color: blue; }")
1231        );
1232        assert!(!components[0].is_client_owned);
1233    }
1234
1235    #[test]
1236    fn test_fast_package_script_ownership_uses_manifest_entrypoints() {
1237        let static_pkg = serde_json::json!({
1238            "exports": {
1239                "./component-asset.js": "./component-asset.js",
1240                "./styles.css": "./styles.css"
1241            }
1242        });
1243        let interactive_pkg = serde_json::json!({
1244            "exports": {
1245                ".": { "import": "./dist/index.js" }
1246            }
1247        });
1248        let module_pkg = serde_json::json!({
1249            "module": "./dist/index.mjs"
1250        });
1251
1252        assert!(!package_has_authored_script(&static_pkg));
1253        assert!(package_has_authored_script(&interactive_pkg));
1254        assert!(package_has_authored_script(&module_pkg));
1255    }
1256
1257    #[test]
1258    fn test_resolve_scoped_package() {
1259        let tmp = TempDir::new().unwrap();
1260        let nm = tmp.path().join("node_modules");
1261        let scope_dir = nm.join("@mylib");
1262        fs::create_dir_all(&scope_dir).unwrap();
1263
1264        create_npm_package(
1265            &scope_dir,
1266            "button",
1267            "mylib-button",
1268            "<button><slot></slot></button>",
1269            Some(".btn { padding: 8px; }"),
1270        );
1271        create_npm_package(
1272            &scope_dir,
1273            "text",
1274            "mylib-text",
1275            "<span><slot></slot></span>",
1276            None,
1277        );
1278
1279        let mut cache = DiscoveryCache::open().unwrap();
1280        let result = resolve(
1281            "@mylib",
1282            tmp.path(),
1283            &WebUIDiscoveryPlugin::new(),
1284            &mut cache,
1285        );
1286        assert!(result.is_ok());
1287
1288        let components = result.unwrap();
1289        assert_eq!(components.len(), 2);
1290
1291        let names: Vec<&str> = components.iter().map(|c| c.tag_name.as_str()).collect();
1292        assert!(names.contains(&"mylib-button"));
1293        assert!(names.contains(&"mylib-text"));
1294    }
1295
1296    #[test]
1297    fn test_resolve_missing_package() {
1298        let tmp = TempDir::new().unwrap();
1299        let nm = tmp.path().join("node_modules");
1300        fs::create_dir(&nm).unwrap();
1301
1302        let mut cache = DiscoveryCache::open().unwrap();
1303        let result = resolve(
1304            "nonexistent",
1305            tmp.path(),
1306            &WebUIDiscoveryPlugin::new(),
1307            &mut cache,
1308        );
1309        assert!(result.is_err());
1310    }
1311
1312    #[test]
1313    fn test_parse_custom_elements_manifest() {
1314        let tmp = TempDir::new().unwrap();
1315        let manifest = serde_json::json!({
1316            "schemaVersion": "1.0.0",
1317            "modules": [
1318                {
1319                    "kind": "javascript-module",
1320                    "path": "src/button.js",
1321                    "declarations": [
1322                        {
1323                            "kind": "class",
1324                            "name": "MyButton",
1325                            "tagName": "my-button"
1326                        }
1327                    ]
1328                },
1329                {
1330                    "kind": "javascript-module",
1331                    "path": "src/text.js",
1332                    "declarations": [
1333                        {
1334                            "kind": "class",
1335                            "name": "MyText",
1336                            "tagName": "my-text"
1337                        },
1338                        {
1339                            "kind": "variable",
1340                            "name": "VERSION"
1341                        }
1342                    ]
1343                }
1344            ]
1345        });
1346
1347        let cem_path = tmp.path().join("custom-elements.json");
1348        fs::write(&cem_path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
1349
1350        let result = parse_custom_elements_manifest(&cem_path);
1351        assert!(result.is_ok());
1352
1353        let declarations = result.unwrap();
1354        assert_eq!(declarations.len(), 2);
1355        assert_eq!(declarations[0].tag_name, "my-button");
1356        assert_eq!(declarations[1].tag_name, "my-text");
1357    }
1358
1359    #[test]
1360    fn test_parse_custom_elements_manifest_empty() {
1361        let tmp = TempDir::new().unwrap();
1362        let manifest = serde_json::json!({
1363            "schemaVersion": "1.0.0",
1364            "modules": []
1365        });
1366
1367        let cem_path = tmp.path().join("custom-elements.json");
1368        fs::write(&cem_path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
1369
1370        let result = parse_custom_elements_manifest(&cem_path);
1371        assert!(result.is_ok());
1372        assert!(result.unwrap().is_empty());
1373    }
1374
1375    #[test]
1376    fn test_parse_custom_elements_manifest_deduplicates() {
1377        let tmp = TempDir::new().unwrap();
1378        let manifest = serde_json::json!({
1379            "schemaVersion": "1.0.0",
1380            "modules": [
1381                {
1382                    "kind": "javascript-module",
1383                    "path": "src/button.js",
1384                    "declarations": [{ "kind": "class", "name": "MyButton", "tagName": "my-button" }]
1385                },
1386                {
1387                    "kind": "javascript-module",
1388                    "path": "src/other-button.js",
1389                    "declarations": [{ "kind": "class", "name": "OtherButton", "tagName": "my-button" }]
1390                }
1391            ]
1392        });
1393
1394        let cem_path = tmp.path().join("custom-elements.json");
1395        fs::write(&cem_path, serde_json::to_string(&manifest).unwrap()).unwrap();
1396
1397        let declarations = parse_custom_elements_manifest(&cem_path).unwrap();
1398        assert_eq!(declarations.len(), 1);
1399        assert_eq!(declarations[0].tag_name, "my-button");
1400    }
1401
1402    #[test]
1403    fn test_cached_result_is_reused() {
1404        let tmp = TempDir::new().unwrap();
1405        let nm = tmp.path().join("node_modules");
1406        fs::create_dir(&nm).unwrap();
1407
1408        create_npm_package(&nm, "cached-pkg", "cached-comp", "<div>cached</div>", None);
1409
1410        let mut cache = DiscoveryCache::open().unwrap();
1411
1412        // First resolve: populates cache
1413        let first = resolve(
1414            "cached-pkg",
1415            tmp.path(),
1416            &WebUIDiscoveryPlugin::new(),
1417            &mut cache,
1418        )
1419        .unwrap();
1420        assert_eq!(first.len(), 1);
1421        assert_eq!(first[0].tag_name, "cached-comp");
1422
1423        // Second resolve: should hit cache
1424        let second = resolve(
1425            "cached-pkg",
1426            tmp.path(),
1427            &WebUIDiscoveryPlugin::new(),
1428            &mut cache,
1429        )
1430        .unwrap();
1431        assert_eq!(second.len(), 1);
1432        assert_eq!(second[0].tag_name, "cached-comp");
1433    }
1434
1435    #[test]
1436    fn test_validate_relative_path_rejects_absolute() {
1437        let result = validate_relative_path("/etc/passwd", "exports");
1438        assert!(result.is_err());
1439        assert!(result.unwrap_err().to_string().contains("Absolute path"));
1440    }
1441
1442    #[test]
1443    fn test_validate_relative_path_rejects_parent_traversal() {
1444        let result = validate_relative_path("../../etc/passwd", "exports");
1445        assert!(result.is_err());
1446        assert!(result.unwrap_err().to_string().contains(".."));
1447    }
1448
1449    #[test]
1450    fn test_validate_relative_path_rejects_hidden_traversal() {
1451        let result = validate_relative_path("foo/../../../etc/passwd", "exports");
1452        assert!(result.is_err());
1453    }
1454
1455    #[test]
1456    fn test_validate_relative_path_accepts_valid() {
1457        assert!(validate_relative_path("./dist/template.html", "exports").is_ok());
1458        assert!(validate_relative_path("custom-elements.json", "customElements").is_ok());
1459        assert!(validate_relative_path("dist/nested/file.css", "exports").is_ok());
1460    }
1461
1462    #[test]
1463    fn test_bare_module_export_cannot_escape_package() {
1464        let tmp = TempDir::new().unwrap();
1465        let package = tmp.path().join("node_modules/external-module");
1466        fs::create_dir_all(&package).unwrap();
1467        fs::write(
1468            package.join("package.json"),
1469            r#"{"exports":{"./component.js":"./dist/../outside.js"}}"#,
1470        )
1471        .unwrap();
1472
1473        let error =
1474            resolve_bare_module_specifier("external-module/component.js", tmp.path()).unwrap_err();
1475        assert!(format!("{error:#}").contains(".."));
1476    }
1477
1478    #[test]
1479    fn test_bare_module_resolves_pattern_export() {
1480        let tmp = TempDir::new().unwrap();
1481        let path = resolve_test_package_module_path(
1482            tmp.path(),
1483            Some("components/button"),
1484            r#"{
1485            "exports": {
1486                "./components/*": {
1487                    "default": "./dist/*.js"
1488                }
1489            }
1490        }"#,
1491        )
1492        .unwrap();
1493        assert_eq!(path, PathBuf::from("./dist/button.js"));
1494    }
1495
1496    #[test]
1497    fn test_bare_module_uses_first_active_export_condition() {
1498        let tmp = TempDir::new().unwrap();
1499        let path = resolve_test_package_module_path(
1500            tmp.path(),
1501            Some("component.js"),
1502            r#"{
1503            "exports": {
1504                "./component.js": {
1505                    "default": "./dist/default.js",
1506                    "import": "./dist/import.js"
1507                }
1508            }
1509        }"#,
1510        )
1511        .unwrap();
1512
1513        assert_eq!(path, PathBuf::from("./dist/default.js"));
1514    }
1515
1516    #[test]
1517    fn test_active_null_condition_blocks_later_default() {
1518        let tmp = TempDir::new().unwrap();
1519        let error = resolve_test_package_module_path(
1520            tmp.path(),
1521            Some("component.js"),
1522            r#"{
1523            "exports": {
1524                "./component.js": {
1525                    "import": null,
1526                    "default": "./dist/default.js"
1527                }
1528            }
1529        }"#,
1530        )
1531        .unwrap_err();
1532
1533        assert!(error.to_string().contains("blocks export"));
1534    }
1535
1536    #[test]
1537    fn test_exact_null_export_blocks_pattern_fallback() {
1538        let tmp = TempDir::new().unwrap();
1539        assert!(resolve_test_package_module_path(
1540            tmp.path(),
1541            Some("component.js"),
1542            r#"{
1543            "exports": {
1544                "./component.js": null,
1545                "./*": "./dist/*.js"
1546            }
1547        }"#,
1548        )
1549        .is_err());
1550    }
1551
1552    #[test]
1553    fn test_package_exports_reject_invalid_target_segments() {
1554        let tmp = TempDir::new().unwrap();
1555        for target in [
1556            "dist/component.js",
1557            "./node_modules/component.js",
1558            "./dist/./component.js",
1559            "./dist%2fcomponent.js",
1560            "./dist%5Ccomponent.js",
1561        ] {
1562            let source = serde_json::json!({
1563                "exports": {"./component.js": target}
1564            })
1565            .to_string();
1566            assert!(
1567                resolve_test_package_module_path(tmp.path(), Some("component.js"), &source,)
1568                    .is_err()
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn test_package_export_symlink_cannot_escape_root() {
1575        let tmp = TempDir::new().unwrap();
1576        let package = tmp.path().join("package");
1577        let outside = tmp.path().join("outside");
1578        fs::create_dir_all(&package).unwrap();
1579        fs::create_dir_all(&outside).unwrap();
1580        fs::write(outside.join("component.js"), "export {};").unwrap();
1581        #[cfg(unix)]
1582        std::os::unix::fs::symlink(&outside, package.join("link")).unwrap();
1583        #[cfg(windows)]
1584        std::os::windows::fs::symlink_dir(&outside, package.join("link")).unwrap();
1585        let error = resolve_test_package_module_path(
1586            &package,
1587            Some("component.js"),
1588            r#"{"exports":{"./component.js":"./link/component.js"}}"#,
1589        )
1590        .unwrap_err();
1591
1592        assert!(format!("{error:#}").contains("outside package root"));
1593    }
1594
1595    #[test]
1596    fn test_asset_exports_support_patterns_and_exact_blocks() {
1597        let tmp = TempDir::new().unwrap();
1598        let manifest = ordered_json(
1599            r#"{
1600            "exports": {
1601                "./*.html": "./assets/*.html",
1602                "./*.css": "./assets/*.css"
1603            }
1604        }"#,
1605        );
1606
1607        let template = package_export_path_from_metadata(
1608            "fixture-package",
1609            tmp.path(),
1610            &manifest,
1611            "./template-webui.html",
1612        )
1613        .unwrap();
1614        let styles = package_export_path_from_metadata(
1615            "fixture-package",
1616            tmp.path(),
1617            &manifest,
1618            "./styles.css",
1619        )
1620        .unwrap();
1621
1622        assert_eq!(
1623            template,
1624            Some(tmp.path().join("./assets/template-webui.html"))
1625        );
1626        assert_eq!(styles, Some(tmp.path().join("./assets/styles.css")));
1627
1628        let blocked = ordered_json(
1629            r#"{
1630            "exports": {
1631                "./template-webui.html": null,
1632                "./*.html": "./assets/*.html"
1633            }
1634        }"#,
1635        );
1636        assert!(package_export_path_from_metadata(
1637            "fixture-package",
1638            tmp.path(),
1639            &blocked,
1640            "./template-webui.html",
1641        )
1642        .unwrap()
1643        .is_none());
1644    }
1645
1646    #[test]
1647    fn test_custom_elements_manifest_symlink_cannot_escape_root() {
1648        let tmp = TempDir::new().unwrap();
1649        let package = tmp.path().join("package");
1650        let outside = tmp.path().join("outside");
1651        fs::create_dir_all(&package).unwrap();
1652        fs::create_dir_all(&outside).unwrap();
1653        fs::write(outside.join("custom-elements.json"), r#"{"modules":[]}"#).unwrap();
1654        #[cfg(unix)]
1655        std::os::unix::fs::symlink(
1656            outside.join("custom-elements.json"),
1657            package.join("custom-elements.json"),
1658        )
1659        .unwrap();
1660        #[cfg(windows)]
1661        std::os::windows::fs::symlink_file(
1662            outside.join("custom-elements.json"),
1663            package.join("custom-elements.json"),
1664        )
1665        .unwrap();
1666        let manifest = serde_json::json!({
1667            "customElements": "./custom-elements.json"
1668        });
1669        let context = PackageContext {
1670            name: "fixture-package",
1671            root: &package,
1672            manifest: &manifest,
1673        };
1674
1675        let error = custom_elements_manifest_path(context).unwrap_err();
1676
1677        assert!(format!("{error:#}").contains("leaves package"));
1678    }
1679
1680    #[test]
1681    fn test_resolve_symlinked_package() {
1682        // Simulates pnpm-style layout where node_modules/@scope/pkg is a symlink
1683        // to node_modules/.pnpm/@scope+pkg@version/node_modules/@scope/pkg.
1684        let tmp = TempDir::new().unwrap();
1685        let nm = tmp.path().join("node_modules");
1686        fs::create_dir(&nm).unwrap();
1687
1688        // Create the real package inside .pnpm store
1689        let pnpm_store = nm
1690            .join(".pnpm")
1691            .join("my-widget@1.0.0")
1692            .join("node_modules");
1693        fs::create_dir_all(&pnpm_store).unwrap();
1694        create_npm_package(
1695            &pnpm_store,
1696            "my-widget",
1697            "my-widget",
1698            "<div>symlinked</div>",
1699            Some(".w { color: red; }"),
1700        );
1701
1702        // Create a symlink: node_modules/my-widget -> .pnpm store location
1703        let link_path = nm.join("my-widget");
1704        let target_path = pnpm_store.join("my-widget");
1705
1706        #[cfg(unix)]
1707        std::os::unix::fs::symlink(&target_path, &link_path).unwrap();
1708        #[cfg(windows)]
1709        std::os::windows::fs::symlink_dir(&target_path, &link_path).unwrap();
1710
1711        let mut cache = DiscoveryCache::open().unwrap();
1712        let result = resolve(
1713            "my-widget",
1714            tmp.path(),
1715            &WebUIDiscoveryPlugin::new(),
1716            &mut cache,
1717        );
1718        assert!(
1719            result.is_ok(),
1720            "symlinked package should resolve: {result:?}"
1721        );
1722
1723        let components = result.unwrap();
1724        assert_eq!(components.len(), 1);
1725        assert_eq!(components[0].tag_name, "my-widget");
1726        assert_eq!(components[0].html_content, "<div>symlinked</div>");
1727    }
1728
1729    #[test]
1730    fn test_resolve_scoped_symlinked_package() {
1731        // Simulates pnpm layout for scoped packages:
1732        // node_modules/@scope/pkg -> .pnpm/@scope+pkg@ver/node_modules/@scope/pkg
1733        let tmp = TempDir::new().unwrap();
1734        let nm = tmp.path().join("node_modules");
1735        let scope_dir = nm.join("@mai-ui");
1736        fs::create_dir_all(&scope_dir).unwrap();
1737
1738        // Real package inside .pnpm store
1739        let pnpm_store = nm
1740            .join(".pnpm")
1741            .join("@mai-ui+button@1.8.3")
1742            .join("node_modules")
1743            .join("@mai-ui");
1744        fs::create_dir_all(&pnpm_store).unwrap();
1745        create_npm_package(
1746            &pnpm_store,
1747            "button",
1748            "mai-button",
1749            "<button><slot></slot></button>",
1750            None,
1751        );
1752
1753        // Symlink: node_modules/@mai-ui/button -> .pnpm store
1754        let link_path = scope_dir.join("button");
1755        let target_path = pnpm_store.join("button");
1756
1757        #[cfg(unix)]
1758        std::os::unix::fs::symlink(&target_path, &link_path).unwrap();
1759        #[cfg(windows)]
1760        std::os::windows::fs::symlink_dir(&target_path, &link_path).unwrap();
1761
1762        let mut cache = DiscoveryCache::open().unwrap();
1763        let result = resolve(
1764            "@mai-ui",
1765            tmp.path(),
1766            &WebUIDiscoveryPlugin::new(),
1767            &mut cache,
1768        );
1769        assert!(
1770            result.is_ok(),
1771            "scoped symlinked package should resolve: {result:?}"
1772        );
1773
1774        let components = result.unwrap();
1775        assert_eq!(components.len(), 1);
1776        assert_eq!(components[0].tag_name, "mai-button");
1777    }
1778
1779    #[test]
1780    fn test_fast_resolve_rejects_path_traversal_in_manifest() {
1781        let tmp = TempDir::new().unwrap();
1782        let nm = tmp.path().join("node_modules");
1783        let pkg_dir = nm.join("evil-pkg");
1784        fs::create_dir_all(&pkg_dir).unwrap();
1785
1786        // FAST still validates metadata paths before reading them.
1787        let pkg_json = serde_json::json!({
1788            "name": "evil-pkg",
1789            "version": "1.0.0",
1790            "customElements": "../../../etc/passwd"
1791        });
1792        fs::write(
1793            pkg_dir.join("package.json"),
1794            serde_json::to_string(&pkg_json).unwrap(),
1795        )
1796        .unwrap();
1797
1798        let mut cache = DiscoveryCache::open().unwrap();
1799        let result = resolve(
1800            "evil-pkg",
1801            tmp.path(),
1802            &FastDiscoveryPlugin::new(),
1803            &mut cache,
1804        );
1805        assert!(result.is_err());
1806        assert!(result.unwrap_err().to_string().contains(".."));
1807    }
1808}