use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Component, Path, PathBuf};
use super::cache::{CacheKey, DiscoveryCache};
use super::{DiscoveredComponent, DiscoveryPlugin};
#[derive(Debug, Clone, Copy)]
pub struct PackageContext<'a> {
pub name: &'a str,
pub root: &'a Path,
pub manifest: &'a serde_json::Value,
pub is_client_owned: bool,
}
pub(crate) struct ComponentDeclaration {
pub(crate) tag_name: String,
pub(crate) name: Option<String>,
pub(crate) module_path: Option<PathBuf>,
}
pub(crate) struct WebUIAssets {
pub(crate) template_path: PathBuf,
pub(crate) styles_path: Option<PathBuf>,
pub(crate) manifest_path: PathBuf,
}
const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
const EXPORT_PRIORITY: &[&str] = &["default", "import", "require"];
const SCRIPT_ENTRY_FIELDS: &[&str] = &["main", "module", "browser"];
const WEBUI_ASSET_EXPORTS: &[&str] = &[
"./template-webui.html",
"./styles.css",
"./component-asset.js",
];
fn find_node_modules(start: &Path) -> Result<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
let candidate = dir.join("node_modules");
if candidate.is_dir() {
return Ok(candidate);
}
current = dir.parent();
}
bail!(
"Could not find node_modules/ directory \
(searched upward from {})",
start.display()
);
}
fn find_node_modules_with_fallback(primary: &Path, fallback: &Path) -> Result<PathBuf> {
find_node_modules(primary).or_else(|primary_err| {
if fallback == primary {
return Err(primary_err);
}
find_node_modules(fallback).map_err(|_| primary_err)
})
}
fn is_bare_scope(name: &str) -> bool {
name.starts_with('@') && !name.contains('/')
}
fn validate_relative_path(rel_path: &str, field_name: &str) -> Result<()> {
let path = Path::new(rel_path);
if path.is_absolute() || rel_path.starts_with('/') || rel_path.starts_with('\\') {
bail!("Absolute path not allowed in {}: {}", field_name, rel_path);
}
if path.components().any(|c| matches!(c, Component::ParentDir)) {
bail!(
"Parent directory traversal (..) not allowed in {}: {}",
field_name,
rel_path
);
}
Ok(())
}
fn read_to_string_limited(path: &Path, max_size: u64) -> Result<String> {
let metadata =
fs::metadata(path).with_context(|| format!("Failed to stat {}", path.display()))?;
if metadata.len() > max_size {
bail!(
"File too large ({} bytes, max {}): {}",
metadata.len(),
max_size,
path.display()
);
}
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
}
pub fn resolve(
name: &str,
search_dir: &Path,
plugin: &dyn DiscoveryPlugin,
cache: &mut DiscoveryCache,
) -> Result<Vec<DiscoveredComponent>> {
let fallback = std::env::current_dir().unwrap_or_else(|_| search_dir.to_path_buf());
let node_modules = find_node_modules_with_fallback(search_dir, &fallback)?;
if is_bare_scope(name) {
resolve_scoped(name, &node_modules, plugin, cache)
} else {
resolve_single(name, &node_modules, plugin, cache)
}
}
fn resolve_scoped(
scope: &str,
node_modules: &Path,
plugin: &dyn DiscoveryPlugin,
cache: &mut DiscoveryCache,
) -> Result<Vec<DiscoveredComponent>> {
let scope_dir = node_modules.join(scope);
if !scope_dir.is_dir() {
bail!(
"Scoped package directory not found: {}",
scope_dir.display()
);
}
let mut all = Vec::new();
for entry in fs::read_dir(&scope_dir)
.with_context(|| format!("Failed to read scope directory: {}", scope_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let sub_name = format!("{}/{}", scope, entry.file_name().to_string_lossy());
if let Ok(components) = resolve_single(&sub_name, node_modules, plugin, cache) {
all.extend(components);
}
}
Ok(all)
}
fn resolve_single(
name: &str,
node_modules: &Path,
plugin: &dyn DiscoveryPlugin,
cache: &mut DiscoveryCache,
) -> Result<Vec<DiscoveredComponent>> {
let pkg_dir = node_modules.join(name);
let pkg_dir = fs::canonicalize(&pkg_dir).with_context(|| {
format!(
"Package not found or broken symlink: {} (looked in {})",
name,
node_modules.display()
)
})?;
if !pkg_dir.is_dir() {
bail!("Package path is not a directory: {}", pkg_dir.display());
}
let pkg_json_path = pkg_dir.join("package.json");
if !pkg_json_path.exists() {
bail!("No package.json found at {}", pkg_json_path.display());
}
let pkg_json_content = read_to_string_limited(&pkg_json_path, MAX_MANIFEST_SIZE)?;
let pkg_json: serde_json::Value = serde_json::from_str(&pkg_json_content)
.with_context(|| format!("Failed to parse {}", pkg_json_path.display()))?;
let is_client_owned = package_has_authored_script(&pkg_json);
let package = PackageContext {
name,
root: &pkg_dir,
manifest: &pkg_json,
is_client_owned,
};
let cache_files = plugin.package_cache_files(package)?;
let fingerprint = DiscoveryCache::fingerprint(&pkg_json_path, &cache_files)?;
let cache_key = CacheKey {
namespace: plugin.cache_namespace(),
source: name,
package_json: &pkg_json_path,
fingerprint,
};
if let Some(cached) = cache.get(&cache_key)? {
return Ok(cached);
}
let components = plugin.discover_package(package)?;
if DiscoveryCache::fingerprint(&pkg_json_path, &cache_files)? == fingerprint {
cache.put(&cache_key, &components)?;
}
Ok(components)
}
fn resolve_export(exports: &serde_json::Value, key: &str) -> Option<String> {
match exports.get(key)? {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(obj) => {
for key in EXPORT_PRIORITY {
if let Some(serde_json::Value::String(s)) = obj.get(*key) {
return Some(s.clone());
}
}
None
}
_ => None,
}
}
pub(crate) fn resolve_webui_assets(package: PackageContext<'_>) -> Result<WebUIAssets> {
let package_json = package.root.join("package.json");
let exports = package
.manifest
.get("exports")
.with_context(|| format!("No 'exports' field in {}", package_json.display()))?;
let template_rel = resolve_export(exports, "./template-webui.html").with_context(|| {
format!(
"No './template-webui.html' export in {}",
package_json.display()
)
})?;
validate_relative_path(&template_rel, "exports[\"./template-webui.html\"]")?;
let styles_path = if let Some(relative) = resolve_export(exports, "./styles.css") {
validate_relative_path(&relative, "exports[\"./styles.css\"]")?;
Some(package.root.join(relative))
} else {
None
};
Ok(WebUIAssets {
template_path: package.root.join(template_rel),
styles_path,
manifest_path: custom_elements_manifest_path(package)?,
})
}
pub(crate) fn package_component_declarations(
package: PackageContext<'_>,
) -> Result<Vec<ComponentDeclaration>> {
let path = custom_elements_manifest_path(package)?;
parse_custom_elements_manifest(&path)
}
pub(crate) fn custom_elements_manifest_path(package: PackageContext<'_>) -> Result<PathBuf> {
let package_json = package.root.join("package.json");
let relative = package
.manifest
.get("customElements")
.and_then(serde_json::Value::as_str)
.with_context(|| format!("No 'customElements' field in {}", package_json.display()))?;
validate_relative_path(relative, "customElements")?;
Ok(package.root.join(relative))
}
pub(crate) fn read_required_file(path: &Path, kind: &str) -> Result<String> {
read_to_string_limited(path, MAX_MANIFEST_SIZE)
.with_context(|| format!("Failed to read {kind}: {}", path.display()))
}
pub(crate) fn read_optional_file(path: Option<&Path>, kind: &str) -> Result<Option<String>> {
match path {
Some(path) if path.is_file() => read_required_file(path, kind).map(Some),
_ => Ok(None),
}
}
fn package_has_authored_script(pkg_json: &serde_json::Value) -> bool {
for field in SCRIPT_ENTRY_FIELDS {
match pkg_json.get(*field) {
Some(serde_json::Value::String(path)) if is_script_path(path) => return true,
Some(serde_json::Value::Object(map)) if map.values().any(export_value_has_script) => {
return true;
}
_ => {}
}
}
let Some(exports) = pkg_json.get("exports") else {
return false;
};
match exports {
serde_json::Value::String(path) => is_script_path(path),
serde_json::Value::Array(values) => values.iter().any(export_value_has_script),
serde_json::Value::Object(map) => {
if let Some(root) = map.get(".") {
return export_value_has_script(root);
}
map.iter()
.any(|(key, value)| !is_webui_asset_export(key) && export_value_has_script(value))
}
_ => false,
}
}
fn export_value_has_script(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::String(path) => is_script_path(path),
serde_json::Value::Array(values) => values.iter().any(export_value_has_script),
serde_json::Value::Object(map) => map.values().any(export_value_has_script),
_ => false,
}
}
fn is_webui_asset_export(key: &str) -> bool {
WEBUI_ASSET_EXPORTS.contains(&key)
}
fn is_script_path(path: &str) -> bool {
let path = path.split_once('?').map_or(path, |(prefix, _)| prefix);
path.ends_with(".js")
|| path.ends_with(".mjs")
|| path.ends_with(".cjs")
|| path.ends_with(".ts")
}
fn parse_custom_elements_manifest(path: &Path) -> Result<Vec<ComponentDeclaration>> {
let content = read_to_string_limited(path, MAX_MANIFEST_SIZE)
.with_context(|| format!("Custom elements manifest: {}", path.display()))?;
let manifest: serde_json::Value = serde_json::from_str(&content).with_context(|| {
format!(
"Failed to parse custom elements manifest: {}",
path.display()
)
})?;
let mut seen = std::collections::HashSet::new();
let mut declarations = Vec::new();
if let Some(modules) = manifest.get("modules").and_then(|v| v.as_array()) {
for module in modules {
let module_path = module.get("path").and_then(|value| value.as_str());
if let Some(path) = module_path {
validate_relative_path(path, "customElements modules[].path")?;
}
if let Some(module_declarations) = module.get("declarations").and_then(|v| v.as_array())
{
for declaration in module_declarations {
let Some(tag_name) =
declaration.get("tagName").and_then(|value| value.as_str())
else {
continue;
};
if seen.insert(tag_name) {
declarations.push(ComponentDeclaration {
tag_name: tag_name.to_string(),
name: declaration
.get("name")
.and_then(|value| value.as_str())
.map(str::to_string),
module_path: module_path.map(PathBuf::from),
});
}
}
}
}
}
Ok(declarations)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WebUIDiscoveryPlugin;
use std::fs;
use tempfile::TempDir;
fn create_npm_package(dir: &Path, name: &str, tag_name: &str, html: &str, css: Option<&str>) {
let pkg_dir = dir.join(name);
fs::create_dir_all(&pkg_dir).unwrap();
fs::write(pkg_dir.join("template-webui.html"), html).unwrap();
if let Some(css_content) = css {
fs::write(pkg_dir.join("styles.css"), css_content).unwrap();
}
let manifest = serde_json::json!({
"schemaVersion": "1.0.0",
"modules": [{
"kind": "javascript-module",
"path": "src/index.js",
"declarations": [{
"kind": "class",
"name": "MyComponent",
"tagName": tag_name
}]
}]
});
fs::write(
pkg_dir.join("custom-elements.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
let mut exports = serde_json::json!({
"./template-webui.html": "./template-webui.html",
});
if css.is_some() {
exports["./styles.css"] = serde_json::json!("./styles.css");
}
let pkg_json = serde_json::json!({
"name": name,
"version": "1.0.0",
"customElements": "./custom-elements.json",
"exports": exports
});
fs::write(
pkg_dir.join("package.json"),
serde_json::to_string_pretty(&pkg_json).unwrap(),
)
.unwrap();
}
#[test]
fn test_find_node_modules_in_cwd() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir(&nm).unwrap();
let result = find_node_modules(tmp.path());
assert!(result.is_ok());
assert_eq!(
result.unwrap().canonicalize().unwrap(),
nm.canonicalize().unwrap()
);
}
#[test]
fn test_find_node_modules_walks_up() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir(&nm).unwrap();
let sub = tmp.path().join("packages").join("my-app");
fs::create_dir_all(&sub).unwrap();
let result = find_node_modules(&sub);
assert!(result.is_ok());
assert_eq!(
result.unwrap().canonicalize().unwrap(),
nm.canonicalize().unwrap()
);
}
#[test]
fn test_find_node_modules_not_found() {
let tmp = TempDir::new().unwrap();
let result = find_node_modules(tmp.path());
assert!(result.is_err());
}
#[test]
fn test_find_node_modules_fallback_used_when_primary_empty() {
let primary = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let nm = project.path().join("node_modules");
fs::create_dir_all(&nm).unwrap();
let found = find_node_modules_with_fallback(primary.path(), project.path()).unwrap();
assert_eq!(found, nm);
}
#[test]
fn test_find_node_modules_fallback_prefers_primary() {
let primary = TempDir::new().unwrap();
let nm_primary = primary.path().join("node_modules");
fs::create_dir_all(&nm_primary).unwrap();
let project = TempDir::new().unwrap();
fs::create_dir_all(project.path().join("node_modules")).unwrap();
let found = find_node_modules_with_fallback(primary.path(), project.path()).unwrap();
assert_eq!(found, nm_primary);
}
#[test]
fn test_find_node_modules_fallback_errors_when_neither_has_it() {
let primary = TempDir::new().unwrap();
let fallback = TempDir::new().unwrap();
assert!(find_node_modules_with_fallback(primary.path(), fallback.path()).is_err());
}
#[test]
fn test_is_bare_scope() {
assert!(is_bare_scope("@reactive-ui"));
assert!(is_bare_scope("@scope"));
assert!(!is_bare_scope("@scope/button"));
assert!(!is_bare_scope("my-widget"));
}
#[test]
fn test_resolve_single_package() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir(&nm).unwrap();
create_npm_package(
&nm,
"my-widget",
"my-widget",
"<div><slot></slot></div>",
Some(".widget { color: blue; }"),
);
let mut cache = DiscoveryCache::open().unwrap();
let result = resolve(
"my-widget",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
);
assert!(result.is_ok());
let components = result.unwrap();
assert_eq!(components.len(), 1);
assert_eq!(components[0].tag_name, "my-widget");
assert_eq!(components[0].html_content, "<div><slot></slot></div>");
assert_eq!(
components[0].css_content.as_deref(),
Some(".widget { color: blue; }")
);
assert!(!components[0].is_client_owned);
}
#[test]
fn test_package_script_ownership_uses_manifest_entrypoints() {
let static_pkg = serde_json::json!({
"exports": {
"./template-webui.html": "./template-webui.html",
"./styles.css": "./styles.css"
}
});
let interactive_pkg = serde_json::json!({
"exports": {
".": { "import": "./dist/index.js" },
"./template-webui.html": "./template-webui.html"
}
});
let module_pkg = serde_json::json!({
"module": "./dist/index.mjs",
"exports": {
"./template-webui.html": "./template-webui.html"
}
});
assert!(!package_has_authored_script(&static_pkg));
assert!(package_has_authored_script(&interactive_pkg));
assert!(package_has_authored_script(&module_pkg));
}
#[test]
fn test_resolve_scoped_package() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
let scope_dir = nm.join("@mylib");
fs::create_dir_all(&scope_dir).unwrap();
create_npm_package(
&scope_dir,
"button",
"mylib-button",
"<button><slot></slot></button>",
Some(".btn { padding: 8px; }"),
);
create_npm_package(
&scope_dir,
"text",
"mylib-text",
"<span><slot></slot></span>",
None,
);
let mut cache = DiscoveryCache::open().unwrap();
let result = resolve(
"@mylib",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
);
assert!(result.is_ok());
let components = result.unwrap();
assert_eq!(components.len(), 2);
let names: Vec<&str> = components.iter().map(|c| c.tag_name.as_str()).collect();
assert!(names.contains(&"mylib-button"));
assert!(names.contains(&"mylib-text"));
}
#[test]
fn test_resolve_missing_package() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir(&nm).unwrap();
let mut cache = DiscoveryCache::open().unwrap();
let result = resolve(
"nonexistent",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
);
assert!(result.is_err());
}
#[test]
fn test_resolve_export_direct_string() {
let exports = serde_json::json!({
"./template-webui.html": "./dist/template.html"
});
let result = resolve_export(&exports, "./template-webui.html");
assert_eq!(result, Some("./dist/template.html".to_string()));
}
#[test]
fn test_resolve_export_conditional_default() {
let exports = serde_json::json!({
"./template-webui.html": {
"import": "./dist/template.mjs",
"default": "./dist/template.html"
}
});
let result = resolve_export(&exports, "./template-webui.html");
assert_eq!(result, Some("./dist/template.html".to_string()));
}
#[test]
fn test_resolve_export_conditional_fallback() {
let exports = serde_json::json!({
"./template-webui.html": {
"import": "./dist/template.mjs"
}
});
let result = resolve_export(&exports, "./template-webui.html");
assert_eq!(result, Some("./dist/template.mjs".to_string()));
}
#[test]
fn test_resolve_export_missing() {
let exports = serde_json::json!({
"./other.html": "./dist/other.html"
});
let result = resolve_export(&exports, "./template-webui.html");
assert!(result.is_none());
}
#[test]
fn test_parse_custom_elements_manifest() {
let tmp = TempDir::new().unwrap();
let manifest = serde_json::json!({
"schemaVersion": "1.0.0",
"modules": [
{
"kind": "javascript-module",
"path": "src/button.js",
"declarations": [
{
"kind": "class",
"name": "MyButton",
"tagName": "my-button"
}
]
},
{
"kind": "javascript-module",
"path": "src/text.js",
"declarations": [
{
"kind": "class",
"name": "MyText",
"tagName": "my-text"
},
{
"kind": "variable",
"name": "VERSION"
}
]
}
]
});
let cem_path = tmp.path().join("custom-elements.json");
fs::write(&cem_path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
let result = parse_custom_elements_manifest(&cem_path);
assert!(result.is_ok());
let declarations = result.unwrap();
assert_eq!(declarations.len(), 2);
assert_eq!(declarations[0].tag_name, "my-button");
assert_eq!(declarations[1].tag_name, "my-text");
}
#[test]
fn test_parse_custom_elements_manifest_empty() {
let tmp = TempDir::new().unwrap();
let manifest = serde_json::json!({
"schemaVersion": "1.0.0",
"modules": []
});
let cem_path = tmp.path().join("custom-elements.json");
fs::write(&cem_path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
let result = parse_custom_elements_manifest(&cem_path);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_parse_custom_elements_manifest_deduplicates() {
let tmp = TempDir::new().unwrap();
let manifest = serde_json::json!({
"schemaVersion": "1.0.0",
"modules": [
{
"kind": "javascript-module",
"path": "src/button.js",
"declarations": [{ "kind": "class", "name": "MyButton", "tagName": "my-button" }]
},
{
"kind": "javascript-module",
"path": "src/other-button.js",
"declarations": [{ "kind": "class", "name": "OtherButton", "tagName": "my-button" }]
}
]
});
let cem_path = tmp.path().join("custom-elements.json");
fs::write(&cem_path, serde_json::to_string(&manifest).unwrap()).unwrap();
let declarations = parse_custom_elements_manifest(&cem_path).unwrap();
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].tag_name, "my-button");
}
#[test]
fn test_cached_result_is_reused() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir(&nm).unwrap();
create_npm_package(&nm, "cached-pkg", "cached-comp", "<div>cached</div>", None);
let mut cache = DiscoveryCache::open().unwrap();
let first = resolve(
"cached-pkg",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
)
.unwrap();
assert_eq!(first.len(), 1);
assert_eq!(first[0].tag_name, "cached-comp");
let second = resolve(
"cached-pkg",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
)
.unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second[0].tag_name, "cached-comp");
}
#[test]
fn test_validate_relative_path_rejects_absolute() {
let result = validate_relative_path("/etc/passwd", "exports");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Absolute path"));
}
#[test]
fn test_validate_relative_path_rejects_parent_traversal() {
let result = validate_relative_path("../../etc/passwd", "exports");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains(".."));
}
#[test]
fn test_validate_relative_path_rejects_hidden_traversal() {
let result = validate_relative_path("foo/../../../etc/passwd", "exports");
assert!(result.is_err());
}
#[test]
fn test_validate_relative_path_accepts_valid() {
assert!(validate_relative_path("./dist/template.html", "exports").is_ok());
assert!(validate_relative_path("template-webui.html", "exports").is_ok());
assert!(validate_relative_path("dist/nested/file.css", "exports").is_ok());
}
#[test]
fn test_resolve_symlinked_package() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir(&nm).unwrap();
let pnpm_store = nm
.join(".pnpm")
.join("my-widget@1.0.0")
.join("node_modules");
fs::create_dir_all(&pnpm_store).unwrap();
create_npm_package(
&pnpm_store,
"my-widget",
"my-widget",
"<div>symlinked</div>",
Some(".w { color: red; }"),
);
let link_path = nm.join("my-widget");
let target_path = pnpm_store.join("my-widget");
#[cfg(unix)]
std::os::unix::fs::symlink(&target_path, &link_path).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&target_path, &link_path).unwrap();
let mut cache = DiscoveryCache::open().unwrap();
let result = resolve(
"my-widget",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
);
assert!(
result.is_ok(),
"symlinked package should resolve: {result:?}"
);
let components = result.unwrap();
assert_eq!(components.len(), 1);
assert_eq!(components[0].tag_name, "my-widget");
assert_eq!(components[0].html_content, "<div>symlinked</div>");
}
#[test]
fn test_resolve_scoped_symlinked_package() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
let scope_dir = nm.join("@mai-ui");
fs::create_dir_all(&scope_dir).unwrap();
let pnpm_store = nm
.join(".pnpm")
.join("@mai-ui+button@1.8.3")
.join("node_modules")
.join("@mai-ui");
fs::create_dir_all(&pnpm_store).unwrap();
create_npm_package(
&pnpm_store,
"button",
"mai-button",
"<button><slot></slot></button>",
None,
);
let link_path = scope_dir.join("button");
let target_path = pnpm_store.join("button");
#[cfg(unix)]
std::os::unix::fs::symlink(&target_path, &link_path).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&target_path, &link_path).unwrap();
let mut cache = DiscoveryCache::open().unwrap();
let result = resolve(
"@mai-ui",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
);
assert!(
result.is_ok(),
"scoped symlinked package should resolve: {result:?}"
);
let components = result.unwrap();
assert_eq!(components.len(), 1);
assert_eq!(components[0].tag_name, "mai-button");
}
#[test]
fn test_resolve_rejects_path_traversal_in_exports() {
let tmp = TempDir::new().unwrap();
let nm = tmp.path().join("node_modules");
let pkg_dir = nm.join("evil-pkg");
fs::create_dir_all(&pkg_dir).unwrap();
let pkg_json = serde_json::json!({
"name": "evil-pkg",
"version": "1.0.0",
"customElements": "./custom-elements.json",
"exports": {
"./template-webui.html": "../../../etc/passwd"
}
});
fs::write(
pkg_dir.join("package.json"),
serde_json::to_string(&pkg_json).unwrap(),
)
.unwrap();
let mut cache = DiscoveryCache::open().unwrap();
let result = resolve(
"evil-pkg",
tmp.path(),
&WebUIDiscoveryPlugin::new(),
&mut cache,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains(".."));
}
}