/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/npm_cache/lib.rs
913 строк
28 KB
Bartek Iwańczuk
fix(npm): tolerate malformed `scripts` field in packuments (#36324)
29 июл 2026, 12:02
Не верифицирован
29 июл 2026, 12:02
1715647
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::collections::BTreeMap; use std::collections::HashSet; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use deno_cache_dir::file_fetcher::CacheSetting; use deno_cache_dir::npm::NpmCacheDir; use deno_error::JsErrorBox; use deno_npm::NpmPackageCacheFolderId; use deno_npm::fast_registry_json; use deno_npmrc::RegistryConfig; use deno_npmrc::ResolvedNpmRc; use deno_path_util::fs::atomic_write_file_with_retries; use deno_semver::StackString; use deno_semver::Version; use deno_semver::package::PackageNv; use parking_lot::Mutex; use serde_json::value::RawValue; use sys_traits::FsCanonicalize; use sys_traits::FsCreateDirAll; use sys_traits::FsHardLink; use sys_traits::FsMetadata; use sys_traits::FsOpen; use sys_traits::FsRead; use sys_traits::FsReadDir; use sys_traits::FsRemoveDirAll; use sys_traits::FsRemoveFile; use sys_traits::FsRename; use sys_traits::SystemRandom; use sys_traits::ThreadSleep; use url::Url; mod fs_util; mod registry_info; mod remote; mod rt; mod tarball; mod tarball_extract; pub use fs_util::create_dir_all_no_symlink; pub use fs_util::ensure_not_symlink; pub use fs_util::hard_link_dir_recursive; pub use fs_util::hard_link_file; pub use registry_info::RegistryInfoProvider; pub use registry_info::SerializedCachedPackageInfo; pub use registry_info::get_package_url; pub use remote::maybe_auth_header_value_for_npm_registry; pub use tarball::EnsurePackageError; pub use tarball::TarballCache; pub use tarball::TarballCacheReporter; use self::rt::spawn_blocking; #[derive(Debug, deno_error::JsError)] #[class(generic)] pub struct DownloadError { pub status_code: Option<u16>, pub error: JsErrorBox, } impl std::error::Error for DownloadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.error.source() } } impl std::fmt::Display for DownloadError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { self.error.fmt(f) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NpmPackumentFormat { /// Request the abbreviated install manifest (smaller, but omits `time` and `scripts`). Abbreviated, /// Request the full packument (needed when `minimumDependencyAge` is configured). Full, } pub enum NpmCacheHttpClientResponse { NotFound, NotModified, Bytes(NpmCacheHttpClientBytesResponse), } pub struct NpmCacheHttpClientBytesResponse { pub bytes: Vec<u8>, pub etag: Option<String>, } #[async_trait::async_trait(?Send)] pub trait NpmCacheHttpClient: std::fmt::Debug + Send + Sync + 'static { async fn download_with_retries_on_any_tokio_runtime( &self, url: Url, maybe_auth: Option<String>, maybe_etag: Option<String>, maybe_registry_config: Option<&RegistryConfig>, ) -> Result<NpmCacheHttpClientResponse, DownloadError>; } /// Indicates how cached source files should be handled. #[derive(Debug, Clone, Eq, PartialEq)] pub enum NpmCacheSetting { /// Only the cached files should be used. Any files not in the cache will /// error. This is the equivalent of `--cached-only` in the CLI. Only, /// No cached source files should be used, and all files should be reloaded. /// This is the equivalent of `--reload` in the CLI. ReloadAll, /// Only some cached resources should be used. This is the equivalent of /// `--reload=npm:chalk` ReloadSome { npm_package_names: Vec<String> }, /// The cached source files should be used for local modules. This is the /// default behavior of the CLI. Use, } impl NpmCacheSetting { pub fn from_cache_setting(cache_setting: &CacheSetting) -> NpmCacheSetting { match cache_setting { CacheSetting::Only => NpmCacheSetting::Only, CacheSetting::ReloadAll => NpmCacheSetting::ReloadAll, CacheSetting::ReloadSome(values) => { if values.iter().any(|v| v == "npm:") { NpmCacheSetting::ReloadAll } else { NpmCacheSetting::ReloadSome { npm_package_names: values .iter() .filter_map(|v| v.strip_prefix("npm:")) .map(|n| n.to_string()) .collect(), } } } CacheSetting::RespectHeaders => panic!("not supported"), CacheSetting::Use => NpmCacheSetting::Use, } } pub fn should_use_for_npm_package(&self, package_name: &str) -> bool { match self { NpmCacheSetting::ReloadAll => false, NpmCacheSetting::ReloadSome { npm_package_names } => { !npm_package_names.iter().any(|n| n == package_name) } _ => true, } } } #[sys_traits::auto_impl] pub trait NpmCacheSys: FsCanonicalize + FsCreateDirAll + FsHardLink + FsMetadata + FsOpen + FsRead + FsReadDir + FsRemoveDirAll + FsRemoveFile + FsRename + ThreadSleep + SystemRandom + Send + Sync + Clone + std::fmt::Debug + 'static { } /// Stores a single copy of npm packages in a cache. #[derive(Debug)] pub struct NpmCache<TSys: NpmCacheSys> { cache_dir: Arc<NpmCacheDir>, sys: TSys, cache_setting: NpmCacheSetting, npmrc: Arc<ResolvedNpmRc>, previously_reloaded_packages: Mutex<HashSet<PackageNv>>, } impl<TSys: NpmCacheSys> NpmCache<TSys> { pub fn new( cache_dir: Arc<NpmCacheDir>, sys: TSys, cache_setting: NpmCacheSetting, npmrc: Arc<ResolvedNpmRc>, ) -> Self { Self { cache_dir, sys, cache_setting, npmrc, previously_reloaded_packages: Default::default(), } } pub fn cache_setting(&self) -> &NpmCacheSetting { &self.cache_setting } pub fn root_dir_path(&self) -> &Path { self.cache_dir.root_dir() } pub fn root_dir_url(&self) -> &Url { self.cache_dir.root_dir_url() } /// Checks if the cache should be used for the provided name and version. /// NOTE: Subsequent calls for the same package will always return `true` /// to ensure a package is only downloaded once per run of the CLI. This /// prevents downloads from re-occurring when someone has `--reload` and /// and imports a dynamic import that imports the same package again for example. pub fn should_use_cache_for_package(&self, package: &PackageNv) -> bool { self.cache_setting.should_use_for_npm_package(&package.name) || !self .previously_reloaded_packages .lock() .insert(package.clone()) } /// Ensures a copy of the package exists in the global cache. /// /// This assumes that the original package folder being hard linked /// from exists before this is called. pub fn ensure_copy_package( &self, folder_id: &NpmPackageCacheFolderId, ) -> Result<(), WithFolderSyncLockError> { let registry_url = self.npmrc.get_registry_url(&folder_id.nv.name); assert_ne!(folder_id.copy_index, 0); let package_folder = self.cache_dir.package_folder_for_id( &folder_id.nv.name, &folder_id.nv.version.to_string(), folder_id.copy_index, registry_url, ); if self.sys.fs_exists_no_err(&package_folder) // if this file exists, then the package didn't successfully initialize // the first time, or another process is currently extracting the zip file && !self.sys.fs_exists_no_err(package_folder.join(NPM_PACKAGE_SYNC_LOCK_FILENAME)) && self.cache_setting.should_use_for_npm_package(&folder_id.nv.name) { return Ok(()); } let original_package_folder = self.cache_dir.package_folder_for_id( &folder_id.nv.name, &folder_id.nv.version.to_string(), 0, // original copy index registry_url, ); // it seems Windows does an "AccessDenied" error when moving a // directory with hard links, so that's why this solution is done with_folder_sync_lock(&self.sys, &folder_id.nv, &package_folder, || { hard_link_dir_recursive( &self.sys, &original_package_folder, &package_folder, ) .map_err(JsErrorBox::from_err) })?; Ok(()) } pub fn package_folder_for_id(&self, id: &NpmPackageCacheFolderId) -> PathBuf { let registry_url = self.npmrc.get_registry_url(&id.nv.name); self.cache_dir.package_folder_for_id( &id.nv.name, &id.nv.version.to_string(), id.copy_index, registry_url, ) } pub fn package_folder_for_nv(&self, package: &PackageNv) -> PathBuf { let registry_url = self.npmrc.get_registry_url(&package.name); self.package_folder_for_nv_and_url(package, registry_url) } pub fn package_folder_for_nv_and_url( &self, package: &PackageNv, registry_url: &Url, ) -> PathBuf { self.cache_dir.package_folder_for_id( &package.name, &package.version.to_string(), 0, // original copy_index registry_url, ) } pub fn package_name_folder(&self, name: &str) -> PathBuf { let registry_url = self.npmrc.get_registry_url(name); self.cache_dir.package_name_folder(name, registry_url) } pub fn resolve_package_folder_id_from_specifier( &self, specifier: &Url, ) -> Option<NpmPackageCacheFolderId> { self .cache_dir .resolve_package_folder_id_from_specifier(specifier) .and_then(|cache_id| { Some(NpmPackageCacheFolderId { nv: PackageNv { name: StackString::from_string(cache_id.name), version: Version::parse_from_npm(&cache_id.version).ok()?, }, copy_index: cache_id.copy_index, }) }) } pub async fn load_package_info( &self, name: &str, _packument_format: NpmPackumentFormat, ) -> Result<Option<SerializedCachedPackageInfo>, serde_json::Error> { let file_cache_path = self.get_registry_package_info_file_cache_path(name); let file_bytes = match self.sys.fs_read(&file_cache_path) { Ok(file_text) => file_text, Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), Err(err) => return Err(serde_json::Error::io(err)), }; spawn_blocking(move || { let (info, cache_metadata) = deno_npm::registry::NpmPackageInfo::from_packument_bytes_with_cache_info( file_bytes.into_owned(), ) .map_err(|err| serde_json::Error::io(std::io::Error::other(err)))?; Ok(Some(SerializedCachedPackageInfo { info, etag: cache_metadata.etag, full_packument: cache_metadata.full_packument, })) }) .await .unwrap() } pub fn save_package_info( &self, name: &str, package_info: &SerializedCachedPackageInfo, ) -> Result<(), JsErrorBox> { let file_cache_path = self.get_registry_package_info_file_cache_path(name); let file_text = serde_json::to_string(&package_info).map_err(JsErrorBox::from_err)?; atomic_write_file_with_retries( &self.sys, &file_cache_path, file_text.as_bytes(), 0o644, ) .map_err(JsErrorBox::from_err)?; Ok(()) } pub fn build_package_info_cache_bytes( &self, package_info_bytes: &[u8], etag: Option<&str>, packument_format: NpmPackumentFormat, ) -> Result<Vec<u8>, JsErrorBox> { slim_package_info_bytes( package_info_bytes, etag, packument_format == NpmPackumentFormat::Full, ) } pub fn save_package_info_bytes( &self, name: &str, package_info_bytes: &[u8], ) -> Result<(), JsErrorBox> { let file_cache_path = self.get_registry_package_info_file_cache_path(name); atomic_write_file_with_retries( &self.sys, &file_cache_path, package_info_bytes, 0o644, ) .map_err(JsErrorBox::from_err)?; Ok(()) } fn get_registry_package_info_file_cache_path(&self, name: &str) -> PathBuf { let name_folder_path = self.package_name_folder(name); name_folder_path.join("registry.json") } } fn slim_package_info_bytes( package_info_bytes: &[u8], etag: Option<&str>, full_packument: bool, ) -> Result<Vec<u8>, JsErrorBox> { let text = std::str::from_utf8(package_info_bytes) .map_err(|err| JsErrorBox::generic(err.to_string()))?; let index = fast_registry_json::pluck_packument_index(text) .map_err(|err| JsErrorBox::generic(format!("{err:?}")))?; let mut output = Vec::with_capacity(package_info_bytes.len().min(1024 * 1024)); output.push(b'{'); let mut first = true; if let Some(name) = index.name { write_json_property_name(&mut output, &mut first, "name") .map_err(JsErrorBox::from_err)?; serde_json::to_writer(&mut output, name).map_err(JsErrorBox::from_err)?; } write_json_property_name(&mut output, &mut first, "dist-tags") .map_err(JsErrorBox::from_err)?; write_string_map(&mut output, index.dist_tags.iter()) .map_err(JsErrorBox::from_err)?; write_json_property_name(&mut output, &mut first, "versions") .map_err(JsErrorBox::from_err)?; output.push(b'{'); let mut first_version = true; for (version, (start, end)) in index.versions.iter().zip(index.version_ranges.iter()) { write_json_property_name(&mut output, &mut first_version, version) .map_err(JsErrorBox::from_err)?; let evidence = index.trust_evidence.get(version).copied(); write_slim_version( &mut output, version, &text[*start as usize..*end as usize], evidence, )?; } output.push(b'}'); write_json_property_name(&mut output, &mut first, "time") .map_err(JsErrorBox::from_err)?; write_string_map(&mut output, index.time.iter()) .map_err(JsErrorBox::from_err)?; if let Some(etag) = etag { write_json_property_name(&mut output, &mut first, "_deno.etag") .map_err(JsErrorBox::from_err)?; serde_json::to_writer(&mut output, etag).map_err(JsErrorBox::from_err)?; } if full_packument { // Record that this cache entry came from a full packument response, so // an empty `time` map means the registry provides no publish dates and // there's no point re-fetching to look for them (see #35761). write_json_property_name(&mut output, &mut first, "_deno.packumentFormat") .map_err(JsErrorBox::from_err)?; output.extend_from_slice(b"\"full\""); } output.push(b'}'); Ok(output) } fn write_slim_version( output: &mut Vec<u8>, version: &str, version_json: &str, evidence: Option<fast_registry_json::TrustEvidence>, ) -> Result<(), JsErrorBox> { let raw_fields = serde_json::from_str::<BTreeMap<String, &RawValue>>(version_json) .map_err(JsErrorBox::from_err)?; output.push(b'{'); let mut first = true; let mut wrote_version = false; for key in [ "version", "bin", "dependencies", "bundleDependencies", "bundledDependencies", "optionalDependencies", "peerDependencies", "peerDependenciesMeta", "os", "cpu", "deprecated", ] { if let Some(value) = raw_fields.get(key) { if key == "version" { wrote_version = true; } write_raw_property(output, &mut first, key, value.get()) .map_err(JsErrorBox::from_err)?; } } if !wrote_version { write_json_property_name(output, &mut first, "version") .map_err(JsErrorBox::from_err)?; serde_json::to_writer(&mut *output, version) .map_err(JsErrorBox::from_err)?; } if has_install_script(&raw_fields)? { output.extend_from_slice(if first { first = false; br#""hasInstallScript":true"# } else { br#","hasInstallScript":true"# }); } if let Some(dist) = raw_fields.get("dist") && should_write_slim_dist(dist.get(), evidence) { write_json_property_name(output, &mut first, "dist") .map_err(JsErrorBox::from_err)?; write_slim_dist(output, dist.get(), evidence)?; } match evidence { Some(fast_registry_json::TrustEvidence::TrustedPublisher) => { output.extend_from_slice(if first { br#""_npmUser":{"trustedPublisher":true}"# } else { br#","_npmUser":{"trustedPublisher":true}"# }); } Some(fast_registry_json::TrustEvidence::StagedPublish) => { output.extend_from_slice(if first { br#""_npmUser":{"approver":true}"# } else { br#","_npmUser":{"approver":true}"# }); } Some(fast_registry_json::TrustEvidence::Provenance) | None => {} } output.push(b'}'); Ok(()) } fn has_install_script( raw_fields: &BTreeMap<String, &RawValue>, ) -> Result<bool, JsErrorBox> { if raw_fields .get("hasInstallScript") .is_some_and(|value| value.get() == "true") { return Ok(true); } let Some(scripts) = raw_fields.get("scripts") else { return Ok(false); }; // Historically some packuments contain a malformed `scripts` field that is // not a map (e.g. an empty string, see #36323). Treat anything that doesn't // deserialize as an object as having no lifecycle scripts rather than failing // to load the entire packument. let Ok(scripts) = serde_json::from_str::<BTreeMap<String, &RawValue>>(scripts.get()) else { return Ok(false); }; Ok( scripts.contains_key("preinstall") || scripts.contains_key("install") || scripts.contains_key("postinstall"), ) } fn should_write_slim_dist( dist_json: &str, evidence: Option<fast_registry_json::TrustEvidence>, ) -> bool { evidence .is_some_and(|e| e <= fast_registry_json::TrustEvidence::TrustedPublisher) || dist_json.contains(r#""tarball""#) || dist_json.contains(r#""shasum""#) || dist_json.contains(r#""integrity""#) } fn write_slim_dist( output: &mut Vec<u8>, dist_json: &str, evidence: Option<fast_registry_json::TrustEvidence>, ) -> Result<(), JsErrorBox> { let raw_fields = serde_json::from_str::<BTreeMap<String, &RawValue>>(dist_json) .map_err(JsErrorBox::from_err)?; output.push(b'{'); let mut first = true; for key in ["tarball", "shasum", "integrity"] { if let Some(value) = raw_fields.get(key) { write_raw_property(output, &mut first, key, value.get()) .map_err(JsErrorBox::from_err)?; } } if evidence .is_some_and(|e| e <= fast_registry_json::TrustEvidence::TrustedPublisher) { // `TrustedPublisher` already means the source had provenance too. The slim // cache keeps only presence markers because the resolver only ranks signal // presence, not the full registry attestation payload. output.extend_from_slice(if first { br#""attestations":{"provenance":true}"# } else { br#","attestations":{"provenance":true}"# }); } output.push(b'}'); Ok(()) } fn write_string_map<'a>( output: &mut Vec<u8>, entries: impl Iterator<Item = (&'a &'a str, &'a &'a str)>, ) -> Result<(), serde_json::Error> { output.push(b'{'); let mut first = true; for (key, value) in entries { write_json_property_name(output, &mut first, key)?; serde_json::to_writer(&mut *output, value)?; } output.push(b'}'); Ok(()) } fn write_raw_property( output: &mut Vec<u8>, first: &mut bool, key: &str, value: &str, ) -> Result<(), serde_json::Error> { write_json_property_name(output, first, key)?; output.extend_from_slice(value.as_bytes()); Ok(()) } fn write_json_property_name( output: &mut Vec<u8>, first: &mut bool, key: &str, ) -> Result<(), serde_json::Error> { if *first { *first = false; } else { output.push(b','); } serde_json::to_writer(&mut *output, key)?; output.push(b':'); Ok(()) } const NPM_PACKAGE_SYNC_LOCK_FILENAME: &str = ".deno_sync_lock"; #[derive(Debug, thiserror::Error, deno_error::JsError)] pub enum WithFolderSyncLockError { #[class(inherit)] #[error("Error creating '{path}'")] CreateDir { path: PathBuf, #[source] #[inherit] source: std::io::Error, }, #[class(inherit)] #[error( "Error creating package sync lock file at '{path}'. Maybe try manually deleting this folder." )] CreateLockFile { path: PathBuf, #[source] #[inherit] source: std::io::Error, }, #[class(inherit)] #[error(transparent)] Action(#[from] JsErrorBox), #[class(generic)] #[error( "Failed setting up package cache directory for {package}, then failed cleaning it up.\n\nOriginal error:\n\n{error}\n\nRemove error:\n\n{remove_error}\n\nPlease manually delete this folder or you will run into issues using this package in the future:\n\n{output_folder}" )] SetUpPackageCacheDir { package: Box<PackageNv>, error: Box<WithFolderSyncLockError>, remove_error: std::io::Error, output_folder: PathBuf, }, } fn with_folder_sync_lock( sys: &(impl FsCreateDirAll + FsOpen + FsRemoveDirAll + FsRemoveFile), package: &PackageNv, output_folder: &Path, action: impl FnOnce() -> Result<(), JsErrorBox>, ) -> Result<(), WithFolderSyncLockError> { fn inner( sys: &(impl FsCreateDirAll + FsOpen + FsRemoveFile), output_folder: &Path, action: impl FnOnce() -> Result<(), JsErrorBox>, ) -> Result<(), WithFolderSyncLockError> { sys.fs_create_dir_all(output_folder).map_err(|source| { WithFolderSyncLockError::CreateDir { path: output_folder.to_path_buf(), source, } })?; // This sync lock file is a way to ensure that partially created // npm package directories aren't considered valid. This could maybe // be a bit smarter in the future to not bother extracting here // if another process has taken the lock in the past X seconds and // wait for the other process to finish (it could try to create the // file with `create_new(true)` then if it exists, check the metadata // then wait until the other process finishes with a timeout), but // for now this is good enough. let sync_lock_path = output_folder.join(NPM_PACKAGE_SYNC_LOCK_FILENAME); let mut open_options = sys_traits::OpenOptions::new(); open_options.write = true; open_options.create = true; open_options.truncate = false; match sys.fs_open(&sync_lock_path, &open_options) { Ok(_) => { action()?; // extraction succeeded, so only now delete this file let _ignore = sys.fs_remove_file(&sync_lock_path); Ok(()) } Err(err) => Err(WithFolderSyncLockError::CreateLockFile { path: output_folder.to_path_buf(), source: err, }), } } match inner(sys, output_folder, action) { Ok(()) => Ok(()), Err(err) => { if let Err(remove_err) = sys.fs_remove_dir_all(output_folder) && remove_err.kind() != std::io::ErrorKind::NotFound { return Err(WithFolderSyncLockError::SetUpPackageCacheDir { package: Box::new(package.clone()), error: Box::new(err), remove_error: remove_err, output_folder: output_folder.to_path_buf(), }); } Err(err) } } } #[cfg(test)] mod tests { use super::*; #[test] fn slim_package_info_bytes_round_trips_version_info_fields() { let input = br#"{ "name":"pkg", "readme":"large", "dist-tags":{"latest":"1.0.0"}, "versions":{ "1.0.0":{ "version":"1.0.0", "exports":{"./x":"./x.js"}, "scripts":{"postinstall":"node postinstall.js","test":"node test.js"}, "bin":{"pkg":"./bin.js"}, "dependencies":{"dep":"^1.0.0"}, "bundleDependencies":["bundled-dep"], "bundledDependencies":["bundled-alias-dep"], "optionalDependencies":{"optional-dep":"^2.0.0"}, "peerDependencies":{"peer-dep":"^3.0.0"}, "peerDependenciesMeta":{"peer-dep":{"optional":true}}, "os":["darwin"], "cpu":["arm64"], "deprecated":"use something else", "dist":{ "tarball":"https://example.com/pkg.tgz", "shasum":"abc123", "integrity":"sha512-test", "fileCount":100, "attestations":{"provenance":{"large":true}} }, "_npmUser":{"trustedPublisher":{"large":true},"name":"ignored"} } }, "time":{"1.0.0":"2026-01-01T00:00:00.000Z"} }"#; let original = deno_npm::registry::NpmPackageInfo::from_packument_slice(input).unwrap(); let output = slim_package_info_bytes(input, Some("etag"), true).unwrap(); let reparsed = deno_npm::registry::NpmPackageInfo::from_packument_slice(&output) .unwrap(); let version = Version::parse_from_npm("1.0.0").unwrap(); let mut expected = original.versions.get(&version).unwrap().clone(); expected.scripts.clear(); expected.has_install_script = Some(true); assert_eq!(reparsed.name, original.name); assert_eq!(reparsed.dist_tags, original.dist_tags); assert_eq!(reparsed.time, original.time); assert_eq!(reparsed.versions.get(&version), Some(&expected)); let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); let version_json = &value["versions"]["1.0.0"]; assert_eq!(value["name"], "pkg"); assert_eq!(value["_deno.etag"], "etag"); assert_eq!(value["_deno.packumentFormat"], "full"); assert_eq!(version_json["hasInstallScript"], true); assert_eq!(version_json["_npmUser"]["trustedPublisher"], true); assert!(version_json.get("exports").is_none()); assert!(version_json.get("scripts").is_none()); assert!(version_json["dist"].get("fileCount").is_none()); assert!(value.get("readme").is_none()); } #[test] fn slim_package_info_bytes_full_packument_marker() { // a registry response with no time data at all let input = br#"{ "name":"pkg", "dist-tags":{"latest":"1.0.0"}, "versions":{"1.0.0":{"version":"1.0.0"}} }"#; // abbreviated fetches don't write the marker let output = slim_package_info_bytes(input, None, false).unwrap(); let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); assert!(value.get("_deno.packumentFormat").is_none()); let (_, cache_metadata) = deno_npm::registry::NpmPackageInfo::from_packument_bytes_with_cache_info( output, ) .unwrap(); assert!(!cache_metadata.full_packument); // full packument fetches record the marker even when the registry // provides no `time` data, so it doesn't get re-fetched on every // process start (see #35761) let output = slim_package_info_bytes(input, None, true).unwrap(); let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); assert_eq!(value["_deno.packumentFormat"], "full"); let (info, cache_metadata) = deno_npm::registry::NpmPackageInfo::from_packument_bytes_with_cache_info( output, ) .unwrap(); assert!(cache_metadata.full_packument); assert!(info.time.is_empty()); // a marker nested inside a version object is ignored let nested = br#"{ "name":"pkg", "dist-tags":{"latest":"1.0.0"}, "versions":{"1.0.0":{"version":"1.0.0","_deno.packumentFormat":"full"}} }"#; let (_, cache_metadata) = deno_npm::registry::NpmPackageInfo::from_packument_bytes_with_cache_info( nested.to_vec(), ) .unwrap(); assert!(!cache_metadata.full_packument); } #[test] fn slim_package_info_bytes_tolerates_malformed_scripts() { // Some historical packuments contain a `scripts` field that isn't a map // (e.g. npm:jsox@1.1.121 has `"scripts":""`). Loading the packument should // succeed and treat it as having no lifecycle scripts (see #36323). let input = br#"{ "name":"pkg", "dist-tags":{"latest":"1.0.0"}, "versions":{ "1.0.0":{"version":"1.0.0","scripts":""}, "1.0.1":{"version":"1.0.1","scripts":[]}, "1.0.2":{"version":"1.0.2","scripts":{"postinstall":"node x.js"}} }, "time":{"1.0.0":"2026-01-01T00:00:00.000Z"} }"#; let output = slim_package_info_bytes(input, None, false).unwrap(); let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); // malformed `scripts` values are dropped and don't mark an install script assert!(value["versions"]["1.0.0"].get("hasInstallScript").is_none()); assert!(value["versions"]["1.0.1"].get("hasInstallScript").is_none()); // a well-formed `scripts` map with a lifecycle hook is still detected assert_eq!(value["versions"]["1.0.2"]["hasInstallScript"], true); // the whole packument still parses back into an NpmPackageInfo deno_npm::registry::NpmPackageInfo::from_packument_bytes(output).unwrap(); } }