/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/cache_dir/local.rs
1 380 строк
39 KB
Bartek Iwańczuk
fix: cache 404 responses for remote modules (#35138)
14 июн 2026, 13:10
Не верифицирован
14 июн 2026, 13:10
e855ab5
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; use deno_maybe_sync::MaybeSend; use deno_maybe_sync::MaybeSync; use deno_media_type::MediaType; use deno_path_util::fs::atomic_write_file_with_retries; use once_cell::sync::Lazy; use parking_lot::RwLock; use sys_traits::FsCreateDirAll; use sys_traits::FsMetadata; use sys_traits::FsMetadataValue; use sys_traits::FsOpen; use sys_traits::FsRead; use sys_traits::FsRemoveFile; use sys_traits::FsRename; use sys_traits::SystemRandom; use sys_traits::SystemTimeNow; use sys_traits::ThreadSleep; use url::Url; use super::Checksum; use super::HttpCache; use super::HttpCacheItemKey; use super::common::HeadersMap; use super::common::base_url_to_filename_parts; use super::common::checksum; use crate::CACHE_PERM; use crate::SerializedCachedUrlMetadata; use crate::cache::CacheEntry; use crate::cache::CacheReadFileError; use crate::cache::GlobalToLocalCopy; use crate::cache::NOT_FOUND_CACHE_HEADER; use crate::global::GlobalHttpCacheRc; #[sys_traits::auto_impl] pub trait LocalHttpCacheSys: FsCreateDirAll + FsMetadata + FsOpen + FsRead + FsRemoveFile + FsRename + ThreadSleep + SystemRandom + SystemTimeNow + MaybeSend + MaybeSync + std::fmt::Debug + Clone { } /// A vendor/ folder http cache for the lsp that provides functionality /// for doing a reverse mapping. #[derive(Debug)] pub struct LocalLspHttpCache<TSys: LocalHttpCacheSys> { cache: LocalHttpCache<TSys>, } impl<TSys: LocalHttpCacheSys> LocalLspHttpCache<TSys> { pub fn new(path: PathBuf, global_cache: GlobalHttpCacheRc<TSys>) -> Self { #[cfg(not(target_arch = "wasm32"))] assert!(path.is_absolute()); let manifest = LocalCacheManifest::new_for_lsp( path.join("manifest.json"), global_cache.sys.clone(), ); Self { cache: LocalHttpCache { path, manifest, global_cache, // In the LSP, we disallow the cache from automatically copying from // the global cache to the local cache for technical reasons. // // 1. We need to verify the checksums from the lockfile are correct when // moving from the global to the local cache. // 2. We need to verify the checksums for JSR https specifiers match what // is found in the package's manifest. allow_global_to_local: GlobalToLocalCopy::Disallow, jsr_registry_url: None, // only used when GlobalToLocalCopy::Allow }, } } pub fn get_file_url(&self, url: &Url) -> Option<Url> { let sub_path = { let data = self.cache.manifest.data.read(); let maybe_content_type = data.get(url).and_then(|d| d.content_type_header()); url_to_local_sub_path(url, maybe_content_type).ok()? }; let path = sub_path.as_path_from_root(&self.cache.path); if self.cache.env().fs_is_file_no_err(&path) { deno_path_util::url_from_file_path(&path).ok() } else { None } } pub fn get_remote_url(&self, path: &Path) -> Option<Url> { let Ok(path) = path.strip_prefix(&self.cache.path) else { return None; // not in this directory }; let components = path .components() .map(|c| c.as_os_str().to_string_lossy()) .collect::<Vec<_>>(); if components .last() .map(|c| c.starts_with('#')) .unwrap_or(false) { // the file itself will have an entry in the manifest let data = self.cache.manifest.data.read(); data.get_reverse_mapping(path) } else if let Some(last_index) = components.iter().rposition(|c| c.starts_with('#')) { // get the mapping to the deepest hashed directory and // then add the remaining path components to the url let dir_path: PathBuf = components[..last_index + 1].iter().fold( PathBuf::new(), |mut path, c| { path.push(c.as_ref()); path }, ); let dir_url = self .cache .manifest .data .read() .get_reverse_mapping(&dir_path)?; let file_url = dir_url.join(&components[last_index + 1..].join("/")).ok()?; Some(file_url) } else { // we can work backwards from the path to the url let mut parts = Vec::new(); for (i, part) in path.components().enumerate() { let part = part.as_os_str().to_string_lossy(); if i == 0 { let mut result = String::new(); let part = if let Some(part) = part.strip_prefix("http_") { result.push_str("http://"); part } else { result.push_str("https://"); &part }; if let Some((domain, port)) = part.rsplit_once('_') { result.push_str(&format!("{}:{}", domain, port)); } else { result.push_str(part); } parts.push(result); } else { parts.push(part.to_string()); } } Url::parse(&parts.join("/")).ok() } } } impl<TSys: LocalHttpCacheSys> HttpCache for LocalLspHttpCache<TSys> { fn cache_item_key<'a>( &self, url: &'a Url, ) -> std::io::Result<HttpCacheItemKey<'a>> { self.cache.cache_item_key(url) } fn contains(&self, url: &Url) -> bool { self.cache.contains(url) } fn set( &self, url: &Url, headers: HeadersMap, content: &[u8], ) -> std::io::Result<()> { self.cache.set(url, headers, content) } fn get( &self, key: &HttpCacheItemKey, maybe_checksum: Option<Checksum>, ) -> Result<Option<crate::cache::CacheEntry>, CacheReadFileError> { self.cache.get(key, maybe_checksum) } fn read_modified_time( &self, key: &HttpCacheItemKey, ) -> std::io::Result<Option<SystemTime>> { self.cache.read_modified_time(key) } fn read_headers( &self, key: &HttpCacheItemKey, ) -> std::io::Result<Option<HeadersMap>> { self.cache.read_headers(key) } fn read_download_time( &self, key: &HttpCacheItemKey, ) -> std::io::Result<Option<SystemTime>> { self.cache.read_modified_time(key) } } #[allow(clippy::disallowed_types, reason = "arc wrapper type")] pub type LocalHttpCacheRc<TSys> = deno_maybe_sync::MaybeArc<LocalHttpCache<TSys>>; #[derive(Debug)] pub struct LocalHttpCache<TSys: LocalHttpCacheSys> { path: PathBuf, manifest: LocalCacheManifest<TSys>, global_cache: GlobalHttpCacheRc<TSys>, allow_global_to_local: GlobalToLocalCopy, jsr_registry_url: Option<Url>, } impl<TSys: LocalHttpCacheSys> LocalHttpCache<TSys> { pub fn new( path: PathBuf, global_cache: GlobalHttpCacheRc<TSys>, allow_global_to_local: GlobalToLocalCopy, jsr_registry_url: Url, ) -> Self { #[cfg(not(target_arch = "wasm32"))] assert!(path.is_absolute()); let manifest = LocalCacheManifest::new( path.join("manifest.json"), global_cache.sys.clone(), ); Self { path, manifest, global_cache, allow_global_to_local, jsr_registry_url: Some(jsr_registry_url), } } #[inline] fn env(&self) -> &TSys { &self.global_cache.sys } fn get_url_headers(&self, url: &Url) -> std::io::Result<Option<HeadersMap>> { if let Some(metadata) = self.manifest.get_stored_headers(url) { return Ok(Some(metadata)); } // if the local path exists, don't copy the headers from the global cache // to the local let local_path = url_to_local_sub_path(url, None)?; if self .env() .fs_is_file_no_err(local_path.as_path_from_root(&self.path)) { return Ok(Some(Default::default())); } if !self.allow_global_to_local.is_true() { return Ok(None); } // not found locally, so try to copy from the global manifest let global_key = self.global_cache.cache_item_key(url)?; let Some(headers) = self.global_cache.read_headers(&global_key)? else { return Ok(None); }; let local_path = url_to_local_sub_path(url, headers_content_type(&headers))?; self.manifest.insert_data(local_path, url.clone(), headers); Ok(Some(self.manifest.get_stored_headers(url).unwrap_or_else( || { // if it's not in the stored headers at this point then that means // the file has no headers that need to be stored for the local cache Default::default() }, ))) } pub fn local_path_for_url( &self, url: &Url, ) -> std::io::Result<Option<PathBuf>> { if let Some(headers) = self.get_url_headers(url)? { let is_content_less = headers.contains_key("location") || headers.contains_key(NOT_FOUND_CACHE_HEADER); if is_content_less { return Ok(None); } let local_path = url_to_local_sub_path(url, headers_content_type(&headers))?; Ok(Some(local_path.as_path_from_root(&self.path))) } else { Ok(None) } } fn transform_content_on_copy_to_local<'a>( &self, url: &Url, content: Cow<'a, [u8]>, ) -> Cow<'a, [u8]> { let Some(jsr_url) = &self.jsr_registry_url else { return content; }; if is_jsr_version_metadata_url(url, jsr_url) && let Some(data) = transform_jsr_version_metadata(&content) { return Cow::Owned(data); } content } } fn is_jsr_version_metadata_url(url: &Url, jsr_url: &Url) -> bool { // example url: https://jsr.io/@david/dax/0.43.0_meta.json let Some(suffix) = url.as_str().strip_prefix(jsr_url.as_str()) else { return false; }; let Some(suffix) = suffix.strip_prefix('@') else { return false; }; let Some(prefix) = suffix.strip_suffix("_meta.json") else { return false; }; prefix.chars().filter(|c| *c == '/').count() == 2 } fn transform_jsr_version_metadata(content: &[u8]) -> Option<Vec<u8>> { let checksum = checksum(content); let mut json_data = serde_json::from_slice::<serde_json::Value>(content).ok()?; let obj = json_data.as_object_mut()?; let keys_to_remove = obj .keys() .filter(|k| k.starts_with("moduleGraph")) .cloned() .collect::<Vec<_>>(); for key in keys_to_remove { obj.remove(&key); } obj.insert("lockfileChecksum".into(), checksum.into()); serde_json::to_vec(&json_data).ok() } impl<TSys: LocalHttpCacheSys> HttpCache for LocalHttpCache<TSys> { fn cache_item_key<'a>( &self, url: &'a Url, ) -> std::io::Result<HttpCacheItemKey<'a>> { Ok(HttpCacheItemKey { #[cfg(debug_assertions)] is_local_key: true, url, file_path: None, // need to compute this every time }) } fn contains(&self, url: &Url) -> bool { self .get_url_headers(url) .ok() .map(|d| d.is_some()) .unwrap_or(false) } fn read_modified_time( &self, key: &HttpCacheItemKey, ) -> std::io::Result<Option<SystemTime>> { #[cfg(debug_assertions)] debug_assert!(key.is_local_key); if let Some(headers) = self.get_url_headers(key.url)? { let local_path = url_to_local_sub_path(key.url, headers_content_type(&headers))?; if let Ok(metadata) = self .env() .fs_metadata(local_path.as_path_from_root(&self.path)) && let Ok(modified_time) = metadata.modified() { return Ok(Some(modified_time)); } } // fallback to the global cache let global_key = self.global_cache.cache_item_key(key.url)?; self.global_cache.read_modified_time(&global_key) } fn set( &self, url: &Url, headers: HeadersMap, content: &[u8], ) -> std::io::Result<()> { // redirects and cached 404s have no content on disk let is_content_less = headers.contains_key("location") || headers.contains_key(NOT_FOUND_CACHE_HEADER); let sub_path = url_to_local_sub_path(url, headers_content_type(&headers))?; if !is_content_less { let content = self.transform_content_on_copy_to_local(url, Cow::Borrowed(content)); // Cache content atomic_write_file_with_retries( self.env(), &sub_path.as_path_from_root(&self.path), &content, CACHE_PERM, )?; } self.manifest.insert_data(sub_path, url.clone(), headers); Ok(()) } fn get( &self, key: &HttpCacheItemKey, maybe_checksum: Option<Checksum>, ) -> Result<Option<CacheEntry>, CacheReadFileError> { #[cfg(debug_assertions)] debug_assert!(key.is_local_key); let maybe_headers = self.get_url_headers(key.url)?; match maybe_headers { Some(headers) => { let is_content_less = headers.contains_key("location") || headers.contains_key(NOT_FOUND_CACHE_HEADER); let bytes: Cow<'static, [u8]> = if is_content_less { // return back an empty file for redirects and cached 404s Cow::Borrowed(&[]) } else { // if it's not a redirect, then it should have a file path let local_file_path = url_to_local_sub_path(key.url, headers_content_type(&headers))? .as_path_from_root(&self.path); let file_bytes_result = self.env().fs_read(&local_file_path); match file_bytes_result { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { if self.allow_global_to_local.is_true() { // only check the checksum when copying from the global to the local cache let global_key = self.global_cache.cache_item_key(key.url)?; let maybe_global_cache_file = self.global_cache.get(&global_key, maybe_checksum)?; if let Some(file) = maybe_global_cache_file { let content = self .transform_content_on_copy_to_local(key.url, file.content); atomic_write_file_with_retries( self.env(), &local_file_path, &content, CACHE_PERM, )?; content } else { return Ok(None); } } else { return Ok(None); } } Err(err) => return Err(CacheReadFileError::Io(err)), } }; Ok(Some(CacheEntry { metadata: SerializedCachedUrlMetadata { headers, url: key.url.to_string(), // not used for the local cache time: None, }, content: bytes, })) } None => Ok(None), } } fn read_headers( &self, key: &HttpCacheItemKey, ) -> std::io::Result<Option<HeadersMap>> { #[cfg(debug_assertions)] debug_assert!(key.is_local_key); self.get_url_headers(key.url) } fn read_download_time( &self, key: &HttpCacheItemKey, ) -> std::io::Result<Option<SystemTime>> { // This will never be called for the local cache in practice // because only the LSP ever reads this time for telling if // a file should be re-downloaded when respecting cache headers // and it only does this using a global cache self.read_modified_time(key) } } pub(super) struct LocalCacheSubPath<'a> { pub has_hash: bool, pub parts: Vec<Cow<'a, str>>, } impl LocalCacheSubPath<'_> { pub fn as_path_from_root(&self, root_path: &Path) -> PathBuf { let mut path = root_path.to_path_buf(); for part in &self.parts { path.push(part.as_ref()); } path } pub fn as_relative_path(&self) -> PathBuf { let mut path = PathBuf::with_capacity(self.parts.iter().map(|p| p.len() + 1).sum()); for part in &self.parts { path.push(part.as_ref()); } path } } fn headers_content_type(headers: &HeadersMap) -> Option<&str> { headers.get("content-type").map(|s| s.as_str()) } fn url_to_local_sub_path<'a>( url: &'a Url, content_type: Option<&str>, ) -> std::io::Result<LocalCacheSubPath<'a>> { // https://stackoverflow.com/a/31976060/188246 static FORBIDDEN_CHARS: Lazy<HashSet<char>> = Lazy::new(|| { HashSet::from(['?', '<', '>', ':', '*', '|', '\\', ':', '"', '\'', '/']) }); // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file static FORBIDDEN_WINDOWS_NAMES: Lazy<HashSet<&'static str>> = Lazy::new(|| { let set = HashSet::from([ "con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", ]); // ensure everything is lowercase because we'll be comparing // lowercase filenames against this debug_assert!(set.iter().all(|s| s.to_lowercase() == *s)); set }); fn has_forbidden_chars(segment: &str) -> bool { segment.chars().any(|c| { let is_uppercase = c.is_ascii_alphabetic() && !c.is_ascii_lowercase(); FORBIDDEN_CHARS.contains(&c) // do not allow uppercase letters in order to make this work // the same on case insensitive file systems || is_uppercase }) } fn has_known_extension(path: &str) -> bool { let path = path.to_lowercase(); path.ends_with(".js") || path.ends_with(".ts") || path.ends_with(".jsx") || path.ends_with(".tsx") || path.ends_with(".mts") || path.ends_with(".mjs") || path.ends_with(".json") || path.ends_with(".wasm") } fn get_extension(url: &Url, content_type: Option<&str>) -> &'static str { let media_type = MediaType::from_specifier_and_content_type(url, content_type); match media_type { MediaType::JavaScript => ".js", MediaType::Jsx => ".jsx", MediaType::Mjs => ".mjs", MediaType::Cjs => ".cjs", MediaType::TypeScript => ".ts", MediaType::Mts => ".mts", MediaType::Cts => ".cts", MediaType::Dts => ".d.ts", MediaType::Dmts => ".d.mts", MediaType::Dcts => ".d.cts", MediaType::Tsx => ".tsx", MediaType::Css => ".css", MediaType::Json => ".json", MediaType::Jsonc => ".jsonc", MediaType::Json5 => ".json5", MediaType::Markdown => ".md", MediaType::Wasm => ".wasm", MediaType::SourceMap => ".js", MediaType::Html => ".html", MediaType::Sql => ".sql", MediaType::Unknown => ".js", } } fn short_hash(data: &str, last_ext: Option<&str>) -> String { // This function is a bit of a balancing act between readability // and avoiding collisions. let hash = checksum(data.as_bytes()); // keep the paths short because of windows path limit const MAX_LENGTH: usize = 20; let mut sub = String::with_capacity(MAX_LENGTH); for c in data.chars().take(MAX_LENGTH) { // don't include the query string (only use it in the hash) if c == '?' { break; } if FORBIDDEN_CHARS.contains(&c) { sub.push('_'); } else { sub.extend(c.to_lowercase()); } } let sub = match last_ext { Some(ext) => sub.strip_suffix(ext).unwrap_or(&sub), None => &sub, }; let ext = last_ext.unwrap_or(""); if sub.is_empty() { format!("#{}{}", &hash[..7], ext) } else { format!("#{}_{}{}", &sub, &hash[..5], ext) } } fn should_hash_part(part: &str, last_ext: Option<&str>) -> bool { if part.is_empty() || part.len() > 30 { // keep short due to windows path limit return true; } let hash_context_specific = if let Some(last_ext) = last_ext { // if the last part does not have a known extension, hash it in order to // prevent collisions with a directory of the same name !has_known_extension(part) || !part.ends_with(last_ext) } else { // if any non-ending path part has a known extension, hash it in order to // prevent collisions where a filename has the same name as a directory name has_known_extension(part) }; // the hash symbol at the start designates a hash for the url part hash_context_specific || part.starts_with('#') || has_forbidden_chars(part) || last_ext.is_none() && FORBIDDEN_WINDOWS_NAMES.contains(part) || part.ends_with('.') } // get the base url let port_separator = "_"; // make this shorter with just an underscore let Some(mut base_parts) = base_url_to_filename_parts(url, port_separator) else { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!("Can't convert url (\"{}\") to filename.", url), )); }; if base_parts[0] == "https" { base_parts.remove(0); } else { let scheme = base_parts.remove(0); base_parts[0] = Cow::Owned(format!("{}_{}", scheme, base_parts[0])); } // first, try to get the filename of the path let path_segments = url_path_segments(url); let mut parts = base_parts .into_iter() .chain(path_segments.map(Cow::Borrowed)) .collect::<Vec<_>>(); // push the query parameter onto the last part if let Some(query) = url.query() { let last_part = parts.last_mut().unwrap(); let last_part = match last_part { Cow::Borrowed(_) => { *last_part = Cow::Owned(last_part.to_string()); match last_part { Cow::Borrowed(_) => unreachable!(), Cow::Owned(s) => s, } } Cow::Owned(last_part) => last_part, }; last_part.push('?'); last_part.push_str(query); } let mut has_hash = false; let parts_len = parts.len(); let parts = parts .into_iter() .enumerate() .map(|(i, part)| { let is_last = i == parts_len - 1; let last_ext = if is_last { Some(get_extension(url, content_type)) } else { None }; if should_hash_part(&part, last_ext) { has_hash = true; Cow::Owned(short_hash(&part, last_ext)) } else { part } }) .collect::<Vec<_>>(); Ok(LocalCacheSubPath { has_hash, parts }) } #[derive(Debug)] struct LocalCacheManifest< Sys: FsCreateDirAll + FsMetadata + FsOpen + FsRead + FsRemoveFile + FsRename + ThreadSleep + SystemRandom + MaybeSend + MaybeSync + std::fmt::Debug, > { sys: Sys, file_path: PathBuf, data: RwLock<manifest::LocalCacheManifestData>, } impl< Sys: FsCreateDirAll + FsMetadata + FsOpen + FsRead + FsRemoveFile + FsRename + ThreadSleep + SystemRandom + MaybeSend + MaybeSync + std::fmt::Debug + Clone, > LocalCacheManifest<Sys> { pub fn new(file_path: PathBuf, sys: Sys) -> Self { Self::new_internal(file_path, false, sys) } pub fn new_for_lsp(file_path: PathBuf, sys: Sys) -> Self { Self::new_internal(file_path, true, sys) } fn new_internal( file_path: PathBuf, use_reverse_mapping: bool, sys: Sys, ) -> Self { let text = sys .fs_read(&file_path) .ok() .and_then(|bytes| String::from_utf8(bytes.into_owned()).ok()); Self { sys, data: RwLock::new(manifest::LocalCacheManifestData::new( text.as_deref(), use_reverse_mapping, )), file_path, } } pub fn insert_data( &self, sub_path: LocalCacheSubPath, url: Url, mut original_headers: HashMap<String, String>, ) { fn should_keep_content_type_header( url: &Url, headers: &HashMap<String, String>, ) -> bool { // only keep the location header if it can't be derived from the url MediaType::from_specifier(url) != MediaType::from_specifier_and_headers(url, Some(headers)) } let mut headers_subset = BTreeMap::new(); const HEADER_KEYS_TO_KEEP: [&str; 5] = [ // keep alphabetical for cleanliness in the output "content-type", "location", NOT_FOUND_CACHE_HEADER, "x-deno-warning", "x-typescript-types", ]; for key in HEADER_KEYS_TO_KEEP { if key == "content-type" && !should_keep_content_type_header(&url, &original_headers) { continue; } if let Some((k, v)) = original_headers.remove_entry(key) { headers_subset.insert(k, v); } } let mut data = self.data.write(); let add_module_entry = headers_subset.is_empty() && !sub_path .parts .last() .map(|s| s.starts_with('#')) .unwrap_or(false); let mut has_changed = if add_module_entry { data.remove(&url, &sub_path) } else { let new_data = manifest::SerializedLocalCacheManifestDataModule { headers: headers_subset, }; if data.get(&url) == Some(&new_data) { false } else { data.insert(url.clone(), &sub_path, new_data); true } }; if sub_path.has_hash { let url_path_parts = url_path_segments(&url).collect::<Vec<_>>(); let base_url = { let mut url = url.clone(); url.set_path("/"); url.set_query(None); url.set_fragment(None); url }; for (i, local_part) in sub_path.parts[1..sub_path.parts.len() - 1] .iter() .enumerate() { if local_part.starts_with('#') { let mut url = base_url.clone(); url.set_path(&format!("{}/", url_path_parts[..i + 1].join("/"))); if data.add_directory(url, sub_path.parts[..i + 2].join("/")) { has_changed = true; } } } } if has_changed { // don't bother ensuring the directory here because it will // eventually be created by files being added to the cache let result = atomic_write_file_with_retries( &self.sys, &self.file_path, data.as_json().as_bytes(), CACHE_PERM, ); if let Err(err) = result { log::debug!("Failed saving local cache manifest: {:#}", err); } } } pub fn get_stored_headers(&self, url: &Url) -> Option<HeadersMap> { let data = self.data.read(); data.get(url).map(|module| { module .headers .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect::<HashMap<_, _>>() }) } } // This is in a separate module in order to enforce keeping // the internal implementation private. mod manifest { use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; use url::Url; use super::LocalCacheSubPath; use super::NOT_FOUND_CACHE_HEADER; use super::url_to_local_sub_path; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SerializedLocalCacheManifestDataModule { #[serde( default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty" )] pub headers: BTreeMap<String, String>, } impl SerializedLocalCacheManifestDataModule { pub fn content_type_header(&self) -> Option<&str> { self.headers.get("content-type").map(|s| s.as_str()) } } // Using BTreeMap to make sure that the data is always sorted #[derive(Debug, Default, Clone, Serialize, Deserialize)] struct SerializedLocalCacheManifestData { #[serde( default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty" )] pub folders: BTreeMap<Url, String>, #[serde( default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty" )] pub modules: BTreeMap<Url, SerializedLocalCacheManifestDataModule>, } #[derive(Debug, Default, Clone)] pub(super) struct LocalCacheManifestData { serialized: SerializedLocalCacheManifestData, // reverse mapping used in the lsp reverse_mapping: Option<BTreeMap<PathBuf, Url>>, } impl LocalCacheManifestData { pub fn new(maybe_text: Option<&str>, use_reverse_mapping: bool) -> Self { let serialized: SerializedLocalCacheManifestData = maybe_text .and_then(|text| match serde_json::from_str(text) { Ok(data) => Some(data), Err(err) => { log::debug!("Failed deserializing local cache manifest: {:#}", err); None } }) .unwrap_or_default(); let reverse_mapping = if use_reverse_mapping { Some( serialized .modules .iter() .filter_map(|(url, module)| { // redirects and cached 404s have no local file if module.headers.contains_key("location") || module.headers.contains_key(NOT_FOUND_CACHE_HEADER) { return None; } url_to_local_sub_path(url, module.content_type_header()) .ok() .map(|local_path| { let path = if cfg!(windows) { PathBuf::from(local_path.parts.join("\\")) } else { PathBuf::from(local_path.parts.join("/")) }; (path, url.clone()) }) }) .chain(serialized.folders.iter().map(|(url, local_path)| { let path = if cfg!(windows) { PathBuf::from(local_path.replace('/', "\\")) } else { PathBuf::from(local_path) }; (path, url.clone()) })) .collect::<BTreeMap<_, _>>(), ) } else { None }; Self { serialized, reverse_mapping, } } pub fn get( &self, url: &Url, ) -> Option<&SerializedLocalCacheManifestDataModule> { self.serialized.modules.get(url) } pub fn get_reverse_mapping(&self, path: &Path) -> Option<Url> { debug_assert!(self.reverse_mapping.is_some()); // only call this if you're in the lsp self .reverse_mapping .as_ref() .and_then(|mapping| mapping.get(path)) .cloned() } pub fn add_directory(&mut self, url: Url, local_path: String) -> bool { if let Some(current) = self.serialized.folders.get(&url) && *current == local_path { return false; } if let Some(reverse_mapping) = &mut self.reverse_mapping { reverse_mapping.insert( if cfg!(windows) { PathBuf::from(local_path.replace('/', "\\")) } else { PathBuf::from(&local_path) }, url.clone(), ); } self.serialized.folders.insert(url, local_path); true } pub fn insert( &mut self, url: Url, sub_path: &LocalCacheSubPath, new_data: SerializedLocalCacheManifestDataModule, ) { if let Some(reverse_mapping) = &mut self.reverse_mapping { reverse_mapping.insert(sub_path.as_relative_path(), url.clone()); } self.serialized.modules.insert(url, new_data); } pub fn remove(&mut self, url: &Url, sub_path: &LocalCacheSubPath) -> bool { if self.serialized.modules.remove(url).is_some() { if let Some(reverse_mapping) = &mut self.reverse_mapping { reverse_mapping.remove(&sub_path.as_relative_path()); } true } else { false } } pub fn as_json(&self) -> String { serde_json::to_string_pretty(&self.serialized).unwrap() } } } fn url_path_segments(url: &Url) -> impl Iterator<Item = &str> { url .path() .strip_prefix('/') .unwrap_or(url.path()) .split('/') } #[cfg(test)] mod test { use deno_maybe_sync::new_rc; use pretty_assertions::assert_eq; use sys_traits::impls::RealSys; use tempfile::TempDir; use tempfile::tempdir; use super::*; use crate::GlobalHttpCache; struct TestCaches { global_cache: GlobalHttpCacheRc<RealSys>, local_cache: LocalHttpCacheRc<RealSys>, local_temp: PathBuf, _temp: TempDir, } impl TestCaches { fn new() -> Self { let temp = tempdir().unwrap(); let global_temp = temp.path().join("global"); let local_temp = temp.path().join("local"); let global_cache = GlobalHttpCache::new(RealSys, global_temp); let global_cache = new_rc(global_cache); let local_cache = new_rc(LocalHttpCache::new( local_temp.clone(), global_cache.clone(), GlobalToLocalCopy::Allow, Url::parse("https://jsr.io/").unwrap(), )); Self { global_cache, local_cache, local_temp, _temp: temp, } } } #[test] fn test_url_to_local_sub_path() { run_test("https://deno.land/x/mod.ts", &[], "deno.land/x/mod.ts"); run_test( "http://deno.land/x/mod.ts", &[], // http gets added to the folder name, but not https "http_deno.land/x/mod.ts", ); run_test( // capital letter in filename "https://deno.land/x/MOD.ts", &[], "deno.land/x/#mod_fa860.ts", ); run_test( // query string "https://deno.land/x/mod.ts?testing=1", &[], "deno.land/x/#mod_2eb80.ts", ); run_test( // capital letter in directory "https://deno.land/OTHER/mod.ts", &[], "deno.land/#other_1c55d/mod.ts", ); run_test( // under max of 30 chars "https://deno.land/x/012345678901234567890123456.js", &[], "deno.land/x/012345678901234567890123456.js", ); run_test( // max 30 chars "https://deno.land/x/0123456789012345678901234567.js", &[], "deno.land/x/#01234567890123456789_836de.js", ); run_test( // forbidden char "https://deno.land/x/mod's.js", &[], "deno.land/x/#mod_s_44fc8.js", ); run_test( // no extension "https://deno.land/x/mod", &[("content-type", "application/typescript")], "deno.land/x/#mod_e55cf.ts", ); run_test( // known extension in directory is not allowed // because it could conflict with a file of the same name "https://deno.land/x/mod.js/mod.js", &[], "deno.land/x/#mod.js_59c58/mod.js", ); run_test( // slash slash in path "http://localhost//mod.js", &[], "http_localhost/#e3b0c44/mod.js", ); run_test( // headers same extension "https://deno.land/x/mod.ts", &[("content-type", "application/typescript")], "deno.land/x/mod.ts", ); run_test( // headers different extension... We hash this because // if someone deletes the manifest file, then we don't want // https://deno.land/x/mod.ts to resolve as a typescript file "https://deno.land/x/mod.ts", &[("content-type", "application/javascript")], "deno.land/x/#mod.ts_e8c36.js", ); run_test( // not allowed windows folder name "https://deno.land/x/con/con.ts", &[], "deno.land/x/#con_1143d/con.ts", ); run_test( // disallow ending a directory with a period // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file "https://deno.land/x/test./main.ts", &[], "deno.land/x/#test._4ee3d/main.ts", ); run_test("https://deno.land/x/mod.wasm", &[], "deno.land/x/mod.wasm"); #[track_caller] fn run_test(url: &str, headers: &[(&str, &str)], expected: &str) { let test_caches = TestCaches::new(); let url = Url::parse(url).unwrap(); let headers: HashMap<String, String> = headers .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); test_caches .global_cache .set(&url, headers.clone(), b"console.log('hello');") .unwrap(); let path = test_caches .local_cache .local_path_for_url(&url) .unwrap() .unwrap(); let result = url_to_local_sub_path(&url, headers_content_type(&headers)).unwrap(); let parts = result.parts.join("/"); assert_eq!(path, test_caches.local_temp.join(&parts)); assert_eq!(parts, expected); assert_eq!( result.parts.iter().any(|p| p.starts_with('#')), result.has_hash ) } } #[test] fn local_path_no_headers() { let test_caches = TestCaches::new(); let url = Url::parse("https://deno.land/x/mod.ts").unwrap(); assert!(matches!( test_caches.local_cache.local_path_for_url(&url), Ok(None) )); } #[test] fn local_path_redirect() { let test_caches = TestCaches::new(); let url = Url::parse("https://deno.land/x/mod.ts").unwrap(); test_caches .global_cache .set( &url, HashMap::from([( "location".to_string(), "https://deno.land/x/mod.ts".to_string(), )]), b"", ) .unwrap(); assert!(matches!( test_caches.local_cache.local_path_for_url(&url), Ok(None) )); } #[test] fn test_copy_version_metadata_file() { let test_caches = TestCaches::new(); let data = r#"{ "moduleGraph2": "testing", "checksums": { "test": "test" } }"#; // has the moduleGraph2 property stripped let expected_data = r#"{"checksums":{"test":"test"},"lockfileChecksum":"dc108ae9ffb13086cb1551692960c21893991a7f0f9dc770814ff21522fd1d48"}"#; { let metadata_url = Url::parse("https://jsr.io/@david/dax/1.2.3_meta.json").unwrap(); test_caches .global_cache .set(&metadata_url, Default::default(), data.as_bytes()) .unwrap(); let key = test_caches .local_cache .cache_item_key(&metadata_url) .unwrap(); let final_data = test_caches.local_cache.get(&key, None).unwrap().unwrap(); assert_eq!( String::from_utf8(final_data.content.to_vec()).unwrap(), expected_data, ); } { // now try just setting directly in the local cache let metadata_url = Url::parse("https://jsr.io/@david/dax/1.2.2_meta.json").unwrap(); test_caches .local_cache .set(&metadata_url, Default::default(), data.as_bytes()) .unwrap(); let key = test_caches .local_cache .cache_item_key(&metadata_url) .unwrap(); let final_data = test_caches.local_cache.get(&key, None).unwrap().unwrap(); assert_eq!( String::from_utf8(final_data.content.to_vec()).unwrap(), expected_data, ); } } #[test] fn test_is_jsr_version_metadata_url() { let cases = [ ("https://jsr.io/@test/test/1.2.3_meta.json", true), ("https://jsr.io/@test/test/test/1.2.3_meta.json", false), ("https://jsr.io/@test/test/meta.json", false), ("https://jsr.io/test/test/1.2.3_meta.json", false), ("https://jsr.com/@test/test/1.2.3_meta.json", false), ]; let jsr_url = Url::parse("https://jsr.io/").unwrap(); for (url, expected) in cases { let value = is_jsr_version_metadata_url(&Url::parse(url).unwrap(), &jsr_url); assert_eq!(value, expected); } } #[test] fn test_transform_jsr_version_metadata() { let cases = [ ( r#"{ "moduleGraph1": "data", "moduleGraph2": "data", "moduleGraph3": "data", "other": "data" }"#, Some( r#"{"other":"data","lockfileChecksum":"1438025e1aa277249e97fa99a2283542ab1156b0967c3e0997f78bee22d121ad"}"#, ), ), ( r#"{ "other": "data" }"#, Some( r#"{"other":"data","lockfileChecksum":"62db4d2ded7cb6348ccb1648b2a27ed96dbe0fadc42c8359024c0213bab2f0e5"}"#, ), ), ]; for (input, expected) in cases { let output = transform_jsr_version_metadata(input.as_bytes()); assert_eq!( output.map(|o| String::from_utf8(o).unwrap()).as_deref(), expected ) } } }