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::global::GlobalHttpCacheRc;
#[sys_traits::auto_impl]
pub trait LocalHttpCacheSys:
FsCreateDirAll
+ FsMetadata
+ FsOpen
+ FsRead
+ FsRemoveFile
+ FsRename
+ ThreadSleep
+ SystemRandom
+ SystemTimeNow
+ MaybeSend
+ MaybeSync
+ std::fmt::Debug
+ Clone
{
}
#[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,
allow_global_to_local: GlobalToLocalCopy::Disallow,
jsr_registry_url: None, },
}
}
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; };
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)
{
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('#'))
{
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 {
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));
}
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);
}
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(
|| {
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_redirect = headers.contains_key("location");
if is_redirect {
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 {
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, })
}
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));
}
}
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<()> {
let is_redirect = headers.contains_key("location");
let sub_path = url_to_local_sub_path(url, headers_content_type(&headers))?;
if !is_redirect {
let content =
self.transform_content_on_copy_to_local(url, Cow::Borrowed(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_redirect = headers.contains_key("location");
let bytes: Cow<'static, [u8]> = if is_redirect {
Cow::Borrowed(&[])
} else {
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() {
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(),
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>> {
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>> {
static FORBIDDEN_CHARS: Lazy<HashSet<char>> = Lazy::new(|| {
HashSet::from(['?', '<', '>', ':', '*', '|', '\\', ':', '"', '\'', '/'])
});
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",
]);
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)
|| 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 {
let hash = checksum(data.as_bytes());
const MAX_LENGTH: usize = 20;
let mut sub = String::with_capacity(MAX_LENGTH);
for c in data.chars().take(MAX_LENGTH) {
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 {
return true;
}
let hash_context_specific = if let Some(last_ext) = last_ext {
!has_known_extension(part) || !part.ends_with(last_ext)
} else {
has_known_extension(part)
};
hash_context_specific
|| part.starts_with('#')
|| has_forbidden_chars(part)
|| last_ext.is_none() && FORBIDDEN_WINDOWS_NAMES.contains(part)
|| part.ends_with('.')
}
let port_separator = "_"; 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]));
}
let path_segments = url_path_segments(url);
let mut parts = base_parts
.into_iter()
.chain(path_segments.map(Cow::Borrowed))
.collect::<Vec<_>>();
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 {
MediaType::from_specifier(url)
!= MediaType::from_specifier_and_headers(url, Some(headers))
}
let mut headers_subset = BTreeMap::new();
const HEADER_KEYS_TO_KEEP: [&str; 4] = [
"content-type",
"location",
"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 {
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<_, _>>()
})
}
}
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::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())
}
}
#[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: 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)| {
if module.headers.contains_key("location") {
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()); 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_deno.land/x/mod.ts",
);
run_test(
"https://deno.land/x/MOD.ts",
&[],
"deno.land/x/#mod_fa860.ts",
);
run_test(
"https://deno.land/x/mod.ts?testing=1",
&[],
"deno.land/x/#mod_2eb80.ts",
);
run_test(
"https://deno.land/OTHER/mod.ts",
&[],
"deno.land/#other_1c55d/mod.ts",
);
run_test(
"https://deno.land/x/012345678901234567890123456.js",
&[],
"deno.land/x/012345678901234567890123456.js",
);
run_test(
"https://deno.land/x/0123456789012345678901234567.js",
&[],
"deno.land/x/#01234567890123456789_836de.js",
);
run_test(
"https://deno.land/x/mod's.js",
&[],
"deno.land/x/#mod_s_44fc8.js",
);
run_test(
"https://deno.land/x/mod",
&[("content-type", "application/typescript")],
"deno.land/x/#mod_e55cf.ts",
);
run_test(
"https://deno.land/x/mod.js/mod.js",
&[],
"deno.land/x/#mod.js_59c58/mod.js",
);
run_test(
"http://localhost//mod.js",
&[],
"http_localhost/#e3b0c44/mod.js",
);
run_test(
"https://deno.land/x/mod.ts",
&[("content-type", "application/typescript")],
"deno.land/x/mod.ts",
);
run_test(
"https://deno.land/x/mod.ts",
&[("content-type", "application/javascript")],
"deno.land/x/#mod.ts_e8c36.js",
);
run_test(
"https://deno.land/x/con/con.ts",
&[],
"deno.land/x/#con_1143d/con.ts",
);
run_test(
"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" } }"#;
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,
);
}
{
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
)
}
}
}