1use 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#[derive(Debug, Clone, Copy)]
19pub struct PackageContext<'a> {
20 pub name: &'a str,
22 pub root: &'a Path,
24 pub manifest: &'a serde_json::Value,
26 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
42const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
44
45const EXPORT_PRIORITY: &[&str] = &["default", "import", "require"];
47
48const SCRIPT_ENTRY_FIELDS: &[&str] = &["main", "module", "browser"];
50
51const WEBUI_ASSET_EXPORTS: &[&str] = &[
53 "./template-webui.html",
54 "./styles.css",
55 "./component-asset.js",
56];
57
58fn 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
75fn 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
93fn is_bare_scope(name: &str) -> bool {
95 name.starts_with('@') && !name.contains('/')
96}
97
98fn 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
117fn 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
132pub fn resolve(
134 name: &str,
135 search_dir: &Path,
136 plugin: &dyn DiscoveryPlugin,
137 cache: &mut DiscoveryCache,
138) -> Result<Vec<DiscoveredComponent>> {
139 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
154fn 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 if let Ok(components) = resolve_single(&sub_name, node_modules, plugin, cache) {
181 all.extend(components);
182 }
183 }
184
185 Ok(all)
186}
187
188fn 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 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 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 if DiscoveryCache::fingerprint(&pkg_json_path, &cache_files)? == fingerprint {
242 cache.put(&cache_key, &components)?;
243 }
244
245 Ok(components)
246}
247
248fn 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
373fn 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 fs::write(pkg_dir.join("template-webui.html"), html).unwrap();
433
434 if let Some(css_content) = css {
436 fs::write(pkg_dir.join("styles.css"), css_content).unwrap();
437 }
438
439 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 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 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 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 let tmp = TempDir::new().unwrap();
864 let nm = tmp.path().join("node_modules");
865 fs::create_dir(&nm).unwrap();
866
867 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 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 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 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 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 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}