use url::Url; use crate::config::{self, Error, Value, ValueKind, invalid, object}; #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ResolveError { #[error("Import {0:?} is blocked by the import map")] Blocked(String), #[error("Import {0:?} contains an encoded path separator")] EncodedSeparator(String), #[error("Import {0:?} escapes its mapped prefix")] EscapesPrefix(String), #[error("Import subpath must be UTF-8")] SubpathUtf8, #[error("Cannot append a subpath to import target {0}")] CannotAppend(String), #[error(transparent)] Url(#[from] url::ParseError), } type Mappings = Vec<(String, Option)>; #[derive(Default, Debug)] pub struct ImportMap { imports: Mappings, scopes: Vec<(String, Mappings)>, } impl ImportMap { pub fn parse(source: &str, base: &Url) -> Result { let root = config::parse(source)?; let mut imports = None; let mut scopes = None; for member in object(&root)? { match member.key.as_str() { "imports" => imports = Some(&member.value), "scopes" => scopes = Some(&member.value), _ => {} } } Self::from_fields(imports, scopes, base) } pub(crate) fn from_fields( imports: Option<&Value>, scopes: Option<&Value>, base: &Url, ) -> Result { let mut map = Self::default(); if let Some(imports) = imports { map.imports = mappings(imports, base)?; } if let Some(scopes) = scopes { for scope in object(scopes)? { let prefix = base .join(&scope.key) .map_err(|_| invalid(scope.key_span.clone(), "Invalid scope URL"))? .to_string(); if map.scopes.iter().any(|(existing, _)| existing == &prefix) { return Err(invalid( scope.key_span.clone(), "Duplicate normalized scope URL", )); } map.scopes.push((prefix, mappings(&scope.value, base)?)); } map.scopes.sort_by_key(|a| std::cmp::Reverse(a.0.len())); } Ok(map) } /// Maps each bound package name, and its subpaths, to a `jsr:` package's exports. pub(crate) fn packages(packages: impl IntoIterator) -> Self { let mut imports: Mappings = packages .into_iter() .flat_map(|(name, package)| { let slash_name = format!("{name}/"); let slash_package = format!("{package}/"); [(name, Some(package)), (slash_name, Some(slash_package))] }) .collect(); imports.sort_by_key(|a| std::cmp::Reverse(a.0.len())); Self { imports, scopes: Vec::new() } } pub(crate) fn namespace_conflict(&self, name: &str) -> Option<&str> { self.imports .iter() .chain(self.scopes.iter().flat_map(|(_, mappings)| mappings)) .map(|(key, _)| key.as_str()) .find(|key| { *key == name || key .strip_prefix(name) .is_some_and(|suffix| suffix.starts_with('/')) || (key.ends_with('/') && name.starts_with(key)) }) } pub fn resolve(&self, specifier: &str, referrer: &Url) -> Result, ResolveError> { let key = normalize(specifier, referrer)?; for (prefix, mappings) in &self.scopes { if (referrer.as_str() == prefix || (prefix.ends_with('/') && referrer.as_str().starts_with(prefix))) && let Some(target) = apply(mappings, &key)? { return Ok(Some(target)); } } apply(&self.imports, &key) } } fn normalize(specifier: &str, base: &Url) -> Result { if specifier.starts_with("./") || specifier.starts_with("../") || specifier.starts_with('/') { return base .join(specifier) .map(String::from) .map_err(ResolveError::from); } if let Ok(url) = Url::parse(specifier) { return Ok(url.to_string()); } Ok(specifier.to_owned()) } fn mappings(value: &Value, base: &Url) -> Result)>, Error> { let mut mappings = Vec::new(); for member in object(value)? { if member.key.is_empty() { return Err(invalid( member.key_span.clone(), "Import keys must not be empty", )); } let key = normalize(&member.key, base) .map_err(|error| invalid(member.key_span.clone(), error.to_string()))?; if mappings.iter().any(|(existing, _)| existing == &key) { return Err(invalid( member.key_span.clone(), "Duplicate normalized import key", )); } let target = match &member.value.kind { ValueKind::Null => None, ValueKind::String(target) => { let target = normalize(target, base) .map_err(|error| invalid(member.value.span.clone(), error.to_string()))?; if Url::parse(&target).is_err() { return Err(invalid( member.value.span.clone(), "Import targets must be URLs or relative paths", )); } if key.ends_with('/') && !target.ends_with('/') { return Err(invalid( member.value.span.clone(), "Prefix import targets must end with '/'", )); } Some(target) } _ => { return Err(invalid( member.value.span.clone(), "Import targets must be strings or null", )); } }; mappings.push((key, target)); } mappings.sort_by_key(|a| std::cmp::Reverse(a.0.len())); Ok(mappings) } fn apply(mappings: &[(String, Option)], key: &str) -> Result, ResolveError> { for (prefix, target) in mappings { if key != prefix && !(prefix.ends_with('/') && key.starts_with(prefix)) { continue; } let target = target .as_ref() .ok_or_else(|| ResolveError::Blocked(key.to_owned()))?; if key == prefix { return Ok(Some(target.clone())); } let suffix = &key[prefix.len()..]; let encoded = suffix.to_ascii_lowercase(); if encoded.contains("%2f") || encoded.contains("%5c") { return Err(ResolveError::EncodedSeparator(key.to_owned())); } if target.starts_with("jsr:") || target.starts_with("npm:") { let decoded = percent_encoding::percent_decode_str(suffix) .decode_utf8() .map_err(|_| ResolveError::SubpathUtf8)?; if decoded.split('/').any(|part| matches!(part, "." | "..")) || decoded.contains(['?', '#', '\\']) { return Err(ResolveError::EscapesPrefix(key.to_owned())); } return Ok(Some(format!("{target}{suffix}"))); } let url = Url::parse(target)?; let resolved = url .join(suffix) .map_err(|_| ResolveError::CannotAppend(target.clone()))?; if !resolved.as_str().starts_with(target) { return Err(ResolveError::EscapesPrefix(key.to_owned())); } return Ok(Some(resolved.to_string())); } Ok(None) }