1use 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#[derive(Debug, Clone, Copy)]
21pub struct PackageContext<'a> {
22 pub name: &'a str,
24 pub root: &'a Path,
26 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
45pub(crate) const MAX_MANIFEST_SIZE: u64 = 10 * 1024 * 1024;
47
48const SCRIPT_ENTRY_FIELDS: &[&str] = &["main", "module", "browser"];
50
51const 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
171fn 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
208fn 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
227fn 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
242pub 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 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
297fn 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
332fn 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 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
846fn 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 fs::write(pkg_dir.join(format!("{tag_name}.html")), html).unwrap();
1080
1081 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 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 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 let tmp = TempDir::new().unwrap();
1685 let nm = tmp.path().join("node_modules");
1686 fs::create_dir(&nm).unwrap();
1687
1688 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 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 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 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 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 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}