char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop: consolidate public type and registry test utils7325e8c

main
125.1 KiB3469 linesraw
1#[path = "common/registry.rs"]
2mod fixture;
3
4use avril_runtime::registry::{Options, PackageStore, npm::parse_npm_specifier};
5use fixture::{Registry, success};
6use futures::executor::block_on;
7use std::sync::Arc;
8
9fn offline_frozen_options(server: &Registry, directory: &std::path::Path) -> Options {
10    let mut options = server.options(directory);
11    options.offline = true;
12    options.frozen = true;
13    options
14}
15
16fn parity_files(version: &str) -> Vec<(&'static str, String)> {
17    vec![
18        (
19            "package.json",
20            format!(
21                "{{\"name\": \"parity\", \"version\": \"{version}\", \"type\": \"module\", \"exports\": {{\".\": \"./index.js\", \"./sub\": \"./sub.js\"}}}}"
22            ),
23        ),
24        ("index.js", format!("export default 'v{version}';")),
25        ("sub.js", format!("export default 'sub-{version}';")),
26    ]
27}
28
29fn info_cli(server: &Registry, directory: &std::path::Path) -> serde_json::Value {
30    let output = server
31        .command(directory, "info")
32        .arg("main.js")
33        .output()
34        .unwrap();
35    serde_json::from_str(&success(&output)).unwrap()
36}
37
38/// Project-independent identity of the resolved npm modules: the local
39/// materialization paths differ per project by construction.
40fn parity_packages(info: &serde_json::Value) -> Vec<serde_json::Value> {
41    let mut packages: Vec<_> = info["modules"]
42        .as_object()
43        .unwrap()
44        .values()
45        .filter_map(|module| {
46            let package = &module["package"];
47            if package.get("name").and_then(|name| name.as_str()) != Some("parity") {
48                return None;
49            }
50            let mut package = package.clone();
51            package.as_object_mut().unwrap().remove("file");
52            Some(package)
53        })
54        .collect();
55    packages.sort_by(|a, b| a["instance"].as_str().cmp(&b["instance"].as_str()));
56    packages
57}
58
59fn leftpad_files(version: &str) -> Vec<(&'static str, String)> {
60    vec![
61        (
62            "package.json",
63            format!(
64                "{{\"name\": \"leftpad\", \"version\": \"{version}\", \"type\": \"module\", \"exports\": {{\".\": \"./index.js\"}}}}"
65            ),
66        ),
67        ("index.js", format!("export default {version};")),
68    ]
69}
70
71fn files_ref<'a>(files: &'a [(&'a str, String)]) -> Vec<(&'a str, &'a str)> {
72    files
73        .iter()
74        .map(|(path, source)| (*path, source.as_str()))
75        .collect()
76}
77
78#[test]
79fn selects_ranges_tags_and_latest_and_pins_verified_files() {
80    let server = Registry::new();
81    server.publish_npm("leftpad", "1.0.0", &files_ref(&leftpad_files("1.0.0")));
82    server.publish_npm("leftpad", "1.1.0", &files_ref(&leftpad_files("1.1.0")));
83    let dir = tempfile::tempdir().unwrap();
84    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
85    assert!(parse_npm_specifier("npm:leftpad").is_ok());
86    assert!(parse_npm_specifier("npm:leftpad/sub").is_err());
87    assert!(parse_npm_specifier("npm:../evil").is_err());
88    let latest = block_on(store.npm_acquire("npm:leftpad")).unwrap();
89    assert_eq!(latest.identity.version, "1.1.0");
90    assert_eq!(latest.identity.name, "leftpad");
91    assert!(
92        latest
93            .dir
94            .starts_with(dir.path().join(".avril").join("npm"))
95    );
96    assert!(!latest.dir.to_string_lossy().contains("node_modules"));
97    assert_eq!(
98        std::fs::read_to_string(latest.dir.join("index.js")).unwrap(),
99        "export default 1.1.0;"
100    );
101    assert_eq!(
102        latest.package.get("name").and_then(|v| v.as_str()),
103        Some("leftpad")
104    );
105    let ranged = block_on(store.npm_acquire("npm:leftpad@^1.0.0")).unwrap();
106    assert_eq!(ranged.identity.version, "1.1.0");
107    assert_eq!(ranged.dir, latest.dir);
108    let exact = block_on(store.npm_acquire("npm:leftpad@1.0.0")).unwrap();
109    assert_eq!(exact.identity.version, "1.0.0");
110    assert_ne!(exact.dir, latest.dir);
111    store.save().unwrap();
112    let lock_text = std::fs::read_to_string(dir.path().join("avril.lock")).unwrap();
113    assert!(!lock_text.contains(dir.path().to_string_lossy().as_ref()));
114    assert!(!lock_text.contains("node_modules"));
115    let lock = fixture::read_lock(dir.path());
116    assert_eq!(lock["version"], 2);
117    assert_eq!(lock["npm"]["specifiers"]["npm:leftpad"], "1.1.0");
118    assert!(
119        lock["npm"]["packages"]
120            .as_object()
121            .unwrap()
122            .values()
123            .all(|pin| pin["tarball"]
124                .as_str()
125                .unwrap()
126                .starts_with(server.url.as_str())
127                && pin["integrity"].as_str().unwrap().starts_with("sha512-")
128                && pin["manifest"]
129                    .as_str()
130                    .is_some_and(|digest| digest.starts_with("sha256-"))
131                && pin.get("files").is_none()),
132        "compact pins carry one manifest digest, never the file map"
133    );
134    assert_eq!(
135        std::fs::read_to_string(dir.path().join(".avril").join(".gitignore")).unwrap(),
136        "*\n"
137    );
138}
139
140#[test]
141fn updates_reselect_ranges_but_frozen_requires_the_exact_request() {
142    let server = Registry::new();
143    server.publish_npm("leftpad", "1.0.0", &files_ref(&leftpad_files("1.0.0")));
144    let dir = tempfile::tempdir().unwrap();
145    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
146    block_on(store.npm_acquire("npm:leftpad@^1")).unwrap();
147    store.save().unwrap();
148    server.publish_npm("leftpad", "1.1.0", &files_ref(&leftpad_files("1.1.0")));
149    let mut options = server.options(dir.path());
150    options.update = true;
151    let store = PackageStore::new(fixture::runtime_handle(), options).unwrap();
152    assert_eq!(
153        block_on(store.npm_acquire("npm:leftpad@^1"))
154            .unwrap()
155            .identity
156            .version,
157        "1.1.0"
158    );
159    store.save().unwrap();
160    let mut options = server.options(dir.path());
161    options.frozen = true;
162    let store = PackageStore::new(fixture::runtime_handle(), options).unwrap();
163    let error = block_on(store.npm_acquire("npm:leftpad@~1.1.0"))
164        .unwrap_err()
165        .to_string();
166    assert!(error.contains("Frozen lockfile is missing"), "{error}");
167}
168
169#[test]
170fn provenance_urls_encode_literal_file_names() {
171    use avril_runtime::registry::npm::npm_virtual_module;
172    let registry = url::Url::parse("https://registry.npmjs.org/").unwrap();
173    let url = npm_virtual_module(&registry, "example", "1.0.0", "%2e%2e/asset#name?.js").unwrap();
174    assert_eq!(
175        url.as_str(),
176        "https://registry.npmjs.org/example/1.0.0/%252e%252e/asset%23name%3F.js"
177    );
178    assert!(url.query().is_none());
179    assert!(url.fragment().is_none());
180}
181
182#[test]
183fn warmed_tree_runs_offline_frozen_without_network_or_global_cache() {
184    let server = Registry::new();
185    server.publish_npm(
186        "tiny",
187        "2.0.0",
188        &[
189            (
190                "package.json",
191                "{\"name\": \"tiny\", \"version\": \"2.0.0\"}",
192            ),
193            ("index.js", "export default 1;"),
194        ],
195    );
196    let dir = tempfile::tempdir().unwrap();
197    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
198    let before_requests = server.requests.lock().unwrap().len();
199    block_on(store.npm_acquire("npm:tiny")).unwrap();
200    store.save().unwrap();
201    let warmed_requests = server.requests.lock().unwrap().len();
202    assert!(warmed_requests > before_requests);
203    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
204    let mut offline = offline_frozen_options(&server, dir.path());
205    offline.cache = dir.path().join("fresh-cache-that-must-stay-empty");
206    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
207    let artifact = block_on(store.npm_acquire("npm:tiny")).unwrap();
208    assert_eq!(
209        std::fs::read_to_string(artifact.dir.join("index.js")).unwrap(),
210        "export default 1;"
211    );
212    assert_eq!(
213        server.requests.lock().unwrap().len(),
214        warmed_requests,
215        "frozen offline hits must not touch the network"
216    );
217    assert!(
218        !dir.path().join("fresh-cache-that-must-stay-empty").exists(),
219        "npm must not depend on the global cache"
220    );
221    store.save().unwrap();
222    assert_eq!(
223        std::fs::read(dir.path().join("avril.lock")).unwrap(),
224        lock_before
225    );
226}
227
228#[test]
229fn corrupted_trees_fail_closed_while_absent_trees_materialize() {
230    let server = Registry::new();
231    server.publish_npm(
232        "mut",
233        "1.0.0",
234        &[
235            (
236                "package.json",
237                "{\"name\": \"mut\", \"version\": \"1.0.0\"}",
238            ),
239            ("index.js", "export default 1;"),
240        ],
241    );
242    let dir = tempfile::tempdir().unwrap();
243    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
244    let artifact = block_on(store.npm_acquire("npm:mut")).unwrap();
245    store.save().unwrap();
246    std::fs::write(artifact.dir.join("index.js"), "tampered").unwrap();
247    // A corrupted existing tree fails closed: it is never deleted or
248    // repaired in place, so publication stays atomic and immutable.
249    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
250    assert!(
251        block_on(store.npm_acquire("npm:mut"))
252            .unwrap_err()
253            .to_string()
254            .contains("corrupted")
255    );
256    assert_eq!(
257        std::fs::read_to_string(artifact.dir.join("index.js")).unwrap(),
258        "tampered"
259    );
260    // An absent tree materializes from the verified cache instead.
261    std::fs::remove_dir_all(&artifact.dir).unwrap();
262    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
263    let restored = block_on(store.npm_acquire("npm:mut")).unwrap();
264    assert_eq!(
265        std::fs::read_to_string(restored.dir.join("index.js")).unwrap(),
266        "export default 1;"
267    );
268    store.save().unwrap();
269    // A tampered legacy pin must fail closed instead of repairing: rewrite
270    // the entry in legacy wire form with one file hash pointed at other
271    // bytes, so the tree no longer matches the authenticated map.
272    let mut lock_value = fixture::read_lock(dir.path());
273    let pin = lock_value["npm"]["packages"]
274        .as_object_mut()
275        .unwrap()
276        .values_mut()
277        .next()
278        .unwrap();
279    let sidecar = dir.path().join(".avril").join("npm").join(".manifests");
280    let sidecar = std::fs::read_dir(&sidecar)
281        .unwrap()
282        .next()
283        .unwrap()
284        .unwrap()
285        .path();
286    let mut files: serde_json::Value =
287        serde_json::from_slice(&std::fs::read(&sidecar).unwrap()).unwrap();
288    files["index.js"] = serde_json::Value::String(avril_runtime::registry::checksum(b"tampered"));
289    *pin = serde_json::json!({
290        "tarball": pin["tarball"].clone(),
291        "integrity": pin["integrity"].clone(),
292        "files": files,
293    });
294    std::fs::write(dir.path().join("avril.lock"), format!("{lock_value}\n")).unwrap();
295    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
296    assert!(
297        block_on(store.npm_acquire("npm:mut"))
298            .unwrap_err()
299            .to_string()
300            .contains("corrupted")
301    );
302}
303
304#[test]
305fn malicious_archives_are_rejected() {
306    let server = Registry::new();
307    // tar::Builder refuses `..` itself, so the traversal entry is crafted as
308    // raw ustar blocks: the extractor must still reject it.
309    let traversal = {
310        use flate2::{Compression, write::GzEncoder};
311        use std::io::Write;
312        fn ustar_bytes(name: &str, data: &[u8]) -> Vec<u8> {
313            let mut header = [0u8; 512];
314            header[..name.len()].copy_from_slice(name.as_bytes());
315            header[100..108].copy_from_slice(format!("{:07o}\0", 0o644).as_bytes());
316            header[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
317            header[136..148].copy_from_slice(format!("{:011o}\0", 0).as_bytes());
318            header[156] = b'0';
319            header[257..262].copy_from_slice(b"ustar");
320            header[263..265].copy_from_slice(b"00");
321            let sum: u32 = header.iter().map(|byte| *byte as u32).sum::<u32>() + 8 * 32;
322            header[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
323            let mut out = header.to_vec();
324            out.extend_from_slice(data);
325            while out.len() % 512 != 0 {
326                out.push(0);
327            }
328            out
329        }
330        let package = "{\"name\": \"evil\", \"version\": \"1.0.0\"}";
331        let mut tar_data = ustar_bytes("package/package.json", package.as_bytes());
332        tar_data.extend_from_slice(&ustar_bytes("package/../evil.js", b"evil"));
333        tar_data.extend_from_slice(&[0u8; 1024]);
334        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
335        encoder.write_all(&tar_data).unwrap();
336        encoder.finish().unwrap()
337    };
338    let integrity = Registry::tarball_integrity(&traversal);
339    server.publish_npm_dist("evil", "1.0.0", traversal, &integrity, None, true);
340    let dir = tempfile::tempdir().unwrap();
341    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
342    assert!(
343        block_on(store.npm_acquire("npm:evil"))
344            .unwrap_err()
345            .to_string()
346            .contains("Invalid npm file path")
347    );
348
349    let symlink = {
350        use flate2::{Compression, write::GzEncoder};
351        use std::io::Write;
352        let mut tar_data = Vec::new();
353        {
354            let mut tar = tar::Builder::new(&mut tar_data);
355            let package = "{\"name\": \"link\", \"version\": \"1.0.0\"}";
356            let mut header = tar::Header::new_gnu();
357            header.set_mode(0o644);
358            header.set_size(package.len() as u64);
359            header.set_cksum();
360            tar.append_data(&mut header, "package/package.json", package.as_bytes())
361                .unwrap();
362            let mut header = tar::Header::new_gnu();
363            header.set_entry_type(tar::EntryType::Symlink);
364            header.set_size(0);
365            header.set_cksum();
366            tar.append_link(&mut header, "package/link.js", "/etc/passwd")
367                .unwrap();
368            tar.finish().unwrap();
369        }
370        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
371        encoder.write_all(&tar_data).unwrap();
372        encoder.finish().unwrap()
373    };
374    let integrity = Registry::tarball_integrity(&symlink);
375    server.publish_npm_dist("link", "1.0.0", symlink, &integrity, None, true);
376    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
377    assert!(
378        block_on(store.npm_acquire("npm:link"))
379            .unwrap_err()
380            .to_string()
381            .contains("only regular files and directories")
382    );
383}
384
385#[test]
386fn concurrent_acquisitions_share_a_single_download() {
387    let server = Registry::new();
388    server.publish_npm(
389        "shared",
390        "3.0.0",
391        &[
392            (
393                "package.json",
394                "{\"name\": \"shared\", \"version\": \"3.0.0\"}",
395            ),
396            ("index.js", "export default 1;"),
397        ],
398    );
399    let dir = tempfile::tempdir().unwrap();
400    let store =
401        Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap());
402    // Same-specifier concurrency shares one download via the inflight map;
403    // distinct specifiers resolving to one version may still fetch twice and
404    // converge on the lock through the per-package file lock.
405    let (a, b) = block_on(futures::future::join(
406        store.npm_acquire("npm:shared"),
407        store.npm_acquire("npm:shared"),
408    ));
409    assert!(a.is_ok() && b.is_ok());
410    let tarballs = server
411        .requests
412        .lock()
413        .unwrap()
414        .iter()
415        .filter(|path| path.ends_with(".tgz"))
416        .count();
417    assert_eq!(tarballs, 1);
418    store.save().unwrap();
419}
420
421#[test]
422fn shasum_fallback_works_and_external_tarballs_are_rejected() {
423    let server = Registry::new();
424    let tarball = Registry::tarball_bytes(&[
425        (
426            "package.json",
427            "{\"name\": \"legacy\", \"version\": \"1.0.0\"}",
428        ),
429        ("index.js", "export default 1;"),
430    ]);
431    let shasum = {
432        use sha1::Digest;
433        let digest = sha1::Sha1::digest(&tarball);
434        digest
435            .iter()
436            .map(|byte| format!("{byte:02x}"))
437            .collect::<String>()
438    };
439    server.publish_npm_dist(
440        "legacy",
441        "1.0.0",
442        tarball,
443        "sha512-invalid-will-be-ignored",
444        Some(&shasum),
445        true,
446    );
447    // Repair the fixture entry to carry only the shasum fallback.
448    {
449        let mut files = server.files.lock().unwrap();
450        let mut packument: serde_json::Value = serde_json::from_slice(&files["/legacy"]).unwrap();
451        packument["versions"]["1.0.0"]["dist"]
452            .as_object_mut()
453            .unwrap()
454            .remove("integrity");
455        files.insert("/legacy".into(), serde_json::to_vec(&packument).unwrap());
456    }
457    let dir = tempfile::tempdir().unwrap();
458    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
459    let artifact = block_on(store.npm_acquire("npm:legacy")).unwrap();
460    assert!(artifact.integrity.starts_with("sha1-"));
461
462    server.publish_npm(
463        "ext",
464        "1.0.0",
465        &[(
466            "package.json",
467            "{\"name\": \"ext\", \"version\": \"1.0.0\"}",
468        )],
469    );
470    {
471        let mut files = server.files.lock().unwrap();
472        let mut packument: serde_json::Value = serde_json::from_slice(&files["/ext"]).unwrap();
473        packument["versions"]["1.0.0"]["dist"]["tarball"] =
474            serde_json::Value::String("https://example.com/ext-1.0.0.tgz".into());
475        files.insert("/ext".into(), serde_json::to_vec(&packument).unwrap());
476    }
477    let tgz_before = server
478        .requests
479        .lock()
480        .unwrap()
481        .iter()
482        .filter(|path| path.ends_with(".tgz"))
483        .count();
484    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
485    assert!(
486        block_on(store.npm_acquire("npm:ext"))
487            .unwrap_err()
488            .to_string()
489            .contains("escapes the registry")
490    );
491    // Learning the external tarball URL costs one packument fetch; the
492    // tarball itself must never be fetched.
493    assert_eq!(
494        server
495            .requests
496            .lock()
497            .unwrap()
498            .iter()
499            .filter(|path| path.ends_with(".tgz"))
500            .count(),
501        tgz_before
502    );
503}
504
505#[test]
506fn denied_packages_are_not_fetched() {
507    use avril_runtime::caps::{Capabilities, Category, Initial};
508    let server = Registry::new();
509    server.publish_npm(
510        "secret",
511        "1.0.0",
512        &[(
513            "package.json",
514            "{\"name\": \"secret\", \"version\": \"1.0.0\"}",
515        )],
516    );
517    let dir = tempfile::tempdir().unwrap();
518    let mut initial = Initial::new(true);
519    initial
520        .grant(Category::JsExec, true, "https://jsr.io/", dir.path())
521        .unwrap();
522    let caps = Capabilities::new(initial, dir.path().to_owned());
523    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
524        .unwrap()
525        .with_capabilities(caps);
526    assert!(
527        block_on(store.npm_acquire("npm:secret"))
528            .unwrap_err()
529            .to_string()
530            .contains("PermissionDenied")
531    );
532    assert!(server.requests.lock().unwrap().is_empty());
533}
534
535#[test]
536fn locks_merge_and_prune_npm_pins() {
537    let server = Registry::new();
538    server.publish_npm(
539        "alpha",
540        "1.0.0",
541        &[(
542            "package.json",
543            "{\"name\": \"alpha\", \"version\": \"1.0.0\"}",
544        )],
545    );
546    server.publish_npm(
547        "beta",
548        "1.0.0",
549        &[(
550            "package.json",
551            "{\"name\": \"beta\", \"version\": \"1.0.0\"}",
552        )],
553    );
554    let dir = tempfile::tempdir().unwrap();
555    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
556    block_on(store.npm_acquire("npm:alpha")).unwrap();
557    block_on(store.npm_acquire("npm:beta")).unwrap();
558    store.save().unwrap();
559    assert_eq!(
560        fixture::read_lock(dir.path())["npm"]["specifiers"]
561            .as_object()
562            .unwrap()
563            .len(),
564        2
565    );
566    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
567    block_on(store.npm_acquire("npm:alpha")).unwrap();
568    store.prune().unwrap();
569    let lock = fixture::read_lock(dir.path());
570    assert!(lock["npm"]["specifiers"].get("npm:alpha").is_some());
571    assert!(lock["npm"]["specifiers"].get("npm:beta").is_none());
572}
573
574#[test]
575fn cold_frozen_pins_download_without_metadata_queries() {
576    let server = Registry::new();
577    server.publish_npm(
578        "cold",
579        "1.2.3",
580        &[
581            (
582                "package.json",
583                "{\"name\": \"cold\", \"version\": \"1.2.3\"}",
584            ),
585            ("index.js", "export default 1;"),
586        ],
587    );
588    let dir = tempfile::tempdir().unwrap();
589    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
590    let artifact = block_on(store.npm_acquire("npm:cold@^1")).unwrap();
591    store.save().unwrap();
592    let warmed = server.requests.lock().unwrap().len();
593    // Cold: remove the tree and the tarball cache but keep the lock.
594    std::fs::remove_dir_all(&artifact.dir).unwrap();
595    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
596    let mut frozen = server.options(dir.path());
597    frozen.frozen = true;
598    let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap();
599    let restored = block_on(store.npm_acquire("npm:cold@^1")).unwrap();
600    assert_eq!(restored.identity.version, "1.2.3");
601    let paths = server.requests.lock().unwrap().clone();
602    assert!(
603        paths[warmed..].iter().all(|path| path.ends_with(".tgz")),
604        "frozen pins must not query mutable packument metadata: {paths:?}"
605    );
606    assert!(
607        paths[warmed..].iter().any(|path| path.ends_with(".tgz")),
608        "frozen pins still download their artifacts: {paths:?}"
609    );
610}
611
612#[test]
613fn offline_update_selects_from_cached_metadata() {
614    let server = Registry::new();
615    server.publish_npm(
616        "cached",
617        "1.0.0",
618        &[(
619            "package.json",
620            "{\"name\": \"cached\", \"version\": \"1.0.0\"}",
621        )],
622    );
623    let dir = tempfile::tempdir().unwrap();
624    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
625    block_on(store.npm_acquire("npm:cached@1.0.0")).unwrap();
626    store.save().unwrap();
627    let warmed = server.requests.lock().unwrap().len();
628    // update clears specifiers; offline forces selection from cached metadata.
629    let mut offline = server.options(dir.path());
630    offline.update = true;
631    offline.offline = true;
632    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
633    let artifact = block_on(store.npm_acquire("npm:cached@*")).unwrap();
634    assert_eq!(artifact.identity.version, "1.0.0");
635    assert_eq!(server.requests.lock().unwrap().len(), warmed);
636}
637
638#[test]
639fn new_ranges_reuse_pinned_artifacts_without_metadata() {
640    let server = Registry::new();
641    server.publish_npm(
642        "reuse",
643        "2.1.0",
644        &[
645            (
646                "package.json",
647                "{\"name\": \"reuse\", \"version\": \"2.1.0\"}",
648            ),
649            ("index.js", "export default 1;"),
650        ],
651    );
652    let dir = tempfile::tempdir().unwrap();
653    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
654    let first = block_on(store.npm_acquire("npm:reuse@^2.0.0")).unwrap();
655    store.save().unwrap();
656    let warmed = server.requests.lock().unwrap().len();
657    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
658    let second = block_on(store.npm_acquire("npm:reuse@~2.1.0")).unwrap();
659    assert_eq!(second.dir, first.dir);
660    assert_eq!(
661        server.requests.lock().unwrap().len(),
662        warmed,
663        "an already-pinned version needs no metadata round trip"
664    );
665    store.save().unwrap();
666    let lock = fixture::read_lock(dir.path());
667    assert_eq!(lock["npm"]["specifiers"]["npm:reuse@~2.1.0"], "2.1.0");
668}
669
670#[test]
671fn archive_directories_materialize_nested_files() {
672    let server = Registry::new();
673    let tarball = {
674        use flate2::{Compression, write::GzEncoder};
675        use std::io::Write;
676        let mut tar_data = Vec::new();
677        {
678            let mut tar = tar::Builder::new(&mut tar_data);
679            tar.append_dir("package/", ".").unwrap();
680            tar.append_dir("package/lib/", ".").unwrap();
681            for (path, data) in [
682                (
683                    "package/package.json",
684                    "{\"name\": \"nested\", \"version\": \"1.0.0\"}",
685                ),
686                ("package/index.js", "export * from './lib/util.js';"),
687                ("package/lib/util.js", "export default 1;"),
688            ] {
689                let mut header = tar::Header::new_gnu();
690                header.set_mode(0o644);
691                header.set_size(data.len() as u64);
692                header.set_cksum();
693                tar.append_data(&mut header, path, data.as_bytes()).unwrap();
694            }
695            tar.finish().unwrap();
696        }
697        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
698        encoder.write_all(&tar_data).unwrap();
699        encoder.finish().unwrap()
700    };
701    let integrity = Registry::tarball_integrity(&tarball);
702    server.publish_npm_dist("nested", "1.0.0", tarball, &integrity, None, true);
703    let dir = tempfile::tempdir().unwrap();
704    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
705    let artifact = block_on(store.npm_acquire("npm:nested")).unwrap();
706    assert_eq!(
707        std::fs::read_to_string(artifact.dir.join("lib").join("util.js")).unwrap(),
708        "export default 1;"
709    );
710    assert_eq!(
711        artifact.read_file(dir.path(), "lib/util.js").unwrap(),
712        b"export default 1;"
713    );
714    assert!(!artifact.files.contains_key("lib"));
715}
716
717#[test]
718fn warmed_scan_checks_nested_siblings_and_unused_files() {
719    let server = Registry::new();
720    server.publish_npm(
721        "nested-files",
722        "1.0.0",
723        &[
724            (
725                "package.json",
726                r#"{"name":"nested-files","version":"1.0.0"}"#,
727            ),
728            ("lib/entry.js", "export const value = 1;"),
729            ("lib/sibling.js", "export const sibling = 2;"),
730            ("lib/README.txt", "not executable code"),
731        ],
732    );
733    let dir = tempfile::tempdir().unwrap();
734    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
735    let artifact = block_on(store.npm_acquire("npm:nested-files")).unwrap();
736    store.save().unwrap();
737    std::fs::write(artifact.dir.join("lib/README.txt"), "tampered").unwrap();
738
739    let offline = offline_frozen_options(&server, dir.path());
740    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
741    assert!(
742        block_on(store.npm_acquire("npm:nested-files"))
743            .unwrap_err()
744            .to_string()
745            .contains("corrupted")
746    );
747}
748
749#[cfg(unix)]
750#[test]
751fn warmed_scan_rejects_nested_directory_symlinks() {
752    use std::os::unix::fs::symlink;
753
754    let server = Registry::new();
755    server.publish_npm(
756        "nested-link",
757        "1.0.0",
758        &[
759            (
760                "package.json",
761                r#"{"name":"nested-link","version":"1.0.0"}"#,
762            ),
763            ("lib/entry.js", "export const value = 1;"),
764        ],
765    );
766    let dir = tempfile::tempdir().unwrap();
767    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
768    let artifact = block_on(store.npm_acquire("npm:nested-link")).unwrap();
769    store.save().unwrap();
770    let outside = dir.path().join("outside");
771    std::fs::create_dir(&outside).unwrap();
772    std::fs::write(outside.join("entry.js"), "export const value = 1;").unwrap();
773    std::fs::remove_dir_all(artifact.dir.join("lib")).unwrap();
774    symlink(outside, artifact.dir.join("lib")).unwrap();
775
776    let offline = offline_frozen_options(&server, dir.path());
777    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
778    assert!(
779        block_on(store.npm_acquire("npm:nested-link"))
780            .unwrap_err()
781            .to_string()
782            .contains("corrupted")
783    );
784}
785
786#[test]
787fn acquisition_rechecks_tree_after_materialization_callback() {
788    use avril_runtime::registry::ProgressEvent;
789    use std::sync::atomic::{AtomicBool, Ordering};
790
791    let server = Registry::new();
792    server.publish_npm(
793        "finish-check",
794        "1.0.0",
795        &[
796            (
797                "package.json",
798                r#"{"name":"finish-check","version":"1.0.0"}"#,
799            ),
800            ("index.js", "export const value = 1;"),
801        ],
802    );
803    let dir = tempfile::tempdir().unwrap();
804    let root = dir.path().to_owned();
805    let tampered = Arc::new(AtomicBool::new(false));
806    let callback_tampered = tampered.clone();
807    let sink: Arc<dyn Fn(&ProgressEvent) + Send + Sync> = Arc::new(move |event| {
808        if matches!(event, ProgressEvent::NpmMaterialized { name, .. } if name == "finish-check") {
809            let package_root = root.join(".avril/npm/default");
810            let registry_dir = std::fs::read_dir(package_root)
811                .unwrap()
812                .next()
813                .unwrap()
814                .unwrap()
815                .path();
816            std::fs::write(registry_dir.join("finish-check/1.0.0/index.js"), "tampered").unwrap();
817            callback_tampered.store(true, Ordering::SeqCst);
818        }
819    });
820    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
821        .unwrap()
822        .with_progress(sink);
823    let error = block_on(store.npm_acquire("npm:finish-check"))
824        .expect_err("acquisition must reject callback tampering");
825    assert!(
826        tampered.load(Ordering::SeqCst),
827        "callback did not tamper with the file"
828    );
829    assert!(
830        error.to_string().contains("integrity mismatch: index.js"),
831        "unexpected acquisition error: {error}"
832    );
833}
834
835#[test]
836fn acquired_artifact_rechecks_bytes_when_read() {
837    let server = Registry::new();
838    server.publish_npm(
839        "read-check",
840        "1.0.0",
841        &[
842            ("package.json", r#"{"name":"read-check","version":"1.0.0"}"#),
843            ("lib/entry.js", "export const value = 1;"),
844        ],
845    );
846    let dir = tempfile::tempdir().unwrap();
847    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
848    let artifact = block_on(store.npm_acquire("npm:read-check")).unwrap();
849    assert_eq!(
850        artifact.read_file(dir.path(), "lib/entry.js").unwrap(),
851        b"export const value = 1;"
852    );
853    std::fs::write(artifact.dir.join("lib/entry.js"), "tampered").unwrap();
854    let error = artifact.read_file(dir.path(), "lib/entry.js").unwrap_err();
855    assert!(
856        error
857            .to_string()
858            .contains("integrity mismatch: lib/entry.js"),
859        "unexpected read error: {error}"
860    );
861}
862
863#[test]
864fn malicious_tags_and_versions_never_reach_the_filesystem() {
865    let server = Registry::new();
866    server.publish_npm(
867        "tagged",
868        "1.0.0",
869        &[(
870            "package.json",
871            "{\"name\": \"tagged\", \"version\": \"1.0.0\"}",
872        )],
873    );
874    {
875        let mut files = server.files.lock().unwrap();
876        let mut packument: serde_json::Value = serde_json::from_slice(&files["/tagged"]).unwrap();
877        packument["dist-tags"]["evil"] = serde_json::Value::String("../../evil".into());
878        packument["versions"]["../../evil"] = serde_json::json!({
879            "name": "tagged",
880            "version": "../../evil",
881            "dist": {"tarball": "http://127.0.0.1:9/x.tgz", "shasum": "da39a3ee5e6b4b0d3255bfef95601890afd80709"},
882        });
883        files.insert("/tagged".into(), serde_json::to_vec(&packument).unwrap());
884    }
885    let dir = tempfile::tempdir().unwrap();
886    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
887    let before = server.requests.lock().unwrap().len();
888    assert!(
889        block_on(store.npm_acquire("npm:tagged@evil"))
890            .unwrap_err()
891            .to_string()
892            .contains("invalid version")
893    );
894    assert!(
895        server.requests.lock().unwrap()[before..]
896            .iter()
897            .all(|path| !path.ends_with(".tgz")),
898        "invalid selected versions must never be fetched"
899    );
900    assert!(
901        !dir.path()
902            .join(".avril")
903            .join("npm")
904            .join("default")
905            .exists()
906    );
907}
908
909#[test]
910#[cfg(unix)]
911fn warmed_readonly_trees_need_no_writes() {
912    use std::os::unix::fs::PermissionsExt;
913    let server = Registry::new();
914    server.publish_npm(
915        "ro",
916        "1.0.0",
917        &[
918            ("package.json", "{\"name\": \"ro\", \"version\": \"1.0.0\"}"),
919            ("index.js", "export default 1;"),
920        ],
921    );
922    let dir = tempfile::tempdir().unwrap();
923    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
924    let artifact = block_on(store.npm_acquire("npm:ro")).unwrap();
925    store.save().unwrap();
926    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
927    fn readonly(path: &std::path::Path) {
928        for entry in walkdir(path) {
929            std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).unwrap();
930        }
931        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o555)).unwrap();
932    }
933    fn walkdir(path: &std::path::Path) -> Vec<std::path::PathBuf> {
934        let mut out = Vec::new();
935        for entry in std::fs::read_dir(path).unwrap() {
936            let entry = entry.unwrap().path();
937            if entry.is_dir() {
938                out.extend(walkdir(&entry));
939            }
940            out.push(entry);
941        }
942        out
943    }
944    let npm_tree = dir.path().join(".avril").join("npm");
945    readonly(&npm_tree);
946    let offline = offline_frozen_options(&server, dir.path());
947    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
948    let hit = block_on(store.npm_acquire("npm:ro")).unwrap();
949    assert_eq!(hit.dir, artifact.dir);
950    assert_eq!(
951        std::fs::read(dir.path().join("avril.lock")).unwrap(),
952        lock_before
953    );
954    for entry in walkdir(&npm_tree) {
955        let _ = std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o755));
956    }
957    let _ = std::fs::set_permissions(&npm_tree, std::fs::Permissions::from_mode(0o755));
958}
959
960#[test]
961fn registries_are_isolated_on_disk_and_in_locks() {
962    let first = Registry::new();
963    first.publish_npm(
964        "iso",
965        "1.0.0",
966        &[(
967            "package.json",
968            "{\"name\": \"iso\", \"version\": \"1.0.0\"}",
969        )],
970    );
971    let second = Registry::new();
972    second.publish_npm(
973        "iso",
974        "9.9.9",
975        &[(
976            "package.json",
977            "{\"name\": \"iso\", \"version\": \"9.9.9\"}",
978        )],
979    );
980    let dir = tempfile::tempdir().unwrap();
981    let mut a = first.options(dir.path());
982    a.lock = dir.path().join("a.lock");
983    let store = PackageStore::new(fixture::runtime_handle(), a).unwrap();
984    let artifact_a = block_on(store.npm_acquire("npm:iso")).unwrap();
985    store.save().unwrap();
986    let mut b = second.options(dir.path());
987    b.lock = dir.path().join("b.lock");
988    let store = PackageStore::new(fixture::runtime_handle(), b).unwrap();
989    let artifact_b = block_on(store.npm_acquire("npm:iso")).unwrap();
990    assert_eq!(artifact_b.identity.version, "9.9.9");
991    assert_ne!(artifact_a.dir, artifact_b.dir);
992    // A lock pinned to one registry is rejected under another.
993    let mut mismatched = first.options(dir.path());
994    mismatched.lock = dir.path().join("a.lock");
995    mismatched.npm_registry = second.url.clone();
996    assert!(
997        PackageStore::new(fixture::runtime_handle(), mismatched)
998            .map(|_| ())
999            .unwrap_err()
1000            .to_string()
1001            .contains("npm registry")
1002    );
1003}
1004
1005#[test]
1006fn corrupted_caches_fail_closed() {
1007    let server = Registry::new();
1008    server.publish_npm(
1009        "cache",
1010        "1.0.0",
1011        &[
1012            (
1013                "package.json",
1014                "{\"name\": \"cache\", \"version\": \"1.0.0\"}",
1015            ),
1016            ("index.js", "export default 1;"),
1017        ],
1018    );
1019    let dir = tempfile::tempdir().unwrap();
1020    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1021    block_on(store.npm_acquire("npm:cache")).unwrap();
1022    store.save().unwrap();
1023    let mut blob = None;
1024    let artifacts = dir.path().join(".avril").join("npm").join(".artifacts");
1025    for entry in std::fs::read_dir(artifacts.join("tarballs")).unwrap() {
1026        let path = entry.unwrap().path();
1027        blob = Some(path);
1028    }
1029    let blob = blob.unwrap();
1030    let mut bytes = std::fs::read(&blob).unwrap();
1031    let middle = bytes.len() / 2;
1032    bytes[middle] ^= 0xff;
1033    std::fs::write(&blob, bytes).unwrap();
1034    // Evict the tree so the corrupt cache entry is actually read.
1035    for entry in std::fs::read_dir(dir.path().join(".avril").join("npm").join("default")).unwrap() {
1036        std::fs::remove_dir_all(entry.unwrap().path()).unwrap();
1037    }
1038    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1039    assert!(
1040        block_on(store.npm_acquire("npm:cache"))
1041            .unwrap_err()
1042            .to_string()
1043            .contains("integrity mismatch")
1044    );
1045}
1046
1047#[test]
1048fn version_and_file_grants_authorize_without_tarball_urls() {
1049    use avril_runtime::caps::{Capabilities, Category, Initial};
1050    let server = Registry::new();
1051    server.publish_npm(
1052        "scoped",
1053        "4.0.0",
1054        &[
1055            (
1056                "package.json",
1057                "{\"name\": \"scoped\", \"version\": \"4.0.0\"}",
1058            ),
1059            ("index.js", "export default 1;"),
1060            ("secret.js", "export default 2;"),
1061        ],
1062    );
1063    let dir = tempfile::tempdir().unwrap();
1064    let version_url = format!("{}scoped/4.0.0/", server.url);
1065    let mut initial = Initial::new(true);
1066    initial
1067        .grant(Category::JsExec, false, &version_url, dir.path())
1068        .unwrap();
1069    let caps = Capabilities::new(initial, dir.path().to_owned());
1070    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
1071        .unwrap()
1072        .with_capabilities(caps);
1073    let artifact = block_on(store.npm_acquire("npm:scoped")).unwrap();
1074    assert_eq!(artifact.identity.version, "4.0.0");
1075
1076    // A grant on one file alone still authorizes the package fetch.
1077    let dir = tempfile::tempdir().unwrap();
1078    let file_url = format!("{}scoped/4.0.0/index.js", server.url);
1079    let mut initial = Initial::new(true);
1080    initial
1081        .grant(Category::JsExec, false, &file_url, dir.path())
1082        .unwrap();
1083    let caps = Capabilities::new(initial, dir.path().to_owned());
1084    // Seed the second project from the first server without extra grants.
1085    let mut options = server.options(dir.path());
1086    options.cache = dir.path().join("cache");
1087    let store = PackageStore::new(fixture::runtime_handle(), options)
1088        .unwrap()
1089        .with_capabilities(caps);
1090    let artifact = block_on(store.npm_acquire("npm:scoped")).unwrap();
1091    assert_eq!(
1092        artifact.read_file(dir.path(), "index.js").unwrap(),
1093        b"export default 1;"
1094    );
1095
1096    // A denied sibling does not block the package, and revoking the version
1097    // base blocks later acquisitions without further fetches.
1098    let dir = tempfile::tempdir().unwrap();
1099    let mut initial = Initial::new(true);
1100    initial
1101        .grant(Category::JsExec, false, &version_url, dir.path())
1102        .unwrap();
1103    let caps = Capabilities::new(initial, dir.path().to_owned());
1104    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
1105        .unwrap()
1106        .with_capabilities(caps.clone());
1107    block_on(store.npm_acquire("npm:scoped")).unwrap();
1108    let fetched = server.requests.lock().unwrap().len();
1109    caps.pledge(vec![(Category::JsExec, None, vec![version_url.clone()])])
1110        .unwrap();
1111    assert!(
1112        block_on(store.npm_acquire("npm:scoped"))
1113            .unwrap_err()
1114            .to_string()
1115            .contains("PermissionDenied")
1116    );
1117    assert_eq!(server.requests.lock().unwrap().len(), fetched);
1118}
1119
1120#[test]
1121fn failed_prepares_do_not_leak_acquisitions_into_save_graph() {
1122    use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest};
1123    use std::collections::{BTreeMap, BTreeSet, HashSet};
1124    let server = Registry::new();
1125    server.publish_npm(
1126        "plug",
1127        "1.0.0",
1128        &[
1129            ("package.json", "{\"name\": \"plug\", \"version\": \"1.0.0\", \"type\": \"module\", \"peerDependencies\": {\"host\": \"^1.0.0\"}}"),
1130            ("index.js", "export {version} from 'host';"),
1131        ],
1132    );
1133    for version in ["1.0.0", "2.0.0"] {
1134        server.publish_npm(
1135            "host",
1136            version,
1137            &[
1138                (
1139                    "package.json",
1140                    &format!(
1141                        "{{\"name\": \"host\", \"version\": \"{version}\", \"type\": \"module\"}}"
1142                    ),
1143                ),
1144                ("index.js", "export const version = 'h';"),
1145            ],
1146        );
1147    }
1148    server.publish_npm(
1149        "good",
1150        "1.0.0",
1151        &[(
1152            "package.json",
1153            "{\"name\": \"good\", \"version\": \"1.0.0\", \"type\": \"module\"}",
1154        )],
1155    );
1156    let dir = tempfile::tempdir().unwrap();
1157    let store =
1158        Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap());
1159    let resolver = NpmResolver::new(store.clone());
1160    let host2 = format!("{}|host|2.0.0", server.url.as_str());
1161    // The conflicting peer fails the graph after its acquisitions recorded
1162    // usage; the concurrent success must not save any of that.
1163    let (failed, succeeded) = block_on(futures::future::join(
1164        resolver.prepare(&[NpmRootRequest {
1165            request: "npm:plug@1".into(),
1166            context: BTreeMap::from([("host".to_owned(), host2)]),
1167        }]),
1168        resolver.prepare(&[NpmRootRequest {
1169            request: "npm:good@1".into(),
1170            context: BTreeMap::new(),
1171        }]),
1172    ));
1173    failed.unwrap_err();
1174    let handle = succeeded.unwrap();
1175    let tracked: BTreeSet<String> = handle.roots.keys().cloned().collect();
1176    store.save_graph(&tracked, &HashSet::new()).unwrap();
1177    let lock: serde_json::Value = fixture::read_lock(dir.path());
1178    let text = serde_json::to_string(&lock).unwrap();
1179    assert!(text.contains("npm:good@1"), "{text}");
1180    assert!(!text.contains("plug"), "{text}");
1181    // Pruning afterwards cannot resurrect the failed acquisitions either.
1182    store.prune().unwrap();
1183    let lock: serde_json::Value = fixture::read_lock(dir.path());
1184    let text = serde_json::to_string(&lock).unwrap();
1185    assert!(text.contains("npm:good@1"), "{text}");
1186    assert!(!text.contains("plug"), "{text}");
1187}
1188
1189#[test]
1190fn save_graph_and_prune_keep_only_successful_contexts() {
1191    use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest};
1192    use std::collections::{BTreeMap, BTreeSet, HashSet};
1193    let server = Registry::new();
1194    server.publish_npm(
1195        "plug",
1196        "1.0.0",
1197        &[
1198            ("package.json", "{\"name\": \"plug\", \"version\": \"1.0.0\", \"type\": \"module\", \"peerDependencies\": {\"host\": \"*\"}}"),
1199            ("index.js", "export {version} from 'host';"),
1200        ],
1201    );
1202    for version in ["1.0.0", "2.0.0"] {
1203        server.publish_npm(
1204            "host",
1205            version,
1206            &[
1207                (
1208                    "package.json",
1209                    &format!(
1210                        "{{\"name\": \"host\", \"version\": \"{version}\", \"type\": \"module\"}}"
1211                    ),
1212                ),
1213                ("index.js", "export const version = 'h';"),
1214            ],
1215        );
1216    }
1217    let dir = tempfile::tempdir().unwrap();
1218    let store =
1219        Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap());
1220    let resolver = NpmResolver::new(store.clone());
1221    let context = |version: &str| {
1222        BTreeMap::from([(
1223            "host".to_owned(),
1224            format!("{}|host|{version}", server.url.as_str()),
1225        )])
1226    };
1227    let _first = block_on(resolver.prepare(&[NpmRootRequest {
1228        request: "npm:plug@1".into(),
1229        context: context("1.0.0"),
1230    }]))
1231    .unwrap();
1232    let second = block_on(resolver.prepare(&[NpmRootRequest {
1233        request: "npm:plug@1".into(),
1234        context: context("2.0.0"),
1235    }]))
1236    .unwrap();
1237    // Same request, two peer contexts: saving only the second keeps
1238    // exactly its roots and instances, never base-wide leftovers.
1239    let tracked: BTreeSet<String> = second.roots.keys().cloned().collect();
1240    assert!(!tracked.is_empty());
1241    store.save_graph(&tracked, &HashSet::new()).unwrap();
1242    let lock: serde_json::Value = fixture::read_lock(dir.path());
1243    let roots = lock["npm"]["roots"].as_object().unwrap();
1244    assert_eq!(roots.len(), 1, "{roots:?}");
1245    assert!(
1246        roots.keys().next().unwrap().contains("|host|2.0.0"),
1247        "{roots:?}"
1248    );
1249    let instances = lock["npm"]["instances"].as_object().unwrap();
1250    assert!(
1251        instances.keys().all(|id| {
1252            let pin = &instances[id];
1253            !serde_json::to_string(pin).unwrap().contains("|host|1.0.0")
1254        }),
1255        "{instances:?}"
1256    );
1257    store.prune().unwrap();
1258    let lock: serde_json::Value = fixture::read_lock(dir.path());
1259    assert_eq!(lock["npm"]["roots"].as_object().unwrap().len(), 1);
1260}
1261
1262#[test]
1263#[cfg(unix)]
1264fn symlinked_npm_parent_fails_closed() {
1265    use std::os::unix::fs::symlink;
1266    let server = Registry::new();
1267    server.publish_npm(
1268        "linkparent",
1269        "1.0.0",
1270        &[(
1271            "package.json",
1272            "{\"name\": \"linkparent\", \"version\": \"1.0.0\"}",
1273        )],
1274    );
1275    let dir = tempfile::tempdir().unwrap();
1276    let evil = tempfile::tempdir().unwrap();
1277    symlink(evil.path(), dir.path().join(".avril")).unwrap();
1278    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1279    let error = block_on(store.npm_acquire("npm:linkparent"))
1280        .unwrap_err()
1281        .to_string();
1282    // No-follow descents reject the swapped parent instead of writing
1283    // through it; the exact OS error text is platform dependent.
1284    assert!(
1285        error.contains("usable")
1286            || error.contains("corrupt")
1287            || error.contains("project")
1288            || error.contains("directory")
1289            || error.contains("Directory"),
1290        "{error}"
1291    );
1292    // Nothing was written through the link.
1293    assert!(
1294        std::fs::read_dir(evil.path()).unwrap().next().is_none(),
1295        "symlinked parent must not be written through"
1296    );
1297}
1298
1299#[test]
1300#[cfg(unix)]
1301fn cache_file_symlink_fails_closed() {
1302    use std::os::unix::fs::symlink;
1303    let server = Registry::new();
1304    server.publish_npm(
1305        "cachelink",
1306        "1.0.0",
1307        &[
1308            (
1309                "package.json",
1310                "{\"name\": \"cachelink\", \"version\": \"1.0.0\"}",
1311            ),
1312            ("index.js", "export default 1;"),
1313        ],
1314    );
1315    let dir = tempfile::tempdir().unwrap();
1316    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1317    let artifact = block_on(store.npm_acquire("npm:cachelink")).unwrap();
1318    store.save().unwrap();
1319    // Replace the tarball cache blob with a symlink, even one pointing at
1320    // valid bytes, and evict the tree so the cache is actually read.
1321    let mut blob = None;
1322    for entry in std::fs::read_dir(
1323        dir.path()
1324            .join(".avril")
1325            .join("npm")
1326            .join(".artifacts")
1327            .join("tarballs"),
1328    )
1329    .unwrap()
1330    {
1331        blob = Some(entry.unwrap().path());
1332    }
1333    let blob = blob.unwrap();
1334    let bytes = std::fs::read(&blob).unwrap();
1335    std::fs::remove_file(&blob).unwrap();
1336    let elsewhere = dir.path().join("elsewhere.tgz");
1337    std::fs::write(&elsewhere, &bytes).unwrap();
1338    symlink(&elsewhere, &blob).unwrap();
1339    std::fs::remove_dir_all(&artifact.dir).unwrap();
1340    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1341    let error = block_on(store.npm_acquire("npm:cachelink"))
1342        .unwrap_err()
1343        .to_string();
1344    assert!(
1345        error.contains("usable") || error.contains("integrity") || error.contains("corrupt"),
1346        "{error}"
1347    );
1348}
1349
1350#[test]
1351#[cfg(unix)]
1352fn lock_path_symlink_and_fifo_fail_without_hanging() {
1353    use std::os::unix::fs::symlink;
1354    let server = Registry::new();
1355    server.publish_npm(
1356        "locklink",
1357        "1.0.0",
1358        &[
1359            (
1360                "package.json",
1361                "{\"name\": \"locklink\", \"version\": \"1.0.0\"}",
1362            ),
1363            ("index.js", "export default 1;"),
1364        ],
1365    );
1366    let dir = tempfile::tempdir().unwrap();
1367    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1368    let artifact = block_on(store.npm_acquire("npm:locklink")).unwrap();
1369    store.save().unwrap();
1370    // Verified hits take no lock: removing the lock tree entirely still hits.
1371    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".locks")).unwrap();
1372    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1373    assert!(block_on(store.npm_acquire("npm:locklink")).is_ok());
1374    // A symlinked lock directory fails a miss instead of locking elsewhere.
1375    std::fs::remove_dir_all(&artifact.dir).unwrap();
1376    let evil = tempfile::tempdir().unwrap();
1377    symlink(
1378        evil.path(),
1379        dir.path().join(".avril").join("npm").join(".locks"),
1380    )
1381    .unwrap();
1382    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1383    let error = block_on(store.npm_acquire("npm:locklink"))
1384        .unwrap_err()
1385        .to_string();
1386    assert!(
1387        error.contains("usable")
1388            || error.contains("regular")
1389            || error.contains("corrupt")
1390            || error.contains("lock")
1391            || error.contains("directory")
1392            || error.contains("Directory"),
1393        "{error}"
1394    );
1395    assert!(std::fs::read_dir(evil.path()).unwrap().next().is_none());
1396    // A FIFO in place of the lock directory fails promptly instead of hanging.
1397    std::fs::remove_file(dir.path().join(".avril").join("npm").join(".locks")).unwrap();
1398    nix_fifo(dir.path().join(".avril").join("npm").join(".locks"));
1399    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1400    let error = block_on(store.npm_acquire("npm:locklink"))
1401        .unwrap_err()
1402        .to_string();
1403    assert!(
1404        error.contains("usable")
1405            || error.contains("directory")
1406            || error.contains("corrupt")
1407            || error.contains("lock"),
1408        "{error}"
1409    );
1410}
1411
1412#[test]
1413#[cfg(unix)]
1414fn materialized_fifo_sources_fail_promptly() {
1415    let server = Registry::new();
1416    server.publish_npm(
1417        "fifosrc",
1418        "1.0.0",
1419        &[
1420            (
1421                "package.json",
1422                "{\"name\": \"fifosrc\", \"version\": \"1.0.0\"}",
1423            ),
1424            ("index.js", "export default 1;"),
1425        ],
1426    );
1427    let dir = tempfile::tempdir().unwrap();
1428    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1429    let artifact = block_on(store.npm_acquire("npm:fifosrc")).unwrap();
1430    store.save().unwrap();
1431    // Swap a materialized source for a FIFO: the bounded regular-file read
1432    // must fail promptly (non-blocking open + fstat) rather than hang.
1433    std::fs::remove_file(artifact.dir.join("index.js")).unwrap();
1434    nix_fifo(artifact.dir.join("index.js"));
1435    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1436    let (send, receive) = std::sync::mpsc::channel();
1437    std::thread::spawn(move || {
1438        send.send(block_on(store.npm_acquire("npm:fifosrc")))
1439            .unwrap();
1440    });
1441    let result = receive
1442        .recv_timeout(std::time::Duration::from_secs(15))
1443        .expect("materialized FIFO must fail promptly, not hang");
1444    let error = result.unwrap_err().to_string();
1445    assert!(
1446        error.contains("regular") || error.contains("corrupt") || error.contains("missing"),
1447        "{error}"
1448    );
1449    // The loader-facing primitive fails the same way.
1450    let error = artifact
1451        .read_file(dir.path(), "index.js")
1452        .unwrap_err()
1453        .to_string();
1454    assert!(
1455        error.contains("regular") || error.contains("missing") || error.contains("corrupt"),
1456        "{error}"
1457    );
1458}
1459
1460#[test]
1461#[cfg(unix)]
1462fn symlinked_vendor_parent_fails_closed() {
1463    use std::collections::{BTreeMap, HashSet};
1464    use std::os::unix::fs::symlink;
1465    let server = Registry::new();
1466    server.publish_npm(
1467        "vendlink",
1468        "1.0.0",
1469        &[
1470            (
1471                "package.json",
1472                "{\"name\": \"vendlink\", \"version\": \"1.0.0\"}",
1473            ),
1474            ("index.js", "export default 1;"),
1475        ],
1476    );
1477    let dir = tempfile::tempdir().unwrap();
1478    let store = std::sync::Arc::new(
1479        PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(),
1480    );
1481    let resolver = avril_runtime::registry::npm::resolution::NpmResolver::new(store.clone());
1482    let handle =
1483        block_on(
1484            resolver.prepare(&[avril_runtime::registry::npm::resolution::NpmRootRequest {
1485                request: "npm:vendlink".into(),
1486                context: BTreeMap::new(),
1487            }]),
1488        )
1489        .unwrap();
1490    let tracked: std::collections::BTreeSet<String> = handle.roots.keys().cloned().collect();
1491    store.save_graph(&tracked, &HashSet::new()).unwrap();
1492    let vendor = dir.path().join("vendor-real");
1493    store.vendor(&vendor).unwrap();
1494    assert!(vendor.join("npm").exists());
1495    let redirected = tempfile::tempdir().unwrap();
1496    std::fs::rename(vendor.join("npm"), vendor.join("npm-real")).unwrap();
1497    symlink(redirected.path(), vendor.join("npm")).unwrap();
1498    assert!(store.vendor(&vendor).is_err());
1499    assert!(
1500        std::fs::read_dir(redirected.path())
1501            .unwrap()
1502            .next()
1503            .is_none()
1504    );
1505    std::fs::remove_file(vendor.join("npm")).unwrap();
1506    std::fs::rename(vendor.join("npm-real"), vendor.join("npm")).unwrap();
1507    std::fs::remove_dir_all(dir.path().join(".avril")).unwrap();
1508    // A symlinked directory inside the vendor mirror must fail instead of
1509    // being followed or silently ignored.
1510    let evil = tempfile::tempdir().unwrap();
1511    std::fs::rename(vendor.join("npm"), vendor.join("npm-real")).unwrap();
1512    symlink(evil.path(), vendor.join("npm")).unwrap();
1513    let mut options = offline_frozen_options(&server, dir.path());
1514    options.vendor = Some(vendor.clone());
1515    let store = PackageStore::new(fixture::runtime_handle(), options).unwrap();
1516    let error = block_on(store.npm_acquire("npm:vendlink"))
1517        .unwrap_err()
1518        .to_string();
1519    assert!(
1520        error.contains("vendor")
1521            || error.contains("usable")
1522            || error.contains("corrupt")
1523            || error.contains("Offline"),
1524        "{error}"
1525    );
1526    assert!(std::fs::read_dir(evil.path()).unwrap().next().is_none());
1527    // Sanity: the intact mirror reconstructs the same tree offline.
1528    std::fs::remove_file(vendor.join("npm")).unwrap();
1529    std::fs::rename(vendor.join("npm-real"), vendor.join("npm")).unwrap();
1530    let mut options = offline_frozen_options(&server, dir.path());
1531    options.vendor = Some(vendor);
1532    let store = PackageStore::new(fixture::runtime_handle(), options).unwrap();
1533    let restored = block_on(store.npm_acquire("npm:vendlink")).unwrap();
1534    assert_eq!(
1535        std::fs::read_to_string(restored.dir.join("index.js")).unwrap(),
1536        "export default 1;"
1537    );
1538}
1539
1540#[cfg(unix)]
1541fn nix_fifo(path: std::path::PathBuf) {
1542    let status = std::process::Command::new("mkfifo")
1543        .arg(&path)
1544        .status()
1545        .expect("mkfifo is required for FIFO tests");
1546    assert!(status.success());
1547}
1548
1549/// Rewrite every compact pin on disk into legacy wire form using the
1550/// published sidecars, then drop the sidecars: the tree stays warm but only
1551/// legacy maps authenticate it.
1552fn downconvert_lock_to_legacy(dir: &std::path::Path) {
1553    let manifests = dir.join(".avril").join("npm").join(".manifests");
1554    let mut lock = fixture::read_lock(dir);
1555    for pin in lock["npm"]["packages"]
1556        .as_object_mut()
1557        .unwrap()
1558        .values_mut()
1559    {
1560        let manifest = pin["manifest"].as_str().unwrap().to_owned();
1561        let hex = manifest.strip_prefix("sha256-").unwrap();
1562        let bytes = std::fs::read(manifests.join(format!("{hex}.json"))).unwrap();
1563        let files: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1564        *pin = serde_json::json!({
1565            "tarball": pin["tarball"].clone(),
1566            "integrity": pin["integrity"].clone(),
1567            "files": files,
1568        });
1569    }
1570    std::fs::write(dir.join("avril.lock"), format!("{lock}\n")).unwrap();
1571    std::fs::remove_dir_all(manifests).unwrap();
1572}
1573
1574#[test]
1575fn compact_lock_carries_no_file_maps() {
1576    let server = Registry::new();
1577    let mut many = vec![(
1578        "package.json",
1579        "{\"name\": \"many\", \"version\": \"1.0.0\"}".to_string(),
1580    )];
1581    for index in 0..40 {
1582        many.push((
1583            Box::leak(format!("data/{index}.js").into_boxed_str()) as &'static str,
1584            format!("export default {index};"),
1585        ));
1586    }
1587    let refs: Vec<(&str, &str)> = many
1588        .iter()
1589        .map(|(path, source)| (*path, source.as_str()))
1590        .collect();
1591    server.publish_npm("many", "1.0.0", &refs);
1592    let dir = tempfile::tempdir().unwrap();
1593    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1594    let artifact = block_on(store.npm_acquire("npm:many")).unwrap();
1595    assert_eq!(artifact.files.len(), 41);
1596    store.save().unwrap();
1597    let lock = fixture::read_lock(dir.path());
1598    let pins = lock["npm"]["packages"].as_object().unwrap();
1599    assert_eq!(pins.len(), 1);
1600    let pin = pins.values().next().unwrap();
1601    assert!(
1602        pin["manifest"]
1603            .as_str()
1604            .is_some_and(|digest| digest.starts_with("sha256-")),
1605        "{pin}"
1606    );
1607    assert!(pin.get("files").is_none(), "compact pins omit the file map");
1608    // One short digest regardless of file count: the whole entry stays small.
1609    assert!(serde_json::to_string(pin).unwrap().len() < 1024, "{pin}");
1610    // The sidecar holds the full authenticated map instead.
1611    let manifests = std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
1612        .unwrap()
1613        .count();
1614    assert_eq!(manifests, 1);
1615}
1616
1617#[test]
1618fn warm_offline_frozen_runs_without_archives_or_metadata() {
1619    let server = Registry::new();
1620    server.publish_npm(
1621        "slim",
1622        "1.0.0",
1623        &[
1624            (
1625                "package.json",
1626                "{\"name\": \"slim\", \"version\": \"1.0.0\"}",
1627            ),
1628            ("index.js", "export default 1;"),
1629        ],
1630    );
1631    let dir = tempfile::tempdir().unwrap();
1632    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1633    block_on(store.npm_acquire("npm:slim")).unwrap();
1634    store.save().unwrap();
1635    // No retained tarball is required: drop both caches, keep the sidecar.
1636    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
1637    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap();
1638    assert!(
1639        dir.path()
1640            .join(".avril")
1641            .join("npm")
1642            .join(".manifests")
1643            .is_dir(),
1644        "sidecars live outside the deletable caches"
1645    );
1646    let warmed = server.requests.lock().unwrap().len();
1647    let offline = offline_frozen_options(&server, dir.path());
1648    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
1649    let hit = block_on(store.npm_acquire("npm:slim")).unwrap();
1650    assert_eq!(
1651        std::fs::read_to_string(hit.dir.join("index.js")).unwrap(),
1652        "export default 1;"
1653    );
1654    assert_eq!(server.requests.lock().unwrap().len(), warmed);
1655    // Relocated read-only copies keep working: sidecars travel with the tree.
1656    let moved = tempfile::tempdir().unwrap();
1657    copy_npm_tree(
1658        &dir.path().join(".avril").join("npm"),
1659        &moved.path().join(".avril").join("npm"),
1660    );
1661    std::fs::copy(
1662        dir.path().join("avril.lock"),
1663        moved.path().join("avril.lock"),
1664    )
1665    .unwrap();
1666    #[cfg(unix)]
1667    chmod_readonly(&moved.path().join(".avril"));
1668    let mut relocated = server.options(moved.path());
1669    relocated.offline = true;
1670    relocated.frozen = true;
1671    // A relocated lock stays valid only with matching registry options.
1672    relocated.jsr_registry = server.url.clone();
1673    relocated.npm_registry = server.url.clone();
1674    let store = PackageStore::new(fixture::runtime_handle(), relocated).unwrap();
1675    assert!(block_on(store.npm_acquire("npm:slim")).is_ok());
1676    assert_eq!(server.requests.lock().unwrap().len(), warmed);
1677    #[cfg(unix)]
1678    chmod_writable(&moved.path().join(".avril"));
1679}
1680
1681#[cfg(unix)]
1682fn chmod_readonly(path: &std::path::Path) {
1683    use std::os::unix::fs::PermissionsExt;
1684    let mut stack = vec![path.to_owned()];
1685    while let Some(next) = stack.pop() {
1686        for entry in std::fs::read_dir(&next).unwrap() {
1687            let entry = entry.unwrap().path();
1688            // Directories keep their search bit so the walk below them
1689            // still works; only the final mode on each level is locked down.
1690            if entry.is_dir() {
1691                stack.push(entry.clone());
1692                std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o555)).unwrap();
1693            } else {
1694                std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o444)).unwrap();
1695            }
1696        }
1697        std::fs::set_permissions(&next, std::fs::Permissions::from_mode(0o555)).unwrap();
1698    }
1699}
1700
1701#[cfg(unix)]
1702fn chmod_writable(path: &std::path::Path) {
1703    use std::os::unix::fs::PermissionsExt;
1704    let mut stack = vec![path.to_owned()];
1705    while let Some(next) = stack.pop() {
1706        for entry in std::fs::read_dir(&next).unwrap() {
1707            let entry = entry.unwrap().path();
1708            if entry.is_dir() {
1709                stack.push(entry.clone());
1710            }
1711            let _ = std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o755));
1712        }
1713        let _ = std::fs::set_permissions(next, std::fs::Permissions::from_mode(0o755));
1714    }
1715}
1716
1717fn copy_npm_tree(from: &std::path::Path, to: &std::path::Path) {
1718    std::fs::create_dir_all(to).unwrap();
1719    for entry in std::fs::read_dir(from).unwrap() {
1720        let entry = entry.unwrap();
1721        let target = to.join(entry.file_name());
1722        if entry.file_type().unwrap().is_dir() {
1723            copy_npm_tree(&entry.path(), &target);
1724        } else {
1725            std::fs::copy(entry.path(), target).unwrap();
1726        }
1727    }
1728}
1729
1730#[test]
1731fn tampered_sidecar_and_tree_fail_without_tarball() {
1732    let server = Registry::new();
1733    server.publish_npm(
1734        "sealed",
1735        "1.0.0",
1736        &[
1737            (
1738                "package.json",
1739                "{\"name\": \"sealed\", \"version\": \"1.0.0\"}",
1740            ),
1741            ("index.js", "export default 1;"),
1742        ],
1743    );
1744    let dir = tempfile::tempdir().unwrap();
1745    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1746    let artifact = block_on(store.npm_acquire("npm:sealed")).unwrap();
1747    store.save().unwrap();
1748    // No tarball anywhere: authentication must stand on the sidecar alone.
1749    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
1750    let sidecar = std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
1751        .unwrap()
1752        .next()
1753        .unwrap()
1754        .unwrap()
1755        .path();
1756    // A forged map with a valid shape but a changed file hash fails the
1757    // digest even with matching tampered tree bytes and no tarball present.
1758    let map_path = sidecar.clone();
1759    let mut map: serde_json::Value =
1760        serde_json::from_slice(&std::fs::read(&map_path).unwrap()).unwrap();
1761    map["index.js"] = serde_json::Value::String(avril_runtime::registry::checksum(b"tampered"));
1762    std::fs::write(&sidecar, serde_json::to_vec(&map).unwrap()).unwrap();
1763    std::fs::write(artifact.dir.join("index.js"), "tampered").unwrap();
1764    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1765    assert!(
1766        block_on(store.npm_acquire("npm:sealed"))
1767            .unwrap_err()
1768            .to_string()
1769            .contains("mismatch")
1770    );
1771    // A deleted sidecar plus a tampered tree fails offline with no archive.
1772    std::fs::remove_file(&sidecar).unwrap();
1773    let mut offline = server.options(dir.path());
1774    offline.offline = true;
1775    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
1776    assert!(block_on(store.npm_acquire("npm:sealed")).is_err());
1777    // Healing the tree rebuilds from the network and republishes the sidecar.
1778    std::fs::remove_dir_all(&artifact.dir).unwrap();
1779    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1780    let healed = block_on(store.npm_acquire("npm:sealed")).unwrap();
1781    assert_eq!(
1782        std::fs::read_to_string(healed.dir.join("index.js")).unwrap(),
1783        "export default 1;"
1784    );
1785    assert_eq!(
1786        std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
1787            .unwrap()
1788            .count(),
1789        1
1790    );
1791}
1792
1793#[test]
1794fn missing_sidecar_recovers_from_archive_and_cold_fetch() {
1795    let server = Registry::new();
1796    server.publish_npm(
1797        "rebuild",
1798        "1.0.0",
1799        &[
1800            (
1801                "package.json",
1802                "{\"name\": \"rebuild\", \"version\": \"1.0.0\"}",
1803            ),
1804            ("index.js", "export default 1;"),
1805        ],
1806    );
1807    let dir = tempfile::tempdir().unwrap();
1808    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1809    let artifact = block_on(store.npm_acquire("npm:rebuild@^1")).unwrap();
1810    store.save().unwrap();
1811    let warmed = server.requests.lock().unwrap().len();
1812    // Tree and sidecar gone, tarball cache kept: offline archive rebuild.
1813    std::fs::remove_dir_all(&artifact.dir).unwrap();
1814    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".manifests")).unwrap();
1815    let mut offline = server.options(dir.path());
1816    offline.offline = true;
1817    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
1818    let rebuilt = block_on(store.npm_acquire("npm:rebuild")).unwrap();
1819    assert_eq!(
1820        std::fs::read_to_string(rebuilt.dir.join("index.js")).unwrap(),
1821        "export default 1;"
1822    );
1823    assert_eq!(server.requests.lock().unwrap().len(), warmed);
1824    assert_eq!(
1825        std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
1826            .unwrap()
1827            .count(),
1828        1,
1829        "archive recovery republishes the sidecar"
1830    );
1831    // Nothing local at all: a cold pinned fetch still works, and frozen pins
1832    // download artifacts without metadata queries.
1833    std::fs::remove_dir_all(&artifact.dir).unwrap();
1834    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".manifests")).unwrap();
1835    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
1836    let mut frozen = server.options(dir.path());
1837    frozen.frozen = true;
1838    let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap();
1839    let cold = block_on(store.npm_acquire("npm:rebuild@^1")).unwrap();
1840    assert_eq!(cold.identity.version, "1.0.0");
1841    let paths = server.requests.lock().unwrap().clone();
1842    assert!(
1843        paths[warmed..].iter().all(|path| path.ends_with(".tgz")),
1844        "frozen pins must not query mutable packument metadata: {paths:?}"
1845    );
1846}
1847
1848#[test]
1849#[cfg(unix)]
1850fn corrupt_symlink_and_fifo_manifests_fail_promptly() {
1851    let server = Registry::new();
1852    server.publish_npm(
1853        "manifested",
1854        "1.0.0",
1855        &[
1856            (
1857                "package.json",
1858                "{\"name\": \"manifested\", \"version\": \"1.0.0\"}",
1859            ),
1860            ("index.js", "export default 1;"),
1861        ],
1862    );
1863    let dir = tempfile::tempdir().unwrap();
1864    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1865    block_on(store.npm_acquire("npm:manifested")).unwrap();
1866    store.save().unwrap();
1867    let sidecar = std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
1868        .unwrap()
1869        .next()
1870        .unwrap()
1871        .unwrap()
1872        .path();
1873    // Garbage bytes fail on the digest before any parse.
1874    std::fs::write(&sidecar, b"not json at all").unwrap();
1875    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1876    assert!(
1877        block_on(store.npm_acquire("npm:manifested"))
1878            .unwrap_err()
1879            .to_string()
1880            .contains("mismatch")
1881    );
1882    // A final-component symlink is never followed.
1883    std::fs::remove_file(&sidecar).unwrap();
1884    std::os::unix::fs::symlink(dir.path().join("avril.lock"), &sidecar).unwrap();
1885    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1886    assert!(
1887        block_on(store.npm_acquire("npm:manifested"))
1888            .unwrap_err()
1889            .to_string()
1890            .contains("manifest")
1891    );
1892    // A FIFO fails promptly instead of hanging the caller.
1893    std::fs::remove_file(&sidecar).unwrap();
1894    nix_fifo(sidecar.clone());
1895    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
1896    let (send, receive) = std::sync::mpsc::channel();
1897    std::thread::spawn(move || {
1898        send.send(block_on(store.npm_acquire("npm:manifested")))
1899            .unwrap();
1900    });
1901    let error = receive
1902        .recv_timeout(std::time::Duration::from_secs(15))
1903        .expect("manifest FIFO must fail promptly, not hang")
1904        .unwrap_err()
1905        .to_string();
1906    assert!(error.contains("manifest"), "{error}");
1907}
1908
1909#[test]
1910fn vendor_mirror_restores_offline_frozen_with_peers() {
1911    use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest};
1912    use std::collections::{BTreeMap, BTreeSet, HashSet};
1913    let server = Registry::new();
1914    server.publish_npm(
1915        "vutil",
1916        "1.0.0",
1917        &[(
1918            "package.json",
1919            "{\"name\": \"vutil\", \"version\": \"1.0.0\", \"type\": \"module\"}",
1920        )],
1921    );
1922    server.publish_npm(
1923        "vlib",
1924        "1.0.0",
1925        &[(
1926            "package.json",
1927            "{\"name\": \"vlib\", \"version\": \"1.0.0\", \"type\": \"module\", \"peerDependencies\": {\"vutil\": \"*\"}}",
1928        )],
1929    );
1930    let dir = tempfile::tempdir().unwrap();
1931    let store =
1932        Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap());
1933    let resolver = NpmResolver::new(store.clone());
1934    let handle = block_on(resolver.prepare(&[
1935        NpmRootRequest {
1936            request: "npm:vlib@1".into(),
1937            context: BTreeMap::new(),
1938        },
1939        NpmRootRequest {
1940            request: "npm:vutil@1".into(),
1941            context: BTreeMap::new(),
1942        },
1943    ]))
1944    .unwrap();
1945    let tracked: BTreeSet<String> = handle.roots.keys().cloned().collect();
1946    store.save_graph(&tracked, &HashSet::new()).unwrap();
1947    let vendor = dir.path().join("vendor");
1948    store.vendor(&vendor).unwrap();
1949    assert!(
1950        vendor
1951            .join("npm")
1952            .join(".manifests")
1953            .read_dir()
1954            .unwrap()
1955            .count()
1956            >= 2,
1957        "the mirror carries one sidecar per artifact content"
1958    );
1959    let before: BTreeMap<String, String> = handle
1960        .roots
1961        .iter()
1962        .map(|(k, v)| (k.clone(), v.clone()))
1963        .collect();
1964    // Pure mirror: wipe all local state except the lock, then restore
1965    // offline and frozen, including the peer-scoped instance.
1966    std::fs::remove_dir_all(dir.path().join(".avril")).unwrap();
1967    let mut options = offline_frozen_options(&server, dir.path());
1968    options.vendor = Some(vendor);
1969    let store = Arc::new(PackageStore::new(fixture::runtime_handle(), options).unwrap());
1970    let resolver = NpmResolver::new(store.clone());
1971    let restored = block_on(resolver.prepare(&[
1972        NpmRootRequest {
1973            request: "npm:vlib@1".into(),
1974            context: BTreeMap::new(),
1975        },
1976        NpmRootRequest {
1977            request: "npm:vutil@1".into(),
1978            context: BTreeMap::new(),
1979        },
1980    ]))
1981    .unwrap();
1982    assert_eq!(restored.roots.len(), before.len());
1983    for (key, instance) in &before {
1984        assert_eq!(restored.roots.get(key), Some(instance), "{key}");
1985    }
1986}
1987
1988#[test]
1989fn legacy_lock_migrates_used_and_preserves_unvisited() {
1990    let server = Registry::new();
1991    for name in ["legacya", "legacyb"] {
1992        server.publish_npm(
1993            name,
1994            "1.0.0",
1995            &[(
1996                "package.json",
1997                &format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}"),
1998            )],
1999        );
2000    }
2001    let dir = tempfile::tempdir().unwrap();
2002    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2003    block_on(store.npm_acquire("npm:legacya")).unwrap();
2004    block_on(store.npm_acquire("npm:legacyb")).unwrap();
2005    store.save().unwrap();
2006    downconvert_lock_to_legacy(dir.path());
2007    // Only legacya is visited: it migrates, legacyb stays byte-identical.
2008    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2009    block_on(store.npm_acquire("npm:legacya")).unwrap();
2010    store.save().unwrap();
2011    let lock = fixture::read_lock(dir.path());
2012    let packages = lock["npm"]["packages"].as_object().unwrap();
2013    let key_a = packages
2014        .keys()
2015        .find(|key| key.contains("legacya"))
2016        .unwrap()
2017        .clone();
2018    let key_b = packages
2019        .keys()
2020        .find(|key| key.contains("legacyb"))
2021        .unwrap()
2022        .clone();
2023    assert!(packages[&key_a].get("files").is_none(), "used pins migrate");
2024    assert!(
2025        packages[&key_a]["manifest"]
2026            .as_str()
2027            .is_some_and(|digest| digest.starts_with("sha256-"))
2028    );
2029    assert!(
2030        packages[&key_b]["files"].as_object().is_some(),
2031        "unvisited legacy pins keep their inline map"
2032    );
2033    assert!(packages[&key_b].get("manifest").is_none());
2034    assert_eq!(
2035        std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
2036            .unwrap()
2037            .count(),
2038        1,
2039        "sidecars publish only for visited pins"
2040    );
2041    // The unvisited legacy entry still acquires frozen, offline, and
2042    // read-only with no tarballs: its warm tree authenticates inline.
2043    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
2044    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
2045    #[cfg(unix)]
2046    {
2047        use std::os::unix::fs::PermissionsExt;
2048        chmod_readonly(&dir.path().join(".avril"));
2049        std::fs::set_permissions(
2050            dir.path().join("avril.lock"),
2051            std::fs::Permissions::from_mode(0o444),
2052        )
2053        .unwrap();
2054    }
2055    let frozen = offline_frozen_options(&server, dir.path());
2056    let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap();
2057    assert!(block_on(store.npm_acquire("npm:legacya")).is_ok());
2058    assert!(block_on(store.npm_acquire("npm:legacyb")).is_ok());
2059    store.save().unwrap();
2060    assert_eq!(
2061        std::fs::read(dir.path().join("avril.lock")).unwrap(),
2062        lock_before
2063    );
2064    #[cfg(unix)]
2065    {
2066        use std::os::unix::fs::PermissionsExt;
2067        chmod_writable(&dir.path().join(".avril"));
2068        let _ = std::fs::set_permissions(
2069            dir.path().join("avril.lock"),
2070            std::fs::Permissions::from_mode(0o644),
2071        );
2072    }
2073}
2074
2075#[test]
2076#[cfg(unix)]
2077fn frozen_legacy_lock_needs_no_writes_and_stays_identical() {
2078    use std::os::unix::fs::PermissionsExt;
2079    let server = Registry::new();
2080    server.publish_npm(
2081        "frozenlegacy",
2082        "1.0.0",
2083        &[(
2084            "package.json",
2085            "{\"name\": \"frozenlegacy\", \"version\": \"1.0.0\"}",
2086        )],
2087    );
2088    let dir = tempfile::tempdir().unwrap();
2089    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2090    block_on(store.npm_acquire("npm:frozenlegacy")).unwrap();
2091    store.save().unwrap();
2092    downconvert_lock_to_legacy(dir.path());
2093    // No caches at all: the warm tree plus the inline map must suffice.
2094    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
2095    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap();
2096    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
2097    chmod_readonly(&dir.path().join(".avril"));
2098    std::fs::set_permissions(
2099        dir.path().join("avril.lock"),
2100        std::fs::Permissions::from_mode(0o444),
2101    )
2102    .unwrap();
2103    let frozen = offline_frozen_options(&server, dir.path());
2104    let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap();
2105    assert!(block_on(store.npm_acquire("npm:frozenlegacy")).is_ok());
2106    store.save().unwrap();
2107    assert_eq!(
2108        std::fs::read(dir.path().join("avril.lock")).unwrap(),
2109        lock_before,
2110        "frozen legacy locks stay byte-identical"
2111    );
2112    assert!(
2113        !dir.path()
2114            .join(".avril")
2115            .join("npm")
2116            .join(".manifests")
2117            .exists(),
2118        "frozen warm hits publish no sidecars"
2119    );
2120    chmod_writable(&dir.path().join(".avril"));
2121    let _ = std::fs::set_permissions(
2122        dir.path().join("avril.lock"),
2123        std::fs::Permissions::from_mode(0o644),
2124    );
2125}
2126
2127#[test]
2128fn malformed_pins_are_rejected_at_open() {
2129    let server = Registry::new();
2130    let dir = tempfile::tempdir().unwrap();
2131    let key = format!("{}|pkg|1.0.0", server.url.as_str());
2132    let tarball = format!(
2133        "{}/pkg/-/pkg-1.0.0.tgz",
2134        server.url.as_str().trim_end_matches('/')
2135    );
2136    let integrity = Registry::tarball_integrity(&[]);
2137    let good_files = serde_json::json!({
2138        "package.json": avril_runtime::registry::checksum(b"{}"),
2139        "index.js": avril_runtime::registry::checksum(b"1"),
2140    });
2141    let good_map: std::collections::BTreeMap<String, String> = good_files
2142        .as_object()
2143        .unwrap()
2144        .iter()
2145        .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_owned()))
2146        .collect();
2147    let manifest = {
2148        use sha2::Digest;
2149        let digest = sha2::Sha256::digest(serde_json::to_vec(&good_map).unwrap());
2150        format!(
2151            "sha256-{}",
2152            digest
2153                .iter()
2154                .map(|byte| format!("{byte:02x}"))
2155                .collect::<String>()
2156        )
2157    };
2158    let valid = serde_json::json!({
2159        "tarball": tarball,
2160        "integrity": integrity,
2161        "manifest": manifest,
2162    });
2163    let cases: Vec<(&str, serde_json::Value)> = vec![
2164        (
2165            "neither",
2166            serde_json::json!({"tarball": tarball, "integrity": integrity}),
2167        ),
2168        (
2169            "mismatch",
2170            serde_json::json!({"tarball": tarball, "integrity": integrity, "manifest": manifest, "files": good_files}),
2171        ),
2172        (
2173            "bad-digest",
2174            serde_json::json!({"tarball": tarball, "integrity": integrity, "manifest": "sha256-zzz"}),
2175        ),
2176        (
2177            "empty-files",
2178            serde_json::json!({"tarball": tarball, "integrity": integrity, "files": {}}),
2179        ),
2180        (
2181            "bad-path",
2182            serde_json::json!({"tarball": tarball, "integrity": integrity, "files": {"../evil": good_files["index.js"], "package.json": good_files["package.json"]}}),
2183        ),
2184        (
2185            "no-package-json",
2186            serde_json::json!({"tarball": tarball, "integrity": integrity, "files": {"index.js": good_files["index.js"]}}),
2187        ),
2188        (
2189            "unknown-field",
2190            serde_json::json!({"tarball": tarball, "integrity": integrity, "manifest": manifest, "future": 1}),
2191        ),
2192    ];
2193    for (label, mut pin) in cases {
2194        if label == "mismatch" {
2195            let mut tampered = good_files.clone();
2196            tampered["index.js"] =
2197                serde_json::Value::String(avril_runtime::registry::checksum(b"other"));
2198            pin["files"] = tampered;
2199        }
2200        let mut lock = serde_json::json!({
2201            "version": 2,
2202            "jsrRegistry": server.url.as_str(),
2203            "npmRegistry": server.url.as_str(),
2204            "specifiers": {},
2205            "packages": {},
2206            "npm": {"specifiers": {}, "packages": {}, "roots": {}, "instances": {}},
2207        });
2208        lock["npm"]["packages"]
2209            .as_object_mut()
2210            .unwrap()
2211            .insert(key.clone(), pin);
2212        std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap();
2213        let error = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
2214            .err()
2215            .expect("malformed pins must be rejected")
2216            .to_string();
2217        assert!(!error.is_empty(), "{label} must be rejected");
2218    }
2219    // The valid compact pin opens cleanly.
2220    let mut lock = serde_json::json!({
2221        "version": 2,
2222        "jsrRegistry": server.url.as_str(),
2223        "npmRegistry": server.url.as_str(),
2224        "specifiers": {},
2225        "packages": {},
2226        "npm": {"specifiers": {}, "packages": {}, "roots": {}, "instances": {}},
2227    });
2228    lock["npm"]["packages"]
2229        .as_object_mut()
2230        .unwrap()
2231        .insert(key.clone(), valid);
2232    std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap();
2233    assert!(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).is_ok());
2234}
2235
2236#[test]
2237fn cold_frozen_publishes_sidecars_for_readonly_replay() {
2238    let server = Registry::new();
2239    server.publish_npm(
2240        "coldfrozen",
2241        "1.0.0",
2242        &[
2243            (
2244                "package.json",
2245                "{\"name\": \"coldfrozen\", \"version\": \"1.0.0\"}",
2246            ),
2247            ("index.js", "export default 1;"),
2248        ],
2249    );
2250    let dir = tempfile::tempdir().unwrap();
2251    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2252    block_on(store.npm_acquire("npm:coldfrozen@^1")).unwrap();
2253    store.save().unwrap();
2254    // Cold frozen fetch over the network still publishes the sidecar.
2255    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join("default")).unwrap();
2256    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".manifests")).unwrap();
2257    let mut frozen = server.options(dir.path());
2258    frozen.frozen = true;
2259    let store = PackageStore::new(fixture::runtime_handle(), frozen).unwrap();
2260    let cold = block_on(store.npm_acquire("npm:coldfrozen@^1")).unwrap();
2261    assert_eq!(cold.identity.version, "1.0.0");
2262    assert_eq!(
2263        std::fs::read_dir(dir.path().join(".avril").join("npm").join(".manifests"))
2264            .unwrap()
2265            .count(),
2266        1,
2267        "frozen acquisition leaves a complete tree"
2268    );
2269    // Strip every cache, go read-only, and replay offline and frozen.
2270    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
2271    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap();
2272    #[cfg(unix)]
2273    {
2274        use std::os::unix::fs::PermissionsExt;
2275        chmod_readonly(&dir.path().join(".avril"));
2276        std::fs::set_permissions(
2277            dir.path().join("avril.lock"),
2278            std::fs::Permissions::from_mode(0o444),
2279        )
2280        .unwrap();
2281    }
2282    let warmed = server.requests.lock().unwrap().len();
2283    let mut replay = server.options(dir.path());
2284    replay.offline = true;
2285    replay.frozen = true;
2286    let store = PackageStore::new(fixture::runtime_handle(), replay).unwrap();
2287    let hit = block_on(store.npm_acquire("npm:coldfrozen@^1")).unwrap();
2288    assert_eq!(
2289        std::fs::read_to_string(hit.dir.join("index.js")).unwrap(),
2290        "export default 1;"
2291    );
2292    assert_eq!(server.requests.lock().unwrap().len(), warmed);
2293    #[cfg(unix)]
2294    {
2295        use std::os::unix::fs::PermissionsExt;
2296        chmod_writable(&dir.path().join(".avril"));
2297        let _ = std::fs::set_permissions(
2298            dir.path().join("avril.lock"),
2299            std::fs::Permissions::from_mode(0o644),
2300        );
2301    }
2302}
2303
2304fn tar_entry(name: &str, entry_type: tar::EntryType, data: &[u8]) -> Vec<u8> {
2305    let mut header = tar::Header::new_gnu();
2306    header.set_mode(if entry_type.is_dir() { 0o755 } else { 0o644 });
2307    header.set_size(if entry_type.is_dir() {
2308        0
2309    } else {
2310        data.len() as u64
2311    });
2312    header.set_entry_type(entry_type);
2313    header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes());
2314    header.set_cksum();
2315    let mut out = header.as_bytes().to_vec();
2316    if !entry_type.is_dir() {
2317        out.extend_from_slice(data);
2318        while !out.len().is_multiple_of(512) {
2319            out.push(0);
2320        }
2321    }
2322    out
2323}
2324
2325fn gzip_tarball(blocks: &[Vec<u8>]) -> Vec<u8> {
2326    use flate2::{Compression, write::GzEncoder};
2327    use std::io::Write;
2328    let mut tar_data = Vec::new();
2329    for block in blocks {
2330        tar_data.extend_from_slice(block);
2331    }
2332    tar_data.extend_from_slice(&[0u8; 1024]);
2333    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
2334    encoder.write_all(&tar_data).unwrap();
2335    encoder.finish().unwrap()
2336}
2337
2338#[test]
2339fn dot_segments_normalize_to_canonical_paths() {
2340    let package_json = "{\"name\": \"dotted\", \"version\": \"1.0.0\"}";
2341    let index_js = "export default 1;";
2342    let util_js = "export const util = 2;";
2343    use tar::EntryType::{Directory, Regular};
2344    let blocks = vec![
2345        tar_entry("package/./", Directory, b""),
2346        tar_entry("package/./dist/", Directory, b""),
2347        tar_entry("package/./package.json", Regular, package_json.as_bytes()),
2348        tar_entry("package/./dist/index.js", Regular, index_js.as_bytes()),
2349        tar_entry("package/dist/./util.js", Regular, util_js.as_bytes()),
2350    ];
2351    let tarball = gzip_tarball(&blocks);
2352    let server = Registry::new();
2353    let integrity = Registry::tarball_integrity(&tarball);
2354    server.publish_npm_dist("dotted", "1.0.0", tarball, &integrity, None, true);
2355    let dir = tempfile::tempdir().unwrap();
2356    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2357    let artifact = block_on(store.npm_acquire("npm:dotted")).unwrap();
2358    assert_eq!(artifact.identity.version, "1.0.0");
2359    let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect();
2360    assert_eq!(keys, vec!["dist/index.js", "dist/util.js", "package.json"]);
2361    assert_eq!(
2362        artifact.read_file(dir.path(), "dist/index.js").unwrap(),
2363        index_js.as_bytes()
2364    );
2365    assert_eq!(
2366        artifact.read_file(dir.path(), "dist/util.js").unwrap(),
2367        util_js.as_bytes()
2368    );
2369    assert_eq!(
2370        std::fs::read_to_string(artifact.dir.join("dist").join("index.js")).unwrap(),
2371        index_js
2372    );
2373    assert_eq!(
2374        std::fs::read_to_string(artifact.dir.join("dist").join("util.js")).unwrap(),
2375        util_js
2376    );
2377    store.save().unwrap();
2378    let warmed = server.requests.lock().unwrap().len();
2379    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
2380    let mut offline = offline_frozen_options(&server, dir.path());
2381    offline.cache = dir.path().join("fresh-cache-that-must-stay-empty");
2382    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
2383    let hit = block_on(store.npm_acquire("npm:dotted")).unwrap();
2384    assert_eq!(hit.files, artifact.files);
2385    assert_eq!(
2386        std::fs::read_to_string(hit.dir.join("dist").join("index.js")).unwrap(),
2387        index_js
2388    );
2389    assert_eq!(server.requests.lock().unwrap().len(), warmed);
2390    assert!(
2391        !dir.path().join("fresh-cache-that-must-stay-empty").exists(),
2392        "npm must not depend on the global cache"
2393    );
2394    store.save().unwrap();
2395    assert_eq!(
2396        std::fs::read(dir.path().join("avril.lock")).unwrap(),
2397        lock_before
2398    );
2399}
2400
2401#[test]
2402fn duplicate_canonical_dot_paths_are_rejected() {
2403    use tar::EntryType::Regular;
2404    let package_json = "{\"name\": \"dupdot\", \"version\": \"1.0.0\"}";
2405    let blocks = vec![
2406        tar_entry("package/package.json", Regular, package_json.as_bytes()),
2407        tar_entry("package/index.js", Regular, b"export default 1;"),
2408        tar_entry("package/./index.js", Regular, b"export default 2;"),
2409    ];
2410    let tarball = gzip_tarball(&blocks);
2411    let server = Registry::new();
2412    let integrity = Registry::tarball_integrity(&tarball);
2413    server.publish_npm_dist("dupdot", "1.0.0", tarball, &integrity, None, true);
2414    let dir = tempfile::tempdir().unwrap();
2415    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2416    assert!(
2417        block_on(store.npm_acquire("npm:dupdot"))
2418            .unwrap_err()
2419            .to_string()
2420            .contains("duplicate")
2421    );
2422}
2423
2424#[test]
2425fn identical_dot_alias_duplicates_coalesce_in_either_order() {
2426    use tar::EntryType::Regular;
2427    for (name, dotted_first) in [("dupok", false), ("dupok_rev", true)] {
2428        let package_json = format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}");
2429        let index_js = "export default 1;";
2430        let plain = tar_entry("package/dist/index.js", Regular, index_js.as_bytes());
2431        let dotted = tar_entry("package/./dist/index.js", Regular, index_js.as_bytes());
2432        let mut blocks = vec![tar_entry(
2433            "package/package.json",
2434            Regular,
2435            package_json.as_bytes(),
2436        )];
2437        if dotted_first {
2438            blocks.push(dotted);
2439            blocks.push(plain);
2440        } else {
2441            blocks.push(plain);
2442            blocks.push(dotted);
2443        }
2444        let tarball = gzip_tarball(&blocks);
2445        let server = Registry::new();
2446        let integrity = Registry::tarball_integrity(&tarball);
2447        server.publish_npm_dist(name, "1.0.0", tarball, &integrity, None, true);
2448        let dir = tempfile::tempdir().unwrap();
2449        let store =
2450            PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2451        let artifact = block_on(store.npm_acquire(&format!("npm:{name}"))).unwrap();
2452        let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect();
2453        assert_eq!(keys, vec!["dist/index.js", "package.json"], "{name}");
2454        assert_eq!(
2455            artifact.read_file(dir.path(), "dist/index.js").unwrap(),
2456            index_js.as_bytes()
2457        );
2458        assert_eq!(
2459            std::fs::read_to_string(artifact.dir.join("dist").join("index.js")).unwrap(),
2460            index_js
2461        );
2462        store.save().unwrap();
2463        let warmed = server.requests.lock().unwrap().len();
2464        let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
2465        let mut offline = offline_frozen_options(&server, dir.path());
2466        offline.cache = dir.path().join("fresh-cache-that-must-stay-empty");
2467        let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
2468        let hit = block_on(store.npm_acquire(&format!("npm:{name}"))).unwrap();
2469        assert_eq!(hit.files, artifact.files);
2470        assert_eq!(server.requests.lock().unwrap().len(), warmed);
2471        assert!(!dir.path().join("fresh-cache-that-must-stay-empty").exists());
2472        store.save().unwrap();
2473        assert_eq!(
2474            std::fs::read(dir.path().join("avril.lock")).unwrap(),
2475            lock_before
2476        );
2477    }
2478}
2479
2480#[test]
2481fn identical_exact_name_duplicates_coalesce() {
2482    use tar::EntryType::Regular;
2483    let package_json = "{\"name\": \"dupx\", \"version\": \"1.0.0\"}";
2484    let index_js = "export default 1;";
2485    let blocks = vec![
2486        tar_entry("package/package.json", Regular, package_json.as_bytes()),
2487        tar_entry("package/index.js", Regular, index_js.as_bytes()),
2488        tar_entry("package/index.js", Regular, index_js.as_bytes()),
2489    ];
2490    let tarball = gzip_tarball(&blocks);
2491    let server = Registry::new();
2492    let integrity = Registry::tarball_integrity(&tarball);
2493    server.publish_npm_dist("dupx", "1.0.0", tarball, &integrity, None, true);
2494    let dir = tempfile::tempdir().unwrap();
2495    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2496    let artifact = block_on(store.npm_acquire("npm:dupx")).unwrap();
2497    let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect();
2498    assert_eq!(keys, vec!["index.js", "package.json"]);
2499}
2500
2501#[test]
2502fn duplicate_entries_count_toward_tarball_limits() {
2503    use tar::EntryType::Regular;
2504    let package_json = "{\"name\": \"many\", \"version\": \"1.0.0\"}";
2505    let mut blocks = vec![
2506        tar_entry("package/package.json", Regular, package_json.as_bytes()),
2507        tar_entry("package/index.js", Regular, b"export default 1;"),
2508    ];
2509    for _ in 0..65_535 {
2510        blocks.push(tar_entry("package/index.js", Regular, b"export default 1;"));
2511    }
2512    let tarball = gzip_tarball(&blocks);
2513    let server = Registry::new();
2514    let integrity = Registry::tarball_integrity(&tarball);
2515    server.publish_npm_dist("many", "1.0.0", tarball, &integrity, None, true);
2516    let dir = tempfile::tempdir().unwrap();
2517    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2518    assert!(
2519        block_on(store.npm_acquire("npm:many"))
2520            .unwrap_err()
2521            .to_string()
2522            .contains("too many entries")
2523    );
2524}
2525
2526#[test]
2527fn legacy_single_directory_roots_are_accepted() {
2528    use tar::EntryType::{Directory, Regular};
2529    let package_json = "{\"name\": \"@types/retry\", \"version\": \"0.12.0\"}";
2530    let index = "export declare const retry: number;";
2531    let file_blocks = || {
2532        vec![
2533            tar_entry("retry/package.json", Regular, package_json.as_bytes()),
2534            tar_entry("retry/index.d.ts", Regular, index.as_bytes()),
2535            tar_entry("retry/README.md", Regular, b"# retry\n"),
2536            tar_entry("retry/LICENSE", Regular, b"MIT\n"),
2537        ]
2538    };
2539    let variants: &[(&str, &[&str], &[&str])] = &[
2540        ("no header", &[], &[]),
2541        ("root header first", &["retry/"], &[]),
2542        ("root header last", &[], &["retry/"]),
2543        ("bare root header", &["retry"], &[]),
2544    ];
2545    for (label, first, last) in variants {
2546        let mut blocks = vec![];
2547        for name in *first {
2548            blocks.push(tar_entry(name, Directory, b""));
2549        }
2550        blocks.extend(file_blocks());
2551        for name in *last {
2552            blocks.push(tar_entry(name, Directory, b""));
2553        }
2554        let tarball = gzip_tarball(&blocks);
2555        let server = Registry::new();
2556        let integrity = Registry::tarball_integrity(&tarball);
2557        server.publish_npm_dist("@types/retry", "0.12.0", tarball, &integrity, None, true);
2558        let dir = tempfile::tempdir().unwrap();
2559        let store =
2560            PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2561        let artifact = block_on(store.npm_acquire("npm:@types/retry")).unwrap();
2562        let keys: Vec<&str> = artifact.files.keys().map(String::as_str).collect();
2563        assert_eq!(
2564            keys,
2565            vec!["LICENSE", "README.md", "index.d.ts", "package.json"],
2566            "{label}"
2567        );
2568        assert_eq!(
2569            artifact.read_file(dir.path(), "index.d.ts").unwrap(),
2570            index.as_bytes()
2571        );
2572        assert_eq!(
2573            std::fs::read_to_string(artifact.dir.join("index.d.ts")).unwrap(),
2574            index
2575        );
2576        store.save().unwrap();
2577        let warmed = server.requests.lock().unwrap().len();
2578        let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
2579        let mut offline = offline_frozen_options(&server, dir.path());
2580        offline.cache = dir.path().join("fresh-cache-that-must-stay-empty");
2581        let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
2582        let hit = block_on(store.npm_acquire("npm:@types/retry")).unwrap();
2583        assert_eq!(hit.files, artifact.files);
2584        assert_eq!(server.requests.lock().unwrap().len(), warmed);
2585        assert!(!dir.path().join("fresh-cache-that-must-stay-empty").exists());
2586        store.save().unwrap();
2587        assert_eq!(
2588            std::fs::read(dir.path().join("avril.lock")).unwrap(),
2589            lock_before
2590        );
2591    }
2592}
2593
2594#[test]
2595fn mixed_and_unsafe_archive_roots_are_rejected() {
2596    use tar::EntryType::{Directory, Regular};
2597    let package_json = "{\"name\": \"t\", \"version\": \"1.0.0\"}";
2598    let file = |name: &str| tar_entry(name, Regular, b"x");
2599    let dir = |name: &str| tar_entry(name, Directory, b"");
2600    let pkg = || tar_entry("retry/package.json", Regular, package_json.as_bytes());
2601    let cases: Vec<(&str, Vec<Vec<u8>>, &str)> = vec![
2602        (
2603            "mixed file roots",
2604            vec![pkg(), file("package/index.js")],
2605            "mixes archive roots",
2606        ),
2607        (
2608            "stray directory root",
2609            vec![pkg(), dir("other/")],
2610            "mixes archive roots",
2611        ),
2612        (
2613            "root-only file",
2614            vec![tar_entry("retry", Regular, b"x")],
2615            "Invalid npm file path",
2616        ),
2617        (
2618            "dot-dot root",
2619            vec![file("../evil.js")],
2620            "Invalid npm file path",
2621        ),
2622        ("dot root", vec![file("./evil.js")], "Invalid npm file path"),
2623        (
2624            "absolute root",
2625            vec![file("/evil.js")],
2626            "Invalid npm file path",
2627        ),
2628        (
2629            "backslash root",
2630            vec![file("a\\b/evil.js")],
2631            "Invalid npm file path",
2632        ),
2633        (
2634            "colon root",
2635            vec![file("a:b/evil.js")],
2636            "Invalid npm file path",
2637        ),
2638        (
2639            "control root",
2640            vec![file("a\u{1}b/evil.js")],
2641            "Invalid npm file path",
2642        ),
2643        (
2644            "encoded separator root",
2645            vec![file("a%2fb/evil.js")],
2646            "Invalid npm file path",
2647        ),
2648        (
2649            "traversal below root",
2650            vec![pkg(), file("retry/../evil.js")],
2651            "Invalid npm file path",
2652        ),
2653        (
2654            "missing package.json",
2655            vec![file("retry/index.js")],
2656            "missing package.json",
2657        ),
2658        (
2659            "mismatched identity",
2660            vec![tar_entry(
2661                "retry/package.json",
2662                Regular,
2663                b"{\"name\": \"other\", \"version\": \"1.0.0\"}",
2664            )],
2665            "does not match",
2666        ),
2667    ];
2668    for (label, blocks, expected) in &cases {
2669        let tarball = gzip_tarball(blocks);
2670        let server = Registry::new();
2671        let integrity = Registry::tarball_integrity(&tarball);
2672        server.publish_npm_dist("t", "1.0.0", tarball, &integrity, None, true);
2673        let dir = tempfile::tempdir().unwrap();
2674        let store =
2675            PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2676        assert!(
2677            block_on(store.npm_acquire("npm:t"))
2678                .unwrap_err()
2679                .to_string()
2680                .contains(expected),
2681            "{label}"
2682        );
2683        assert!(!dir.path().join("evil.js").exists(), "{label}");
2684        assert!(
2685            !dir.path()
2686                .join(".avril")
2687                .join("npm")
2688                .join("default")
2689                .exists(),
2690            "{label}"
2691        );
2692    }
2693}
2694
2695#[test]
2696fn dot_prefixed_unsafe_paths_are_still_rejected() {
2697    use tar::EntryType::{Directory, Regular};
2698    let server = Registry::new();
2699    let cases = [
2700        ("dotdot-file", "package/./../evil.js", false),
2701        ("backslash-file", "package/./foo\\bar.js", false),
2702        ("empty-file", "package//index.js", false),
2703        ("internal-empty-file", "package/dist//index.js", false),
2704        ("traversal-file", "package/dist/../evil.js", false),
2705        ("trailing-slash-file", "package/./dist/index.js/", false),
2706        ("root-file", "package/./", false),
2707        ("empty-dir", "package//", true),
2708        ("dot-double-slash-dir", "package/.//", true),
2709        ("internal-empty-dir", "package/a//b/", true),
2710        ("dotdot-dir", "package/./../evil/", true),
2711        ("traversal-dir", "package/dist/../evil/", true),
2712    ];
2713    for (suffix, entry, is_dir) in cases {
2714        let name = format!("evil-{suffix}");
2715        let package_json = format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}");
2716        let blocks = vec![
2717            tar_entry("package/package.json", Regular, package_json.as_bytes()),
2718            tar_entry(
2719                entry,
2720                if is_dir { Directory } else { Regular },
2721                if is_dir {
2722                    b"".as_slice()
2723                } else {
2724                    b"evil".as_slice()
2725                },
2726            ),
2727        ];
2728        let tarball = gzip_tarball(&blocks);
2729        let integrity = Registry::tarball_integrity(&tarball);
2730        server.publish_npm_dist(&name, "1.0.0", tarball, &integrity, None, true);
2731    }
2732    let dir = tempfile::tempdir().unwrap();
2733    for (suffix, _, _) in cases {
2734        let name = format!("evil-{suffix}");
2735        let store =
2736            PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2737        assert!(
2738            block_on(store.npm_acquire(&format!("npm:{name}")))
2739                .unwrap_err()
2740                .to_string()
2741                .contains("Invalid npm file path"),
2742            "{suffix} must be rejected"
2743        );
2744    }
2745}
2746
2747#[test]
2748fn npm_progress_reports_network_fetches_but_not_warm_hits() {
2749    use avril_runtime::registry::ProgressEvent;
2750    use std::sync::Mutex;
2751    let server = Registry::new();
2752    server.publish_npm(
2753        "tool",
2754        "1.0.0",
2755        &[
2756            (
2757                "package.json",
2758                "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
2759            ),
2760            ("index.js", "export default 1;"),
2761        ],
2762    );
2763    let dir = tempfile::tempdir().unwrap();
2764    let events = Arc::new(Mutex::new(Vec::new()));
2765    let sink_events = events.clone();
2766    let sink: Arc<dyn Fn(&ProgressEvent) + Send + Sync> =
2767        Arc::new(move |event| sink_events.lock().unwrap().push(event.clone()));
2768    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
2769        .unwrap()
2770        .with_progress(sink);
2771    let artifact = block_on(store.npm_acquire("npm:tool")).unwrap();
2772    assert_eq!(artifact.identity.version, "1.0.0");
2773    let seen = events.lock().unwrap().clone();
2774    assert!(
2775        seen.iter()
2776            .any(|event| *event == ProgressEvent::NpmMetadata { name: "tool".into() }),
2777        "metadata fetch must be reported: {seen:?}"
2778    );
2779    assert!(
2780        seen.iter().any(|event| *event
2781            == ProgressEvent::NpmArchive {
2782                name: "tool".into(),
2783                version: "1.0.0".into(),
2784            }),
2785        "archive download must be reported: {seen:?}"
2786    );
2787    store.save().unwrap();
2788    let warm_events = Arc::new(Mutex::new(Vec::new()));
2789    let warm_sink_events = warm_events.clone();
2790    let warm_sink: Arc<dyn Fn(&ProgressEvent) + Send + Sync> =
2791        Arc::new(move |event| warm_sink_events.lock().unwrap().push(event.clone()));
2792    let mut offline = offline_frozen_options(&server, dir.path());
2793    offline.cache = dir.path().join("fresh-cache-that-must-stay-empty");
2794    let store = PackageStore::new(fixture::runtime_handle(), offline)
2795        .unwrap()
2796        .with_progress(warm_sink);
2797    let hit = block_on(store.npm_acquire("npm:tool")).unwrap();
2798    assert_eq!(hit.identity.version, "1.0.0");
2799    let warm = warm_events.lock().unwrap().clone();
2800    assert!(
2801        !warm.iter().any(|event| matches!(
2802            event,
2803            ProgressEvent::NpmMetadata { .. } | ProgressEvent::NpmArchive { .. }
2804        )),
2805        "warm offline hits must not claim network work: {warm:?}"
2806    );
2807}
2808
2809#[test]
2810fn npm_progress_arrives_before_download_completes() {
2811    use avril_runtime::registry::ProgressEvent;
2812    use std::sync::Mutex;
2813    use std::sync::atomic::Ordering;
2814    use std::time::{Duration, Instant};
2815    let server = Registry::new();
2816    server.publish_npm(
2817        "tool",
2818        "1.0.0",
2819        &[
2820            (
2821                "package.json",
2822                "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
2823            ),
2824            ("index.js", "export default 1;"),
2825        ],
2826    );
2827    let dir = tempfile::tempdir().unwrap();
2828    let events = Arc::new(Mutex::new(Vec::new()));
2829    let sink_events = events.clone();
2830    let sink: Arc<dyn Fn(&ProgressEvent) + Send + Sync> =
2831        Arc::new(move |event| sink_events.lock().unwrap().push(event.clone()));
2832    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
2833        .unwrap()
2834        .with_progress(sink);
2835    server.hold_tarballs.store(true, Ordering::Relaxed);
2836    let handle = std::thread::spawn(move || block_on(store.npm_acquire("npm:tool")));
2837    let deadline = Instant::now() + Duration::from_secs(10);
2838    loop {
2839        let has_archive = events.lock().unwrap().iter().any(|event| {
2840            *event
2841                == ProgressEvent::NpmArchive {
2842                    name: "tool".into(),
2843                    version: "1.0.0".into(),
2844                }
2845        });
2846        if has_archive {
2847            break;
2848        }
2849        if Instant::now() > deadline {
2850            server.hold_tarballs.store(false, Ordering::Relaxed);
2851            panic!("archive progress must arrive while the download is stalled");
2852        }
2853        std::thread::sleep(Duration::from_millis(10));
2854    }
2855    assert!(
2856        !handle.is_finished(),
2857        "archive progress must precede download completion"
2858    );
2859    server.hold_tarballs.store(false, Ordering::Relaxed);
2860    let artifact = handle
2861        .join()
2862        .expect("acquisition thread must finish")
2863        .expect("stalled acquisition must succeed once released");
2864    assert_eq!(artifact.identity.version, "1.0.0");
2865}
2866
2867#[test]
2868fn npm_select_version_uses_tags_ranges_scopes_and_subpaths() {
2869    let server = Registry::new();
2870    for version in ["1.0.0", "1.1.0", "2.0.0"] {
2871        server.publish_npm(
2872            "tool",
2873            version,
2874            &[
2875                (
2876                    "package.json",
2877                    &format!("{{\"name\": \"tool\", \"version\": \"{version}\"}}"),
2878                ),
2879                ("index.js", "export default 1;"),
2880            ],
2881        );
2882    }
2883    {
2884        let mut files = server.files.lock().unwrap();
2885        let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap();
2886        packument["dist-tags"]["latest"] = serde_json::Value::String("1.1.0".into());
2887        files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap());
2888    }
2889    server.publish_npm(
2890        "@scope/pkg",
2891        "1.0.0",
2892        &[(
2893            "package.json",
2894            "{\"name\": \"@scope/pkg\", \"version\": \"1.0.0\"}",
2895        )],
2896    );
2897    let dir = tempfile::tempdir().unwrap();
2898    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2899    for (specifier, version) in [
2900        ("npm:tool", "1.1.0"),
2901        ("npm:tool@latest", "1.1.0"),
2902        ("npm:tool@^1", "1.1.0"),
2903        ("npm:tool@2.0.0", "2.0.0"),
2904        ("npm:tool/sub", "1.1.0"),
2905        ("npm:@scope/pkg", "1.0.0"),
2906    ] {
2907        assert_eq!(
2908            block_on(store.npm_select_version(specifier)).unwrap(),
2909            version,
2910            "{specifier}"
2911        );
2912    }
2913    assert!(
2914        server
2915            .requests
2916            .lock()
2917            .unwrap()
2918            .iter()
2919            .all(|path| !path.ends_with(".tgz")),
2920        "selection must not fetch archives"
2921    );
2922    store.save().unwrap();
2923    if dir.path().join("avril.lock").exists() {
2924        let lock = fixture::read_lock(dir.path());
2925        assert!(lock["npm"]["packages"].as_object().unwrap().is_empty());
2926        assert!(lock["npm"]["specifiers"].as_object().unwrap().is_empty());
2927    }
2928}
2929
2930#[test]
2931fn npm_select_version_succeeds_when_archives_or_deps_are_broken() {
2932    let server = Registry::new();
2933    server.publish_npm(
2934        "broken",
2935        "1.0.0",
2936        &[(
2937            "package.json",
2938            "{\"name\": \"broken\", \"version\": \"1.0.0\"}",
2939        )],
2940    );
2941    {
2942        let mut files = server.files.lock().unwrap();
2943        let tarball = files
2944            .keys()
2945            .find(|path| path.ends_with(".tgz"))
2946            .unwrap()
2947            .clone();
2948        files.insert(tarball, b"not gzip".to_vec());
2949    }
2950    server.publish_npm(
2951        "hasdep",
2952        "1.0.0",
2953        &[(
2954            "package.json",
2955            "{\"name\": \"hasdep\", \"version\": \"1.0.0\", \"dependencies\": {\"nodep\": \"^1.0.0\"}}",
2956        )],
2957    );
2958    let dir = tempfile::tempdir().unwrap();
2959    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
2960    assert_eq!(
2961        block_on(store.npm_select_version("npm:broken")).unwrap(),
2962        "1.0.0"
2963    );
2964    assert_eq!(
2965        block_on(store.npm_select_version("npm:hasdep")).unwrap(),
2966        "1.0.0"
2967    );
2968    assert!(
2969        server
2970            .requests
2971            .lock()
2972            .unwrap()
2973            .iter()
2974            .all(|path| !path.ends_with(".tgz")),
2975        "selection must not fetch archives"
2976    );
2977    assert!(block_on(store.npm_acquire("npm:broken")).is_err());
2978    {
2979        use avril_runtime::registry::npm::resolution::{NpmResolver, NpmRootRequest};
2980        use std::collections::BTreeMap;
2981        use std::sync::Arc;
2982        let store = Arc::new(store);
2983        let resolver = NpmResolver::new(store);
2984        assert!(
2985            block_on(resolver.prepare(&[NpmRootRequest {
2986                request: "npm:hasdep".into(),
2987                context: BTreeMap::new(),
2988            }]))
2989            .is_err()
2990        );
2991    }
2992}
2993
2994#[test]
2995fn npm_select_version_reuses_locks_and_caches() {
2996    use avril_runtime::registry::ProgressEvent;
2997    use std::sync::Mutex;
2998    let server = Registry::new();
2999    server.publish_npm(
3000        "tool",
3001        "1.0.0",
3002        &[(
3003            "package.json",
3004            "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
3005        )],
3006    );
3007    server.publish_npm(
3008        "tool",
3009        "1.1.0",
3010        &[(
3011            "package.json",
3012            "{\"name\": \"tool\", \"version\": \"1.1.0\"}",
3013        )],
3014    );
3015    let dir = tempfile::tempdir().unwrap();
3016    let events = Arc::new(Mutex::new(Vec::new()));
3017    let sink_events = events.clone();
3018    let sink: Arc<dyn Fn(&ProgressEvent) + Send + Sync> =
3019        Arc::new(move |event| sink_events.lock().unwrap().push(event.clone()));
3020    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
3021        .unwrap()
3022        .with_progress(sink);
3023    assert_eq!(
3024        block_on(store.npm_select_version("npm:tool@^1")).unwrap(),
3025        "1.1.0"
3026    );
3027    let packuments = server
3028        .requests
3029        .lock()
3030        .unwrap()
3031        .iter()
3032        .filter(|path| path.as_str() == "/tool")
3033        .count();
3034    assert_eq!(packuments, 1);
3035    assert!(
3036        events
3037            .lock()
3038            .unwrap()
3039            .iter()
3040            .any(|event| *event == ProgressEvent::NpmMetadata { name: "tool".into() })
3041    );
3042    assert!(!events.lock().unwrap().iter().any(|event| matches!(
3043        event,
3044        ProgressEvent::NpmArchive { .. } | ProgressEvent::NpmMaterialized { .. }
3045    )));
3046    let artifact = block_on(store.npm_acquire("npm:tool@^1")).unwrap();
3047    assert_eq!(artifact.identity.version, "1.1.0");
3048    let packuments = server
3049        .requests
3050        .lock()
3051        .unwrap()
3052        .iter()
3053        .filter(|path| path.as_str() == "/tool")
3054        .count();
3055    assert_eq!(
3056        packuments, 1,
3057        "acquisition must reuse the selected packument"
3058    );
3059    store.save().unwrap();
3060    std::fs::remove_dir_all(&artifact.dir).unwrap();
3061    assert_eq!(
3062        block_on(store.npm_select_version("npm:tool@^1")).unwrap(),
3063        "1.1.0"
3064    );
3065    let offline = offline_frozen_options(&server, dir.path());
3066    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
3067    assert_eq!(
3068        block_on(store.npm_select_version("npm:tool@^1")).unwrap(),
3069        "1.1.0"
3070    );
3071    let warmed = server.requests.lock().unwrap().len();
3072    assert!(
3073        block_on(store.npm_select_version("npm:other"))
3074            .unwrap_err()
3075            .to_string()
3076            .contains("Frozen")
3077    );
3078    assert_eq!(server.requests.lock().unwrap().len(), warmed);
3079    for specifier in ["npm:../evil", "npm:tool@", "npm:@bad", "npm:tool@nope"] {
3080        assert!(
3081            block_on(store.npm_select_version(specifier)).is_err(),
3082            "{specifier}"
3083        );
3084    }
3085    assert_eq!(server.requests.lock().unwrap().len(), warmed);
3086}
3087
3088#[test]
3089fn npm_select_version_denies_without_network() {
3090    use avril_runtime::caps::{Capabilities, Category, Initial};
3091    let server = Registry::new();
3092    server.publish_npm(
3093        "secret",
3094        "1.0.0",
3095        &[(
3096            "package.json",
3097            "{\"name\": \"secret\", \"version\": \"1.0.0\"}",
3098        )],
3099    );
3100    let dir = tempfile::tempdir().unwrap();
3101    let mut initial = Initial::new(true);
3102    initial
3103        .grant(Category::JsExec, true, "https://jsr.io/", dir.path())
3104        .unwrap();
3105    let caps = Capabilities::new(initial, dir.path().to_owned());
3106    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
3107        .unwrap()
3108        .with_capabilities(caps);
3109    assert!(
3110        block_on(store.npm_select_version("npm:secret"))
3111            .unwrap_err()
3112            .to_string()
3113            .contains("PermissionDenied")
3114    );
3115    assert!(server.requests.lock().unwrap().is_empty());
3116}
3117
3118#[test]
3119fn npm_packument_shared_across_concurrent_requests() {
3120    let server = Registry::new();
3121    server.publish_npm(
3122        "tool",
3123        "1.0.0",
3124        &[(
3125            "package.json",
3126            "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
3127        )],
3128    );
3129    let dir = tempfile::tempdir().unwrap();
3130    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3131    let (first, second, third) = block_on(async {
3132        futures::join!(
3133            store.npm_select_version("npm:tool"),
3134            store.npm_select_version("npm:tool@^1"),
3135            store.npm_acquire("npm:tool@^1"),
3136        )
3137    });
3138    assert_eq!(first.unwrap(), "1.0.0");
3139    assert_eq!(second.unwrap(), "1.0.0");
3140    assert_eq!(third.unwrap().identity.version, "1.0.0");
3141    assert_eq!(
3142        server
3143            .requests
3144            .lock()
3145            .unwrap()
3146            .iter()
3147            .filter(|path| path.as_str() == "/tool")
3148            .count(),
3149        1,
3150        "concurrent requests must share one metadata fetch"
3151    );
3152}
3153
3154#[test]
3155fn npm_packument_failures_evict_for_retry_on_same_store() {
3156    let server = Registry::new();
3157    let dir = tempfile::tempdir().unwrap();
3158    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3159    assert!(block_on(store.npm_select_version("npm:tool")).is_err());
3160    assert_eq!(server.requests.lock().unwrap().len(), 1);
3161    server.publish_npm(
3162        "tool",
3163        "1.0.0",
3164        &[(
3165            "package.json",
3166            "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
3167        )],
3168    );
3169    assert_eq!(
3170        block_on(store.npm_select_version("npm:tool")).unwrap(),
3171        "1.0.0"
3172    );
3173    assert_eq!(server.requests.lock().unwrap().len(), 2);
3174    {
3175        let mut files = server.files.lock().unwrap();
3176        let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap();
3177        packument["name"] = serde_json::Value::String("evil".into());
3178        files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap());
3179    }
3180    let dir = tempfile::tempdir().unwrap();
3181    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3182    assert!(
3183        block_on(store.npm_select_version("npm:tool"))
3184            .unwrap_err()
3185            .to_string()
3186            .contains("mismatch")
3187    );
3188    {
3189        let mut files = server.files.lock().unwrap();
3190        let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap();
3191        packument["name"] = serde_json::Value::String("tool".into());
3192        files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap());
3193    }
3194    assert_eq!(
3195        block_on(store.npm_select_version("npm:tool")).unwrap(),
3196        "1.0.0"
3197    );
3198}
3199
3200#[test]
3201fn npm_select_version_uses_offline_disk_cache_without_pins() {
3202    let server = Registry::new();
3203    server.publish_npm(
3204        "tool",
3205        "1.0.0",
3206        &[(
3207            "package.json",
3208            "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
3209        )],
3210    );
3211    let dir = tempfile::tempdir().unwrap();
3212    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3213    assert_eq!(
3214        block_on(store.npm_select_version("npm:tool")).unwrap(),
3215        "1.0.0"
3216    );
3217    let warmed = server.requests.lock().unwrap().len();
3218    assert!(warmed > 0);
3219    assert!(
3220        server
3221            .requests
3222            .lock()
3223            .unwrap()
3224            .iter()
3225            .all(|path| !path.ends_with(".tgz"))
3226    );
3227    let mut offline = server.options(dir.path());
3228    offline.offline = true;
3229    let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
3230    assert_eq!(
3231        block_on(store.npm_select_version("npm:tool")).unwrap(),
3232        "1.0.0"
3233    );
3234    assert_eq!(server.requests.lock().unwrap().len(), warmed);
3235    assert!(
3236        server
3237            .requests
3238            .lock()
3239            .unwrap()
3240            .iter()
3241            .all(|path| !path.ends_with(".tgz"))
3242    );
3243}
3244
3245#[test]
3246fn npm_select_version_rejects_bad_metadata_without_evicting_valid_snapshot() {
3247    let server = Registry::new();
3248    server.publish_npm(
3249        "tool",
3250        "1.0.0",
3251        &[(
3252            "package.json",
3253            "{\"name\": \"tool\", \"version\": \"1.0.0\"}",
3254        )],
3255    );
3256    {
3257        let mut files = server.files.lock().unwrap();
3258        let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap();
3259        packument["dist-tags"]["latest"] = serde_json::Value::String("../../evil".into());
3260        files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap());
3261    }
3262    let dir = tempfile::tempdir().unwrap();
3263    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3264    assert!(block_on(store.npm_select_version("npm:tool")).is_err());
3265    assert_eq!(
3266        block_on(store.npm_select_version("npm:tool@1.0.0")).unwrap(),
3267        "1.0.0"
3268    );
3269    assert_eq!(
3270        server
3271            .requests
3272            .lock()
3273            .unwrap()
3274            .iter()
3275            .filter(|path| path.as_str() == "/tool")
3276            .count(),
3277        1,
3278        "selection errors reuse the valid snapshot"
3279    );
3280    assert!(block_on(store.npm_select_version("npm:tool@nope")).is_err());
3281}
3282
3283fn abbreviated_doc(full: &[u8]) -> Vec<u8> {
3284    let full: serde_json::Value = serde_json::from_slice(full).unwrap();
3285    let mut versions = serde_json::Map::new();
3286    for (version, entry) in full["versions"].as_object().unwrap() {
3287        versions.insert(version.clone(), serde_json::json!({"dist": entry["dist"]}));
3288    }
3289    serde_json::to_vec(&serde_json::json!({
3290        "name": full["name"],
3291        "dist-tags": full["dist-tags"],
3292        "versions": versions,
3293    }))
3294    .unwrap()
3295}
3296
3297#[test]
3298fn abbreviated_install_metadata_selects_and_acquires() {
3299    let server = Registry::new();
3300    for version in ["1.0.0", "1.1.0", "2.0.0"] {
3301        server.publish_npm(
3302            "tool",
3303            version,
3304            &[
3305                (
3306                    "package.json",
3307                    &format!("{{\"name\": \"tool\", \"version\": \"{version}\"}}"),
3308                ),
3309                ("index.js", "export default 1;"),
3310            ],
3311        );
3312    }
3313    server.publish_npm(
3314        "@scope/pkg",
3315        "1.0.0",
3316        &[(
3317            "package.json",
3318            "{\"name\": \"@scope/pkg\", \"version\": \"1.0.0\"}",
3319        )],
3320    );
3321    {
3322        let mut files = server.files.lock().unwrap();
3323        let mut packument: serde_json::Value = serde_json::from_slice(&files["/tool"]).unwrap();
3324        packument["dist-tags"]["latest"] = serde_json::Value::String("1.1.0".into());
3325        files.insert("/tool".into(), serde_json::to_vec(&packument).unwrap());
3326        server.publish_npm_abbreviated("tool", abbreviated_doc(&files["/tool"]));
3327        server.publish_npm_abbreviated("@scope/pkg", abbreviated_doc(&files["/@scope/pkg"]));
3328        files.insert("/tool".into(), b"{\"name\": \"tool\"}".to_vec());
3329        files.insert("/@scope/pkg".into(), b"{\"name\": \"@scope/pkg\"}".to_vec());
3330    }
3331    let dir = tempfile::tempdir().unwrap();
3332    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3333    assert_eq!(
3334        block_on(store.npm_select_version("npm:tool")).unwrap(),
3335        "1.1.0"
3336    );
3337    assert_eq!(
3338        block_on(store.npm_select_version("npm:@scope/pkg")).unwrap(),
3339        "1.0.0"
3340    );
3341    store.save().unwrap();
3342    assert!(
3343        !dir.path().join("avril.lock").exists(),
3344        "select-only leaves no pins"
3345    );
3346    {
3347        let before = server.requests.lock().unwrap().len();
3348        let mut offline = server.options(dir.path());
3349        offline.offline = true;
3350        let store = PackageStore::new(fixture::runtime_handle(), offline).unwrap();
3351        assert_eq!(
3352            block_on(store.npm_select_version("npm:tool@^1")).unwrap(),
3353            "1.1.0"
3354        );
3355        assert_eq!(
3356            server.requests.lock().unwrap().len(),
3357            before,
3358            "offline reuse makes no network requests"
3359        );
3360    }
3361    let fetched = server.requests.lock().unwrap().len();
3362    assert_eq!(
3363        server
3364            .requests
3365            .lock()
3366            .unwrap()
3367            .iter()
3368            .filter(|path| path.as_str() == "/tool")
3369            .count(),
3370        1,
3371        "metadata fetched once"
3372    );
3373    let artifact = block_on(store.npm_acquire("npm:tool")).unwrap();
3374    assert_eq!(artifact.identity.version, "1.1.0");
3375    assert_eq!(
3376        std::fs::read_to_string(artifact.dir.join("index.js")).unwrap(),
3377        "export default 1;"
3378    );
3379    let scoped = block_on(store.npm_acquire("npm:@scope/pkg")).unwrap();
3380    assert_eq!(scoped.identity.version, "1.0.0");
3381    assert_eq!(
3382        server
3383            .requests
3384            .lock()
3385            .unwrap()
3386            .iter()
3387            .filter(|path| path.as_str() == "/tool")
3388            .count(),
3389        1,
3390        "acquisition reuses selected metadata"
3391    );
3392    assert_eq!(server.requests.lock().unwrap().len(), fetched + 2);
3393}
3394
3395#[test]
3396fn oversized_abbreviated_metadata_is_rejected_within_cap() {
3397    let server = Registry::new();
3398    server.publish_npm(
3399        "big",
3400        "1.0.0",
3401        &[(
3402            "package.json",
3403            "{\"name\": \"big\", \"version\": \"1.0.0\"}",
3404        )],
3405    );
3406    server.publish_npm_abbreviated("big", vec![b'z'; 8 * 1024 * 1024 + 1]);
3407    let dir = tempfile::tempdir().unwrap();
3408    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap();
3409    assert!(
3410        block_on(store.npm_select_version("npm:big"))
3411            .unwrap_err()
3412            .to_string()
3413            .contains("too large"),
3414        "cap still applies to abbreviated responses"
3415    );
3416}
3417
3418#[test]
3419fn direct_and_binding_imports_agree_on_selection_locking_and_modules() {
3420    let server = Registry::new();
3421    for version in ["1.0.0", "1.1.0"] {
3422        let files = parity_files(version);
3423        server.publish_npm(
3424            "parity",
3425            version,
3426            &files
3427                .iter()
3428                .map(|(path, source)| (*path, source.as_str()))
3429                .collect::<Vec<_>>(),
3430        );
3431    }
3432    let direct = tempfile::tempdir().unwrap();
3433    std::fs::write(
3434        direct.path().join("main.js"),
3435        "import value from 'npm:parity@^1';\nimport sub from 'npm:parity@^1/sub';\nconsole.log(value, sub);",
3436    )
3437    .unwrap();
3438    let bound = tempfile::tempdir().unwrap();
3439    std::fs::write(bound.path().join("avril.conf"), "deps: ['npm:parity@^1']").unwrap();
3440    std::fs::write(
3441        bound.path().join("main.js"),
3442        "import value from 'parity';\nimport sub from 'parity/sub';\nconsole.log(value, sub);",
3443    )
3444    .unwrap();
3445    let direct_stdout = success(&server.run_npm(direct.path(), &["run"], "main.js"));
3446    let bound_stdout = success(&server.run_npm(bound.path(), &["run"], "main.js"));
3447    // The caret range resolves identically through both routes.
3448    assert_eq!(direct_stdout, bound_stdout);
3449    assert_eq!(direct_stdout, "v1.1.0 sub-1.1.0\n");
3450    let direct_lock = fixture::read_lock(direct.path());
3451    let bound_lock = fixture::read_lock(bound.path());
3452    assert_eq!(
3453        direct_lock["npm"]["specifiers"],
3454        bound_lock["npm"]["specifiers"]
3455    );
3456    assert_eq!(direct_lock["npm"]["specifiers"]["npm:parity@^1"], "1.1.0");
3457    assert_eq!(
3458        direct_lock["npm"]["packages"],
3459        bound_lock["npm"]["packages"]
3460    );
3461    assert_eq!(direct_lock, bound_lock);
3462    // The subpath resolves to the same package instance through both routes.
3463    // (Module file paths are per-project materializations, so identity is
3464    // compared via the registry/name/version/instance record instead.)
3465    assert_eq!(
3466        parity_packages(&info_cli(&server, direct.path())),
3467        parity_packages(&info_cli(&server, bound.path()))
3468    );
3469}