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. Reads `package.json` exports for template and styles, and
8//! parses the Custom Elements Manifest for component tag names.
9
10use anyhow::{bail, Context, Result};
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14use super::cache::{CacheKey, DiscoveryCache};
15use super::{DiscoveredComponent, DiscoveryPlugin};
16
17/// Validated npm package context presented to a discovery plugin.
18#[derive(Debug, Clone, Copy)]
19pub struct PackageContext<'a> {
20    /// npm package name.
21    pub name: &'a str,
22    /// Canonical package root.
23    pub root: &'a Path,
24    /// Parsed `package.json`.
25    pub manifest: &'a serde_json::Value,
26    /// Whether package metadata exposes authored browser code.
27    pub is_client_owned: bool,
28}
29
30pub(crate) struct ComponentDeclaration {
31    pub(crate) tag_name: String,
32    pub(crate) name: Option<String>,
33    pub(crate) module_path: Option<PathBuf>,
34}
35
36pub(crate) struct WebUIAssets {
37    pub(crate) template_path: PathBuf,
38    pub(crate) styles_path: Option<PathBuf>,
39    pub(crate) manifest_path: PathBuf,
40}
41
42/// Maximum file size for package.json and custom elements manifests (10 MB).
43const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
44
45/// Conditional export keys in priority order for fallback resolution.
46const EXPORT_PRIORITY: &[&str] = &["default", "import", "require"];
47
48/// Package fields that conventionally point to a browser/module entry.
49const SCRIPT_ENTRY_FIELDS: &[&str] = &["main", "module", "browser"];
50
51/// WebUI asset exports that do not imply authored browser code.
52const WEBUI_ASSET_EXPORTS: &[&str] = &[
53    "./template-webui.html",
54    "./styles.css",
55    "./component-asset.js",
56];
57
58/// Find `node_modules/` by walking up from `start` directory.
59fn find_node_modules(start: &Path) -> Result<PathBuf> {
60    let mut current = Some(start);
61    while let Some(dir) = current {
62        let candidate = dir.join("node_modules");
63        if candidate.is_dir() {
64            return Ok(candidate);
65        }
66        current = dir.parent();
67    }
68    bail!(
69        "Could not find node_modules/ directory \
70         (searched upward from {})",
71        start.display()
72    );
73}
74
75/// Find `node_modules/` by walking up from `primary`, falling back to a
76/// walk up from `fallback` when the primary search comes up empty.
77///
78/// The fallback rescues callers whose primary search root lives outside
79/// any project tree. For example, `webui-press` builds each docs page in a
80/// synthesized scratch directory under the system temp folder, which has no
81/// `node_modules` ancestor; the project's `node_modules` is instead reached
82/// from the process working directory the command was invoked in. The error
83/// from the primary search is preserved when both roots fail.
84fn find_node_modules_with_fallback(primary: &Path, fallback: &Path) -> Result<PathBuf> {
85    find_node_modules(primary).or_else(|primary_err| {
86        if fallback == primary {
87            return Err(primary_err);
88        }
89        find_node_modules(fallback).map_err(|_| primary_err)
90    })
91}
92
93/// Check if a package name is a bare scope (e.g., `@reactive-ui` without a sub-package).
94fn is_bare_scope(name: &str) -> bool {
95    name.starts_with('@') && !name.contains('/')
96}
97
98/// Validate that a relative path from package.json does not escape the package directory.
99///
100/// Rejects absolute paths, root-relative paths, and any `..` components
101/// to prevent path traversal attacks.
102fn validate_relative_path(rel_path: &str, field_name: &str) -> Result<()> {
103    let path = Path::new(rel_path);
104    if path.is_absolute() || rel_path.starts_with('/') || rel_path.starts_with('\\') {
105        bail!("Absolute path not allowed in {}: {}", field_name, rel_path);
106    }
107    if path.components().any(|c| matches!(c, Component::ParentDir)) {
108        bail!(
109            "Parent directory traversal (..) not allowed in {}: {}",
110            field_name,
111            rel_path
112        );
113    }
114    Ok(())
115}
116
117/// Read a file with a size limit to prevent denial-of-service via oversized manifests.
118fn read_to_string_limited(path: &Path, max_size: u64) -> Result<String> {
119    let metadata =
120        fs::metadata(path).with_context(|| format!("Failed to stat {}", path.display()))?;
121    if metadata.len() > max_size {
122        bail!(
123            "File too large ({} bytes, max {}): {}",
124            metadata.len(),
125            max_size,
126            path.display()
127        );
128    }
129    fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
130}
131
132/// Resolve an npm package or scope to discovered components.
133pub fn resolve(
134    name: &str,
135    search_dir: &Path,
136    plugin: &dyn DiscoveryPlugin,
137    cache: &mut DiscoveryCache,
138) -> Result<Vec<DiscoveredComponent>> {
139    // Walk up from the build's app directory first, then fall back to the
140    // process working directory. The fallback covers callers whose app
141    // directory lives outside the project (e.g. a system-temp scratch dir),
142    // where the project's `node_modules` is only reachable from the cwd the
143    // command was invoked in.
144    let fallback = std::env::current_dir().unwrap_or_else(|_| search_dir.to_path_buf());
145    let node_modules = find_node_modules_with_fallback(search_dir, &fallback)?;
146
147    if is_bare_scope(name) {
148        resolve_scoped(name, &node_modules, plugin, cache)
149    } else {
150        resolve_single(name, &node_modules, plugin, cache)
151    }
152}
153
154/// Enumerate all sub-packages under a scoped directory (e.g., `@reactive-ui/*`).
155fn resolve_scoped(
156    scope: &str,
157    node_modules: &Path,
158    plugin: &dyn DiscoveryPlugin,
159    cache: &mut DiscoveryCache,
160) -> Result<Vec<DiscoveredComponent>> {
161    let scope_dir = node_modules.join(scope);
162    if !scope_dir.is_dir() {
163        bail!(
164            "Scoped package directory not found: {}",
165            scope_dir.display()
166        );
167    }
168
169    let mut all = Vec::new();
170    for entry in fs::read_dir(&scope_dir)
171        .with_context(|| format!("Failed to read scope directory: {}", scope_dir.display()))?
172    {
173        let entry = entry?;
174        let path = entry.path();
175        if !path.is_dir() {
176            continue;
177        }
178        let sub_name = format!("{}/{}", scope, entry.file_name().to_string_lossy());
179        // Sub-packages without WebUI exports are expected — skip silently.
180        if let Ok(components) = resolve_single(&sub_name, node_modules, plugin, cache) {
181            all.extend(components);
182        }
183    }
184
185    Ok(all)
186}
187
188/// Resolve a single npm package to discovered components.
189fn resolve_single(
190    name: &str,
191    node_modules: &Path,
192    plugin: &dyn DiscoveryPlugin,
193    cache: &mut DiscoveryCache,
194) -> Result<Vec<DiscoveredComponent>> {
195    let pkg_dir = node_modules.join(name);
196
197    // Resolve symlinks (pnpm, npm workspaces, yarn)
198    let pkg_dir = fs::canonicalize(&pkg_dir).with_context(|| {
199        format!(
200            "Package not found or broken symlink: {} (looked in {})",
201            name,
202            node_modules.display()
203        )
204    })?;
205
206    if !pkg_dir.is_dir() {
207        bail!("Package path is not a directory: {}", pkg_dir.display());
208    }
209
210    let pkg_json_path = pkg_dir.join("package.json");
211    if !pkg_json_path.exists() {
212        bail!("No package.json found at {}", pkg_json_path.display());
213    }
214
215    // Read and parse package.json
216    let pkg_json_content = read_to_string_limited(&pkg_json_path, MAX_MANIFEST_SIZE)?;
217    let pkg_json: serde_json::Value = serde_json::from_str(&pkg_json_content)
218        .with_context(|| format!("Failed to parse {}", pkg_json_path.display()))?;
219
220    let is_client_owned = package_has_authored_script(&pkg_json);
221    let package = PackageContext {
222        name,
223        root: &pkg_dir,
224        manifest: &pkg_json,
225        is_client_owned,
226    };
227    let cache_files = plugin.package_cache_files(package)?;
228    let fingerprint = DiscoveryCache::fingerprint(&pkg_json_path, &cache_files)?;
229    let cache_key = CacheKey {
230        namespace: plugin.cache_namespace(),
231        source: name,
232        package_json: &pkg_json_path,
233        fingerprint,
234    };
235    if let Some(cached) = cache.get(&cache_key)? {
236        return Ok(cached);
237    }
238    let components = plugin.discover_package(package)?;
239
240    // Do not persist a mixed snapshot if package files changed during discovery.
241    if DiscoveryCache::fingerprint(&pkg_json_path, &cache_files)? == fingerprint {
242        cache.put(&cache_key, &components)?;
243    }
244
245    Ok(components)
246}
247
248/// Resolve an export path from the `exports` field in `package.json`.
249///
250/// Handles two common formats:
251/// - Direct string: `"./template-webui.html": "./dist/template.html"`
252/// - Conditional object: `"./template-webui.html": { "default": "./dist/template.html" }`
253fn resolve_export(exports: &serde_json::Value, key: &str) -> Option<String> {
254    match exports.get(key)? {
255        serde_json::Value::String(s) => Some(s.clone()),
256        serde_json::Value::Object(obj) => {
257            for key in EXPORT_PRIORITY {
258                if let Some(serde_json::Value::String(s)) = obj.get(*key) {
259                    return Some(s.clone());
260                }
261            }
262            None
263        }
264        _ => None,
265    }
266}
267
268pub(crate) fn resolve_webui_assets(package: PackageContext<'_>) -> Result<WebUIAssets> {
269    let package_json = package.root.join("package.json");
270    let exports = package
271        .manifest
272        .get("exports")
273        .with_context(|| format!("No 'exports' field in {}", package_json.display()))?;
274    let template_rel = resolve_export(exports, "./template-webui.html").with_context(|| {
275        format!(
276            "No './template-webui.html' export in {}",
277            package_json.display()
278        )
279    })?;
280    validate_relative_path(&template_rel, "exports[\"./template-webui.html\"]")?;
281    let styles_path = if let Some(relative) = resolve_export(exports, "./styles.css") {
282        validate_relative_path(&relative, "exports[\"./styles.css\"]")?;
283        Some(package.root.join(relative))
284    } else {
285        None
286    };
287    Ok(WebUIAssets {
288        template_path: package.root.join(template_rel),
289        styles_path,
290        manifest_path: custom_elements_manifest_path(package)?,
291    })
292}
293
294pub(crate) fn package_component_declarations(
295    package: PackageContext<'_>,
296) -> Result<Vec<ComponentDeclaration>> {
297    let path = custom_elements_manifest_path(package)?;
298    parse_custom_elements_manifest(&path)
299}
300
301pub(crate) fn custom_elements_manifest_path(package: PackageContext<'_>) -> Result<PathBuf> {
302    let package_json = package.root.join("package.json");
303    let relative = package
304        .manifest
305        .get("customElements")
306        .and_then(serde_json::Value::as_str)
307        .with_context(|| format!("No 'customElements' field in {}", package_json.display()))?;
308    validate_relative_path(relative, "customElements")?;
309    Ok(package.root.join(relative))
310}
311
312pub(crate) fn read_required_file(path: &Path, kind: &str) -> Result<String> {
313    read_to_string_limited(path, MAX_MANIFEST_SIZE)
314        .with_context(|| format!("Failed to read {kind}: {}", path.display()))
315}
316
317pub(crate) fn read_optional_file(path: Option<&Path>, kind: &str) -> Result<Option<String>> {
318    match path {
319        Some(path) if path.is_file() => read_required_file(path, kind).map(Some),
320        _ => Ok(None),
321    }
322}
323
324fn package_has_authored_script(pkg_json: &serde_json::Value) -> bool {
325    for field in SCRIPT_ENTRY_FIELDS {
326        match pkg_json.get(*field) {
327            Some(serde_json::Value::String(path)) if is_script_path(path) => return true,
328            Some(serde_json::Value::Object(map)) if map.values().any(export_value_has_script) => {
329                return true;
330            }
331            _ => {}
332        }
333    }
334
335    let Some(exports) = pkg_json.get("exports") else {
336        return false;
337    };
338    match exports {
339        serde_json::Value::String(path) => is_script_path(path),
340        serde_json::Value::Array(values) => values.iter().any(export_value_has_script),
341        serde_json::Value::Object(map) => {
342            if let Some(root) = map.get(".") {
343                return export_value_has_script(root);
344            }
345            map.iter()
346                .any(|(key, value)| !is_webui_asset_export(key) && export_value_has_script(value))
347        }
348        _ => false,
349    }
350}
351
352fn export_value_has_script(value: &serde_json::Value) -> bool {
353    match value {
354        serde_json::Value::String(path) => is_script_path(path),
355        serde_json::Value::Array(values) => values.iter().any(export_value_has_script),
356        serde_json::Value::Object(map) => map.values().any(export_value_has_script),
357        _ => false,
358    }
359}
360
361fn is_webui_asset_export(key: &str) -> bool {
362    WEBUI_ASSET_EXPORTS.contains(&key)
363}
364
365fn is_script_path(path: &str) -> bool {
366    let path = path.split_once('?').map_or(path, |(prefix, _)| prefix);
367    path.ends_with(".js")
368        || path.ends_with(".mjs")
369        || path.ends_with(".cjs")
370        || path.ends_with(".ts")
371}
372
373// Parse `modules[].{path,declarations[].{name,tagName}}` from a Custom Elements
374// Manifest.
375fn parse_custom_elements_manifest(path: &Path) -> Result<Vec<ComponentDeclaration>> {
376    let content = read_to_string_limited(path, MAX_MANIFEST_SIZE)
377        .with_context(|| format!("Custom elements manifest: {}", path.display()))?;
378    let manifest: serde_json::Value = serde_json::from_str(&content).with_context(|| {
379        format!(
380            "Failed to parse custom elements manifest: {}",
381            path.display()
382        )
383    })?;
384
385    let mut seen = std::collections::HashSet::new();
386    let mut declarations = Vec::new();
387
388    if let Some(modules) = manifest.get("modules").and_then(|v| v.as_array()) {
389        for module in modules {
390            let module_path = module.get("path").and_then(|value| value.as_str());
391            if let Some(path) = module_path {
392                validate_relative_path(path, "customElements modules[].path")?;
393            }
394            if let Some(module_declarations) = module.get("declarations").and_then(|v| v.as_array())
395            {
396                for declaration in module_declarations {
397                    let Some(tag_name) =
398                        declaration.get("tagName").and_then(|value| value.as_str())
399                    else {
400                        continue;
401                    };
402                    if seen.insert(tag_name) {
403                        declarations.push(ComponentDeclaration {
404                            tag_name: tag_name.to_string(),
405                            name: declaration
406                                .get("name")
407                                .and_then(|value| value.as_str())
408                                .map(str::to_string),
409                            module_path: module_path.map(PathBuf::from),
410                        });
411                    }
412                }
413            }
414        }
415    }
416
417    Ok(declarations)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::WebUIDiscoveryPlugin;
424    use std::fs;
425    use tempfile::TempDir;
426
427    fn create_npm_package(dir: &Path, name: &str, tag_name: &str, html: &str, css: Option<&str>) {
428        let pkg_dir = dir.join(name);
429        fs::create_dir_all(&pkg_dir).unwrap();
430
431        // Create template
432        fs::write(pkg_dir.join("template-webui.html"), html).unwrap();
433
434        // Create styles (optional)
435        if let Some(css_content) = css {
436            fs::write(pkg_dir.join("styles.css"), css_content).unwrap();
437        }
438
439        // Create custom elements manifest
440        let manifest = serde_json::json!({
441            "schemaVersion": "1.0.0",
442            "modules": [{
443                "kind": "javascript-module",
444                "path": "src/index.js",
445                "declarations": [{
446                    "kind": "class",
447                    "name": "MyComponent",
448                    "tagName": tag_name
449                }]
450            }]
451        });
452        fs::write(
453            pkg_dir.join("custom-elements.json"),
454            serde_json::to_string_pretty(&manifest).unwrap(),
455        )
456        .unwrap();
457
458        // Create package.json with exports
459        let mut exports = serde_json::json!({
460            "./template-webui.html": "./template-webui.html",
461        });
462        if css.is_some() {
463            exports["./styles.css"] = serde_json::json!("./styles.css");
464        }
465        let pkg_json = serde_json::json!({
466            "name": name,
467            "version": "1.0.0",
468            "customElements": "./custom-elements.json",
469            "exports": exports
470        });
471        fs::write(
472            pkg_dir.join("package.json"),
473            serde_json::to_string_pretty(&pkg_json).unwrap(),
474        )
475        .unwrap();
476    }
477
478    #[test]
479    fn test_find_node_modules_in_cwd() {
480        let tmp = TempDir::new().unwrap();
481        let nm = tmp.path().join("node_modules");
482        fs::create_dir(&nm).unwrap();
483
484        let result = find_node_modules(tmp.path());
485        assert!(result.is_ok());
486        assert_eq!(
487            result.unwrap().canonicalize().unwrap(),
488            nm.canonicalize().unwrap()
489        );
490    }
491
492    #[test]
493    fn test_find_node_modules_walks_up() {
494        let tmp = TempDir::new().unwrap();
495        let nm = tmp.path().join("node_modules");
496        fs::create_dir(&nm).unwrap();
497        let sub = tmp.path().join("packages").join("my-app");
498        fs::create_dir_all(&sub).unwrap();
499
500        let result = find_node_modules(&sub);
501        assert!(result.is_ok());
502        assert_eq!(
503            result.unwrap().canonicalize().unwrap(),
504            nm.canonicalize().unwrap()
505        );
506    }
507
508    #[test]
509    fn test_find_node_modules_not_found() {
510        let tmp = TempDir::new().unwrap();
511        let result = find_node_modules(tmp.path());
512        assert!(result.is_err());
513    }
514
515    #[test]
516    fn test_find_node_modules_fallback_used_when_primary_empty() {
517        let primary = TempDir::new().unwrap();
518        let project = TempDir::new().unwrap();
519        let nm = project.path().join("node_modules");
520        fs::create_dir_all(&nm).unwrap();
521
522        let found = find_node_modules_with_fallback(primary.path(), project.path()).unwrap();
523        assert_eq!(found, nm);
524    }
525
526    #[test]
527    fn test_find_node_modules_fallback_prefers_primary() {
528        let primary = TempDir::new().unwrap();
529        let nm_primary = primary.path().join("node_modules");
530        fs::create_dir_all(&nm_primary).unwrap();
531        let project = TempDir::new().unwrap();
532        fs::create_dir_all(project.path().join("node_modules")).unwrap();
533
534        let found = find_node_modules_with_fallback(primary.path(), project.path()).unwrap();
535        assert_eq!(found, nm_primary);
536    }
537
538    #[test]
539    fn test_find_node_modules_fallback_errors_when_neither_has_it() {
540        let primary = TempDir::new().unwrap();
541        let fallback = TempDir::new().unwrap();
542        assert!(find_node_modules_with_fallback(primary.path(), fallback.path()).is_err());
543    }
544
545    #[test]
546    fn test_is_bare_scope() {
547        assert!(is_bare_scope("@reactive-ui"));
548        assert!(is_bare_scope("@scope"));
549        assert!(!is_bare_scope("@scope/button"));
550        assert!(!is_bare_scope("my-widget"));
551    }
552
553    #[test]
554    fn test_resolve_single_package() {
555        let tmp = TempDir::new().unwrap();
556        let nm = tmp.path().join("node_modules");
557        fs::create_dir(&nm).unwrap();
558
559        create_npm_package(
560            &nm,
561            "my-widget",
562            "my-widget",
563            "<div><slot></slot></div>",
564            Some(".widget { color: blue; }"),
565        );
566
567        let mut cache = DiscoveryCache::open().unwrap();
568        let result = resolve(
569            "my-widget",
570            tmp.path(),
571            &WebUIDiscoveryPlugin::new(),
572            &mut cache,
573        );
574        assert!(result.is_ok());
575
576        let components = result.unwrap();
577        assert_eq!(components.len(), 1);
578        assert_eq!(components[0].tag_name, "my-widget");
579        assert_eq!(components[0].html_content, "<div><slot></slot></div>");
580        assert_eq!(
581            components[0].css_content.as_deref(),
582            Some(".widget { color: blue; }")
583        );
584        assert!(!components[0].is_client_owned);
585    }
586
587    #[test]
588    fn test_package_script_ownership_uses_manifest_entrypoints() {
589        let static_pkg = serde_json::json!({
590            "exports": {
591                "./template-webui.html": "./template-webui.html",
592                "./styles.css": "./styles.css"
593            }
594        });
595        let interactive_pkg = serde_json::json!({
596            "exports": {
597                ".": { "import": "./dist/index.js" },
598                "./template-webui.html": "./template-webui.html"
599            }
600        });
601        let module_pkg = serde_json::json!({
602            "module": "./dist/index.mjs",
603            "exports": {
604                "./template-webui.html": "./template-webui.html"
605            }
606        });
607
608        assert!(!package_has_authored_script(&static_pkg));
609        assert!(package_has_authored_script(&interactive_pkg));
610        assert!(package_has_authored_script(&module_pkg));
611    }
612
613    #[test]
614    fn test_resolve_scoped_package() {
615        let tmp = TempDir::new().unwrap();
616        let nm = tmp.path().join("node_modules");
617        let scope_dir = nm.join("@mylib");
618        fs::create_dir_all(&scope_dir).unwrap();
619
620        create_npm_package(
621            &scope_dir,
622            "button",
623            "mylib-button",
624            "<button><slot></slot></button>",
625            Some(".btn { padding: 8px; }"),
626        );
627        create_npm_package(
628            &scope_dir,
629            "text",
630            "mylib-text",
631            "<span><slot></slot></span>",
632            None,
633        );
634
635        let mut cache = DiscoveryCache::open().unwrap();
636        let result = resolve(
637            "@mylib",
638            tmp.path(),
639            &WebUIDiscoveryPlugin::new(),
640            &mut cache,
641        );
642        assert!(result.is_ok());
643
644        let components = result.unwrap();
645        assert_eq!(components.len(), 2);
646
647        let names: Vec<&str> = components.iter().map(|c| c.tag_name.as_str()).collect();
648        assert!(names.contains(&"mylib-button"));
649        assert!(names.contains(&"mylib-text"));
650    }
651
652    #[test]
653    fn test_resolve_missing_package() {
654        let tmp = TempDir::new().unwrap();
655        let nm = tmp.path().join("node_modules");
656        fs::create_dir(&nm).unwrap();
657
658        let mut cache = DiscoveryCache::open().unwrap();
659        let result = resolve(
660            "nonexistent",
661            tmp.path(),
662            &WebUIDiscoveryPlugin::new(),
663            &mut cache,
664        );
665        assert!(result.is_err());
666    }
667
668    #[test]
669    fn test_resolve_export_direct_string() {
670        let exports = serde_json::json!({
671            "./template-webui.html": "./dist/template.html"
672        });
673        let result = resolve_export(&exports, "./template-webui.html");
674        assert_eq!(result, Some("./dist/template.html".to_string()));
675    }
676
677    #[test]
678    fn test_resolve_export_conditional_default() {
679        let exports = serde_json::json!({
680            "./template-webui.html": {
681                "import": "./dist/template.mjs",
682                "default": "./dist/template.html"
683            }
684        });
685        let result = resolve_export(&exports, "./template-webui.html");
686        assert_eq!(result, Some("./dist/template.html".to_string()));
687    }
688
689    #[test]
690    fn test_resolve_export_conditional_fallback() {
691        let exports = serde_json::json!({
692            "./template-webui.html": {
693                "import": "./dist/template.mjs"
694            }
695        });
696        let result = resolve_export(&exports, "./template-webui.html");
697        assert_eq!(result, Some("./dist/template.mjs".to_string()));
698    }
699
700    #[test]
701    fn test_resolve_export_missing() {
702        let exports = serde_json::json!({
703            "./other.html": "./dist/other.html"
704        });
705        let result = resolve_export(&exports, "./template-webui.html");
706        assert!(result.is_none());
707    }
708
709    #[test]
710    fn test_parse_custom_elements_manifest() {
711        let tmp = TempDir::new().unwrap();
712        let manifest = serde_json::json!({
713            "schemaVersion": "1.0.0",
714            "modules": [
715                {
716                    "kind": "javascript-module",
717                    "path": "src/button.js",
718                    "declarations": [
719                        {
720                            "kind": "class",
721                            "name": "MyButton",
722                            "tagName": "my-button"
723                        }
724                    ]
725                },
726                {
727                    "kind": "javascript-module",
728                    "path": "src/text.js",
729                    "declarations": [
730                        {
731                            "kind": "class",
732                            "name": "MyText",
733                            "tagName": "my-text"
734                        },
735                        {
736                            "kind": "variable",
737                            "name": "VERSION"
738                        }
739                    ]
740                }
741            ]
742        });
743
744        let cem_path = tmp.path().join("custom-elements.json");
745        fs::write(&cem_path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
746
747        let result = parse_custom_elements_manifest(&cem_path);
748        assert!(result.is_ok());
749
750        let declarations = result.unwrap();
751        assert_eq!(declarations.len(), 2);
752        assert_eq!(declarations[0].tag_name, "my-button");
753        assert_eq!(declarations[1].tag_name, "my-text");
754    }
755
756    #[test]
757    fn test_parse_custom_elements_manifest_empty() {
758        let tmp = TempDir::new().unwrap();
759        let manifest = serde_json::json!({
760            "schemaVersion": "1.0.0",
761            "modules": []
762        });
763
764        let cem_path = tmp.path().join("custom-elements.json");
765        fs::write(&cem_path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
766
767        let result = parse_custom_elements_manifest(&cem_path);
768        assert!(result.is_ok());
769        assert!(result.unwrap().is_empty());
770    }
771
772    #[test]
773    fn test_parse_custom_elements_manifest_deduplicates() {
774        let tmp = TempDir::new().unwrap();
775        let manifest = serde_json::json!({
776            "schemaVersion": "1.0.0",
777            "modules": [
778                {
779                    "kind": "javascript-module",
780                    "path": "src/button.js",
781                    "declarations": [{ "kind": "class", "name": "MyButton", "tagName": "my-button" }]
782                },
783                {
784                    "kind": "javascript-module",
785                    "path": "src/other-button.js",
786                    "declarations": [{ "kind": "class", "name": "OtherButton", "tagName": "my-button" }]
787                }
788            ]
789        });
790
791        let cem_path = tmp.path().join("custom-elements.json");
792        fs::write(&cem_path, serde_json::to_string(&manifest).unwrap()).unwrap();
793
794        let declarations = parse_custom_elements_manifest(&cem_path).unwrap();
795        assert_eq!(declarations.len(), 1);
796        assert_eq!(declarations[0].tag_name, "my-button");
797    }
798
799    #[test]
800    fn test_cached_result_is_reused() {
801        let tmp = TempDir::new().unwrap();
802        let nm = tmp.path().join("node_modules");
803        fs::create_dir(&nm).unwrap();
804
805        create_npm_package(&nm, "cached-pkg", "cached-comp", "<div>cached</div>", None);
806
807        let mut cache = DiscoveryCache::open().unwrap();
808
809        // First resolve: populates cache
810        let first = resolve(
811            "cached-pkg",
812            tmp.path(),
813            &WebUIDiscoveryPlugin::new(),
814            &mut cache,
815        )
816        .unwrap();
817        assert_eq!(first.len(), 1);
818        assert_eq!(first[0].tag_name, "cached-comp");
819
820        // Second resolve: should hit cache
821        let second = resolve(
822            "cached-pkg",
823            tmp.path(),
824            &WebUIDiscoveryPlugin::new(),
825            &mut cache,
826        )
827        .unwrap();
828        assert_eq!(second.len(), 1);
829        assert_eq!(second[0].tag_name, "cached-comp");
830    }
831
832    #[test]
833    fn test_validate_relative_path_rejects_absolute() {
834        let result = validate_relative_path("/etc/passwd", "exports");
835        assert!(result.is_err());
836        assert!(result.unwrap_err().to_string().contains("Absolute path"));
837    }
838
839    #[test]
840    fn test_validate_relative_path_rejects_parent_traversal() {
841        let result = validate_relative_path("../../etc/passwd", "exports");
842        assert!(result.is_err());
843        assert!(result.unwrap_err().to_string().contains(".."));
844    }
845
846    #[test]
847    fn test_validate_relative_path_rejects_hidden_traversal() {
848        let result = validate_relative_path("foo/../../../etc/passwd", "exports");
849        assert!(result.is_err());
850    }
851
852    #[test]
853    fn test_validate_relative_path_accepts_valid() {
854        assert!(validate_relative_path("./dist/template.html", "exports").is_ok());
855        assert!(validate_relative_path("template-webui.html", "exports").is_ok());
856        assert!(validate_relative_path("dist/nested/file.css", "exports").is_ok());
857    }
858
859    #[test]
860    fn test_resolve_symlinked_package() {
861        // Simulates pnpm-style layout where node_modules/@scope/pkg is a symlink
862        // to node_modules/.pnpm/@scope+pkg@version/node_modules/@scope/pkg.
863        let tmp = TempDir::new().unwrap();
864        let nm = tmp.path().join("node_modules");
865        fs::create_dir(&nm).unwrap();
866
867        // Create the real package inside .pnpm store
868        let pnpm_store = nm
869            .join(".pnpm")
870            .join("my-widget@1.0.0")
871            .join("node_modules");
872        fs::create_dir_all(&pnpm_store).unwrap();
873        create_npm_package(
874            &pnpm_store,
875            "my-widget",
876            "my-widget",
877            "<div>symlinked</div>",
878            Some(".w { color: red; }"),
879        );
880
881        // Create a symlink: node_modules/my-widget -> .pnpm store location
882        let link_path = nm.join("my-widget");
883        let target_path = pnpm_store.join("my-widget");
884
885        #[cfg(unix)]
886        std::os::unix::fs::symlink(&target_path, &link_path).unwrap();
887        #[cfg(windows)]
888        std::os::windows::fs::symlink_dir(&target_path, &link_path).unwrap();
889
890        let mut cache = DiscoveryCache::open().unwrap();
891        let result = resolve(
892            "my-widget",
893            tmp.path(),
894            &WebUIDiscoveryPlugin::new(),
895            &mut cache,
896        );
897        assert!(
898            result.is_ok(),
899            "symlinked package should resolve: {result:?}"
900        );
901
902        let components = result.unwrap();
903        assert_eq!(components.len(), 1);
904        assert_eq!(components[0].tag_name, "my-widget");
905        assert_eq!(components[0].html_content, "<div>symlinked</div>");
906    }
907
908    #[test]
909    fn test_resolve_scoped_symlinked_package() {
910        // Simulates pnpm layout for scoped packages:
911        // node_modules/@scope/pkg -> .pnpm/@scope+pkg@ver/node_modules/@scope/pkg
912        let tmp = TempDir::new().unwrap();
913        let nm = tmp.path().join("node_modules");
914        let scope_dir = nm.join("@mai-ui");
915        fs::create_dir_all(&scope_dir).unwrap();
916
917        // Real package inside .pnpm store
918        let pnpm_store = nm
919            .join(".pnpm")
920            .join("@mai-ui+button@1.8.3")
921            .join("node_modules")
922            .join("@mai-ui");
923        fs::create_dir_all(&pnpm_store).unwrap();
924        create_npm_package(
925            &pnpm_store,
926            "button",
927            "mai-button",
928            "<button><slot></slot></button>",
929            None,
930        );
931
932        // Symlink: node_modules/@mai-ui/button -> .pnpm store
933        let link_path = scope_dir.join("button");
934        let target_path = pnpm_store.join("button");
935
936        #[cfg(unix)]
937        std::os::unix::fs::symlink(&target_path, &link_path).unwrap();
938        #[cfg(windows)]
939        std::os::windows::fs::symlink_dir(&target_path, &link_path).unwrap();
940
941        let mut cache = DiscoveryCache::open().unwrap();
942        let result = resolve(
943            "@mai-ui",
944            tmp.path(),
945            &WebUIDiscoveryPlugin::new(),
946            &mut cache,
947        );
948        assert!(
949            result.is_ok(),
950            "scoped symlinked package should resolve: {result:?}"
951        );
952
953        let components = result.unwrap();
954        assert_eq!(components.len(), 1);
955        assert_eq!(components[0].tag_name, "mai-button");
956    }
957
958    #[test]
959    fn test_resolve_rejects_path_traversal_in_exports() {
960        let tmp = TempDir::new().unwrap();
961        let nm = tmp.path().join("node_modules");
962        let pkg_dir = nm.join("evil-pkg");
963        fs::create_dir_all(&pkg_dir).unwrap();
964
965        // Malicious package.json with path traversal in exports
966        let pkg_json = serde_json::json!({
967            "name": "evil-pkg",
968            "version": "1.0.0",
969            "customElements": "./custom-elements.json",
970            "exports": {
971                "./template-webui.html": "../../../etc/passwd"
972            }
973        });
974        fs::write(
975            pkg_dir.join("package.json"),
976            serde_json::to_string(&pkg_json).unwrap(),
977        )
978        .unwrap();
979
980        let mut cache = DiscoveryCache::open().unwrap();
981        let result = resolve(
982            "evil-pkg",
983            tmp.path(),
984            &WebUIDiscoveryPlugin::new(),
985            &mut cache,
986        );
987        assert!(result.is_err());
988        assert!(result.unwrap_err().to_string().contains(".."));
989    }
990}