1#[macro_use]
2extern crate log;
3
4pub mod dirs;
5
6mod cas;
7mod git;
8mod index;
9mod integrity;
10mod tarball;
11
12pub use git::{
13 codeload_cache_integrity, codeload_cache_lookup, extract_codeload_tarball, git_host_in_list,
14 git_resolve_ref, git_shallow_clone, git_url_host,
15};
16
17#[cfg(test)]
18pub(crate) use cas::blake3_hex;
19pub(crate) use cas::cas_file_matches_len;
20use cas::copy_dir_recursive;
21#[cfg(test)]
22pub(crate) use cas::parse_compress_store_gate;
23#[cfg(test)]
24use git::{
25 codeload_cache_paths, extract_codeload_tarball_at, git_command, git_commit_matches,
26 validate_git_positional,
27};
28pub use index::{PackageIndex, StoredFile, index_content_fingerprint};
29pub use integrity::{
30 SHA512_INTEGRITY_PREFIX, integrity_to_hex, sha512_integrity, sha512_integrity_from_digest,
31 shasum_to_sri, validate_and_encode_name, validate_pkg_content, validate_version,
32 verify_integrity, verify_precomputed_sha512,
33};
34#[cfg(test)]
35pub(crate) use tarball::normalize_tar_entry_path;
36pub(crate) use tarball::{
37 CappedReader, MAX_TARBALL_DECOMPRESSED_BYTES, MAX_TARBALL_ENTRIES, MAX_TARBALL_ENTRY_BYTES,
38};
39pub use tarball::{
40 directory_content_fingerprint, directory_fingerprints, directory_metadata_fingerprint,
41};
42
43#[cfg(test)]
44use sha1::Sha1;
45#[cfg(test)]
46use sha2::{Digest as _, Sha256, Sha384, Sha512};
47use std::path::{Path, PathBuf};
48use std::sync::atomic::AtomicBool;
49#[cfg(target_os = "macos")]
50use std::sync::atomic::Ordering;
51use std::sync::{Arc, Mutex, OnceLock};
52
53pub const CACHE_DIR_NAME: &str = "aube-cache";
54pub const INDEX_SUBDIR: &str = "index";
55pub const VIRTUAL_STORE_SUBDIR: &str = "virtual-store";
56pub const PACKUMENT_CACHE_SUBDIR: &str = "packuments-v1";
57pub const PACKUMENT_FULL_CACHE_SUBDIR: &str = "packuments-full-v1";
58pub const MAINTENANCE_LOCK_FILE: &str = ".maintenance.lock";
59
60#[derive(Default)]
61struct MaintenanceState {
62 shared: Mutex<Option<std::fs::File>>,
63}
64
65pub struct StoreMaintenanceGuard(std::fs::File);
71
72impl Drop for StoreMaintenanceGuard {
73 fn drop(&mut self) {
74 let _ = self.0.unlock();
75 }
76}
77
78#[derive(Clone)]
96pub struct Store {
97 root: PathBuf,
98 cache_dir: PathBuf,
99 virtual_store_dir: PathBuf,
105 maintenance: Arc<MaintenanceState>,
106 migration_done: Arc<OnceLock<()>>,
107 fast_path: Arc<AtomicBool>,
114}
115
116impl Store {
117 pub fn default_location() -> Result<Self, Error> {
125 let root = dirs::store_dir().ok_or(Error::NoHome)?;
126 let cache_dir = dirs::cache_dir().ok_or(Error::NoHome)?;
127 Ok(Self::with_dirs(root, cache_dir))
128 }
129
130 pub fn with_root(root: PathBuf) -> Result<Self, Error> {
134 let cache_dir = dirs::cache_dir().ok_or(Error::NoHome)?;
135 Ok(Self::with_dirs(root, cache_dir))
136 }
137
138 pub fn with_dirs(root: PathBuf, cache_dir: PathBuf) -> Self {
149 Self {
150 root,
151 virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR),
152 cache_dir,
153 maintenance: Arc::new(MaintenanceState::default()),
154 migration_done: Arc::new(OnceLock::new()),
155 fast_path: Arc::new(AtomicBool::new(false)),
156 }
157 }
158
159 #[must_use]
163 pub fn with_virtual_store_dir(mut self, dir: PathBuf) -> Self {
164 self.virtual_store_dir = dir;
165 self
166 }
167
168 pub fn at(root: PathBuf) -> Self {
172 let cache_dir = root.parent().unwrap_or(&root).join(CACHE_DIR_NAME);
173 Self {
174 root,
175 virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR),
176 cache_dir,
177 maintenance: Arc::new(MaintenanceState::default()),
178 migration_done: Arc::new(OnceLock::new()),
179 fast_path: Arc::new(AtomicBool::new(false)),
180 }
181 }
182
183 #[cfg(target_os = "macos")]
199 pub fn enable_fast_path(&self) {
200 self.fast_path.store(true, Ordering::Release);
201 }
202
203 pub fn root(&self) -> &Path {
204 &self.root
205 }
206
207 pub fn store_v1_dir(&self) -> PathBuf {
218 self.root
219 .parent()
220 .map(Path::to_path_buf)
221 .unwrap_or_else(|| self.root.clone())
222 }
223
224 pub fn index_dir(&self) -> PathBuf {
229 self.store_v1_dir().join(INDEX_SUBDIR)
230 }
231
232 pub fn legacy_index_dir(&self) -> PathBuf {
237 self.cache_dir.join(INDEX_SUBDIR)
238 }
239
240 pub fn legacy_index_migration_needed(&self) -> bool {
242 self.legacy_index_dir().exists() && !self.index_dir().exists()
243 }
244
245 fn migrate_legacy_index_dir(&self) {
263 let legacy = self.legacy_index_dir();
264 let new = self.index_dir();
265 if !legacy.exists() || new.exists() {
266 return;
267 }
268 if let Some(parent) = new.parent()
269 && let Err(e) = std::fs::create_dir_all(parent)
270 {
271 warn!(
272 "failed to create {} for index migration: {e}",
273 parent.display()
274 );
275 return;
276 }
277 if std::fs::rename(&legacy, &new).is_ok() {
278 debug!(
279 "migrated cached indexes from {} to {}",
280 legacy.display(),
281 new.display()
282 );
283 return;
284 }
285 if !legacy.exists() {
289 return;
290 }
291 if let Err(e) = copy_dir_recursive(&legacy, &new) {
295 warn!(
296 "failed to migrate cached indexes from {} to {}: {e}; will be rebuilt on next install",
297 legacy.display(),
298 new.display()
299 );
300 if legacy.exists() {
307 let _ = std::fs::remove_dir_all(&new);
308 }
309 return;
310 }
311 if let Err(e) = std::fs::remove_dir_all(&legacy) {
312 warn!(
313 "migrated indexes to {} but failed to remove old {}: {e}",
314 new.display(),
315 legacy.display()
316 );
317 }
318 }
319
320 pub fn maintenance_lock_path(&self) -> PathBuf {
321 self.store_v1_dir().join(MAINTENANCE_LOCK_FILE)
322 }
323
324 fn open_maintenance_lock(&self) -> Result<std::fs::File, Error> {
325 let path = self.maintenance_lock_path();
326 let Some(parent) = path.parent() else {
327 return Err(Error::Io(
328 path,
329 std::io::Error::new(
330 std::io::ErrorKind::InvalidInput,
331 "store maintenance lock has no parent",
332 ),
333 ));
334 };
335 std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
336 std::fs::OpenOptions::new()
337 .create(true)
338 .truncate(false)
339 .write(true)
340 .open(&path)
341 .map_err(|e| Error::Io(path, e))
342 }
343
344 pub fn prepare_for_write(&self) -> Result<(), Error> {
347 let mut shared = self.maintenance.shared.lock().map_err(|_| {
348 Error::Io(
349 self.maintenance_lock_path(),
350 std::io::Error::other("store maintenance lock state is poisoned"),
351 )
352 })?;
353 if shared.is_none() {
354 let file = self.open_maintenance_lock()?;
355 file.lock_shared()
356 .map_err(|e| Error::Io(self.maintenance_lock_path(), e))?;
357 *shared = Some(file);
358 }
359 drop(shared);
360 self.migration_done.get_or_init(|| {
361 self.migrate_legacy_index_dir();
362 });
363 Ok(())
364 }
365
366 pub fn lock_for_maintenance(&self) -> Result<StoreMaintenanceGuard, Error> {
368 let shared = self.maintenance.shared.lock().map_err(|_| {
369 Error::Io(
370 self.maintenance_lock_path(),
371 std::io::Error::other("store maintenance lock state is poisoned"),
372 )
373 })?;
374 if shared.is_some() {
375 return Err(Error::Io(
376 self.maintenance_lock_path(),
377 std::io::Error::new(
378 std::io::ErrorKind::WouldBlock,
379 "this Store already holds a writer lease",
380 ),
381 ));
382 }
383 let file = self.open_maintenance_lock()?;
384 file.lock()
385 .map_err(|e| Error::Io(self.maintenance_lock_path(), e))?;
386 Ok(StoreMaintenanceGuard(file))
387 }
388
389 pub fn migrate_legacy_index_for_maintenance(&self, _guard: &StoreMaintenanceGuard) {
392 self.migrate_legacy_index_dir();
393 }
394
395 pub fn virtual_store_dir(&self) -> PathBuf {
399 self.virtual_store_dir.clone()
400 }
401
402 pub fn packument_cache_dir(&self) -> PathBuf {
406 self.cache_dir.join(PACKUMENT_CACHE_SUBDIR)
407 }
408
409 pub fn packument_full_cache_dir(&self) -> PathBuf {
415 self.cache_dir.join(PACKUMENT_FULL_CACHE_SUBDIR)
416 }
417
418 pub fn has(&self, integrity: &str) -> bool {
420 self.file_path_from_integrity(integrity)
421 .is_some_and(|p| p.exists())
422 }
423
424 pub fn file_path_from_integrity(&self, integrity: &str) -> Option<PathBuf> {
426 let hex_hash = integrity_to_hex(integrity)?;
427 Some(self.file_path_from_hex(&hex_hash))
428 }
429
430 pub fn file_path_from_hex(&self, hex_hash: &str) -> PathBuf {
432 let (shard, rest) = hex_hash.split_at(2);
433 self.root.join(shard).join(rest)
434 }
435}
436
437#[derive(Debug, thiserror::Error, miette::Diagnostic)]
438#[non_exhaustive]
439pub enum Error {
440 #[error("HOME environment variable not set")]
441 #[diagnostic(code(ERR_AUBE_NO_HOME))]
442 NoHome,
443 #[error("I/O error at {0}: {1}")]
444 Io(PathBuf, std::io::Error),
445 #[error("file error: {0}")]
446 Xx(String),
447 #[error("tarball extraction error: {0}")]
448 #[diagnostic(code(ERR_AUBE_TARBALL_EXTRACT))]
449 Tar(String),
450 #[error("integrity verification failed: {0}")]
451 #[diagnostic(code(ERR_AUBE_TARBALL_INTEGRITY))]
452 Integrity(String),
453 #[error("package.json content mismatch: tarball declares {actual}")]
454 #[diagnostic(code(ERR_AUBE_PKG_CONTENT_MISMATCH))]
455 PkgContentMismatch { actual: String },
456 #[error("git error: {0}")]
457 #[diagnostic(code(ERR_AUBE_GIT_ERROR))]
458 Git(String),
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::git::read_codeload_integrity;
465
466 fn store_for_migration_test(root: PathBuf, cache_dir: PathBuf) -> Store {
471 Store {
472 root,
473 virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR),
474 cache_dir,
475 maintenance: Arc::new(MaintenanceState::default()),
476 migration_done: Arc::new(OnceLock::new()),
477 fast_path: Arc::new(AtomicBool::new(false)),
478 }
479 }
480
481 #[test]
482 fn migrate_legacy_index_dir_relocates_files_and_subdirs() {
483 let tmp = tempfile::tempdir().unwrap();
484 let root = tmp.path().join("data/aube/store/v1/files");
485 let cache_dir = tmp.path().join("cache/aube");
486 std::fs::create_dir_all(&root).unwrap();
487 let legacy_index = cache_dir.join("index");
488 let legacy_shard = legacy_index.join("0123456789abcdef");
489 std::fs::create_dir_all(&legacy_shard).unwrap();
490 std::fs::write(legacy_index.join("foo@1.0.0.json"), b"{\"index\":\"a\"}").unwrap();
491 std::fs::write(legacy_shard.join("bar@2.0.0.json"), b"{\"index\":\"b\"}").unwrap();
492
493 let store = store_for_migration_test(root.clone(), cache_dir.clone());
494 store.migrate_legacy_index_dir();
495
496 let new_index = store.index_dir();
497 assert!(new_index.exists(), "new index dir must exist");
498 assert_eq!(
499 std::fs::read(new_index.join("foo@1.0.0.json")).unwrap(),
500 b"{\"index\":\"a\"}",
501 "integrity-less entry must migrate"
502 );
503 assert_eq!(
504 std::fs::read(new_index.join("0123456789abcdef/bar@2.0.0.json")).unwrap(),
505 b"{\"index\":\"b\"}",
506 "integrity-keyed shard subdir must migrate"
507 );
508 assert!(
509 !legacy_index.exists(),
510 "legacy index dir must be removed after a successful migration"
511 );
512 }
513
514 #[test]
515 fn migrate_legacy_index_dir_is_a_noop_when_new_dir_exists() {
516 let tmp = tempfile::tempdir().unwrap();
517 let root = tmp.path().join("data/aube/store/v1/files");
518 let cache_dir = tmp.path().join("cache/aube");
519 std::fs::create_dir_all(&root).unwrap();
520 let legacy_index = cache_dir.join("index");
521 std::fs::create_dir_all(&legacy_index).unwrap();
522 std::fs::write(legacy_index.join("foo@1.0.0.json"), b"old").unwrap();
523
524 let store = store_for_migration_test(root.clone(), cache_dir.clone());
525 std::fs::create_dir_all(store.index_dir()).unwrap();
527 std::fs::write(store.index_dir().join("keep.json"), b"new").unwrap();
528
529 store.migrate_legacy_index_dir();
530
531 assert!(
532 legacy_index.exists(),
533 "legacy dir must stay untouched when new dir already exists"
534 );
535 assert_eq!(
536 std::fs::read(store.index_dir().join("keep.json")).unwrap(),
537 b"new",
538 "existing new-location content must not be overwritten"
539 );
540 assert!(
541 !store.index_dir().join("foo@1.0.0.json").exists(),
542 "no copy must happen — migration only runs when new dir is absent"
543 );
544 }
545
546 #[test]
547 fn migrate_legacy_index_dir_is_a_noop_when_legacy_absent() {
548 let tmp = tempfile::tempdir().unwrap();
549 let root = tmp.path().join("data/aube/store/v1/files");
550 let cache_dir = tmp.path().join("cache/aube");
551 std::fs::create_dir_all(&root).unwrap();
552
553 let store = store_for_migration_test(root, cache_dir);
554 store.migrate_legacy_index_dir();
555
556 assert!(
557 !store.index_dir().exists(),
558 "migration must not create an empty new dir when there's nothing to migrate"
559 );
560 }
561
562 #[test]
563 fn maintenance_lock_waits_for_writer_lease() {
564 let tmp = tempfile::tempdir().unwrap();
565 let root = tmp.path().join("store/v1/files");
566 let cache = tmp.path().join("cache");
567 let writer = Store::with_dirs(root.clone(), cache.clone());
568 writer.prepare_for_write().unwrap();
569
570 let maintenance = Store::with_dirs(root, cache);
571 let (tx, rx) = std::sync::mpsc::channel();
572 let handle = std::thread::spawn(move || {
573 let guard = maintenance.lock_for_maintenance().unwrap();
574 tx.send(()).unwrap();
575 drop(guard);
576 });
577 assert!(
578 rx.recv_timeout(std::time::Duration::from_millis(50))
579 .is_err(),
580 "maintenance must wait while a writer lease is live"
581 );
582 drop(writer);
583 rx.recv_timeout(std::time::Duration::from_secs(2))
584 .expect("maintenance should proceed after the writer exits");
585 handle.join().unwrap();
586 }
587
588 #[test]
589 fn store_v1_dir_is_parent_of_files() {
590 let tmp = tempfile::tempdir().unwrap();
591 let root = tmp.path().join("data/aube/store/v1/files");
592 let cache_dir = tmp.path().join("cache/aube");
593 let store = store_for_migration_test(root.clone(), cache_dir);
594 assert_eq!(store.store_v1_dir(), root.parent().unwrap());
595 assert_eq!(store.index_dir(), root.parent().unwrap().join("index"));
596 }
597
598 #[test]
599 fn git_commit_matches_abbreviated_sha() {
600 assert!(git_commit_matches(
601 "98e8ff1da1a89f93d1397a24d7413ed15421c139",
602 "98e8ff1"
603 ));
604 assert!(!git_commit_matches(
605 "98e8ff1da1a89f93d1397a24d7413ed15421c139",
606 "98e8ff2"
607 ));
608 assert!(!git_commit_matches(
609 "98e8ff1da1a89f93d1397a24d7413ed15421c139",
610 "main"
611 ));
612 }
613
614 #[test]
615 fn test_integrity_to_hex() {
616 let integrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
617 let result = integrity_to_hex(integrity);
618 assert!(result.is_some());
619 let hex = result.unwrap();
620 assert_eq!(hex.len(), 128);
621 assert!(hex.chars().all(|c| c == '0'));
622 }
623
624 #[test]
625 fn test_integrity_to_hex_invalid() {
626 assert!(integrity_to_hex("md5-abc").is_none());
627 assert!(integrity_to_hex("notahash").is_none());
628 assert!(integrity_to_hex("").is_none());
629 }
630
631 #[test]
632 fn test_integrity_to_hex_sha1() {
633 let hex = integrity_to_hex("sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=").unwrap();
635 assert_eq!(hex.len(), 40);
636 assert_eq!(hex, "6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184");
637 }
638
639 #[test]
640 fn test_integrity_to_hex_sha256() {
641 let hex = integrity_to_hex("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
642 assert_eq!(hex.len(), 64);
643 }
644
645 #[test]
646 fn test_file_path_from_hex_sharding() {
647 let dir = tempfile::tempdir().unwrap();
648 let store = Store::at(dir.path().join("files"));
649
650 let path = store.file_path_from_hex("abcdef1234567890");
651 let sep = std::path::MAIN_SEPARATOR;
654 assert!(path.to_string_lossy().contains(&format!("{sep}ab{sep}")));
655 assert!(path.to_string_lossy().ends_with("cdef1234567890"));
656 }
657
658 #[test]
659 fn test_import_bytes() {
660 let dir = tempfile::tempdir().unwrap();
661 let store = Store::at(dir.path().join("files"));
662
663 let content = b"hello world";
664 let stored = store.import_bytes(content, false).unwrap();
665
666 assert!(stored.store_path.exists());
667 assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
668 assert!(!stored.executable);
669
670 let stored2 = store.import_bytes(content, false).unwrap();
672 assert_eq!(stored.hex_hash, stored2.hex_hash);
673 }
674
675 #[test]
678 fn parse_compress_store_gate_affirmative_is_default_node_glob() {
679 for val in ["1", "true", "on", "yes", ""] {
680 let gate = parse_compress_store_gate(val).expect("affirmative → gate");
681 assert_eq!(gate.glob(), Some("**/*.node"));
682 assert_eq!(gate.size(), None);
683 }
684 }
685
686 #[test]
687 fn parse_compress_store_gate_reads_glob_and_size_directives() {
688 let gate = parse_compress_store_gate("glob:**/*.dylib;size:>= 1MB").unwrap();
689 assert_eq!(gate.glob(), Some("**/*.dylib"));
690 assert!(gate.matches("a/b.dylib", 2_000_000));
691 assert!(!gate.matches("a/b.dylib", 500_000));
692
693 let gate = parse_compress_store_gate("size:> 100").unwrap();
695 assert_eq!(gate.glob(), Some("**/*.node"));
696 assert!(gate.matches("x.node", 200));
697 assert!(!gate.matches("x.node", 50));
698
699 assert!(parse_compress_store_gate("garbage").is_some());
701 }
702
703 #[test]
704 fn parse_compress_store_gate_fails_closed_on_bad_size() {
705 assert!(parse_compress_store_gate("size:< 1MB").is_none());
708 assert!(parse_compress_store_gate("size:nonsense").is_none());
709 }
710
711 #[test]
712 fn import_bytes_gated_off_is_byte_identical_to_import_bytes() {
713 let dir = tempfile::tempdir().unwrap();
714 let store = Store::at(dir.path().join("files"));
715
716 let mut content = vec![0x7f, b'E', b'L', b'F'];
719 content.extend_from_slice(&[7u8; 9000]);
720
721 let plain = store.import_bytes(&content, false).unwrap();
722 let gated = store
724 .import_bytes_with_gate("build/Release/x.node", &content, false, None)
725 .unwrap();
726 assert_eq!(plain.hex_hash, gated.hex_hash);
727 assert_eq!(gated.size, Some(content.len() as u64));
728 assert_eq!(std::fs::read(&gated.store_path).unwrap(), content);
729 }
730
731 #[test]
732 fn import_bytes_gated_stores_node_addon_transparently() {
733 let dir = tempfile::tempdir().unwrap();
734 let store = Store::at(dir.path().join("files"));
735 store.ensure_shards_exist().unwrap();
736
737 let mut content = vec![0x7f, b'E', b'L', b'F'];
738 content.extend_from_slice(&[0x5au8; 12_000]);
739 let gate = decmpfs::Gate::default();
740
741 let stored = store
742 .import_bytes_with_gate("build/Release/addon.node", &content, false, Some(&gate))
743 .unwrap();
744
745 assert!(stored.store_path.exists(), "addon landed in the CAS");
749 assert_eq!(stored.size, Some(content.len() as u64));
750 assert!(cas_file_matches_len(
751 &stored.store_path,
752 content.len() as u64
753 ));
754 assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
755 assert_eq!(stored.hex_hash, blake3_hex(&content));
757 }
758
759 #[test]
760 fn import_bytes_gated_excludes_non_node_paths() {
761 let dir = tempfile::tempdir().unwrap();
762 let store = Store::at(dir.path().join("files"));
763 store.ensure_shards_exist().unwrap();
764
765 let content = b"module.exports = 1;\n".to_vec();
766 let gate = decmpfs::Gate::default();
767 let gated = store
770 .import_bytes_with_gate("index.js", &content, false, Some(&gate))
771 .unwrap();
772 assert_eq!(gated.hex_hash, blake3_hex(&content));
773 assert_eq!(std::fs::read(&gated.store_path).unwrap(), content);
774 }
775
776 #[test]
777 fn import_bytes_gated_unwraps_a_napi_compress_hybrid() {
778 let dir = tempfile::tempdir().unwrap();
779 let store = Store::at(dir.path().join("files"));
780 store.ensure_shards_exist().unwrap();
781
782 let raw = {
784 let mut v = vec![0x7f, b'E', b'L', b'F'];
785 v.extend_from_slice(&[0x66u8; 6000]);
786 v
787 };
788 let hybrid = synth_elf_hybrid(&raw);
789
790 let gate = decmpfs::Gate::default();
791 let stored = store
792 .import_bytes_with_gate("build/Release/native.node", &hybrid, false, Some(&gate))
793 .unwrap();
794
795 assert_eq!(stored.size, Some(raw.len() as u64));
797 assert_eq!(stored.hex_hash, blake3_hex(&raw));
798 assert_eq!(std::fs::read(&stored.store_path).unwrap(), raw);
799 }
800
801 #[cfg(test)]
805 fn synth_elf_hybrid(raw: &[u8]) -> Vec<u8> {
806 use sha2::{Digest as _, Sha512};
807
808 const MAGIC: &[u8; 32] = b"__SMOL_PRESSED_DATA_MAGIC_MARKER";
811 let payload = zstd::stream::encode_all(raw, 3).unwrap();
812 let mut hasher = Sha512::new();
813 hasher.update(&payload);
814 let hash = hasher.finalize();
815 let mut blob = Vec::new();
816 blob.extend_from_slice(MAGIC);
817 blob.extend_from_slice(&(payload.len() as u64).to_le_bytes());
818 blob.extend_from_slice(&(raw.len() as u64).to_le_bytes());
819 blob.extend_from_slice(&[b'a'; 16]); blob.extend_from_slice(&[1u8, 1u8, 255u8]); blob.extend_from_slice(&hash);
822 blob.push(0u8); blob.extend_from_slice(&payload);
824
825 let shentsize = 64usize;
827 let mut strtab = vec![0u8];
828 let shstrtab_name = strtab.len() as u32;
829 strtab.extend_from_slice(b".shstrtab\0");
830 let pressed_name = strtab.len() as u32;
831 strtab.extend_from_slice(b".PRESSED_DATA\0");
832
833 let ehdr_len = 64usize;
834 let strtab_off = ehdr_len;
835 let shoff = strtab_off + strtab.len();
836 let blob_off = shoff + 2 * shentsize;
837
838 let mut bin = vec![0u8; blob_off];
839 bin[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
840 bin[4] = 2; bin[40..48].copy_from_slice(&(shoff as u64).to_le_bytes());
842 bin[58..60].copy_from_slice(&(shentsize as u16).to_le_bytes());
843 bin[60..62].copy_from_slice(&2u16.to_le_bytes());
844 bin[62..64].copy_from_slice(&0u16.to_le_bytes()); bin[strtab_off..strtab_off + strtab.len()].copy_from_slice(&strtab);
846
847 let sh0 = shoff;
848 bin[sh0..sh0 + 4].copy_from_slice(&shstrtab_name.to_le_bytes());
849 bin[sh0 + 24..sh0 + 32].copy_from_slice(&(strtab_off as u64).to_le_bytes());
850 bin[sh0 + 32..sh0 + 40].copy_from_slice(&(strtab.len() as u64).to_le_bytes());
851
852 let sh1 = shoff + shentsize;
853 bin[sh1..sh1 + 4].copy_from_slice(&pressed_name.to_le_bytes());
854 bin[sh1 + 24..sh1 + 32].copy_from_slice(&(blob_off as u64).to_le_bytes());
855 bin[sh1 + 32..sh1 + 40].copy_from_slice(&(blob.len() as u64).to_le_bytes());
856 bin.extend_from_slice(&blob);
857 bin
858 }
859
860 #[test]
861 fn test_import_bytes_repairs_truncated_existing_cas_entry() {
862 let dir = tempfile::tempdir().unwrap();
863 let store = Store::at(dir.path().join("files"));
864 store.ensure_shards_exist().unwrap();
865
866 let content = br#"{"name":"@babel/helper-string-parser","version":"7.27.1"}"#;
867 let hex_hash = blake3_hex(content);
868 let store_path = store.file_path_from_hex(&hex_hash);
869 std::fs::write(&store_path, b"").unwrap();
870
871 let stored = store.import_bytes(content, false).unwrap();
872
873 assert_eq!(stored.hex_hash, hex_hash);
874 assert_eq!(stored.size, Some(content.len() as u64));
875 assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
876 }
877
878 #[test]
879 fn verify_precomputed_sha512_happy_path() {
880 let data = b"hello world";
881 let mut hasher = Sha512::new();
882 hasher.update(data);
883 let mut digest = [0u8; 64];
884 digest.copy_from_slice(&hasher.finalize()[..]);
885 use base64::Engine;
886 let b64 = base64::engine::general_purpose::STANDARD.encode(digest);
887 let integrity = format!("sha512-{b64}");
888 assert!(verify_precomputed_sha512(&digest, &integrity).unwrap());
889 }
890
891 #[test]
892 fn verify_precomputed_sha512_mismatch_errors() {
893 use base64::Engine;
897 let other = [0xFFu8; 64];
898 let other_b64 = base64::engine::general_purpose::STANDARD.encode(other);
899 let wrong = format!("sha512-{other_b64}");
900 let digest = [0u8; 64];
901 let err = verify_precomputed_sha512(&digest, &wrong).unwrap_err();
902 assert!(err.to_string().contains("integrity mismatch"));
903 }
904
905 #[test]
906 fn verify_precomputed_sha512_corrupt_b64_errors_distinctly() {
907 let digest = [0u8; 64];
911 let corrupt = "sha512-not_valid_base64_!!!!!";
912 let err = verify_precomputed_sha512(&digest, corrupt).unwrap_err();
913 assert!(err.to_string().contains("malformed base64"));
914 }
915
916 #[test]
917 fn verify_precomputed_sha512_short_b64_errors_distinctly() {
918 let digest = [0u8; 64];
921 let short = "sha512-AAAA";
922 let err = verify_precomputed_sha512(&digest, short).unwrap_err();
923 assert!(err.to_string().contains("expected 64 for sha512"));
924 }
925
926 #[test]
927 fn verify_precomputed_sha512_non_sha512_returns_false() {
928 let digest = [0u8; 64];
933 for algo in ["sha1-AAAA", "sha256-AAAA", "sha384-AAAA"] {
934 assert!(
935 !verify_precomputed_sha512(&digest, algo).unwrap(),
936 "{algo} should return Ok(false) for fallback"
937 );
938 }
939 }
940
941 #[test]
942 fn verify_precomputed_sha512_malformed_errors() {
943 let digest = [0u8; 64];
944 for bad in ["", "garbage", "not-an-algo-tag", "sha512", "sha512-"] {
945 let result = verify_precomputed_sha512(&digest, bad);
946 assert!(result.is_err(), "{bad:?} should not be Ok");
947 }
948 }
949
950 #[test]
951 fn test_verify_integrity_valid() {
952 let data = b"hello world";
953 let mut hasher = Sha512::new();
955 hasher.update(data);
956 let hash = hasher.finalize();
957 use base64::Engine;
958 let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
959 let integrity = format!("sha512-{b64}");
960
961 assert!(verify_integrity(data, &integrity).is_ok());
962 }
963
964 #[test]
965 fn test_verify_integrity_mismatch() {
966 let data = b"hello world";
967 let wrong = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
968 let result = verify_integrity(data, wrong);
969 assert!(result.is_err());
970 assert!(
971 result
972 .unwrap_err()
973 .to_string()
974 .contains("integrity mismatch")
975 );
976 }
977
978 #[test]
979 fn test_verify_integrity_unsupported_format() {
980 let result = verify_integrity(b"test", "md5-abc123");
981 assert!(result.is_err());
982 assert!(result.unwrap_err().to_string().contains("unsupported"));
983 }
984
985 #[test]
986 fn test_verify_integrity_sha1_valid() {
987 let data = b"hello world";
990 let hash = Sha1::digest(data);
991 use base64::Engine;
992 let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
993 assert!(verify_integrity(data, &format!("sha1-{b64}")).is_ok());
994 }
995
996 #[test]
997 fn test_verify_integrity_sha1_mismatch() {
998 let result = verify_integrity(b"hello world", "sha1-AAAAAAAAAAAAAAAAAAAAAAAAAAA=");
999 let err = result.unwrap_err().to_string();
1000 assert!(err.contains("integrity mismatch"));
1001 assert!(err.contains("sha1-"));
1002 }
1003
1004 #[test]
1005 fn test_verify_integrity_sha256_valid() {
1006 let data = b"hello world";
1007 let hash = Sha256::digest(data);
1008 use base64::Engine;
1009 let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
1010 assert!(verify_integrity(data, &format!("sha256-{b64}")).is_ok());
1011 }
1012
1013 #[test]
1014 fn test_verify_integrity_sha384_valid() {
1015 let data = b"hello world";
1016 let hash = Sha384::digest(data);
1017 use base64::Engine;
1018 let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
1019 assert!(verify_integrity(data, &format!("sha384-{b64}")).is_ok());
1020 }
1021
1022 #[test]
1023 fn test_import_bytes_executable() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let store = Store::at(dir.path().join("files"));
1026
1027 let content = b"#!/bin/sh\necho hello";
1028 let stored = store.import_bytes(content, true).unwrap();
1029 assert!(stored.executable);
1030
1031 let exec_marker = PathBuf::from(format!("{}-exec", stored.store_path.display()));
1033 assert!(exec_marker.exists());
1034 }
1035
1036 #[test]
1037 fn test_import_bytes_different_content_different_hash() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let store = Store::at(dir.path().join("files"));
1040
1041 let stored1 = store.import_bytes(b"content a", false).unwrap();
1042 let stored2 = store.import_bytes(b"content b", false).unwrap();
1043 assert_ne!(stored1.hex_hash, stored2.hex_hash);
1044 }
1045
1046 const TEST_INTEGRITY: &str = "sha512-7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1ntDcowEzF+prYseb2BRlkqKKw==";
1051 const OTHER_INTEGRITY: &str = "sha512-n4udRxsOEWaTbNrUjcrNvWAd1/aLvZeC/CwfsBIJZj0kHqyh0h10DmZerKIyp+/YqR09J8rBmdqkIy9SE/6rcQ==";
1052
1053 #[test]
1054 fn test_index_cache_roundtrip() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let store = Store::at(dir.path().join("files"));
1057
1058 let content = b"test file";
1059 let stored = store.import_bytes(content, false).unwrap();
1060
1061 let mut index = PackageIndex::default();
1062 index.insert("index.js".to_string(), stored);
1063
1064 store
1065 .save_index("test-pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1066 .unwrap();
1067
1068 let loaded = store.load_index("test-pkg", "1.0.0", Some(TEST_INTEGRITY));
1069 assert!(loaded.is_some());
1070 let loaded = loaded.unwrap();
1071 assert_eq!(loaded.len(), 1);
1072 assert!(loaded.contains_key("index.js"));
1073 }
1074
1075 #[test]
1076 fn test_index_cache_scoped_package() {
1077 let dir = tempfile::tempdir().unwrap();
1078 let store = Store::at(dir.path().join("files"));
1079
1080 let stored = store.import_bytes(b"scoped content", false).unwrap();
1081 let mut index = PackageIndex::default();
1082 index.insert("index.js".to_string(), stored);
1083
1084 store
1086 .save_index("@scope/pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1087 .unwrap();
1088 let loaded = store.load_index("@scope/pkg", "1.0.0", Some(TEST_INTEGRITY));
1089 assert!(loaded.is_some());
1090 }
1091
1092 #[test]
1093 fn test_index_cache_stale_detection() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let store = Store::at(dir.path().join("files"));
1096
1097 let stored = store.import_bytes(b"content", false).unwrap();
1098 let store_path = stored.store_path.clone();
1099 let mut index = PackageIndex::default();
1100 index.insert("index.js".to_string(), stored);
1101
1102 store
1103 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1104 .unwrap();
1105
1106 std::fs::remove_file(&store_path).unwrap();
1108
1109 assert!(
1111 store
1112 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1113 .is_none()
1114 );
1115 store
1119 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1120 .unwrap();
1121 assert!(
1122 store
1123 .load_index_verified("pkg", "1.0.0", Some(TEST_INTEGRITY))
1124 .is_none()
1125 );
1126 }
1127
1128 #[test]
1129 fn load_index_passes_partial_corruption_load_index_verified_catches_it() {
1130 let dir = tempfile::tempdir().unwrap();
1139 let store = Store::at(dir.path().join("files"));
1140
1141 let mut index = PackageIndex::default();
1152 for i in 0..8 {
1153 let stored = store
1154 .import_bytes(format!("content-{i}").as_bytes(), false)
1155 .unwrap();
1156 index.insert(format!("file-{i:02}.txt"), stored);
1157 }
1158 store
1159 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1160 .unwrap();
1161 let loaded = store
1162 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1163 .expect("freshly saved index must load before any corruption");
1164 let first_path = loaded.values().next().unwrap().store_path.clone();
1165 let dropped_path = loaded
1166 .values()
1167 .find(|f| f.store_path != first_path)
1168 .unwrap()
1169 .store_path
1170 .clone();
1171
1172 std::fs::remove_file(&dropped_path).unwrap();
1174
1175 assert!(
1179 store
1180 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1181 .is_some(),
1182 "cheap probe must accept partial corruption (precondition for the fix)"
1183 );
1184 store
1188 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1189 .unwrap();
1190
1191 assert!(
1193 store
1194 .load_index_verified("pkg", "1.0.0", Some(TEST_INTEGRITY))
1195 .is_none(),
1196 "verified probe must reject an index whose later files are missing"
1197 );
1198
1199 let path = store.index_path("pkg", "1.0.0", Some(TEST_INTEGRITY));
1202 assert!(
1203 !path.unwrap().exists(),
1204 "verified probe must drop the stale cached index"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_invalidate_cached_index_removes_entry() {
1210 let dir = tempfile::tempdir().unwrap();
1211 let store = Store::at(dir.path().join("files"));
1212
1213 let stored = store.import_bytes(b"content", false).unwrap();
1214 let mut index = PackageIndex::default();
1215 index.insert("index.js".to_string(), stored);
1216
1217 store
1218 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1219 .unwrap();
1220 assert!(
1222 store
1223 .invalidate_cached_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1224 .unwrap()
1225 );
1226 assert!(
1227 !store
1228 .invalidate_cached_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1229 .unwrap()
1230 );
1231 assert!(
1233 store
1234 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1235 .is_none()
1236 );
1237 }
1238
1239 #[test]
1240 fn test_invalidate_cached_index_returns_false_for_invalid_coordinate() {
1241 let dir = tempfile::tempdir().unwrap();
1242 let store = Store::at(dir.path().join("files"));
1243 assert!(
1245 !store
1246 .invalidate_cached_index("", "1.0.0", Some(TEST_INTEGRITY))
1247 .unwrap()
1248 );
1249 }
1250
1251 #[test]
1252 fn test_index_cache_rejects_size_mismatch() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let store = Store::at(dir.path().join("files"));
1255
1256 let stored = store.import_bytes(b"content", false).unwrap();
1257 let store_path = stored.store_path.clone();
1258 let mut index = PackageIndex::default();
1259 index.insert("index.js".to_string(), stored);
1260
1261 store
1262 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1263 .unwrap();
1264 std::fs::write(&store_path, b"").unwrap();
1265
1266 assert!(
1267 store
1268 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1269 .is_none()
1270 );
1271 }
1272
1273 #[cfg(unix)]
1274 #[test]
1275 fn test_import_bytes_uses_world_readable_permissions() {
1276 use std::os::unix::fs::PermissionsExt;
1277
1278 let dir = tempfile::tempdir().unwrap();
1279 let store = Store::at(dir.path().join("files"));
1280
1281 let stored = store.import_bytes(b"content", false).unwrap();
1282 let mode = std::fs::metadata(&stored.store_path)
1283 .unwrap()
1284 .permissions()
1285 .mode()
1286 & 0o777;
1287
1288 assert_eq!(mode, 0o644);
1289 }
1290
1291 #[test]
1292 fn test_index_cache_miss() {
1293 let dir = tempfile::tempdir().unwrap();
1294 let store = Store::at(dir.path().join("files"));
1295
1296 assert!(
1297 store
1298 .load_index("nonexistent", "1.0.0", Some(TEST_INTEGRITY))
1299 .is_none()
1300 );
1301 }
1302
1303 #[test]
1304 fn test_index_cache_integrity_discriminates_sources() {
1305 let dir = tempfile::tempdir().unwrap();
1311 let store = Store::at(dir.path().join("files"));
1312
1313 let registry_bytes = store.import_bytes(b"registry tarball", false).unwrap();
1314 let mut registry_index = PackageIndex::default();
1315 registry_index.insert("package.json".to_string(), registry_bytes);
1316
1317 let github_bytes = store.import_bytes(b"github tarball", false).unwrap();
1318 let mut github_index = PackageIndex::default();
1319 github_index.insert("package.json".to_string(), github_bytes);
1320 github_index.insert("extra-github-only.js".to_string(), {
1321 store.import_bytes(b"extra", false).unwrap()
1322 });
1323
1324 store
1325 .save_index("node-expat", "2.4.1", Some(TEST_INTEGRITY), ®istry_index)
1326 .unwrap();
1327 store
1328 .save_index("node-expat", "2.4.1", Some(OTHER_INTEGRITY), &github_index)
1329 .unwrap();
1330
1331 let registry = store
1333 .load_index("node-expat", "2.4.1", Some(TEST_INTEGRITY))
1334 .unwrap();
1335 let github = store
1336 .load_index("node-expat", "2.4.1", Some(OTHER_INTEGRITY))
1337 .unwrap();
1338 assert_eq!(registry.len(), 1);
1339 assert_eq!(github.len(), 2);
1340 assert!(github.contains_key("extra-github-only.js"));
1341 }
1342
1343 #[test]
1344 fn test_index_cache_rejects_malformed_integrity() {
1345 let dir = tempfile::tempdir().unwrap();
1346 let store = Store::at(dir.path().join("files"));
1347
1348 let stored = store.import_bytes(b"content", false).unwrap();
1349 let mut index = PackageIndex::default();
1350 index.insert("index.js".to_string(), stored);
1351
1352 assert!(
1355 store
1356 .save_index("pkg", "1.0.0", Some("not-an-integrity"), &index)
1357 .is_err()
1358 );
1359 assert!(
1360 store
1361 .load_index("pkg", "1.0.0", Some("not-an-integrity"))
1362 .is_none()
1363 );
1364 }
1365
1366 #[test]
1367 fn test_index_cache_integrity_none_roundtrip() {
1368 let dir = tempfile::tempdir().unwrap();
1373 let store = Store::at(dir.path().join("files"));
1374
1375 let stored = store.import_bytes(b"no-integrity content", false).unwrap();
1376 let mut index = PackageIndex::default();
1377 index.insert("index.js".to_string(), stored);
1378
1379 store.save_index("pkg", "1.0.0", None, &index).unwrap();
1380 let loaded = store.load_index("pkg", "1.0.0", None);
1381 assert!(loaded.is_some());
1382 assert!(loaded.unwrap().contains_key("index.js"));
1383
1384 assert!(
1387 store
1388 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1389 .is_none()
1390 );
1391 }
1392
1393 #[test]
1394 fn test_index_cache_build_metadata_does_not_collide_with_integrity() {
1395 let dir = tempfile::tempdir().unwrap();
1402 let store = Store::at(dir.path().join("files"));
1403
1404 let a = store.import_bytes(b"integrity-keyed bytes", false).unwrap();
1405 let mut integrity_keyed = PackageIndex::default();
1406 integrity_keyed.insert("integrity-keyed.js".to_string(), a);
1407
1408 let b = store.import_bytes(b"build-metadata bytes", false).unwrap();
1409 let mut build_meta = PackageIndex::default();
1410 build_meta.insert("build-meta.js".to_string(), b);
1411
1412 let colliding_version = "1.0.0+ee26b0dd4af7e749";
1418 store
1419 .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &integrity_keyed)
1420 .unwrap();
1421 store
1422 .save_index("pkg", colliding_version, None, &build_meta)
1423 .unwrap();
1424
1425 let by_integrity = store
1426 .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1427 .unwrap();
1428 let by_build_meta = store.load_index("pkg", colliding_version, None).unwrap();
1429 assert!(by_integrity.contains_key("integrity-keyed.js"));
1430 assert!(by_build_meta.contains_key("build-meta.js"));
1431 assert!(!by_integrity.contains_key("build-meta.js"));
1433 assert!(!by_build_meta.contains_key("integrity-keyed.js"));
1434 }
1435
1436 fn index_with_manifest(store: &Store, name: &str, version: &str) -> PackageIndex {
1437 let manifest =
1438 serde_json::json!({"name": name, "version": version, "main": "index.js"}).to_string();
1439 let stored = store.import_bytes(manifest.as_bytes(), false).unwrap();
1440 let mut index = PackageIndex::default();
1441 index.insert("package.json".to_string(), stored);
1442 index
1443 }
1444
1445 #[test]
1446 fn test_validate_pkg_content_match() {
1447 let dir = tempfile::tempdir().unwrap();
1448 let store = Store::at(dir.path().join("files"));
1449 let index = index_with_manifest(&store, "lodash", "4.17.21");
1450 assert!(validate_pkg_content(&index, "lodash", "4.17.21").is_ok());
1451 }
1452
1453 #[test]
1454 fn test_validate_pkg_content_name_mismatch() {
1455 let dir = tempfile::tempdir().unwrap();
1456 let store = Store::at(dir.path().join("files"));
1457 let index = index_with_manifest(&store, "evil-pkg", "1.0.0");
1458 let err = validate_pkg_content(&index, "lodash", "1.0.0").unwrap_err();
1459 let msg = err.to_string();
1460 assert!(msg.contains("content mismatch"), "{msg}");
1464 assert!(msg.contains("declares evil-pkg@1.0.0"), "{msg}");
1465 }
1466
1467 #[test]
1468 fn test_validate_pkg_content_version_mismatch() {
1469 let dir = tempfile::tempdir().unwrap();
1470 let store = Store::at(dir.path().join("files"));
1471 let index = index_with_manifest(&store, "lodash", "9.9.9");
1472 let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
1473 let msg = err.to_string();
1474 assert!(msg.contains("content mismatch"), "{msg}");
1475 assert!(msg.contains("declares lodash@9.9.9"), "{msg}");
1476 }
1477
1478 #[test]
1479 fn test_validate_pkg_content_tolerates_leading_v() {
1480 let dir = tempfile::tempdir().unwrap();
1481 let store = Store::at(dir.path().join("files"));
1482 let index = index_with_manifest(&store, "@upstash/ratelimit", "v2.0.8");
1483 assert!(validate_pkg_content(&index, "@upstash/ratelimit", "2.0.8").is_ok());
1484 }
1485
1486 #[test]
1487 fn test_validate_pkg_content_tolerates_tarball_build_metadata() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let store = Store::at(dir.path().join("files"));
1490 let index = index_with_manifest(&store, "@trpc/react-query", "11.0.0-rc.747+64714681c");
1491 assert!(validate_pkg_content(&index, "@trpc/react-query", "11.0.0-rc.747").is_ok());
1492 }
1493
1494 #[test]
1495 fn test_validate_pkg_content_build_metadata_keeps_base_version_strict() {
1496 let dir = tempfile::tempdir().unwrap();
1497 let store = Store::at(dir.path().join("files"));
1498 let index = index_with_manifest(&store, "@trpc/react-query", "11.0.0-rc.748+64714681c");
1499 let err = validate_pkg_content(&index, "@trpc/react-query", "11.0.0-rc.747").unwrap_err();
1500 assert!(err.to_string().contains("content mismatch"), "{err}");
1501 }
1502
1503 #[test]
1504 fn test_validate_pkg_content_skips_version_for_url_shaped_expected() {
1505 let dir = tempfile::tempdir().unwrap();
1510 let store = Store::at(dir.path().join("files"));
1511 let index = index_with_manifest(&store, "datejs", "1.0.0-rc3");
1512 let url = "https://codeload.github.com/abritinthebay/datejs/tar.gz/3675d46ed96d57e30aeddf9b1d1026ac81d37ae3";
1513 assert!(validate_pkg_content(&index, "datejs", url).is_ok());
1514 let err = validate_pkg_content(&index, "evil", url).unwrap_err();
1516 assert!(err.to_string().contains("content mismatch"), "{err}");
1517 }
1518
1519 #[test]
1520 fn test_validate_pkg_content_missing_manifest() {
1521 let dir = tempfile::tempdir().unwrap();
1522 let store = Store::at(dir.path().join("files"));
1523 let stored = store.import_bytes(b"module.exports = 1;", false).unwrap();
1524 let mut index = PackageIndex::default();
1525 index.insert("index.js".to_string(), stored);
1526 let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
1527 assert!(err.to_string().contains("package.json missing"), "{err}",);
1528 }
1529
1530 #[test]
1531 fn test_validate_pkg_content_unparseable_manifest() {
1532 let dir = tempfile::tempdir().unwrap();
1533 let store = Store::at(dir.path().join("files"));
1534 let stored = store.import_bytes(b"{not json", false).unwrap();
1535 let mut index = PackageIndex::default();
1536 index.insert("package.json".to_string(), stored);
1537 let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
1538 assert!(err.to_string().contains("invalid package.json"), "{err}");
1539 }
1540
1541 #[test]
1542 fn test_import_tarball() {
1543 let mut builder = tar::Builder::new(Vec::new());
1545
1546 let content = b"module.exports = 42;\n";
1547 let mut header = tar::Header::new_gnu();
1548 header.set_size(content.len() as u64);
1549 header.set_mode(0o644);
1550 header.set_cksum();
1551 builder
1552 .append_data(&mut header, "package/index.js", &content[..])
1553 .unwrap();
1554
1555 let bin_content = b"#!/usr/bin/env node\nconsole.log('hi');\n";
1556 let mut bin_header = tar::Header::new_gnu();
1557 bin_header.set_size(bin_content.len() as u64);
1558 bin_header.set_mode(0o755);
1559 bin_header.set_cksum();
1560 builder
1561 .append_data(&mut bin_header, "package/bin/cli.js", &bin_content[..])
1562 .unwrap();
1563
1564 let tar_bytes = builder.into_inner().unwrap();
1565
1566 use flate2::write::GzEncoder;
1568 use std::io::Write;
1569 let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::fast());
1570 encoder.write_all(&tar_bytes).unwrap();
1571 let tgz_bytes = encoder.finish().unwrap();
1572
1573 let dir = tempfile::tempdir().unwrap();
1574 let store = Store::at(dir.path().join("files"));
1575
1576 let index = store.import_tarball(&tgz_bytes).unwrap();
1577 assert_eq!(index.len(), 2);
1578 assert!(index.contains_key("index.js"));
1579 assert!(index.contains_key("bin/cli.js"));
1580
1581 let idx_stored = &index["index.js"];
1583 assert!(!idx_stored.executable);
1584 assert_eq!(std::fs::read(&idx_stored.store_path).unwrap(), content);
1585
1586 let bin_stored = &index["bin/cli.js"];
1587 assert!(bin_stored.executable);
1588 assert_eq!(std::fs::read(&bin_stored.store_path).unwrap(), bin_content);
1589 }
1590
1591 #[test]
1592 fn test_git_url_host_https() {
1593 assert_eq!(
1594 git_url_host("https://github.com/user/repo.git"),
1595 Some("github.com")
1596 );
1597 assert_eq!(
1598 git_url_host("git+https://github.com/user/repo.git#main"),
1599 Some("github.com")
1600 );
1601 assert_eq!(
1602 git_url_host("git://git.example.com/repo.git"),
1603 Some("git.example.com")
1604 );
1605 }
1606
1607 #[test]
1608 fn test_git_url_host_ssh() {
1609 assert_eq!(
1610 git_url_host("git+ssh://git@github.com/user/repo.git"),
1611 Some("github.com")
1612 );
1613 assert_eq!(
1614 git_url_host("ssh://git@gitlab.com:2222/user/repo.git"),
1615 Some("gitlab.com")
1616 );
1617 assert_eq!(
1619 git_url_host("git@github.com:user/repo.git"),
1620 Some("github.com")
1621 );
1622 }
1623
1624 #[test]
1625 fn test_git_url_host_ipv6() {
1626 assert_eq!(git_url_host("https://[::1]/repo.git"), Some("::1"));
1629 assert_eq!(git_url_host("https://[::1]:8443/repo.git"), Some("::1"));
1630 assert_eq!(
1631 git_url_host("ssh://git@[2001:db8::1]:2222/user/repo.git"),
1632 Some("2001:db8::1")
1633 );
1634 }
1635
1636 #[test]
1637 fn test_git_url_host_rejects_garbage() {
1638 assert_eq!(git_url_host(""), None);
1639 assert_eq!(git_url_host("not a url"), None);
1640 assert_eq!(git_url_host("/just/a/path"), None);
1641 }
1642
1643 #[test]
1644 fn test_git_host_in_list_exact_match() {
1645 let hosts = vec![
1646 "github.com".to_string(),
1647 "gitlab.com".to_string(),
1648 "bitbucket.org".to_string(),
1649 ];
1650 assert!(git_host_in_list("https://github.com/user/repo.git", &hosts));
1651 assert!(git_host_in_list(
1652 "git+ssh://git@gitlab.com/user/repo.git",
1653 &hosts
1654 ));
1655 assert!(!git_host_in_list(
1657 "https://api.github.com/user/repo.git",
1658 &hosts
1659 ));
1660 assert!(!git_host_in_list(
1661 "https://self-hosted.example/user/repo.git",
1662 &hosts
1663 ));
1664 }
1665
1666 #[test]
1667 fn test_git_host_in_list_empty_list() {
1668 let hosts: Vec<String> = vec![];
1669 assert!(!git_host_in_list(
1670 "https://github.com/user/repo.git",
1671 &hosts
1672 ));
1673 }
1674
1675 #[test]
1676 fn test_validate_git_positional_accepts_normal_values() {
1677 validate_git_positional("https://github.com/u/r.git", "git url").unwrap();
1678 validate_git_positional("git@github.com:u/r.git", "git url").unwrap();
1679 validate_git_positional("main", "git commit").unwrap();
1680 validate_git_positional("0123456789abcdef0123456789abcdef01234567", "git commit").unwrap();
1681 }
1682
1683 #[test]
1684 fn test_validate_git_positional_rejects_dash_prefix() {
1685 let err = validate_git_positional("--upload-pack=/tmp/evil", "git url").unwrap_err();
1688 assert!(matches!(err, Error::Git(_)));
1689 let err = validate_git_positional("-oX", "git commit").unwrap_err();
1690 assert!(matches!(err, Error::Git(_)));
1691 }
1692
1693 #[test]
1694 fn test_validate_git_positional_rejects_nul() {
1695 let err = validate_git_positional("normal\0tail", "git url").unwrap_err();
1696 assert!(matches!(err, Error::Git(_)));
1697 }
1698
1699 #[test]
1700 fn test_git_resolve_ref_rejects_dash_prefixed_url() {
1701 let err = git_resolve_ref("--upload-pack=/tmp/evil", None).unwrap_err();
1704 assert!(matches!(err, Error::Git(_)));
1705 }
1706
1707 #[test]
1708 fn test_git_commands_disable_terminal_prompts() {
1709 let command = git_command();
1710 let prompt = command
1711 .get_envs()
1712 .find(|(name, _)| *name == "GIT_TERMINAL_PROMPT")
1713 .and_then(|(_, value)| value);
1714 assert_eq!(prompt, Some(std::ffi::OsStr::new("0")));
1715 }
1716
1717 #[test]
1718 fn test_git_resolve_ref_full_sha_is_offline() {
1719 let sha = "0123456789ABCDEF0123456789abcdef01234567";
1723 let resolved = git_resolve_ref("https://example.invalid/missing.git", Some(sha)).unwrap();
1724 assert_eq!(resolved, "0123456789abcdef0123456789abcdef01234567");
1725 }
1726
1727 #[test]
1728 fn test_git_commit_matches_prefix() {
1729 let full = "0b6ea539609031977983f0b2393ebe81ee28c8ec";
1730 assert!(git_commit_matches(full, full));
1731 assert!(git_commit_matches(full, "0b6ea53"));
1732 assert!(!git_commit_matches(full, "0b6ea5"));
1733 assert!(!git_commit_matches(full, "abc1234"));
1734 assert!(!git_commit_matches(full, "main"));
1735 }
1736
1737 fn build_codeload_tarball(wrapper: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
1742 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1743 let mut ar = tar::Builder::new(gz);
1744 let mut dh = tar::Header::new_gnu();
1745 dh.set_path(format!("{wrapper}/")).unwrap();
1746 dh.set_size(0);
1747 dh.set_mode(0o755);
1748 dh.set_entry_type(tar::EntryType::Directory);
1749 dh.set_cksum();
1750 ar.append(&dh, std::io::empty()).unwrap();
1751 for (path, content) in files {
1752 let mut h = tar::Header::new_gnu();
1753 h.set_path(format!("{wrapper}/{path}")).unwrap();
1754 h.set_size(content.len() as u64);
1755 h.set_mode(0o644);
1756 h.set_cksum();
1757 ar.append(&h, *content).unwrap();
1758 }
1759 let gz = ar.into_inner().unwrap();
1760 gz.finish().unwrap()
1761 }
1762
1763 #[test]
1764 fn extract_codeload_tarball_strips_wrapper_and_caches() {
1765 let tmp = tempfile::tempdir().unwrap();
1772 let sha = "abcdef0123456789abcdef0123456789abcdef01";
1773 let wrapper = format!("owner-repo-{}", &sha[..7]);
1774 let bytes = build_codeload_tarball(
1775 &wrapper,
1776 &[
1777 ("package.json", br#"{"name":"x","version":"0.0.1"}"#),
1778 ("src/index.js", b"module.exports = 1;\n"),
1779 ],
1780 );
1781 let url = "https://github.com/owner/repo.git";
1782 let (target, head) =
1783 extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1784 assert_eq!(head, sha);
1785 assert!(target.join("package.json").is_file());
1788 assert!(target.join("src/index.js").is_file());
1789 assert!(!target.join(&wrapper).exists());
1790
1791 let (target2, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1794 assert_eq!(target, target2);
1795 assert!(super::git::codeload_integrity_path(&target).is_file());
1796 assert!(
1797 super::git::read_codeload_integrity(&target)
1798 .as_deref()
1799 .is_some_and(|s| s.starts_with("sha512-"))
1800 );
1801 }
1802
1803 #[test]
1804 fn codeload_cache_lookup_returns_target_only_after_extract() {
1805 let tmp = tempfile::tempdir().unwrap();
1810 let sha = "fedcba9876543210fedcba9876543210fedcba98";
1811 let wrapper = format!("owner-repo-{}", &sha[..7]);
1812 let url = "https://github.com/owner/repo.git";
1813 let bytes = build_codeload_tarball(
1814 &wrapper,
1815 &[("package.json", br#"{"name":"x","version":"0.0.1"}"#)],
1816 );
1817
1818 let (expected_target, expected_sha) =
1820 codeload_cache_paths(tmp.path(), url, sha, None).unwrap();
1821 assert!(!expected_target.exists());
1822 let (target, head) =
1828 extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1829 assert_eq!(target, expected_target);
1830 assert_eq!(head, expected_sha);
1831 assert!(target.is_dir(), "extract must populate the cache target");
1832 let (target2, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1836 assert_eq!(target, target2);
1837 }
1838
1839 #[test]
1840 fn codeload_integrity_sidecar_uses_integrity_keyed_cache_path() {
1841 let tmp = tempfile::tempdir().unwrap();
1842 let sha = "1234567890abcdef1234567890abcdef12345678";
1843 let wrapper = format!("owner-repo-{}", &sha[..7]);
1844 let bytes = build_codeload_tarball(
1845 &wrapper,
1846 &[("package.json", br#"{"name":"x","version":"0.0.1"}"#)],
1847 );
1848 let url = "https://github.com/owner/repo.git";
1849 let expected_integrity = "sha512-expected";
1850
1851 let (target, _) =
1852 extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, Some(expected_integrity))
1853 .unwrap();
1854 let (keyed_target, _) =
1855 codeload_cache_paths(tmp.path(), url, sha, Some(expected_integrity)).unwrap();
1856 let (unkeyed_target, _) = codeload_cache_paths(tmp.path(), url, sha, None).unwrap();
1857
1858 assert_eq!(target, keyed_target);
1859 assert_ne!(target, unkeyed_target);
1860 assert!(read_codeload_integrity(&target).is_some());
1861 assert!(read_codeload_integrity(&unkeyed_target).is_none());
1862 }
1863
1864 #[test]
1865 fn extract_codeload_tarball_backfills_missing_integrity_sidecar() {
1866 let tmp = tempfile::tempdir().unwrap();
1867 let sha = "0123456789abcdef0123456789abcdef01234567";
1868 let wrapper = format!("owner-repo-{}", &sha[..7]);
1869 let bytes = build_codeload_tarball(
1870 &wrapper,
1871 &[("package.json", br#"{"name":"x","version":"0.0.1"}"#)],
1872 );
1873 let url = "https://github.com/owner/repo.git";
1874 let (target, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1875 let sidecar = super::git::codeload_integrity_path(&target);
1876 std::fs::remove_file(&sidecar).unwrap();
1877
1878 let (target2, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1879 assert_eq!(target, target2);
1880 assert!(
1881 super::git::read_codeload_integrity(&target2)
1882 .as_deref()
1883 .is_some_and(|s| s.starts_with("sha512-"))
1884 );
1885 }
1886
1887 #[test]
1888 fn codeload_cache_paths_rejects_invalid_inputs() {
1889 let tmp = tempfile::tempdir().unwrap();
1890 assert!(
1893 codeload_cache_paths(tmp.path(), "https://example.com/r.git", "abc1234", None)
1894 .is_none()
1895 );
1896 assert!(
1898 codeload_cache_paths(
1899 tmp.path(),
1900 "--upload-pack=/tmp/evil",
1901 "abcdef0123456789abcdef0123456789abcdef01",
1902 None,
1903 )
1904 .is_none()
1905 );
1906 assert!(
1908 codeload_cache_paths(tmp.path(), "https://example.com/r.git", "main", None).is_none()
1909 );
1910 }
1911
1912 #[test]
1913 fn codeload_cache_paths_include_integrity_when_present() {
1914 let tmp = tempfile::tempdir().unwrap();
1915 let sha = "abcdef0123456789abcdef0123456789abcdef01";
1916 let url = "https://example.com/r.git";
1917 let no_integrity = codeload_cache_paths(tmp.path(), url, sha, None).unwrap();
1918 let with_integrity = codeload_cache_paths(tmp.path(), url, sha, Some("sha512-a")).unwrap();
1919 let with_other_integrity =
1920 codeload_cache_paths(tmp.path(), url, sha, Some("sha512-b")).unwrap();
1921
1922 assert_ne!(no_integrity.0, with_integrity.0);
1923 assert_ne!(with_integrity.0, with_other_integrity.0);
1924 assert_eq!(with_integrity.1, sha);
1925 }
1926
1927 #[test]
1928 fn extract_codeload_tarball_rejects_unsafe_paths() {
1929 let tmp = tempfile::tempdir().unwrap();
1930 let sha = "1111111111111111111111111111111111111111";
1931 let body = b"pwn";
1937 let mut h = tar::Header::new_gnu();
1938 h.set_size(body.len() as u64);
1939 h.set_mode(0o644);
1940 h.set_entry_type(tar::EntryType::Regular);
1941 let raw = b"wrapper/../escape.txt";
1944 let name = &mut h.as_gnu_mut().unwrap().name;
1945 name[..raw.len()].copy_from_slice(raw);
1946 h.set_cksum();
1947 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1948 let mut ar = tar::Builder::new(gz);
1949 ar.append(&h, &body[..]).unwrap();
1950 let bytes = ar.into_inner().unwrap().finish().unwrap();
1951 let err =
1952 extract_codeload_tarball_at(tmp.path(), &bytes, "https://example.com/r.git", sha, None)
1953 .unwrap_err();
1954 assert!(
1955 matches!(err, Error::Tar(ref m) if m.contains("unsafe")),
1956 "expected Error::Tar with unsafe-path message, got {err:?}",
1957 );
1958 }
1959
1960 #[test]
1961 fn extract_codeload_tarball_rejects_short_commit() {
1962 let tmp = tempfile::tempdir().unwrap();
1967 let bytes = build_codeload_tarball("wrapper", &[("ok", b"ok")]);
1968 let err = extract_codeload_tarball_at(
1969 tmp.path(),
1970 &bytes,
1971 "https://example.com/r.git",
1972 "abc1234",
1973 None,
1974 )
1975 .unwrap_err();
1976 assert!(matches!(err, Error::Git(ref m) if m.contains("40-char")));
1977 }
1978
1979 #[test]
1980 fn test_git_shallow_clone_rejects_dash_prefixed_url() {
1981 let err = git_shallow_clone("--upload-pack=/tmp/evil", "main", false).unwrap_err();
1982 assert!(matches!(err, Error::Git(_)));
1983 }
1984
1985 #[test]
1986 fn test_git_shallow_clone_rejects_dash_prefixed_commit() {
1987 let err = git_shallow_clone("https://github.com/u/r.git", "-X-evil", false).unwrap_err();
1991 assert!(matches!(err, Error::Git(_)));
1992 }
1993
1994 fn build_tarball(path: &str, content: &[u8]) -> Vec<u8> {
1998 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1999 let mut ar = tar::Builder::new(gz);
2000 let mut h = tar::Header::new_gnu();
2001 h.set_path(path).unwrap();
2002 h.set_size(content.len() as u64);
2003 h.set_mode(0o644);
2004 h.set_cksum();
2005 ar.append(&h, content).unwrap();
2006 ar.into_inner().unwrap().finish().unwrap()
2007 }
2008
2009 #[test]
2010 fn test_import_tarball_accepts_normal_sized_entry() {
2011 let dir = tempfile::tempdir().unwrap();
2012 let store = Store::at(dir.path().join("files"));
2013 store.ensure_shards_exist().unwrap();
2014 let tarball = build_tarball("package/index.js", b"console.log('hi');");
2015 let index = store.import_tarball(&tarball).unwrap();
2016 assert_eq!(index.len(), 1);
2017 assert!(index.contains_key("index.js"));
2018 }
2019
2020 #[test]
2021 fn test_import_tarball_streams_large_entry_into_cas() {
2022 let dir = tempfile::tempdir().unwrap();
2023 let store = Store::at(dir.path().join("files"));
2024 store.ensure_shards_exist().unwrap();
2025 let content: Vec<u8> = (0..(256 << 10)).map(|i| (i % 251) as u8).collect();
2026 let tarball = build_tarball("package/bin/native", &content);
2027
2028 let index = store.import_tarball(&tarball).unwrap();
2029 let stored = &index["bin/native"];
2030
2031 assert_eq!(stored.hex_hash, blake3_hex(&content));
2032 assert_eq!(stored.size, Some(content.len() as u64));
2033 assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
2034 assert!(
2035 std::fs::read_dir(dir.path().join("files"))
2036 .unwrap()
2037 .all(|entry| !entry
2038 .unwrap()
2039 .file_name()
2040 .to_string_lossy()
2041 .starts_with(".aube-stream-"))
2042 );
2043 }
2044
2045 #[cfg(not(windows))]
2046 #[test]
2047 fn test_import_tarball_accepts_posix_colon_filename() {
2048 let dir = tempfile::tempdir().unwrap();
2049 let store = Store::at(dir.path().join("files"));
2050 store.ensure_shards_exist().unwrap();
2051 let tarball = build_tarball(
2052 "package/dist/__mocks__/package-json:version.d.ts",
2053 b"export {};",
2054 );
2055 let index = store.import_tarball(&tarball).unwrap();
2056 assert!(index.contains_key("dist/__mocks__/package-json:version.d.ts"));
2057 }
2058
2059 #[test]
2060 fn test_import_tarball_rejects_per_entry_cap_exceeded() {
2061 let dir = tempfile::tempdir().unwrap();
2064 let store = Store::at(dir.path().join("files"));
2065 let oversize = (MAX_TARBALL_ENTRY_BYTES + 1) as usize;
2066 let mut h = tar::Header::new_gnu();
2070 h.set_path("package/huge.bin").unwrap();
2071 h.set_size(oversize as u64);
2072 h.set_mode(0o644);
2073 h.set_cksum();
2074 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2075 let mut ar = tar::Builder::new(gz);
2076 ar.append(&h, &[][..]).ok();
2082 let tarball = ar.into_inner().unwrap().finish().unwrap();
2083 let err = store.import_tarball(&tarball).unwrap_err();
2084 let msg = match err {
2085 Error::Tar(m) => m,
2086 other => panic!("expected Error::Tar, got {other:?}"),
2087 };
2088 assert!(msg.contains("per-entry cap"), "unexpected error: {msg}");
2089 }
2090
2091 #[test]
2092 fn test_import_tarball_rejects_archive_decompression_cap() {
2093 let dir = tempfile::tempdir().unwrap();
2103 let store = Store::at(dir.path().join("files"));
2104 store.ensure_shards_exist().unwrap();
2105
2106 let half = ((MAX_TARBALL_DECOMPRESSED_BYTES / 2) + 1024) as usize;
2107 let chunk = vec![0u8; half];
2108 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2109 let mut ar = tar::Builder::new(gz);
2110 for i in 0..2 {
2111 let mut h = tar::Header::new_gnu();
2112 h.set_path(format!("package/chunk{i}.bin")).unwrap();
2113 h.set_size(chunk.len() as u64);
2114 h.set_mode(0o644);
2115 h.set_cksum();
2116 ar.append(&h, &chunk[..]).unwrap();
2117 }
2118 let tarball = ar.into_inner().unwrap().finish().unwrap();
2119
2120 let err = store.import_tarball(&tarball).unwrap_err();
2121 assert!(matches!(err, Error::Tar(_)));
2122 }
2123
2124 #[test]
2125 fn test_import_tarball_rejects_entry_count_cap() {
2126 let dir = tempfile::tempdir().unwrap();
2129 let store = Store::at(dir.path().join("files"));
2130 store.ensure_shards_exist().unwrap();
2131
2132 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2133 let mut ar = tar::Builder::new(gz);
2134 for i in 0..=MAX_TARBALL_ENTRIES {
2135 let mut h = tar::Header::new_gnu();
2136 h.set_path(format!("package/f{i}.txt")).unwrap();
2137 h.set_size(0);
2138 h.set_mode(0o644);
2139 h.set_cksum();
2140 ar.append(&h, &[][..]).unwrap();
2141 }
2142 let tarball = ar.into_inner().unwrap().finish().unwrap();
2143
2144 let err = store.import_tarball(&tarball).unwrap_err();
2145 let msg = match err {
2146 Error::Tar(m) => m,
2147 other => panic!("expected Error::Tar, got {other:?}"),
2148 };
2149 assert!(msg.contains("entry cap"), "unexpected error: {msg}");
2150 }
2151
2152 fn build_raw_named_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
2165 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2172 let mut ar = tar::Builder::new(gz);
2173 for (path, data) in entries {
2174 let mut h = tar::Header::new_gnu();
2175 h.set_path("placeholder").unwrap();
2176 let name = &mut h.as_old_mut().name;
2177 name.fill(0);
2178 let bytes = path.as_bytes();
2179 assert!(bytes.len() < 100, "path too long for ustar name field");
2180 name[..bytes.len()].copy_from_slice(bytes);
2181 h.set_size(data.len() as u64);
2182 h.set_mode(0o644);
2183 h.set_cksum();
2184 ar.append(&h, *data).unwrap();
2185 }
2186 ar.into_inner().unwrap().finish().unwrap()
2187 }
2188
2189 #[test]
2190 fn normalize_tar_entry_path_accepts_plain_keys() {
2191 assert_eq!(
2192 normalize_tar_entry_path(Path::new("package/index.js")).unwrap(),
2193 Some("index.js".to_string())
2194 );
2195 assert_eq!(
2196 normalize_tar_entry_path(Path::new("package/lib/util/a.js")).unwrap(),
2197 Some("lib/util/a.js".to_string())
2198 );
2199 }
2200
2201 #[test]
2202 fn normalize_tar_entry_path_skips_wrapper_only_entry() {
2203 assert_eq!(
2204 normalize_tar_entry_path(Path::new("package")).unwrap(),
2205 None
2206 );
2207 assert_eq!(
2208 normalize_tar_entry_path(Path::new("package/")).unwrap(),
2209 None
2210 );
2211 }
2212
2213 #[test]
2214 fn normalize_tar_entry_path_collapses_cur_dir() {
2215 assert_eq!(
2216 normalize_tar_entry_path(Path::new("package/./foo.js")).unwrap(),
2217 Some("foo.js".to_string())
2218 );
2219 }
2220
2221 #[test]
2222 fn normalize_tar_entry_path_rejects_parent_dir() {
2223 let err = normalize_tar_entry_path(Path::new("package/../etc/passwd")).unwrap_err();
2224 assert!(matches!(err, Error::Tar(_)));
2225 }
2226
2227 #[test]
2228 fn normalize_tar_entry_path_rejects_parent_dir_after_leading_cur_dir() {
2229 let err = normalize_tar_entry_path(Path::new("./../file")).unwrap_err();
2234 assert!(matches!(err, Error::Tar(_)));
2235 let err = normalize_tar_entry_path(Path::new("././../etc/passwd")).unwrap_err();
2236 assert!(matches!(err, Error::Tar(_)));
2237 }
2238
2239 #[test]
2240 fn normalize_tar_entry_path_rejects_absolute_path() {
2241 let err = normalize_tar_entry_path(Path::new("/etc/passwd")).unwrap_err();
2242 assert!(matches!(err, Error::Tar(_)));
2243 }
2244
2245 #[test]
2246 fn normalize_tar_entry_path_rejects_smuggled_backslash() {
2247 let err = normalize_tar_entry_path(Path::new("package/a\\..\\etc")).unwrap_err();
2250 assert!(matches!(err, Error::Tar(_)));
2251 }
2252
2253 #[cfg(windows)]
2254 #[test]
2255 fn normalize_tar_entry_path_rejects_colon_on_windows() {
2256 let err = normalize_tar_entry_path(Path::new("package/C:evil")).unwrap_err();
2257 assert!(matches!(err, Error::Tar(_)));
2258 }
2259
2260 #[test]
2261 fn normalize_tar_entry_path_rejects_nul() {
2262 let err = normalize_tar_entry_path(Path::new("package/a\0b")).unwrap_err();
2263 assert!(matches!(err, Error::Tar(_)));
2264 }
2265
2266 #[test]
2267 fn test_import_tarball_rejects_parent_dir_escape() {
2268 let dir = tempfile::tempdir().unwrap();
2272 let store = Store::at(dir.path().join("files"));
2273 store.ensure_shards_exist().unwrap();
2274 let tarball = build_raw_named_tarball(&[
2275 ("package/package.json", b"{}"),
2276 ("package/../../../etc/cron.d/evil", b"* * * * * root id\n"),
2277 ]);
2278 let err = store.import_tarball(&tarball).unwrap_err();
2279 assert!(matches!(err, Error::Tar(_)));
2280 }
2281
2282 #[test]
2283 fn test_import_tarball_rejects_absolute_entry() {
2284 let dir = tempfile::tempdir().unwrap();
2285 let store = Store::at(dir.path().join("files"));
2286 store.ensure_shards_exist().unwrap();
2287 let tarball = build_raw_named_tarball(&[
2288 ("package/package.json", b"{}"),
2289 ("/etc/passwd", b"root:x:0:0\n"),
2290 ]);
2291 let err = store.import_tarball(&tarball).unwrap_err();
2292 assert!(matches!(err, Error::Tar(_)));
2293 }
2294
2295 #[test]
2296 fn test_import_tarball_rejects_symlink_entry() {
2297 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2301 let mut ar = tar::Builder::new(gz);
2302 let mut h = tar::Header::new_gnu();
2303 h.set_path("package/sneaky").unwrap();
2304 h.set_size(0);
2305 h.set_mode(0o644);
2306 h.set_entry_type(tar::EntryType::Symlink);
2307 h.set_link_name("/etc/passwd").unwrap();
2308 h.set_cksum();
2309 ar.append(&h, &[][..]).unwrap();
2310 let tarball = ar.into_inner().unwrap().finish().unwrap();
2311
2312 let dir = tempfile::tempdir().unwrap();
2313 let store = Store::at(dir.path().join("files"));
2314 store.ensure_shards_exist().unwrap();
2315 let err = store.import_tarball(&tarball).unwrap_err();
2316 assert!(matches!(err, Error::Tar(_)));
2317 }
2318
2319 #[test]
2320 fn test_import_tarball_rejects_hardlink_entry() {
2321 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2322 let mut ar = tar::Builder::new(gz);
2323 let mut h = tar::Header::new_gnu();
2324 h.set_path("package/clobber").unwrap();
2325 h.set_size(0);
2326 h.set_mode(0o644);
2327 h.set_entry_type(tar::EntryType::Link);
2328 h.set_link_name("../../../../home/victim/.ssh/authorized_keys")
2329 .unwrap();
2330 h.set_cksum();
2331 ar.append(&h, &[][..]).unwrap();
2332 let tarball = ar.into_inner().unwrap().finish().unwrap();
2333
2334 let dir = tempfile::tempdir().unwrap();
2335 let store = Store::at(dir.path().join("files"));
2336 store.ensure_shards_exist().unwrap();
2337 let err = store.import_tarball(&tarball).unwrap_err();
2338 assert!(matches!(err, Error::Tar(_)));
2339 }
2340
2341 #[test]
2342 fn test_import_tarball_skips_pax_global_header() {
2343 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2349 let mut ar = tar::Builder::new(gz);
2350
2351 let pax_body = b"52 comment=867aa88a335a266b904e0b5d1a3b0b5d1a3b0b5d1\n";
2352 let mut gh = tar::Header::new_ustar();
2353 gh.set_path("pax_global_header").unwrap();
2354 gh.set_size(pax_body.len() as u64);
2355 gh.set_mode(0o644);
2356 gh.set_entry_type(tar::EntryType::XGlobalHeader);
2357 gh.set_cksum();
2358 ar.append(&gh, &pax_body[..]).unwrap();
2359
2360 let body = b"// ok";
2361 let mut fh = tar::Header::new_gnu();
2362 fh.set_path("package/index.js").unwrap();
2363 fh.set_size(body.len() as u64);
2364 fh.set_mode(0o644);
2365 fh.set_cksum();
2366 ar.append(&fh, &body[..]).unwrap();
2367
2368 let tarball = ar.into_inner().unwrap().finish().unwrap();
2369
2370 let dir = tempfile::tempdir().unwrap();
2371 let store = Store::at(dir.path().join("files"));
2372 store.ensure_shards_exist().unwrap();
2373 let index = store.import_tarball(&tarball).unwrap();
2374 assert!(index.contains_key("index.js"));
2375 assert!(!index.contains_key("pax_global_header"));
2376 }
2377
2378 #[test]
2379 fn test_import_tarball_still_accepts_normal_nested_paths() {
2380 let dir = tempfile::tempdir().unwrap();
2383 let store = Store::at(dir.path().join("files"));
2384 store.ensure_shards_exist().unwrap();
2385 let tarball = build_tarball("package/lib/sub/a.js", b"// hi");
2386 let index = store.import_tarball(&tarball).unwrap();
2387 assert!(index.contains_key("lib/sub/a.js"));
2388 }
2389
2390 #[test]
2391 fn test_capped_reader_surfaces_exhaustion_as_error() {
2392 use std::io::Read;
2398 let mut r = CappedReader::new(&b"hello world"[..], 5);
2399 let mut first = [0u8; 5];
2400 r.read_exact(&mut first).unwrap();
2401 assert_eq!(&first, b"hello");
2402 let mut rest = Vec::new();
2403 let err = r.read_to_end(&mut rest).unwrap_err();
2404 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2405 }
2406
2407 #[test]
2408 fn test_capped_reader_does_not_error_below_cap() {
2409 use std::io::Read;
2412 let mut r = CappedReader::new(&b"hi"[..], 10);
2413 let mut buf = Vec::new();
2414 r.read_to_end(&mut buf).unwrap();
2415 assert_eq!(&buf, b"hi");
2416 }
2417
2418 #[test]
2419 fn test_capped_reader_empty_buf_is_ok_past_cap() {
2420 use std::io::Read;
2423 let mut r = CappedReader::new(&b"abcd"[..], 4);
2424 let mut buf = [0u8; 4];
2425 r.read_exact(&mut buf).unwrap();
2426 assert_eq!(r.read(&mut []).unwrap(), 0);
2427 }
2428
2429 #[test]
2430 fn test_capped_reader_at_exact_boundary_still_errors() {
2431 use std::io::Read;
2436 let mut r = CappedReader::new(&b"abcd"[..], 4);
2437 let mut buf = [0u8; 4];
2438 r.read_exact(&mut buf).unwrap();
2439 assert_eq!(&buf, b"abcd");
2440 let mut rest = Vec::new();
2441 let err = r.read_to_end(&mut rest).unwrap_err();
2442 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2443 }
2444
2445 #[test]
2446 fn test_import_tarball_declared_size_does_not_overallocate() {
2447 let dir = tempfile::tempdir().unwrap();
2453 let store = Store::at(dir.path().join("files"));
2454 store.ensure_shards_exist().unwrap();
2455
2456 let declared_near_cap = MAX_TARBALL_ENTRY_BYTES;
2461 let actual_content = b"tiny";
2462 let mut h = tar::Header::new_gnu();
2463 h.set_path("package/lying.bin").unwrap();
2464 h.set_size(declared_near_cap);
2465 h.set_mode(0o644);
2466 h.set_cksum();
2467 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2468 let mut ar = tar::Builder::new(gz);
2469 ar.append(&h, &actual_content[..]).ok();
2472 let tarball = ar.into_inner().unwrap().finish().unwrap();
2473
2474 let _ = store.import_tarball(&tarball);
2478 }
2479}