#[path = "common/registry.rs"] mod fixture; use avril_runtime::registry::{Options, PackageStore, npm::parse_npm_specifier}; use fixture::{Registry, success}; use futures::executor::block_on; use std::sync::Arc; fn offline_frozen_options(server: &Registry, directory: &std::path::Path) -> Options { let mut options = server.options(directory); options.offline = true; options.frozen = true; options } fn parity_files(version: &str) -> Vec<(&'static str, String)> { vec![ ( "package.json", format!( "{{\"name\": \"parity\", \"version\": \"{version}\", \"type\": \"module\", \"exports\": {{\".\": \"./index.js\", \"./sub\": \"./sub.js\"}}}}" ), ), ("index.js", format!("export default 'v{version}';")), ("sub.js", format!("export default 'sub-{version}';")), ] } fn info_cli(server: &Registry, directory: &std::path::Path) -> serde_json::Value { let output = server .command(directory, "info") .arg("main.js") .output() .unwrap(); serde_json::from_str(&success(&output)).unwrap() } /// Project-independent identity of the resolved npm modules: the local /// materialization paths differ per project by construction. fn parity_packages(info: &serde_json::Value) -> Vec { let mut packages: Vec<_> = info["modules"] .as_object() .unwrap() .values() .filter_map(|module| { let package = &module["package"]; if package.get("name").and_then(|name| name.as_str()) != Some("parity") { return None; } let mut package = package.clone(); package.as_object_mut().unwrap().remove("file"); Some(package) }) .collect(); packages.sort_by(|a, b| a["instance"].as_str().cmp(&b["instance"].as_str())); packages } fn leftpad_files(version: &str) -> Vec<(&'static str, String)> { vec![ ( "package.json", format!( "{{\"name\": \"leftpad\", \"version\": \"{version}\", \"type\": \"module\", \"exports\": {{\".\": \"./index.js\"}}}}" ), ), ("index.js", format!("export default {version};")), ] } fn files_ref<'a>(files: &'a [(&'a str, String)]) -> Vec<(&'a str, &'a str)> { files .iter() .map(|(path, source)| (*path, source.as_str())) .collect() } #[test] fn selects_ranges_tags_and_latest_and_pins_verified_files() { let server = Registry::new(); server.publish_npm("leftpad", "1.0.0", &files_ref(&leftpad_files("1.0.0"))); server.publish_npm("leftpad", "1.1.0", &files_ref(&leftpad_files("1.1.0"))); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!(parse_npm_specifier("npm:leftpad").is_ok()); assert!(parse_npm_specifier("npm:leftpad/sub").is_err()); assert!(parse_npm_specifier("npm:../evil").is_err()); let latest = block_on(store.npm_acquire("npm:leftpad")).unwrap(); assert_eq!(latest.identity.version, "1.1.0"); assert_eq!(latest.identity.name, "leftpad"); assert!( latest .dir .starts_with(dir.path().join(".avril").join("npm")) ); assert!(!latest.dir.to_string_lossy().contains("node_modules")); assert_eq!( std::fs::read_to_string(latest.dir.join("index.js")).unwrap(), "export default 1.1.0;" ); assert_eq!( latest.package.get("name").and_then(|v| v.as_str()), Some("leftpad") ); let ranged = block_on(store.npm_acquire("npm:leftpad@^1.0.0")).unwrap(); assert_eq!(ranged.identity.version, "1.1.0"); assert_eq!(ranged.dir, latest.dir); let exact = block_on(store.npm_acquire("npm:leftpad@1.0.0")).unwrap(); assert_eq!(exact.identity.version, "1.0.0"); assert_ne!(exact.dir, latest.dir); store.save().unwrap(); let lock_text = std::fs::read_to_string(dir.path().join("avril.lock")).unwrap(); assert!(!lock_text.contains(dir.path().to_string_lossy().as_ref())); assert!(!lock_text.contains("node_modules")); let lock = fixture::read_lock(dir.path()); assert_eq!(lock["version"], 2); assert_eq!(lock["npm"]["specifiers"]["npm:leftpad"], "1.1.0"); assert!( lock["npm"]["packages"] .as_object() .unwrap() .values() .all(|pin| pin["tarball"] .as_str() .unwrap() .starts_with(server.url.as_str()) && pin["integrity"].as_str().unwrap().starts_with("sha512-") && pin["manifest"] .as_str() .is_some_and(|digest| digest.starts_with("sha256-")) && pin.get("files").is_none()), "compact pins carry one manifest digest, never the file map" ); assert_eq!( std::fs::read_to_string(dir.path().join(".avril").join(".gitignore")).unwrap(), "*\n" ); } #[test] fn updates_reselect_ranges_but_frozen_requires_the_exact_request() { let server = Registry::new(); server.publish_npm("leftpad", "1.0.0", &files_ref(&leftpad_files("1.0.0"))); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:leftpad@^1")).unwrap(); store.save().unwrap(); server.publish_npm("leftpad", "1.1.0", &files_ref(&leftpad_files("1.1.0"))); let mut options = server.options(dir.path()); options.update = true; let store = PackageStore::new(fixture::runtime_handle(), options).unwrap(); assert_eq!( block_on(store.npm_acquire("npm:leftpad@^1")) .unwrap() .identity .version, "1.1.0" ); store.save().unwrap(); let mut options = server.options(dir.path()); options.frozen = true; let store = PackageStore::new(fixture::runtime_handle(), options).unwrap(); let error = block_on(store.npm_acquire("npm:leftpad@~1.1.0")) .unwrap_err() .to_string(); assert!(error.contains("Frozen lockfile is missing"), "{error}"); } #[test] fn provenance_urls_encode_literal_file_names() { use avril_runtime::registry::npm::npm_virtual_module; let registry = url::Url::parse("https://registry.npmjs.org/").unwrap(); let url = npm_virtual_module(®istry, "example", "1.0.0", "%2e%2e/asset#name?.js").unwrap(); assert_eq!( url.as_str(), "https://registry.npmjs.org/example/1.0.0/%252e%252e/asset%23name%3F.js" ); assert!(url.query().is_none()); assert!(url.fragment().is_none()); } #[test] fn warmed_tree_runs_offline_frozen_without_network_or_global_cache() { let server = Registry::new(); server.publish_npm( "tiny", "2.0.0", &[ ( "package.json", "{\"name\": \"tiny\", \"version\": \"2.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let before_requests = server.requests.lock().unwrap().len(); block_on(store.npm_acquire("npm:tiny")).unwrap(); store.save().unwrap(); let warmed_requests = server.requests.lock().unwrap().len(); assert!(warmed_requests > before_requests); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); let mut offline = offline_frozen_options(&server, dir.path()); offline.cache = dir.path().join("fresh-cache-that-must-stay-empty"); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let artifact = block_on(store.npm_acquire("npm:tiny")).unwrap(); assert_eq!( std::fs::read_to_string(artifact.dir.join("index.js")).unwrap(), "export default 1;" ); assert_eq!( server.requests.lock().unwrap().len(), warmed_requests, "frozen offline hits must not touch the network" ); assert!( !dir.path().join("fresh-cache-that-must-stay-empty").exists(), "npm must not depend on the global cache" ); store.save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); } #[test] fn corrupted_trees_fail_closed_while_absent_trees_materialize() { let server = Registry::new(); server.publish_npm( "mut", "1.0.0", &[ ( "package.json", "{\"name\": \"mut\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:mut")).unwrap(); store.save().unwrap(); std::fs::write(artifact.dir.join("index.js"), "tampered").unwrap(); // A corrupted existing tree fails closed: it is never deleted or // repaired in place, so publication stays atomic and immutable. let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:mut")) .unwrap_err() .to_string() .contains("corrupted") ); assert_eq!( std::fs::read_to_string(artifact.dir.join("index.js")).unwrap(), "tampered" ); // An absent tree materializes from the verified cache instead. std::fs::remove_dir_all(&artifact.dir).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let restored = block_on(store.npm_acquire("npm:mut")).unwrap(); assert_eq!( std::fs::read_to_string(restored.dir.join("index.js")).unwrap(), "export default 1;" ); store.save().unwrap(); // A tampered legacy pin must fail closed instead of repairing: rewrite // the entry in legacy wire form with one file hash pointed at other // bytes, so the tree no longer matches the authenticated map. let mut lock_value = fixture::read_lock(dir.path()); let pin = lock_value["npm"]["packages"] .as_object_mut() .unwrap() .values_mut() .next() .unwrap(); let sidecar = dir.path().join(".avril").join("npm").join(".manifests"); let sidecar = std::fs::read_dir(&sidecar) .unwrap() .next() .unwrap() .unwrap() .path(); let mut files: serde_json::Value = serde_json::from_slice(&std::fs::read(&sidecar).unwrap()).unwrap(); files["index.js"] = serde_json::Value::String(avril_runtime::registry::checksum(b"tampered")); *pin = serde_json::json!({ "tarball": pin["tarball"].clone(), "integrity": pin["integrity"].clone(), "files": files, }); std::fs::write(dir.path().join("avril.lock"), format!("{lock_value}\n")).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:mut")) .unwrap_err() .to_string() .contains("corrupted") ); } #[test] fn malicious_archives_are_rejected() { let server = Registry::new(); // tar::Builder refuses `..` itself, so the traversal entry is crafted as // raw ustar blocks: the extractor must still reject it. let traversal = { use flate2::{Compression, write::GzEncoder}; use std::io::Write; fn ustar_bytes(name: &str, data: &[u8]) -> Vec { let mut header = [0u8; 512]; header[..name.len()].copy_from_slice(name.as_bytes()); header[100..108].copy_from_slice(format!("{:07o}\0", 0o644).as_bytes()); header[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes()); header[136..148].copy_from_slice(format!("{:011o}\0", 0).as_bytes()); header[156] = b'0'; header[257..262].copy_from_slice(b"ustar"); header[263..265].copy_from_slice(b"00"); let sum: u32 = header.iter().map(|byte| *byte as u32).sum::() + 8 * 32; header[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes()); let mut out = header.to_vec(); out.extend_from_slice(data); while out.len() % 512 != 0 { out.push(0); } out } let package = "{\"name\": \"evil\", \"version\": \"1.0.0\"}"; let mut tar_data = ustar_bytes("package/package.json", package.as_bytes()); tar_data.extend_from_slice(&ustar_bytes("package/../evil.js", b"evil")); tar_data.extend_from_slice(&[0u8; 1024]); let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(&tar_data).unwrap(); encoder.finish().unwrap() }; let integrity = Registry::tarball_integrity(&traversal); server.publish_npm_dist("evil", "1.0.0", traversal, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:evil")) .unwrap_err() .to_string() .contains("Invalid npm file path") ); let symlink = { use flate2::{Compression, write::GzEncoder}; use std::io::Write; let mut tar_data = Vec::new(); { let mut tar = tar::Builder::new(&mut tar_data); let package = "{\"name\": \"link\", \"version\": \"1.0.0\"}"; let mut header = tar::Header::new_gnu(); header.set_mode(0o644); header.set_size(package.len() as u64); header.set_cksum(); tar.append_data(&mut header, "package/package.json", package.as_bytes()) .unwrap(); let mut header = tar::Header::new_gnu(); header.set_entry_type(tar::EntryType::Symlink); header.set_size(0); header.set_cksum(); tar.append_link(&mut header, "package/link.js", "/etc/passwd") .unwrap(); tar.finish().unwrap(); } let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(&tar_data).unwrap(); encoder.finish().unwrap() }; let integrity = Registry::tarball_integrity(&symlink); server.publish_npm_dist("link", "1.0.0", symlink, &integrity, None, true); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:link")) .unwrap_err() .to_string() .contains("only regular files and directories") ); } #[test] fn concurrent_acquisitions_share_a_single_download() { let server = Registry::new(); server.publish_npm( "shared", "3.0.0", &[ ( "package.json", "{\"name\": \"shared\", \"version\": \"3.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap()); // Same-specifier concurrency shares one download via the inflight map; // distinct specifiers resolving to one version may still fetch twice and // converge on the lock through the per-package file lock. let (a, b) = block_on(futures::future::join( store.npm_acquire("npm:shared"), store.npm_acquire("npm:shared"), )); assert!(a.is_ok() && b.is_ok()); let tarballs = server .requests .lock() .unwrap() .iter() .filter(|path| path.ends_with(".tgz")) .count(); assert_eq!(tarballs, 1); store.save().unwrap(); } #[test] fn shasum_fallback_works_and_external_tarballs_are_rejected() { let server = Registry::new(); let tarball = Registry::tarball_bytes(&[ ( "package.json", "{\"name\": \"legacy\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ]); let shasum = { use sha1::Digest; let digest = sha1::Sha1::digest(&tarball); digest .iter() .map(|byte| format!("{byte:02x}")) .collect::() }; server.publish_npm_dist( "legacy", "1.0.0", tarball, "sha512-invalid-will-be-ignored", Some(&shasum), true, ); // Repair the fixture entry to carry only the shasum fallback. { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/legacy"]).unwrap(); packument["versions"]["1.0.0"]["dist"] .as_object_mut() .unwrap() .remove("integrity"); files.insert("/legacy".into(), serde_json::to_vec(&packument).unwrap()); } let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:legacy")).unwrap(); assert!(artifact.integrity.starts_with("sha1-")); server.publish_npm( "ext", "1.0.0", &[( "package.json", "{\"name\": \"ext\", \"version\": \"1.0.0\"}", )], ); { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/ext"]).unwrap(); packument["versions"]["1.0.0"]["dist"]["tarball"] = serde_json::Value::String("https://example.com/ext-1.0.0.tgz".into()); files.insert("/ext".into(), serde_json::to_vec(&packument).unwrap()); } let tgz_before = server .requests .lock() .unwrap() .iter() .filter(|path| path.ends_with(".tgz")) .count(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:ext")) .unwrap_err() .to_string() .contains("escapes the registry") ); // Learning the external tarball URL costs one packument fetch; the // tarball itself must never be fetched. assert_eq!( server .requests .lock() .unwrap() .iter() .filter(|path| path.ends_with(".tgz")) .count(), tgz_before ); } #[test] fn denied_packages_are_not_fetched() { use avril_runtime::caps::{Capabilities, Category, Initial}; let server = Registry::new(); server.publish_npm( "secret", "1.0.0", &[( "package.json", "{\"name\": \"secret\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let mut initial = Initial::new(true); initial .grant(Category::JsExec, true, "https://jsr.io/", dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_capabilities(caps); assert!( block_on(store.npm_acquire("npm:secret")) .unwrap_err() .to_string() .contains("PermissionDenied") ); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn locks_merge_and_prune_npm_pins() { let server = Registry::new(); server.publish_npm( "alpha", "1.0.0", &[( "package.json", "{\"name\": \"alpha\", \"version\": \"1.0.0\"}", )], ); server.publish_npm( "beta", "1.0.0", &[( "package.json", "{\"name\": \"beta\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:alpha")).unwrap(); block_on(store.npm_acquire("npm:beta")).unwrap(); store.save().unwrap(); assert_eq!( fixture::read_lock(dir.path())["npm"]["specifiers"] .as_object() .unwrap() .len(), 2 ); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:alpha")).unwrap(); store.prune().unwrap(); let lock = fixture::read_lock(dir.path()); assert!(lock["npm"]["specifiers"].get("npm:alpha").is_some()); assert!(lock["npm"]["specifiers"].get("npm:beta").is_none()); } #[test] fn cold_frozen_pins_download_without_metadata_queries() { let server = Registry::new(); server.publish_npm( "cold", "1.2.3", &[ ( "package.json", "{\"name\": \"cold\", \"version\": \"1.2.3\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:cold@^1")).unwrap(); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); // Cold: remove the tree and the tarball cache but keep the lock. std::fs::remove_dir_all(&artifact.dir).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); let mut frozen = server.options(dir.path()); frozen.frozen = true; let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap(); let restored = block_on(store.npm_acquire("npm:cold@^1")).unwrap(); assert_eq!(restored.identity.version, "1.2.3"); let paths = server.requests.lock().unwrap().clone(); assert!( paths[warmed..].iter().all(|path| path.ends_with(".tgz")), "frozen pins must not query mutable packument metadata: {paths:?}" ); assert!( paths[warmed..].iter().any(|path| path.ends_with(".tgz")), "frozen pins still download their artifacts: {paths:?}" ); } #[test] fn offline_update_selects_from_cached_metadata() { let server = Registry::new(); server.publish_npm( "cached", "1.0.0", &[( "package.json", "{\"name\": \"cached\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:cached@1.0.0")).unwrap(); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); // update clears specifiers; offline forces selection from cached metadata. let mut offline = server.options(dir.path()); offline.update = true; offline.offline = true; let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let artifact = block_on(store.npm_acquire("npm:cached@*")).unwrap(); assert_eq!(artifact.identity.version, "1.0.0"); assert_eq!(server.requests.lock().unwrap().len(), warmed); } #[test] fn new_ranges_reuse_pinned_artifacts_without_metadata() { let server = Registry::new(); server.publish_npm( "reuse", "2.1.0", &[ ( "package.json", "{\"name\": \"reuse\", \"version\": \"2.1.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let first = block_on(store.npm_acquire("npm:reuse@^2.0.0")).unwrap(); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let second = block_on(store.npm_acquire("npm:reuse@~2.1.0")).unwrap(); assert_eq!(second.dir, first.dir); assert_eq!( server.requests.lock().unwrap().len(), warmed, "an already-pinned version needs no metadata round trip" ); store.save().unwrap(); let lock = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["specifiers"]["npm:reuse@~2.1.0"], "2.1.0"); } #[test] fn archive_directories_materialize_nested_files() { let server = Registry::new(); let tarball = { use flate2::{Compression, write::GzEncoder}; use std::io::Write; let mut tar_data = Vec::new(); { let mut tar = tar::Builder::new(&mut tar_data); tar.append_dir("package/", ".").unwrap(); tar.append_dir("package/lib/", ".").unwrap(); for (path, data) in [ ( "package/package.json", "{\"name\": \"nested\", \"version\": \"1.0.0\"}", ), ("package/index.js", "export * from './lib/util.js';"), ("package/lib/util.js", "export default 1;"), ] { let mut header = tar::Header::new_gnu(); header.set_mode(0o644); header.set_size(data.len() as u64); header.set_cksum(); tar.append_data(&mut header, path, data.as_bytes()).unwrap(); } tar.finish().unwrap(); } let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(&tar_data).unwrap(); encoder.finish().unwrap() }; let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("nested", "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:nested")).unwrap(); assert_eq!( std::fs::read_to_string(artifact.dir.join("lib").join("util.js")).unwrap(), "export default 1;" ); assert_eq!( artifact.read_file(dir.path(), "lib/util.js").unwrap(), b"export default 1;" ); assert!(!artifact.files.contains_key("lib")); } #[test] fn warmed_scan_checks_nested_siblings_and_unused_files() { let server = Registry::new(); server.publish_npm( "nested-files", "1.0.0", &[ ( "package.json", r#"{"name":"nested-files","version":"1.0.0"}"#, ), ("lib/entry.js", "export const value = 1;"), ("lib/sibling.js", "export const sibling = 2;"), ("lib/README.txt", "not executable code"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:nested-files")).unwrap(); store.save().unwrap(); std::fs::write(artifact.dir.join("lib/README.txt"), "tampered").unwrap(); let offline = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); assert!( block_on(store.npm_acquire("npm:nested-files")) .unwrap_err() .to_string() .contains("corrupted") ); } #[cfg(unix)] #[test] fn warmed_scan_rejects_nested_directory_symlinks() { use std::os::unix::fs::symlink; let server = Registry::new(); server.publish_npm( "nested-link", "1.0.0", &[ ( "package.json", r#"{"name":"nested-link","version":"1.0.0"}"#, ), ("lib/entry.js", "export const value = 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:nested-link")).unwrap(); store.save().unwrap(); let outside = dir.path().join("outside"); std::fs::create_dir(&outside).unwrap(); std::fs::write(outside.join("entry.js"), "export const value = 1;").unwrap(); std::fs::remove_dir_all(artifact.dir.join("lib")).unwrap(); symlink(outside, artifact.dir.join("lib")).unwrap(); let offline = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); assert!( block_on(store.npm_acquire("npm:nested-link")) .unwrap_err() .to_string() .contains("corrupted") ); } #[test] fn acquisition_rechecks_tree_after_materialization_callback() { use avril_runtime::registry::ProgressEvent; use std::sync::atomic::{AtomicBool, Ordering}; let server = Registry::new(); server.publish_npm( "finish-check", "1.0.0", &[ ( "package.json", r#"{"name":"finish-check","version":"1.0.0"}"#, ), ("index.js", "export const value = 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_owned(); let tampered = Arc::new(AtomicBool::new(false)); let callback_tampered = tampered.clone(); let sink: Arc = Arc::new(move |event| { if matches!(event, ProgressEvent::NpmMaterialized { name, .. } if name == "finish-check") { let package_root = root.join(".avril/npm/default"); let registry_dir = std::fs::read_dir(package_root) .unwrap() .next() .unwrap() .unwrap() .path(); std::fs::write(registry_dir.join("finish-check/1.0.0/index.js"), "tampered").unwrap(); callback_tampered.store(true, Ordering::SeqCst); } }); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_progress(sink); let error = block_on(store.npm_acquire("npm:finish-check")) .expect_err("acquisition must reject callback tampering"); assert!( tampered.load(Ordering::SeqCst), "callback did not tamper with the file" ); assert!( error.to_string().contains("integrity mismatch: index.js"), "unexpected acquisition error: {error}" ); } #[test] fn acquired_artifact_rechecks_bytes_when_read() { let server = Registry::new(); server.publish_npm( "read-check", "1.0.0", &[ ("package.json", r#"{"name":"read-check","version":"1.0.0"}"#), ("lib/entry.js", "export const value = 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:read-check")).unwrap(); assert_eq!( artifact.read_file(dir.path(), "lib/entry.js").unwrap(), b"export const value = 1;" ); std::fs::write(artifact.dir.join("lib/entry.js"), "tampered").unwrap(); let error = artifact.read_file(dir.path(), "lib/entry.js").unwrap_err(); assert!( error .to_string() .contains("integrity mismatch: lib/entry.js"), "unexpected read error: {error}" ); } #[test] fn malicious_tags_and_versions_never_reach_the_filesystem() { let server = Registry::new(); server.publish_npm( "tagged", "1.0.0", &[( "package.json", "{\"name\": \"tagged\", \"version\": \"1.0.0\"}", )], ); { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/tagged"]).unwrap(); packument["dist-tags"]["evil"] = serde_json::Value::String("../../evil".into()); packument["versions"]["../../evil"] = serde_json::json!({ "name": "tagged", "version": "../../evil", "dist": {"tarball": "http://127.0.0.1:9/x.tgz", "shasum": "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, }); files.insert("/tagged".into(), serde_json::to_vec(&packument).unwrap()); } let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let before = server.requests.lock().unwrap().len(); assert!( block_on(store.npm_acquire("npm:tagged@evil")) .unwrap_err() .to_string() .contains("invalid version") ); assert!( server.requests.lock().unwrap()[before..] .iter() .all(|path| !path.ends_with(".tgz")), "invalid selected versions must never be fetched" ); assert!( !dir.path() .join(".avril") .join("npm") .join("default") .exists() ); } #[test] #[cfg(unix)] fn warmed_readonly_trees_need_no_writes() { use std::os::unix::fs::PermissionsExt; let server = Registry::new(); server.publish_npm( "ro", "1.0.0", &[ ("package.json", "{\"name\": \"ro\", \"version\": \"1.0.0\"}"), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:ro")).unwrap(); store.save().unwrap(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); fn readonly(path: &std::path::Path) { for entry in walkdir(path) { std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).unwrap(); } std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o555)).unwrap(); } fn walkdir(path: &std::path::Path) -> Vec { let mut out = Vec::new(); for entry in std::fs::read_dir(path).unwrap() { let entry = entry.unwrap().path(); if entry.is_dir() { out.extend(walkdir(&entry)); } out.push(entry); } out } let npm_tree = dir.path().join(".avril").join("npm"); readonly(&npm_tree); let offline = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let hit = block_on(store.npm_acquire("npm:ro")).unwrap(); assert_eq!(hit.dir, artifact.dir); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); for entry in walkdir(&npm_tree) { let _ = std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o755)); } let _ = std::fs::set_permissions(&npm_tree, std::fs::Permissions::from_mode(0o755)); } #[test] fn registries_are_isolated_on_disk_and_in_locks() { let first = Registry::new(); first.publish_npm( "iso", "1.0.0", &[( "package.json", "{\"name\": \"iso\", \"version\": \"1.0.0\"}", )], ); let second = Registry::new(); second.publish_npm( "iso", "9.9.9", &[( "package.json", "{\"name\": \"iso\", \"version\": \"9.9.9\"}", )], ); let dir = tempfile::tempdir().unwrap(); let mut a = first.options(dir.path()); a.lock = dir.path().join("a.lock"); let store = PackageStore::new(fixture::runtime_handle(), a).unwrap(); let artifact_a = block_on(store.npm_acquire("npm:iso")).unwrap(); store.save().unwrap(); let mut b = second.options(dir.path()); b.lock = dir.path().join("b.lock"); let store = PackageStore::new(fixture::runtime_handle(), b).unwrap(); let artifact_b = block_on(store.npm_acquire("npm:iso")).unwrap(); assert_eq!(artifact_b.identity.version, "9.9.9"); assert_ne!(artifact_a.dir, artifact_b.dir); // A lock pinned to one registry is rejected under another. let mut mismatched = first.options(dir.path()); mismatched.lock = dir.path().join("a.lock"); mismatched.npm_registry = second.url.clone(); assert!( PackageStore::new(fixture::runtime_handle(), mismatched) .map(|_| ()) .unwrap_err() .to_string() .contains("npm registry") ); } #[test] fn corrupted_caches_fail_closed() { let server = Registry::new(); server.publish_npm( "cache", "1.0.0", &[ ( "package.json", "{\"name\": \"cache\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:cache")).unwrap(); store.save().unwrap(); let mut blob = None; let artifacts = dir.path().join(".avril").join("npm").join(".artifacts"); for entry in std::fs::read_dir(artifacts.join("tarballs")).unwrap() { let path = entry.unwrap().path(); blob = Some(path); } let blob = blob.unwrap(); let mut bytes = std::fs::read(&blob).unwrap(); let middle = bytes.len() / 2; bytes[middle] ^= 0xff; std::fs::write(&blob, bytes).unwrap(); // Evict the tree so the corrupt cache entry is actually read. for entry in std::fs::read_dir(dir.path().join(".avril").join("npm").join("default")).unwrap() { std::fs::remove_dir_all(entry.unwrap().path()).unwrap(); } let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:cache")) .unwrap_err() .to_string() .contains("integrity mismatch") ); } #[test] fn version_and_file_grants_authorize_without_tarball_urls() { use avril_runtime::caps::{Capabilities, Category, Initial}; let server = Registry::new(); server.publish_npm( "scoped", "4.0.0", &[ ( "package.json", "{\"name\": \"scoped\", \"version\": \"4.0.0\"}", ), ("index.js", "export default 1;"), ("secret.js", "export default 2;"), ], ); let dir = tempfile::tempdir().unwrap(); let version_url = format!("{}scoped/4.0.0/", server.url); let mut initial = Initial::new(true); initial .grant(Category::JsExec, false, &version_url, dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_capabilities(caps); let artifact = block_on(store.npm_acquire("npm:scoped")).unwrap(); assert_eq!(artifact.identity.version, "4.0.0"); // A grant on one file alone still authorizes the package fetch. let dir = tempfile::tempdir().unwrap(); let file_url = format!("{}scoped/4.0.0/index.js", server.url); let mut initial = Initial::new(true); initial .grant(Category::JsExec, false, &file_url, dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); // Seed the second project from the first server without extra grants. let mut options = server.options(dir.path()); options.cache = dir.path().join("cache"); let store = PackageStore::new(fixture::runtime_handle(), options) .unwrap() .with_capabilities(caps); let artifact = block_on(store.npm_acquire("npm:scoped")).unwrap(); assert_eq!( artifact.read_file(dir.path(), "index.js").unwrap(), b"export default 1;" ); // A denied sibling does not block the package, and revoking the version // base blocks later acquisitions without further fetches. let dir = tempfile::tempdir().unwrap(); let mut initial = Initial::new(true); initial .grant(Category::JsExec, false, &version_url, dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_capabilities(caps.clone()); block_on(store.npm_acquire("npm:scoped")).unwrap(); let fetched = server.requests.lock().unwrap().len(); caps.pledge(vec![(Category::JsExec, None, vec![version_url.clone()])]) .unwrap(); assert!( block_on(store.npm_acquire("npm:scoped")) .unwrap_err() .to_string() .contains("PermissionDenied") ); assert_eq!(server.requests.lock().unwrap().len(), fetched); } #[test] fn failed_prepares_do_not_leak_acquisitions_into_save_graph() { use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest}; use std::collections::{BTreeMap, BTreeSet, HashSet}; let server = Registry::new(); server.publish_npm( "plug", "1.0.0", &[ ("package.json", "{\"name\": \"plug\", \"version\": \"1.0.0\", \"type\": \"module\", \"peerDependencies\": {\"host\": \"^1.0.0\"}}"), ("index.js", "export {version} from 'host';"), ], ); for version in ["1.0.0", "2.0.0"] { server.publish_npm( "host", version, &[ ( "package.json", &format!( "{{\"name\": \"host\", \"version\": \"{version}\", \"type\": \"module\"}}" ), ), ("index.js", "export const version = 'h';"), ], ); } server.publish_npm( "good", "1.0.0", &[( "package.json", "{\"name\": \"good\", \"version\": \"1.0.0\", \"type\": \"module\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap()); let resolver = NpmResolver::new(store.clone()); let host2 = format!("{}|host|2.0.0", server.url.as_str()); // The conflicting peer fails the graph after its acquisitions recorded // usage; the concurrent success must not save any of that. let (failed, succeeded) = block_on(futures::future::join( resolver.prepare(&[NpmRootRequest { request: "npm:plug@1".into(), context: BTreeMap::from([("host".to_owned(), host2)]), }]), resolver.prepare(&[NpmRootRequest { request: "npm:good@1".into(), context: BTreeMap::new(), }]), )); failed.unwrap_err(); let handle = succeeded.unwrap(); let tracked: BTreeSet = handle.roots.keys().cloned().collect(); store.save_graph(&tracked, &HashSet::new()).unwrap(); let lock: serde_json::Value = fixture::read_lock(dir.path()); let text = serde_json::to_string(&lock).unwrap(); assert!(text.contains("npm:good@1"), "{text}"); assert!(!text.contains("plug"), "{text}"); // Pruning afterwards cannot resurrect the failed acquisitions either. store.prune().unwrap(); let lock: serde_json::Value = fixture::read_lock(dir.path()); let text = serde_json::to_string(&lock).unwrap(); assert!(text.contains("npm:good@1"), "{text}"); assert!(!text.contains("plug"), "{text}"); } #[test] fn save_graph_and_prune_keep_only_successful_contexts() { use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest}; use std::collections::{BTreeMap, BTreeSet, HashSet}; let server = Registry::new(); server.publish_npm( "plug", "1.0.0", &[ ("package.json", "{\"name\": \"plug\", \"version\": \"1.0.0\", \"type\": \"module\", \"peerDependencies\": {\"host\": \"*\"}}"), ("index.js", "export {version} from 'host';"), ], ); for version in ["1.0.0", "2.0.0"] { server.publish_npm( "host", version, &[ ( "package.json", &format!( "{{\"name\": \"host\", \"version\": \"{version}\", \"type\": \"module\"}}" ), ), ("index.js", "export const version = 'h';"), ], ); } let dir = tempfile::tempdir().unwrap(); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap()); let resolver = NpmResolver::new(store.clone()); let context = |version: &str| { BTreeMap::from([( "host".to_owned(), format!("{}|host|{version}", server.url.as_str()), )]) }; let _first = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plug@1".into(), context: context("1.0.0"), }])) .unwrap(); let second = block_on(resolver.prepare(&[NpmRootRequest { request: "npm:plug@1".into(), context: context("2.0.0"), }])) .unwrap(); // Same request, two peer contexts: saving only the second keeps // exactly its roots and instances, never base-wide leftovers. let tracked: BTreeSet = second.roots.keys().cloned().collect(); assert!(!tracked.is_empty()); store.save_graph(&tracked, &HashSet::new()).unwrap(); let lock: serde_json::Value = fixture::read_lock(dir.path()); let roots = lock["npm"]["roots"].as_object().unwrap(); assert_eq!(roots.len(), 1, "{roots:?}"); assert!( roots.keys().next().unwrap().contains("|host|2.0.0"), "{roots:?}" ); let instances = lock["npm"]["instances"].as_object().unwrap(); assert!( instances.keys().all(|id| { let pin = &instances[id]; !serde_json::to_string(pin).unwrap().contains("|host|1.0.0") }), "{instances:?}" ); store.prune().unwrap(); let lock: serde_json::Value = fixture::read_lock(dir.path()); assert_eq!(lock["npm"]["roots"].as_object().unwrap().len(), 1); } #[test] #[cfg(unix)] fn symlinked_npm_parent_fails_closed() { use std::os::unix::fs::symlink; let server = Registry::new(); server.publish_npm( "linkparent", "1.0.0", &[( "package.json", "{\"name\": \"linkparent\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let evil = tempfile::tempdir().unwrap(); symlink(evil.path(), dir.path().join(".avril")).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let error = block_on(store.npm_acquire("npm:linkparent")) .unwrap_err() .to_string(); // No-follow descents reject the swapped parent instead of writing // through it; the exact OS error text is platform dependent. assert!( error.contains("usable") || error.contains("corrupt") || error.contains("project") || error.contains("directory") || error.contains("Directory"), "{error}" ); // Nothing was written through the link. assert!( std::fs::read_dir(evil.path()).unwrap().next().is_none(), "symlinked parent must not be written through" ); } #[test] #[cfg(unix)] fn cache_file_symlink_fails_closed() { use std::os::unix::fs::symlink; let server = Registry::new(); server.publish_npm( "cachelink", "1.0.0", &[ ( "package.json", "{\"name\": \"cachelink\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:cachelink")).unwrap(); store.save().unwrap(); // Replace the tarball cache blob with a symlink, even one pointing at // valid bytes, and evict the tree so the cache is actually read. let mut blob = None; for entry in std::fs::read_dir( dir.path() .join(".avril") .join("npm") .join(".artifacts") .join("tarballs"), ) .unwrap() { blob = Some(entry.unwrap().path()); } let blob = blob.unwrap(); let bytes = std::fs::read(&blob).unwrap(); std::fs::remove_file(&blob).unwrap(); let elsewhere = dir.path().join("elsewhere.tgz"); std::fs::write(&elsewhere, &bytes).unwrap(); symlink(&elsewhere, &blob).unwrap(); std::fs::remove_dir_all(&artifact.dir).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let error = block_on(store.npm_acquire("npm:cachelink")) .unwrap_err() .to_string(); assert!( error.contains("usable") || error.contains("integrity") || error.contains("corrupt"), "{error}" ); } #[test] #[cfg(unix)] fn lock_path_symlink_and_fifo_fail_without_hanging() { use std::os::unix::fs::symlink; let server = Registry::new(); server.publish_npm( "locklink", "1.0.0", &[ ( "package.json", "{\"name\": \"locklink\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:locklink")).unwrap(); store.save().unwrap(); // Verified hits take no lock: removing the lock tree entirely still hits. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".locks")).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!(block_on(store.npm_acquire("npm:locklink")).is_ok()); // A symlinked lock directory fails a miss instead of locking elsewhere. std::fs::remove_dir_all(&artifact.dir).unwrap(); let evil = tempfile::tempdir().unwrap(); symlink( evil.path(), dir.path().join(".avril").join("npm").join(".locks"), ) .unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let error = block_on(store.npm_acquire("npm:locklink")) .unwrap_err() .to_string(); assert!( error.contains("usable") || error.contains("regular") || error.contains("corrupt") || error.contains("lock") || error.contains("directory") || error.contains("Directory"), "{error}" ); assert!(std::fs::read_dir(evil.path()).unwrap().next().is_none()); // A FIFO in place of the lock directory fails promptly instead of hanging. std::fs::remove_file(dir.path().join(".avril").join("npm").join(".locks")).unwrap(); nix_fifo(dir.path().join(".avril").join("npm").join(".locks")); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let error = block_on(store.npm_acquire("npm:locklink")) .unwrap_err() .to_string(); assert!( error.contains("usable") || error.contains("directory") || error.contains("corrupt") || error.contains("lock"), "{error}" ); } #[test] #[cfg(unix)] fn materialized_fifo_sources_fail_promptly() { let server = Registry::new(); server.publish_npm( "fifosrc", "1.0.0", &[ ( "package.json", "{\"name\": \"fifosrc\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:fifosrc")).unwrap(); store.save().unwrap(); // Swap a materialized source for a FIFO: the bounded regular-file read // must fail promptly (non-blocking open + fstat) rather than hang. std::fs::remove_file(artifact.dir.join("index.js")).unwrap(); nix_fifo(artifact.dir.join("index.js")); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let (send, receive) = std::sync::mpsc::channel(); std::thread::spawn(move || { send.send(block_on(store.npm_acquire("npm:fifosrc"))) .unwrap(); }); let result = receive .recv_timeout(std::time::Duration::from_secs(15)) .expect("materialized FIFO must fail promptly, not hang"); let error = result.unwrap_err().to_string(); assert!( error.contains("regular") || error.contains("corrupt") || error.contains("missing"), "{error}" ); // The loader-facing primitive fails the same way. let error = artifact .read_file(dir.path(), "index.js") .unwrap_err() .to_string(); assert!( error.contains("regular") || error.contains("missing") || error.contains("corrupt"), "{error}" ); } #[test] #[cfg(unix)] fn symlinked_vendor_parent_fails_closed() { use std::collections::{BTreeMap, HashSet}; use std::os::unix::fs::symlink; let server = Registry::new(); server.publish_npm( "vendlink", "1.0.0", &[ ( "package.json", "{\"name\": \"vendlink\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = std::sync::Arc::new( PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(), ); let resolver = avril_runtime::registry::npm::resolution::NpmResolver::new(store.clone()); let handle = block_on( resolver.prepare(&[avril_runtime::registry::npm::resolution::NpmRootRequest { request: "npm:vendlink".into(), context: BTreeMap::new(), }]), ) .unwrap(); let tracked: std::collections::BTreeSet = handle.roots.keys().cloned().collect(); store.save_graph(&tracked, &HashSet::new()).unwrap(); let vendor = dir.path().join("vendor-real"); store.vendor(&vendor).unwrap(); assert!(vendor.join("npm").exists()); let redirected = tempfile::tempdir().unwrap(); std::fs::rename(vendor.join("npm"), vendor.join("npm-real")).unwrap(); symlink(redirected.path(), vendor.join("npm")).unwrap(); assert!(store.vendor(&vendor).is_err()); assert!( std::fs::read_dir(redirected.path()) .unwrap() .next() .is_none() ); std::fs::remove_file(vendor.join("npm")).unwrap(); std::fs::rename(vendor.join("npm-real"), vendor.join("npm")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril")).unwrap(); // A symlinked directory inside the vendor mirror must fail instead of // being followed or silently ignored. let evil = tempfile::tempdir().unwrap(); std::fs::rename(vendor.join("npm"), vendor.join("npm-real")).unwrap(); symlink(evil.path(), vendor.join("npm")).unwrap(); let mut options = offline_frozen_options(&server, dir.path()); options.vendor = Some(vendor.clone()); let store = PackageStore::new(fixture::runtime_handle(), options).unwrap(); let error = block_on(store.npm_acquire("npm:vendlink")) .unwrap_err() .to_string(); assert!( error.contains("vendor") || error.contains("usable") || error.contains("corrupt") || error.contains("Offline"), "{error}" ); assert!(std::fs::read_dir(evil.path()).unwrap().next().is_none()); // Sanity: the intact mirror reconstructs the same tree offline. std::fs::remove_file(vendor.join("npm")).unwrap(); std::fs::rename(vendor.join("npm-real"), vendor.join("npm")).unwrap(); let mut options = offline_frozen_options(&server, dir.path()); options.vendor = Some(vendor); let store = PackageStore::new(fixture::runtime_handle(), options).unwrap(); let restored = block_on(store.npm_acquire("npm:vendlink")).unwrap(); assert_eq!( std::fs::read_to_string(restored.dir.join("index.js")).unwrap(), "export default 1;" ); } #[cfg(unix)] fn nix_fifo(path: std::path::PathBuf) { let status = std::process::Command::new("mkfifo") .arg(&path) .status() .expect("mkfifo is required for FIFO tests"); assert!(status.success()); } /// Rewrite every compact pin on disk into legacy wire form using the /// published sidecars, then drop the sidecars: the tree stays warm but only /// legacy maps authenticate it. fn downconvert_lock_to_legacy(dir: &std::path::Path) { let manifests = dir.join(".avril").join("npm").join(".manifests"); let mut lock = fixture::read_lock(dir); for pin in lock["npm"]["packages"] .as_object_mut() .unwrap() .values_mut() { let manifest = pin["manifest"].as_str().unwrap().to_owned(); let hex = manifest.strip_prefix("sha256-").unwrap(); let bytes = std::fs::read(manifests.join(format!("{hex}.json"))).unwrap(); let files: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); *pin = serde_json::json!({ "tarball": pin["tarball"].clone(), "integrity": pin["integrity"].clone(), "files": files, }); } std::fs::write(dir.join("avril.lock"), format!("{lock}\n")).unwrap(); std::fs::remove_dir_all(manifests).unwrap(); } #[test] fn compact_lock_carries_no_file_maps() { let server = Registry::new(); let mut many = vec![( "package.json", "{\"name\": \"many\", \"version\": \"1.0.0\"}".to_string(), )]; for index in 0..40 { many.push(( Box::leak(format!("data/{index}.js").into_boxed_str()) as &'static str, format!("export default {index};"), )); } let refs: Vec<(&str, &str)> = many .iter() .map(|(path, source)| (*path, source.as_str())) .collect(); server.publish_npm("many", "1.0.0", &refs); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:many")).unwrap(); assert_eq!(artifact.files.len(), 41); store.save().unwrap(); let lock = fixture::read_lock(dir.path()); let pins = lock["npm"]["packages"].as_object().unwrap(); assert_eq!(pins.len(), 1); let pin = pins.values().next().unwrap(); assert!( pin["manifest"] .as_str() .is_some_and(|digest| digest.starts_with("sha256-")), "{pin}" ); assert!(pin.get("files").is_none(), "compact pins omit the file map"); // One short digest regardless of file count: the whole entry stays small. assert!(serde_json::to_string(pin).unwrap().len() < 1024, "{pin}"); // The sidecar holds the full authenticated map instead. let manifests = std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .count(); assert_eq!(manifests, 1); } #[test] fn warm_offline_frozen_runs_without_archives_or_metadata() { let server = Registry::new(); server.publish_npm( "slim", "1.0.0", &[ ( "package.json", "{\"name\": \"slim\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:slim")).unwrap(); store.save().unwrap(); // No retained tarball is required: drop both caches, keep the sidecar. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap(); assert!( dir.path() .join(".avril") .join("npm") .join(".manifests") .is_dir(), "sidecars live outside the deletable caches" ); let warmed = server.requests.lock().unwrap().len(); let offline = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let hit = block_on(store.npm_acquire("npm:slim")).unwrap(); assert_eq!( std::fs::read_to_string(hit.dir.join("index.js")).unwrap(), "export default 1;" ); assert_eq!(server.requests.lock().unwrap().len(), warmed); // Relocated read-only copies keep working: sidecars travel with the tree. let moved = tempfile::tempdir().unwrap(); copy_npm_tree( &dir.path().join(".avril").join("npm"), &moved.path().join(".avril").join("npm"), ); std::fs::copy( dir.path().join("avril.lock"), moved.path().join("avril.lock"), ) .unwrap(); #[cfg(unix)] chmod_readonly(&moved.path().join(".avril")); let mut relocated = server.options(moved.path()); relocated.offline = true; relocated.frozen = true; // A relocated lock stays valid only with matching registry options. relocated.jsr_registry = server.url.clone(); relocated.npm_registry = server.url.clone(); let store = PackageStore::new(fixture::runtime_handle(), relocated).unwrap(); assert!(block_on(store.npm_acquire("npm:slim")).is_ok()); assert_eq!(server.requests.lock().unwrap().len(), warmed); #[cfg(unix)] chmod_writable(&moved.path().join(".avril")); } #[cfg(unix)] fn chmod_readonly(path: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let mut stack = vec![path.to_owned()]; while let Some(next) = stack.pop() { for entry in std::fs::read_dir(&next).unwrap() { let entry = entry.unwrap().path(); // Directories keep their search bit so the walk below them // still works; only the final mode on each level is locked down. if entry.is_dir() { stack.push(entry.clone()); std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).unwrap(); } else { std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o444)).unwrap(); } } std::fs::set_permissions(&next, std::fs::Permissions::from_mode(0o555)).unwrap(); } } #[cfg(unix)] fn chmod_writable(path: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let mut stack = vec![path.to_owned()]; while let Some(next) = stack.pop() { for entry in std::fs::read_dir(&next).unwrap() { let entry = entry.unwrap().path(); if entry.is_dir() { stack.push(entry.clone()); } let _ = std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o755)); } let _ = std::fs::set_permissions(next, std::fs::Permissions::from_mode(0o755)); } } fn copy_npm_tree(from: &std::path::Path, to: &std::path::Path) { std::fs::create_dir_all(to).unwrap(); for entry in std::fs::read_dir(from).unwrap() { let entry = entry.unwrap(); let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy_npm_tree(&entry.path(), &target); } else { std::fs::copy(entry.path(), target).unwrap(); } } } #[test] fn tampered_sidecar_and_tree_fail_without_tarball() { let server = Registry::new(); server.publish_npm( "sealed", "1.0.0", &[ ( "package.json", "{\"name\": \"sealed\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:sealed")).unwrap(); store.save().unwrap(); // No tarball anywhere: authentication must stand on the sidecar alone. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); let sidecar = std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .next() .unwrap() .unwrap() .path(); // A forged map with a valid shape but a changed file hash fails the // digest even with matching tampered tree bytes and no tarball present. let map_path = sidecar.clone(); let mut map: serde_json::Value = serde_json::from_slice(&std::fs::read(&map_path).unwrap()).unwrap(); map["index.js"] = serde_json::Value::String(avril_runtime::registry::checksum(b"tampered")); std::fs::write(&sidecar, serde_json::to_vec(&map).unwrap()).unwrap(); std::fs::write(artifact.dir.join("index.js"), "tampered").unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:sealed")) .unwrap_err() .to_string() .contains("mismatch") ); // A deleted sidecar plus a tampered tree fails offline with no archive. std::fs::remove_file(&sidecar).unwrap(); let mut offline = server.options(dir.path()); offline.offline = true; let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); assert!(block_on(store.npm_acquire("npm:sealed")).is_err()); // Healing the tree rebuilds from the network and republishes the sidecar. std::fs::remove_dir_all(&artifact.dir).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let healed = block_on(store.npm_acquire("npm:sealed")).unwrap(); assert_eq!( std::fs::read_to_string(healed.dir.join("index.js")).unwrap(), "export default 1;" ); assert_eq!( std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .count(), 1 ); } #[test] fn missing_sidecar_recovers_from_archive_and_cold_fetch() { let server = Registry::new(); server.publish_npm( "rebuild", "1.0.0", &[ ( "package.json", "{\"name\": \"rebuild\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:rebuild@^1")).unwrap(); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); // Tree and sidecar gone, tarball cache kept: offline archive rebuild. std::fs::remove_dir_all(&artifact.dir).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".manifests")).unwrap(); let mut offline = server.options(dir.path()); offline.offline = true; let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let rebuilt = block_on(store.npm_acquire("npm:rebuild")).unwrap(); assert_eq!( std::fs::read_to_string(rebuilt.dir.join("index.js")).unwrap(), "export default 1;" ); assert_eq!(server.requests.lock().unwrap().len(), warmed); assert_eq!( std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .count(), 1, "archive recovery republishes the sidecar" ); // Nothing local at all: a cold pinned fetch still works, and frozen pins // download artifacts without metadata queries. std::fs::remove_dir_all(&artifact.dir).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".manifests")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); let mut frozen = server.options(dir.path()); frozen.frozen = true; let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap(); let cold = block_on(store.npm_acquire("npm:rebuild@^1")).unwrap(); assert_eq!(cold.identity.version, "1.0.0"); let paths = server.requests.lock().unwrap().clone(); assert!( paths[warmed..].iter().all(|path| path.ends_with(".tgz")), "frozen pins must not query mutable packument metadata: {paths:?}" ); } #[test] #[cfg(unix)] fn corrupt_symlink_and_fifo_manifests_fail_promptly() { let server = Registry::new(); server.publish_npm( "manifested", "1.0.0", &[ ( "package.json", "{\"name\": \"manifested\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:manifested")).unwrap(); store.save().unwrap(); let sidecar = std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .next() .unwrap() .unwrap() .path(); // Garbage bytes fail on the digest before any parse. std::fs::write(&sidecar, b"not json at all").unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:manifested")) .unwrap_err() .to_string() .contains("mismatch") ); // A final-component symlink is never followed. std::fs::remove_file(&sidecar).unwrap(); std::os::unix::fs::symlink(dir.path().join("avril.lock"), &sidecar).unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:manifested")) .unwrap_err() .to_string() .contains("manifest") ); // A FIFO fails promptly instead of hanging the caller. std::fs::remove_file(&sidecar).unwrap(); nix_fifo(sidecar.clone()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let (send, receive) = std::sync::mpsc::channel(); std::thread::spawn(move || { send.send(block_on(store.npm_acquire("npm:manifested"))) .unwrap(); }); let error = receive .recv_timeout(std::time::Duration::from_secs(15)) .expect("manifest FIFO must fail promptly, not hang") .unwrap_err() .to_string(); assert!(error.contains("manifest"), "{error}"); } #[test] fn vendor_mirror_restores_offline_frozen_with_peers() { use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest}; use std::collections::{BTreeMap, BTreeSet, HashSet}; let server = Registry::new(); server.publish_npm( "vutil", "1.0.0", &[( "package.json", "{\"name\": \"vutil\", \"version\": \"1.0.0\", \"type\": \"module\"}", )], ); server.publish_npm( "vlib", "1.0.0", &[( "package.json", "{\"name\": \"vlib\", \"version\": \"1.0.0\", \"type\": \"module\", \"peerDependencies\": {\"vutil\": \"*\"}}", )], ); let dir = tempfile::tempdir().unwrap(); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap()); let resolver = NpmResolver::new(store.clone()); let handle = block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:vlib@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:vutil@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); let tracked: BTreeSet = handle.roots.keys().cloned().collect(); store.save_graph(&tracked, &HashSet::new()).unwrap(); let vendor = dir.path().join("vendor"); store.vendor(&vendor).unwrap(); assert!( vendor .join("npm") .join(".manifests") .read_dir() .unwrap() .count() >= 2, "the mirror carries one sidecar per artifact content" ); let before: BTreeMap = handle .roots .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); // Pure mirror: wipe all local state except the lock, then restore // offline and frozen, including the peer-scoped instance. std::fs::remove_dir_all(dir.path().join(".avril")).unwrap(); let mut options = offline_frozen_options(&server, dir.path()); options.vendor = Some(vendor); let store = Arc::new(PackageStore::new(fixture::runtime_handle(), options).unwrap()); let resolver = NpmResolver::new(store.clone()); let restored = block_on(resolver.prepare(&[ NpmRootRequest { request: "npm:vlib@1".into(), context: BTreeMap::new(), }, NpmRootRequest { request: "npm:vutil@1".into(), context: BTreeMap::new(), }, ])) .unwrap(); assert_eq!(restored.roots.len(), before.len()); for (key, instance) in &before { assert_eq!(restored.roots.get(key), Some(instance), "{key}"); } } #[test] fn legacy_lock_migrates_used_and_preserves_unvisited() { let server = Registry::new(); for name in ["legacya", "legacyb"] { server.publish_npm( name, "1.0.0", &[( "package.json", &format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}"), )], ); } let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:legacya")).unwrap(); block_on(store.npm_acquire("npm:legacyb")).unwrap(); store.save().unwrap(); downconvert_lock_to_legacy(dir.path()); // Only legacya is visited: it migrates, legacyb stays byte-identical. let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:legacya")).unwrap(); store.save().unwrap(); let lock = fixture::read_lock(dir.path()); let packages = lock["npm"]["packages"].as_object().unwrap(); let key_a = packages .keys() .find(|key| key.contains("legacya")) .unwrap() .clone(); let key_b = packages .keys() .find(|key| key.contains("legacyb")) .unwrap() .clone(); assert!(packages[&key_a].get("files").is_none(), "used pins migrate"); assert!( packages[&key_a]["manifest"] .as_str() .is_some_and(|digest| digest.starts_with("sha256-")) ); assert!( packages[&key_b]["files"].as_object().is_some(), "unvisited legacy pins keep their inline map" ); assert!(packages[&key_b].get("manifest").is_none()); assert_eq!( std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .count(), 1, "sidecars publish only for visited pins" ); // The unvisited legacy entry still acquires frozen, offline, and // read-only with no tarballs: its warm tree authenticates inline. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; chmod_readonly(&dir.path().join(".avril")); std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o444), ) .unwrap(); } let frozen = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap(); assert!(block_on(store.npm_acquire("npm:legacya")).is_ok()); assert!(block_on(store.npm_acquire("npm:legacyb")).is_ok()); store.save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; chmod_writable(&dir.path().join(".avril")); let _ = std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o644), ); } } #[test] #[cfg(unix)] fn frozen_legacy_lock_needs_no_writes_and_stays_identical() { use std::os::unix::fs::PermissionsExt; let server = Registry::new(); server.publish_npm( "frozenlegacy", "1.0.0", &[( "package.json", "{\"name\": \"frozenlegacy\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:frozenlegacy")).unwrap(); store.save().unwrap(); downconvert_lock_to_legacy(dir.path()); // No caches at all: the warm tree plus the inline map must suffice. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); chmod_readonly(&dir.path().join(".avril")); std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o444), ) .unwrap(); let frozen = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap(); assert!(block_on(store.npm_acquire("npm:frozenlegacy")).is_ok()); store.save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before, "frozen legacy locks stay byte-identical" ); assert!( !dir.path() .join(".avril") .join("npm") .join(".manifests") .exists(), "frozen warm hits publish no sidecars" ); chmod_writable(&dir.path().join(".avril")); let _ = std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o644), ); } #[test] fn malformed_pins_are_rejected_at_open() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); let key = format!("{}|pkg|1.0.0", server.url.as_str()); let tarball = format!( "{}/pkg/-/pkg-1.0.0.tgz", server.url.as_str().trim_end_matches('/') ); let integrity = Registry::tarball_integrity(&[]); let good_files = serde_json::json!({ "package.json": avril_runtime::registry::checksum(b"{}"), "index.js": avril_runtime::registry::checksum(b"1"), }); let good_map: std::collections::BTreeMap = good_files .as_object() .unwrap() .iter() .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_owned())) .collect(); let manifest = { use sha2::Digest; let digest = sha2::Sha256::digest(serde_json::to_vec(&good_map).unwrap()); format!( "sha256-{}", digest .iter() .map(|byte| format!("{byte:02x}")) .collect::() ) }; let valid = serde_json::json!({ "tarball": tarball, "integrity": integrity, "manifest": manifest, }); let cases: Vec<(&str, serde_json::Value)> = vec![ ( "neither", serde_json::json!({"tarball": tarball, "integrity": integrity}), ), ( "mismatch", serde_json::json!({"tarball": tarball, "integrity": integrity, "manifest": manifest, "files": good_files}), ), ( "bad-digest", serde_json::json!({"tarball": tarball, "integrity": integrity, "manifest": "sha256-zzz"}), ), ( "empty-files", serde_json::json!({"tarball": tarball, "integrity": integrity, "files": {}}), ), ( "bad-path", serde_json::json!({"tarball": tarball, "integrity": integrity, "files": {"../evil": good_files["index.js"], "package.json": good_files["package.json"]}}), ), ( "no-package-json", serde_json::json!({"tarball": tarball, "integrity": integrity, "files": {"index.js": good_files["index.js"]}}), ), ( "unknown-field", serde_json::json!({"tarball": tarball, "integrity": integrity, "manifest": manifest, "future": 1}), ), ]; for (label, mut pin) in cases { if label == "mismatch" { let mut tampered = good_files.clone(); tampered["index.js"] = serde_json::Value::String(avril_runtime::registry::checksum(b"other")); pin["files"] = tampered; } let mut lock = serde_json::json!({ "version": 2, "jsrRegistry": server.url.as_str(), "npmRegistry": server.url.as_str(), "specifiers": {}, "packages": {}, "npm": {"specifiers": {}, "packages": {}, "roots": {}, "instances": {}}, }); lock["npm"]["packages"] .as_object_mut() .unwrap() .insert(key.clone(), pin); std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap(); let error = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .err() .expect("malformed pins must be rejected") .to_string(); assert!(!error.is_empty(), "{label} must be rejected"); } // The valid compact pin opens cleanly. let mut lock = serde_json::json!({ "version": 2, "jsrRegistry": server.url.as_str(), "npmRegistry": server.url.as_str(), "specifiers": {}, "packages": {}, "npm": {"specifiers": {}, "packages": {}, "roots": {}, "instances": {}}, }); lock["npm"]["packages"] .as_object_mut() .unwrap() .insert(key.clone(), valid); std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap(); assert!(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).is_ok()); } #[test] fn cold_frozen_publishes_sidecars_for_readonly_replay() { let server = Registry::new(); server.publish_npm( "coldfrozen", "1.0.0", &[ ( "package.json", "{\"name\": \"coldfrozen\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); block_on(store.npm_acquire("npm:coldfrozen@^1")).unwrap(); store.save().unwrap(); // Cold frozen fetch over the network still publishes the sidecar. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join("default")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".manifests")).unwrap(); let mut frozen = server.options(dir.path()); frozen.frozen = true; let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap(); let cold = block_on(store.npm_acquire("npm:coldfrozen@^1")).unwrap(); assert_eq!(cold.identity.version, "1.0.0"); assert_eq!( std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests")) .unwrap() .count(), 1, "frozen acquisition leaves a complete tree" ); // Strip every cache, go read-only, and replay offline and frozen. std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap(); std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; chmod_readonly(&dir.path().join(".avril")); std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o444), ) .unwrap(); } let warmed = server.requests.lock().unwrap().len(); let mut replay = server.options(dir.path()); replay.offline = true; replay.frozen = true; let store = PackageStore::new(fixture::runtime_handle(), replay).unwrap(); let hit = block_on(store.npm_acquire("npm:coldfrozen@^1")).unwrap(); assert_eq!( std::fs::read_to_string(hit.dir.join("index.js")).unwrap(), "export default 1;" ); assert_eq!(server.requests.lock().unwrap().len(), warmed); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; chmod_writable(&dir.path().join(".avril")); let _ = std::fs::set_permissions( dir.path().join("avril.lock"), std::fs::Permissions::from_mode(0o644), ); } } fn tar_entry(name: &str, entry_type: tar::EntryType, data: &[u8]) -> Vec { let mut header = tar::Header::new_gnu(); header.set_mode(if entry_type.is_dir() { 0o755 } else { 0o644 }); header.set_size(if entry_type.is_dir() { 0 } else { data.len() as u64 }); header.set_entry_type(entry_type); header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); header.set_cksum(); let mut out = header.as_bytes().to_vec(); if !entry_type.is_dir() { out.extend_from_slice(data); while !out.len().is_multiple_of(512) { out.push(0); } } out } fn gzip_tarball(blocks: &[Vec]) -> Vec { use flate2::{Compression, write::GzEncoder}; use std::io::Write; let mut tar_data = Vec::new(); for block in blocks { tar_data.extend_from_slice(block); } tar_data.extend_from_slice(&[0u8; 1024]); let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(&tar_data).unwrap(); encoder.finish().unwrap() } #[test] fn dot_segments_normalize_to_canonical_paths() { let package_json = "{\"name\": \"dotted\", \"version\": \"1.0.0\"}"; let index_js = "export default 1;"; let util_js = "export const util = 2;"; use tar::EntryType::{Directory, Regular}; let blocks = vec![ tar_entry("package/./", Directory, b""), tar_entry("package/./dist/", Directory, b""), tar_entry("package/./package.json", Regular, package_json.as_bytes()), tar_entry("package/./dist/index.js", Regular, index_js.as_bytes()), tar_entry("package/dist/./util.js", Regular, util_js.as_bytes()), ]; let tarball = gzip_tarball(&blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("dotted", "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:dotted")).unwrap(); assert_eq!(artifact.identity.version, "1.0.0"); let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect(); assert_eq!(keys, vec!["dist/index.js", "dist/util.js", "package.json"]); assert_eq!( artifact.read_file(dir.path(), "dist/index.js").unwrap(), index_js.as_bytes() ); assert_eq!( artifact.read_file(dir.path(), "dist/util.js").unwrap(), util_js.as_bytes() ); assert_eq!( std::fs::read_to_string(artifact.dir.join("dist").join("index.js")).unwrap(), index_js ); assert_eq!( std::fs::read_to_string(artifact.dir.join("dist").join("util.js")).unwrap(), util_js ); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); let mut offline = offline_frozen_options(&server, dir.path()); offline.cache = dir.path().join("fresh-cache-that-must-stay-empty"); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let hit = block_on(store.npm_acquire("npm:dotted")).unwrap(); assert_eq!(hit.files, artifact.files); assert_eq!( std::fs::read_to_string(hit.dir.join("dist").join("index.js")).unwrap(), index_js ); assert_eq!(server.requests.lock().unwrap().len(), warmed); assert!( !dir.path().join("fresh-cache-that-must-stay-empty").exists(), "npm must not depend on the global cache" ); store.save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); } #[test] fn duplicate_canonical_dot_paths_are_rejected() { use tar::EntryType::Regular; let package_json = "{\"name\": \"dupdot\", \"version\": \"1.0.0\"}"; let blocks = vec![ tar_entry("package/package.json", Regular, package_json.as_bytes()), tar_entry("package/index.js", Regular, b"export default 1;"), tar_entry("package/./index.js", Regular, b"export default 2;"), ]; let tarball = gzip_tarball(&blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("dupdot", "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:dupdot")) .unwrap_err() .to_string() .contains("duplicate") ); } #[test] fn identical_dot_alias_duplicates_coalesce_in_either_order() { use tar::EntryType::Regular; for (name, dotted_first) in [("dupok", false), ("dupok_rev", true)] { let package_json = format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}"); let index_js = "export default 1;"; let plain = tar_entry("package/dist/index.js", Regular, index_js.as_bytes()); let dotted = tar_entry("package/./dist/index.js", Regular, index_js.as_bytes()); let mut blocks = vec![tar_entry( "package/package.json", Regular, package_json.as_bytes(), )]; if dotted_first { blocks.push(dotted); blocks.push(plain); } else { blocks.push(plain); blocks.push(dotted); } let tarball = gzip_tarball(&blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist(name, "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire(&format!("npm:{name}"))).unwrap(); let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect(); assert_eq!(keys, vec!["dist/index.js", "package.json"], "{name}"); assert_eq!( artifact.read_file(dir.path(), "dist/index.js").unwrap(), index_js.as_bytes() ); assert_eq!( std::fs::read_to_string(artifact.dir.join("dist").join("index.js")).unwrap(), index_js ); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); let mut offline = offline_frozen_options(&server, dir.path()); offline.cache = dir.path().join("fresh-cache-that-must-stay-empty"); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let hit = block_on(store.npm_acquire(&format!("npm:{name}"))).unwrap(); assert_eq!(hit.files, artifact.files); assert_eq!(server.requests.lock().unwrap().len(), warmed); assert!(!dir.path().join("fresh-cache-that-must-stay-empty").exists()); store.save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); } } #[test] fn identical_exact_name_duplicates_coalesce() { use tar::EntryType::Regular; let package_json = "{\"name\": \"dupx\", \"version\": \"1.0.0\"}"; let index_js = "export default 1;"; let blocks = vec![ tar_entry("package/package.json", Regular, package_json.as_bytes()), tar_entry("package/index.js", Regular, index_js.as_bytes()), tar_entry("package/index.js", Regular, index_js.as_bytes()), ]; let tarball = gzip_tarball(&blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("dupx", "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:dupx")).unwrap(); let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect(); assert_eq!(keys, vec!["index.js", "package.json"]); } #[test] fn duplicate_entries_count_toward_tarball_limits() { use tar::EntryType::Regular; let package_json = "{\"name\": \"many\", \"version\": \"1.0.0\"}"; let mut blocks = vec![ tar_entry("package/package.json", Regular, package_json.as_bytes()), tar_entry("package/index.js", Regular, b"export default 1;"), ]; for _ in 0..65_535 { blocks.push(tar_entry("package/index.js", Regular, b"export default 1;")); } let tarball = gzip_tarball(&blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("many", "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:many")) .unwrap_err() .to_string() .contains("too many entries") ); } #[test] fn legacy_single_directory_roots_are_accepted() { use tar::EntryType::{Directory, Regular}; let package_json = "{\"name\": \"@types/retry\", \"version\": \"0.12.0\"}"; let index = "export declare const retry: number;"; let file_blocks = || { vec![ tar_entry("retry/package.json", Regular, package_json.as_bytes()), tar_entry("retry/index.d.ts", Regular, index.as_bytes()), tar_entry("retry/README.md", Regular, b"# retry\n"), tar_entry("retry/LICENSE", Regular, b"MIT\n"), ] }; let variants: &[(&str, &[&str], &[&str])] = &[ ("no header", &[], &[]), ("root header first", &["retry/"], &[]), ("root header last", &[], &["retry/"]), ("bare root header", &["retry"], &[]), ]; for (label, first, last) in variants { let mut blocks = vec![]; for name in *first { blocks.push(tar_entry(name, Directory, b"")); } blocks.extend(file_blocks()); for name in *last { blocks.push(tar_entry(name, Directory, b"")); } let tarball = gzip_tarball(&blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("@types/retry", "0.12.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let artifact = block_on(store.npm_acquire("npm:@types/retry")).unwrap(); let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect(); assert_eq!( keys, vec!["LICENSE", "README.md", "index.d.ts", "package.json"], "{label}" ); assert_eq!( artifact.read_file(dir.path(), "index.d.ts").unwrap(), index.as_bytes() ); assert_eq!( std::fs::read_to_string(artifact.dir.join("index.d.ts")).unwrap(), index ); store.save().unwrap(); let warmed = server.requests.lock().unwrap().len(); let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap(); let mut offline = offline_frozen_options(&server, dir.path()); offline.cache = dir.path().join("fresh-cache-that-must-stay-empty"); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); let hit = block_on(store.npm_acquire("npm:@types/retry")).unwrap(); assert_eq!(hit.files, artifact.files); assert_eq!(server.requests.lock().unwrap().len(), warmed); assert!(!dir.path().join("fresh-cache-that-must-stay-empty").exists()); store.save().unwrap(); assert_eq!( std::fs::read(dir.path().join("avril.lock")).unwrap(), lock_before ); } } #[test] fn mixed_and_unsafe_archive_roots_are_rejected() { use tar::EntryType::{Directory, Regular}; let package_json = "{\"name\": \"t\", \"version\": \"1.0.0\"}"; let file = |name: &str| tar_entry(name, Regular, b"x"); let dir = |name: &str| tar_entry(name, Directory, b""); let pkg = || tar_entry("retry/package.json", Regular, package_json.as_bytes()); let cases: Vec<(&str, Vec>, &str)> = vec![ ( "mixed file roots", vec![pkg(), file("package/index.js")], "mixes archive roots", ), ( "stray directory root", vec![pkg(), dir("other/")], "mixes archive roots", ), ( "root-only file", vec![tar_entry("retry", Regular, b"x")], "Invalid npm file path", ), ( "dot-dot root", vec![file("../evil.js")], "Invalid npm file path", ), ("dot root", vec![file("./evil.js")], "Invalid npm file path"), ( "absolute root", vec![file("/evil.js")], "Invalid npm file path", ), ( "backslash root", vec![file("a\\b/evil.js")], "Invalid npm file path", ), ( "colon root", vec![file("a:b/evil.js")], "Invalid npm file path", ), ( "control root", vec![file("a\u{1}b/evil.js")], "Invalid npm file path", ), ( "encoded separator root", vec![file("a%2fb/evil.js")], "Invalid npm file path", ), ( "traversal below root", vec![pkg(), file("retry/../evil.js")], "Invalid npm file path", ), ( "missing package.json", vec![file("retry/index.js")], "missing package.json", ), ( "mismatched identity", vec![tar_entry( "retry/package.json", Regular, b"{\"name\": \"other\", \"version\": \"1.0.0\"}", )], "does not match", ), ]; for (label, blocks, expected) in &cases { let tarball = gzip_tarball(blocks); let server = Registry::new(); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist("t", "1.0.0", tarball, &integrity, None, true); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire("npm:t")) .unwrap_err() .to_string() .contains(expected), "{label}" ); assert!(!dir.path().join("evil.js").exists(), "{label}"); assert!( !dir.path() .join(".avril") .join("npm") .join("default") .exists(), "{label}" ); } } #[test] fn dot_prefixed_unsafe_paths_are_still_rejected() { use tar::EntryType::{Directory, Regular}; let server = Registry::new(); let cases = [ ("dotdot-file", "package/./../evil.js", false), ("backslash-file", "package/./foo\\bar.js", false), ("empty-file", "package//index.js", false), ("internal-empty-file", "package/dist//index.js", false), ("traversal-file", "package/dist/../evil.js", false), ("trailing-slash-file", "package/./dist/index.js/", false), ("root-file", "package/./", false), ("empty-dir", "package//", true), ("dot-double-slash-dir", "package/.//", true), ("internal-empty-dir", "package/a//b/", true), ("dotdot-dir", "package/./../evil/", true), ("traversal-dir", "package/dist/../evil/", true), ]; for (suffix, entry, is_dir) in cases { let name = format!("evil-{suffix}"); let package_json = format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}"); let blocks = vec![ tar_entry("package/package.json", Regular, package_json.as_bytes()), tar_entry( entry, if is_dir { Directory } else { Regular }, if is_dir { b"".as_slice() } else { b"evil".as_slice() }, ), ]; let tarball = gzip_tarball(&blocks); let integrity = Registry::tarball_integrity(&tarball); server.publish_npm_dist(&name, "1.0.0", tarball, &integrity, None, true); } let dir = tempfile::tempdir().unwrap(); for (suffix, _, _) in cases { let name = format!("evil-{suffix}"); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_acquire(&format!("npm:{name}"))) .unwrap_err() .to_string() .contains("Invalid npm file path"), "{suffix} must be rejected" ); } } #[test] fn npm_progress_reports_network_fetches_but_not_warm_hits() { use avril_runtime::registry::ProgressEvent; use std::sync::Mutex; let server = Registry::new(); server.publish_npm( "tool", "1.0.0", &[ ( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let events = Arc::new(Mutex::new(Vec::new())); let sink_events = events.clone(); let sink: Arc = Arc::new(move |event| sink_events.lock().unwrap().push(event.clone())); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_progress(sink); let artifact = block_on(store.npm_acquire("npm:tool")).unwrap(); assert_eq!(artifact.identity.version, "1.0.0"); let seen = events.lock().unwrap().clone(); assert!( seen.iter() .any(|event| *event == ProgressEvent::NpmMetadata { name: "tool".into() }), "metadata fetch must be reported: {seen:?}" ); assert!( seen.iter().any(|event| *event == ProgressEvent::NpmArchive { name: "tool".into(), version: "1.0.0".into(), }), "archive download must be reported: {seen:?}" ); store.save().unwrap(); let warm_events = Arc::new(Mutex::new(Vec::new())); let warm_sink_events = warm_events.clone(); let warm_sink: Arc = Arc::new(move |event| warm_sink_events.lock().unwrap().push(event.clone())); let mut offline = offline_frozen_options(&server, dir.path()); offline.cache = dir.path().join("fresh-cache-that-must-stay-empty"); let store = PackageStore::new(fixture::runtime_handle(), offline) .unwrap() .with_progress(warm_sink); let hit = block_on(store.npm_acquire("npm:tool")).unwrap(); assert_eq!(hit.identity.version, "1.0.0"); let warm = warm_events.lock().unwrap().clone(); assert!( !warm.iter().any(|event| matches!( event, ProgressEvent::NpmMetadata { .. } | ProgressEvent::NpmArchive { .. } )), "warm offline hits must not claim network work: {warm:?}" ); } #[test] fn npm_progress_arrives_before_download_completes() { use avril_runtime::registry::ProgressEvent; use std::sync::Mutex; use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; let server = Registry::new(); server.publish_npm( "tool", "1.0.0", &[ ( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", ), ("index.js", "export default 1;"), ], ); let dir = tempfile::tempdir().unwrap(); let events = Arc::new(Mutex::new(Vec::new())); let sink_events = events.clone(); let sink: Arc = Arc::new(move |event| sink_events.lock().unwrap().push(event.clone())); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_progress(sink); server.hold_tarballs.store(true, Ordering::Relaxed); let handle = std::thread::spawn(move || block_on(store.npm_acquire("npm:tool"))); let deadline = Instant::now() + Duration::from_secs(10); loop { let has_archive = events.lock().unwrap().iter().any(|event| { *event == ProgressEvent::NpmArchive { name: "tool".into(), version: "1.0.0".into(), } }); if has_archive { break; } if Instant::now() > deadline { server.hold_tarballs.store(false, Ordering::Relaxed); panic!("archive progress must arrive while the download is stalled"); } std::thread::sleep(Duration::from_millis(10)); } assert!( !handle.is_finished(), "archive progress must precede download completion" ); server.hold_tarballs.store(false, Ordering::Relaxed); let artifact = handle .join() .expect("acquisition thread must finish") .expect("stalled acquisition must succeed once released"); assert_eq!(artifact.identity.version, "1.0.0"); } #[test] fn npm_select_version_uses_tags_ranges_scopes_and_subpaths() { let server = Registry::new(); for version in ["1.0.0", "1.1.0", "2.0.0"] { server.publish_npm( "tool", version, &[ ( "package.json", &format!("{{\"name\": \"tool\", \"version\": \"{version}\"}}"), ), ("index.js", "export default 1;"), ], ); } { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap(); packument["dist-tags"]["latest"] = serde_json::Value::String("1.1.0".into()); files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap()); } server.publish_npm( "@scope/pkg", "1.0.0", &[( "package.json", "{\"name\": \"@scope/pkg\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); for (specifier, version) in [ ("npm:tool", "1.1.0"), ("npm:tool@latest", "1.1.0"), ("npm:tool@^1", "1.1.0"), ("npm:tool@2.0.0", "2.0.0"), ("npm:tool/sub", "1.1.0"), ("npm:@scope/pkg", "1.0.0"), ] { assert_eq!( block_on(store.npm_select_version(specifier)).unwrap(), version, "{specifier}" ); } assert!( server .requests .lock() .unwrap() .iter() .all(|path| !path.ends_with(".tgz")), "selection must not fetch archives" ); store.save().unwrap(); if dir.path().join("avril.lock").exists() { let lock = fixture::read_lock(dir.path()); assert!(lock["npm"]["packages"].as_object().unwrap().is_empty()); assert!(lock["npm"]["specifiers"].as_object().unwrap().is_empty()); } } #[test] fn npm_select_version_succeeds_when_archives_or_deps_are_broken() { let server = Registry::new(); server.publish_npm( "broken", "1.0.0", &[( "package.json", "{\"name\": \"broken\", \"version\": \"1.0.0\"}", )], ); { let mut files = server.files.lock().unwrap(); let tarball = files .keys() .find(|path| path.ends_with(".tgz")) .unwrap() .clone(); files.insert(tarball, b"not gzip".to_vec()); } server.publish_npm( "hasdep", "1.0.0", &[( "package.json", "{\"name\": \"hasdep\", \"version\": \"1.0.0\", \"dependencies\": {\"nodep\": \"^1.0.0\"}}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:broken")).unwrap(), "1.0.0" ); assert_eq!( block_on(store.npm_select_version("npm:hasdep")).unwrap(), "1.0.0" ); assert!( server .requests .lock() .unwrap() .iter() .all(|path| !path.ends_with(".tgz")), "selection must not fetch archives" ); assert!(block_on(store.npm_acquire("npm:broken")).is_err()); { use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest}; use std::collections::BTreeMap; use std::sync::Arc; let store = Arc::new(store); let resolver = NpmResolver::new(store); assert!( block_on(resolver.prepare(&[NpmRootRequest { request: "npm:hasdep".into(), context: BTreeMap::new(), }])) .is_err() ); } } #[test] fn npm_select_version_reuses_locks_and_caches() { use avril_runtime::registry::ProgressEvent; use std::sync::Mutex; let server = Registry::new(); server.publish_npm( "tool", "1.0.0", &[( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", )], ); server.publish_npm( "tool", "1.1.0", &[( "package.json", "{\"name\": \"tool\", \"version\": \"1.1.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let events = Arc::new(Mutex::new(Vec::new())); let sink_events = events.clone(); let sink: Arc = Arc::new(move |event| sink_events.lock().unwrap().push(event.clone())); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_progress(sink); assert_eq!( block_on(store.npm_select_version("npm:tool@^1")).unwrap(), "1.1.0" ); let packuments = server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(); assert_eq!(packuments, 1); assert!( events .lock() .unwrap() .iter() .any(|event| *event == ProgressEvent::NpmMetadata { name: "tool".into() }) ); assert!(!events.lock().unwrap().iter().any(|event| matches!( event, ProgressEvent::NpmArchive { .. } | ProgressEvent::NpmMaterialized { .. } ))); let artifact = block_on(store.npm_acquire("npm:tool@^1")).unwrap(); assert_eq!(artifact.identity.version, "1.1.0"); let packuments = server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(); assert_eq!( packuments, 1, "acquisition must reuse the selected packument" ); store.save().unwrap(); std::fs::remove_dir_all(&artifact.dir).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:tool@^1")).unwrap(), "1.1.0" ); let offline = offline_frozen_options(&server, dir.path()); let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:tool@^1")).unwrap(), "1.1.0" ); let warmed = server.requests.lock().unwrap().len(); assert!( block_on(store.npm_select_version("npm:other")) .unwrap_err() .to_string() .contains("Frozen") ); assert_eq!(server.requests.lock().unwrap().len(), warmed); for specifier in ["npm:../evil", "npm:tool@", "npm:@bad", "npm:tool@nope"] { assert!( block_on(store.npm_select_version(specifier)).is_err(), "{specifier}" ); } assert_eq!(server.requests.lock().unwrap().len(), warmed); } #[test] fn npm_select_version_denies_without_network() { use avril_runtime::caps::{Capabilities, Category, Initial}; let server = Registry::new(); server.publish_npm( "secret", "1.0.0", &[( "package.json", "{\"name\": \"secret\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let mut initial = Initial::new(true); initial .grant(Category::JsExec, true, "https://jsr.io/", dir.path()) .unwrap(); let caps = Capabilities::new(initial, dir.path().to_owned()); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())) .unwrap() .with_capabilities(caps); assert!( block_on(store.npm_select_version("npm:secret")) .unwrap_err() .to_string() .contains("PermissionDenied") ); assert!(server.requests.lock().unwrap().is_empty()); } #[test] fn npm_packument_shared_across_concurrent_requests() { let server = Registry::new(); server.publish_npm( "tool", "1.0.0", &[( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); let (first, second, third) = block_on(async { futures::join!( store.npm_select_version("npm:tool"), store.npm_select_version("npm:tool@^1"), store.npm_acquire("npm:tool@^1"), ) }); assert_eq!(first.unwrap(), "1.0.0"); assert_eq!(second.unwrap(), "1.0.0"); assert_eq!(third.unwrap().identity.version, "1.0.0"); assert_eq!( server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(), 1, "concurrent requests must share one metadata fetch" ); } #[test] fn npm_packument_failures_evict_for_retry_on_same_store() { let server = Registry::new(); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!(block_on(store.npm_select_version("npm:tool")).is_err()); assert_eq!(server.requests.lock().unwrap().len(), 1); server.publish_npm( "tool", "1.0.0", &[( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", )], ); assert_eq!( block_on(store.npm_select_version("npm:tool")).unwrap(), "1.0.0" ); assert_eq!(server.requests.lock().unwrap().len(), 2); { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap(); packument["name"] = serde_json::Value::String("evil".into()); files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap()); } let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_select_version("npm:tool")) .unwrap_err() .to_string() .contains("mismatch") ); { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap(); packument["name"] = serde_json::Value::String("tool".into()); files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap()); } assert_eq!( block_on(store.npm_select_version("npm:tool")).unwrap(), "1.0.0" ); } #[test] fn npm_select_version_uses_offline_disk_cache_without_pins() { let server = Registry::new(); server.publish_npm( "tool", "1.0.0", &[( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", )], ); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:tool")).unwrap(), "1.0.0" ); let warmed = server.requests.lock().unwrap().len(); assert!(warmed > 0); assert!( server .requests .lock() .unwrap() .iter() .all(|path| !path.ends_with(".tgz")) ); let mut offline = server.options(dir.path()); offline.offline = true; let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:tool")).unwrap(), "1.0.0" ); assert_eq!(server.requests.lock().unwrap().len(), warmed); assert!( server .requests .lock() .unwrap() .iter() .all(|path| !path.ends_with(".tgz")) ); } #[test] fn npm_select_version_rejects_bad_metadata_without_evicting_valid_snapshot() { let server = Registry::new(); server.publish_npm( "tool", "1.0.0", &[( "package.json", "{\"name\": \"tool\", \"version\": \"1.0.0\"}", )], ); { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap(); packument["dist-tags"]["latest"] = serde_json::Value::String("../../evil".into()); files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap()); } let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!(block_on(store.npm_select_version("npm:tool")).is_err()); assert_eq!( block_on(store.npm_select_version("npm:tool@1.0.0")).unwrap(), "1.0.0" ); assert_eq!( server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(), 1, "selection errors reuse the valid snapshot" ); assert!(block_on(store.npm_select_version("npm:tool@nope")).is_err()); } fn abbreviated_doc(full: &[u8]) -> Vec { let full: serde_json::Value = serde_json::from_slice(full).unwrap(); let mut versions = serde_json::Map::new(); for (version, entry) in full["versions"].as_object().unwrap() { versions.insert(version.clone(), serde_json::json!({"dist": entry["dist"]})); } serde_json::to_vec(&serde_json::json!({ "name": full["name"], "dist-tags": full["dist-tags"], "versions": versions, })) .unwrap() } #[test] fn abbreviated_install_metadata_selects_and_acquires() { let server = Registry::new(); for version in ["1.0.0", "1.1.0", "2.0.0"] { server.publish_npm( "tool", version, &[ ( "package.json", &format!("{{\"name\": \"tool\", \"version\": \"{version}\"}}"), ), ("index.js", "export default 1;"), ], ); } server.publish_npm( "@scope/pkg", "1.0.0", &[( "package.json", "{\"name\": \"@scope/pkg\", \"version\": \"1.0.0\"}", )], ); { let mut files = server.files.lock().unwrap(); let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap(); packument["dist-tags"]["latest"] = serde_json::Value::String("1.1.0".into()); files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap()); server.publish_npm_abbreviated("tool", abbreviated_doc(&files["/tool"])); server.publish_npm_abbreviated("@scope/pkg", abbreviated_doc(&files["/@scope/pkg"])); files.insert("/tool".into(), b"{\"name\": \"tool\"}".to_vec()); files.insert("/@scope/pkg".into(), b"{\"name\": \"@scope/pkg\"}".to_vec()); } let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:tool")).unwrap(), "1.1.0" ); assert_eq!( block_on(store.npm_select_version("npm:@scope/pkg")).unwrap(), "1.0.0" ); store.save().unwrap(); assert!( !dir.path().join("avril.lock").exists(), "select-only leaves no pins" ); { let before = server.requests.lock().unwrap().len(); let mut offline = server.options(dir.path()); offline.offline = true; let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap(); assert_eq!( block_on(store.npm_select_version("npm:tool@^1")).unwrap(), "1.1.0" ); assert_eq!( server.requests.lock().unwrap().len(), before, "offline reuse makes no network requests" ); } let fetched = server.requests.lock().unwrap().len(); assert_eq!( server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(), 1, "metadata fetched once" ); let artifact = block_on(store.npm_acquire("npm:tool")).unwrap(); assert_eq!(artifact.identity.version, "1.1.0"); assert_eq!( std::fs::read_to_string(artifact.dir.join("index.js")).unwrap(), "export default 1;" ); let scoped = block_on(store.npm_acquire("npm:@scope/pkg")).unwrap(); assert_eq!(scoped.identity.version, "1.0.0"); assert_eq!( server .requests .lock() .unwrap() .iter() .filter(|path| path.as_str() == "/tool") .count(), 1, "acquisition reuses selected metadata" ); assert_eq!(server.requests.lock().unwrap().len(), fetched + 2); } #[test] fn oversized_abbreviated_metadata_is_rejected_within_cap() { let server = Registry::new(); server.publish_npm( "big", "1.0.0", &[( "package.json", "{\"name\": \"big\", \"version\": \"1.0.0\"}", )], ); server.publish_npm_abbreviated("big", vec![b'z'; 8 * 1024 * 1024 + 1]); let dir = tempfile::tempdir().unwrap(); let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(); assert!( block_on(store.npm_select_version("npm:big")) .unwrap_err() .to_string() .contains("too large"), "cap still applies to abbreviated responses" ); } #[test] fn direct_and_binding_imports_agree_on_selection_locking_and_modules() { let server = Registry::new(); for version in ["1.0.0", "1.1.0"] { let files = parity_files(version); server.publish_npm( "parity", version, &files .iter() .map(|(path, source)| (*path, source.as_str())) .collect::>(), ); } let direct = tempfile::tempdir().unwrap(); std::fs::write( direct.path().join("main.js"), "import value from 'npm:parity@^1';\nimport sub from 'npm:parity@^1/sub';\nconsole.log(value, sub);", ) .unwrap(); let bound = tempfile::tempdir().unwrap(); std::fs::write(bound.path().join("avril.conf"), "deps: ['npm:parity@^1']").unwrap(); std::fs::write( bound.path().join("main.js"), "import value from 'parity';\nimport sub from 'parity/sub';\nconsole.log(value, sub);", ) .unwrap(); let direct_stdout = success(&server.run_npm(direct.path(), &["run"], "main.js")); let bound_stdout = success(&server.run_npm(bound.path(), &["run"], "main.js")); // The caret range resolves identically through both routes. assert_eq!(direct_stdout, bound_stdout); assert_eq!(direct_stdout, "v1.1.0 sub-1.1.0\n"); let direct_lock = fixture::read_lock(direct.path()); let bound_lock = fixture::read_lock(bound.path()); assert_eq!( direct_lock["npm"]["specifiers"], bound_lock["npm"]["specifiers"] ); assert_eq!(direct_lock["npm"]["specifiers"]["npm:parity@^1"], "1.1.0"); assert_eq!( direct_lock["npm"]["packages"], bound_lock["npm"]["packages"] ); assert_eq!(direct_lock, bound_lock); // The subpath resolves to the same package instance through both routes. // (Module file paths are per-project materializations, so identity is // compared via the registry/name/version/instance record instead.) assert_eq!( parity_packages(&info_cli(&server, direct.path())), parity_packages(&info_cli(&server, bound.path())) ); }