#[macro_use]
extern crate log;
pub mod dirs;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha512};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct Store {
root: PathBuf,
cache_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredFile {
pub hex_hash: String,
pub store_path: PathBuf,
pub executable: bool,
}
pub type PackageIndex = BTreeMap<String, StoredFile>;
impl Store {
pub fn default_location() -> Result<Self, Error> {
let root = dirs::store_dir().ok_or(Error::NoHome)?;
let cache_dir = dirs::cache_dir().ok_or(Error::NoHome)?;
Ok(Self { root, cache_dir })
}
pub fn with_root(root: PathBuf) -> Result<Self, Error> {
let cache_dir = dirs::cache_dir().ok_or(Error::NoHome)?;
Ok(Self { root, cache_dir })
}
pub fn at(root: PathBuf) -> Self {
let cache_dir = root.parent().unwrap_or(&root).join("aube-cache");
Self { root, cache_dir }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn index_dir(&self) -> PathBuf {
self.cache_dir.join("index")
}
pub fn virtual_store_dir(&self) -> PathBuf {
self.cache_dir.join("virtual-store")
}
pub fn packument_cache_dir(&self) -> PathBuf {
self.cache_dir.join("packuments-v1")
}
pub fn packument_full_cache_dir(&self) -> PathBuf {
self.cache_dir.join("packuments-full-v1")
}
pub fn has(&self, integrity: &str) -> bool {
self.file_path_from_integrity(integrity)
.is_some_and(|p| p.exists())
}
pub fn file_path_from_integrity(&self, integrity: &str) -> Option<PathBuf> {
let hex_hash = integrity_to_hex(integrity)?;
Some(self.file_path_from_hex(&hex_hash))
}
pub fn file_path_from_hex(&self, hex_hash: &str) -> PathBuf {
let (shard, rest) = hex_hash.split_at(2);
self.root.join(shard).join(rest)
}
pub fn load_index(&self, name: &str, version: &str) -> Option<PackageIndex> {
self.load_index_inner(name, version, false)
}
pub fn load_index_verified(&self, name: &str, version: &str) -> Option<PackageIndex> {
self.load_index_inner(name, version, true)
}
fn load_index_inner(
&self,
name: &str,
version: &str,
verify_files: bool,
) -> Option<PackageIndex> {
let safe_name = name.replace('/', "__");
let index_path = self.index_dir().join(format!("{safe_name}@{version}.json"));
let content = xx::file::read_to_string(&index_path).ok()?;
let index: PackageIndex = serde_json::from_str(&content).ok()?;
if verify_files {
if !index.values().all(|f| f.store_path.exists()) {
trace!("cache stale: {name}@{version}");
let _ = xx::file::remove_file(&index_path);
return None;
}
} else {
if let Some(f) = index.values().next()
&& !f.store_path.exists()
{
trace!("cache stale: {name}@{version}");
let _ = xx::file::remove_file(&index_path);
return None;
}
}
trace!("cache hit: {name}@{version}");
Some(index)
}
pub fn save_index(&self, name: &str, version: &str, index: &PackageIndex) -> Result<(), Error> {
let safe_name = name.replace('/', "__");
let index_path = self.index_dir().join(format!("{safe_name}@{version}.json"));
let json =
serde_json::to_string(index).map_err(|e| Error::Tar(format!("serialize: {e}")))?;
xx::file::write(&index_path, json).map_err(|e| Error::Xx(e.to_string()))?;
trace!("cached index: {name}@{version}");
Ok(())
}
pub fn ensure_shards_exist(&self) -> Result<(), Error> {
std::fs::create_dir_all(&self.root).map_err(|e| Error::Io(self.root.clone(), e))?;
let mut buf = [0u8; 2];
for hi in 0u8..16 {
for lo in 0u8..16 {
buf[0] = hex_digit(hi);
buf[1] = hex_digit(lo);
let shard = std::str::from_utf8(&buf).unwrap();
let path = self.root.join(shard);
std::fs::create_dir_all(&path).map_err(|e| Error::Io(path, e))?;
}
}
Ok(())
}
pub fn import_bytes(&self, content: &[u8], executable: bool) -> Result<StoredFile, Error> {
let hex_hash = blake3::hash(content).to_hex().to_string();
let store_path = self.file_path_from_hex(&hex_hash);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&store_path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(content)
.map_err(|e| Error::Io(store_path.clone(), e))?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
xx::file::write(&store_path, content).map_err(|e| Error::Xx(e.to_string()))?;
}
Err(e) => return Err(Error::Io(store_path.clone(), e)),
}
if executable {
let exec_marker = PathBuf::from(format!("{}-exec", store_path.display()));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&exec_marker)
{
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
xx::file::write(&exec_marker, "").map_err(|e| Error::Xx(e.to_string()))?;
}
Err(e) => return Err(Error::Io(exec_marker, e)),
}
}
Ok(StoredFile {
hex_hash,
store_path,
executable,
})
}
pub fn import_directory(&self, dir: &Path) -> Result<PackageIndex, Error> {
let mut index = BTreeMap::new();
self.import_directory_recursive(dir, dir, &mut index)?;
Ok(index)
}
fn import_directory_recursive(
&self,
base: &Path,
current: &Path,
index: &mut PackageIndex,
) -> Result<(), Error> {
let entries = std::fs::read_dir(current)
.map_err(|e| Error::Tar(format!("read_dir {}: {e}", current.display())))?;
for entry in entries {
let entry =
entry.map_err(|e| Error::Tar(format!("read_dir {}: {e}", current.display())))?;
let file_type = entry
.file_type()
.map_err(|e| Error::Tar(format!("file_type: {e}")))?;
let name_os = entry.file_name();
let name_str = name_os.to_string_lossy();
if matches!(name_str.as_ref(), ".git" | "node_modules") {
continue;
}
let path = entry.path();
if file_type.is_dir() {
self.import_directory_recursive(base, &path, index)?;
continue;
}
if !file_type.is_file() {
continue;
}
let content = std::fs::read(&path)
.map_err(|e| Error::Tar(format!("read {}: {e}", path.display())))?;
#[cfg(unix)]
let executable = {
use std::os::unix::fs::PermissionsExt;
let meta = entry
.metadata()
.map_err(|e| Error::Tar(format!("metadata: {e}")))?;
meta.permissions().mode() & 0o111 != 0
};
#[cfg(not(unix))]
let executable = false;
let stored = self.import_bytes(&content, executable)?;
let rel = path
.strip_prefix(base)
.map_err(|e| Error::Tar(format!("strip_prefix: {e}")))?
.to_string_lossy()
.replace('\\', "/");
index.insert(rel, stored);
}
Ok(())
}
pub fn import_tarball(&self, tarball_bytes: &[u8]) -> Result<PackageIndex, Error> {
use std::io::Read;
let gz = flate2::read::GzDecoder::new(tarball_bytes);
let capped = CappedReader::new(gz, MAX_TARBALL_DECOMPRESSED_BYTES);
let mut archive = tar::Archive::new(capped);
let mut index = BTreeMap::new();
let mut entries_seen: usize = 0;
for entry in archive.entries().map_err(|e| Error::Tar(e.to_string()))? {
entries_seen += 1;
if entries_seen > MAX_TARBALL_ENTRIES {
return Err(Error::Tar(format!(
"tarball exceeds entry cap of {MAX_TARBALL_ENTRIES}"
)));
}
let mut entry = entry.map_err(|e| Error::Tar(e.to_string()))?;
let entry_type = entry.header().entry_type();
if entry_type.is_dir()
|| matches!(
entry_type,
tar::EntryType::XGlobalHeader | tar::EntryType::XHeader
)
{
continue;
}
if !matches!(
entry_type,
tar::EntryType::Regular | tar::EntryType::Continuous
) {
return Err(Error::Tar(format!(
"tarball entry type {entry_type:?} is not allowed"
)));
}
let declared = entry
.header()
.size()
.map_err(|e| Error::Tar(e.to_string()))?;
if declared > MAX_TARBALL_ENTRY_BYTES {
return Err(Error::Tar(format!(
"tarball entry exceeds per-entry cap: {declared} bytes > {MAX_TARBALL_ENTRY_BYTES}"
)));
}
let raw_path = entry
.path()
.map_err(|e| Error::Tar(e.to_string()))?
.to_path_buf();
let Some(rel_path) = normalize_tar_entry_path(&raw_path)? else {
continue;
};
let mut content = Vec::with_capacity((declared as usize).min(VEC_PREALLOC_CEILING));
(&mut entry)
.take(MAX_TARBALL_ENTRY_BYTES)
.read_to_end(&mut content)
.map_err(|e| Error::Tar(e.to_string()))?;
let mode = entry.header().mode().unwrap_or(0o644);
let executable = mode & 0o111 != 0;
let stored = self.import_bytes(&content, executable)?;
index.insert(rel_path, stored);
}
Ok(index)
}
}
fn normalize_tar_entry_path(raw: &Path) -> Result<Option<String>, Error> {
use std::path::Component;
let mut components = raw.components().peekable();
while matches!(components.peek(), Some(Component::CurDir)) {
components.next();
}
match components.peek() {
Some(Component::RootDir) => {
return Err(Error::Tar(format!(
"tarball entry path is absolute: {raw:?}"
)));
}
Some(Component::Prefix(_)) => {
return Err(Error::Tar(format!(
"tarball entry path has a Windows drive prefix: {raw:?}"
)));
}
Some(Component::ParentDir) => {
return Err(Error::Tar(format!(
"tarball entry path escapes package root via `..`: {raw:?}"
)));
}
_ => {}
}
components.next();
let mut out = String::new();
for comp in components {
match comp {
Component::Normal(os) => {
let s = os.to_str().ok_or_else(|| {
Error::Tar(format!(
"tarball entry path contains non-UTF-8 bytes: {raw:?}"
))
})?;
if s.is_empty()
|| s.contains('\0')
|| s.contains('\\')
|| s.contains('/')
|| s.contains(':')
{
return Err(Error::Tar(format!(
"tarball entry path contains a malformed component: {raw:?}"
)));
}
if !out.is_empty() {
out.push('/');
}
out.push_str(s);
}
Component::ParentDir => {
return Err(Error::Tar(format!(
"tarball entry path escapes package root via `..`: {raw:?}"
)));
}
Component::RootDir => {
return Err(Error::Tar(format!(
"tarball entry path is absolute: {raw:?}"
)));
}
Component::Prefix(_) => {
return Err(Error::Tar(format!(
"tarball entry path has a Windows drive prefix: {raw:?}"
)));
}
Component::CurDir => {}
}
}
if out.is_empty() {
Ok(None)
} else {
Ok(Some(out))
}
}
const VEC_PREALLOC_CEILING: usize = 64 * 1024;
struct CappedReader<R: std::io::Read> {
inner: R,
remaining: u64,
}
impl<R: std::io::Read> CappedReader<R> {
fn new(inner: R, cap: u64) -> Self {
Self {
inner,
remaining: cap,
}
}
}
impl<R: std::io::Read> std::io::Read for CappedReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
if self.remaining == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"tarball decompression exceeds archive cap of {MAX_TARBALL_DECOMPRESSED_BYTES} bytes"
),
));
}
let want = buf.len().min(self.remaining as usize);
let n = self.inner.read(&mut buf[..want])?;
self.remaining -= n as u64;
Ok(n)
}
}
#[cfg(not(test))]
const MAX_TARBALL_DECOMPRESSED_BYTES: u64 = 1 << 30;
#[cfg(test)]
const MAX_TARBALL_DECOMPRESSED_BYTES: u64 = 1 << 20;
#[cfg(not(test))]
const MAX_TARBALL_ENTRY_BYTES: u64 = 512 << 20;
#[cfg(test)]
const MAX_TARBALL_ENTRY_BYTES: u64 = 1 << 20;
#[cfg(not(test))]
const MAX_TARBALL_ENTRIES: usize = 200_000;
#[cfg(test)]
const MAX_TARBALL_ENTRIES: usize = 64;
fn hex_digit(n: u8) -> u8 {
match n {
0..=9 => b'0' + n,
10..=15 => b'a' + n - 10,
_ => unreachable!(),
}
}
pub fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> {
let Some(expected_b64) = expected.strip_prefix("sha512-") else {
return Err(Error::Integrity(format!(
"unsupported integrity format (expected sha512-...): {expected}"
)));
};
let mut hasher = Sha512::new();
hasher.update(data);
let actual_bytes = hasher.finalize();
use base64::Engine;
let actual_b64 = base64::engine::general_purpose::STANDARD.encode(actual_bytes);
if actual_b64 == expected_b64 {
Ok(())
} else {
Err(Error::Integrity(format!(
"integrity mismatch: expected sha512-{expected_b64}, got sha512-{actual_b64}"
)))
}
}
pub fn validate_pkg_content(
index: &PackageIndex,
expected_name: &str,
expected_version: &str,
) -> Result<(), Error> {
let stored = index
.get("package.json")
.ok_or_else(|| Error::Tar("package.json missing from tarball".to_string()))?;
let bytes =
std::fs::read(&stored.store_path).map_err(|e| Error::Io(stored.store_path.clone(), e))?;
let v: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| Error::Tar(format!("invalid package.json: {e}")))?;
let actual_name = v.get("name").and_then(|n| n.as_str()).unwrap_or("");
let actual_version = v.get("version").and_then(|v| v.as_str()).unwrap_or("");
let actual_version_normalized = actual_version
.strip_prefix('v')
.filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
.unwrap_or(actual_version);
if actual_name != expected_name || actual_version_normalized != expected_version {
return Err(Error::PkgContentMismatch {
actual: format!("{actual_name}@{actual_version}"),
});
}
Ok(())
}
pub fn integrity_to_hex(integrity: &str) -> Option<String> {
let b64 = integrity.strip_prefix("sha512-")?;
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
Some(hex::encode(bytes))
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HOME environment variable not set")]
NoHome,
#[error("I/O error at {0}: {1}")]
Io(PathBuf, std::io::Error),
#[error("file error: {0}")]
Xx(String),
#[error("tarball extraction error: {0}")]
Tar(String),
#[error("integrity verification failed: {0}")]
Integrity(String),
#[error("package.json content mismatch: tarball declares {actual}")]
PkgContentMismatch { actual: String },
#[error("git error: {0}")]
Git(String),
}
fn validate_git_positional(value: &str, kind: &str) -> Result<(), Error> {
if value.starts_with('-') {
return Err(Error::Git(format!(
"refusing to pass {kind} starting with `-` to git: {value:?}"
)));
}
if value.contains('\0') {
return Err(Error::Git(format!(
"refusing to pass {kind} containing NUL byte to git"
)));
}
Ok(())
}
pub fn git_resolve_ref(url: &str, committish: Option<&str>) -> Result<String, Error> {
validate_git_positional(url, "git url")?;
if let Some(c) = committish
&& c.len() == 40
&& c.chars().all(|ch| ch.is_ascii_hexdigit())
{
return Ok(c.to_ascii_lowercase());
}
let out = std::process::Command::new("git")
.args(["ls-remote", "--", url])
.output()
.map_err(|e| Error::Git(format!("spawn git ls-remote {url}: {e}")))?;
if !out.status.success() {
return Err(Error::Git(format!(
"git ls-remote {url} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut head: Option<String> = None;
let mut main_branch: Option<String> = None;
let mut master_branch: Option<String> = None;
let mut tag_match: Option<String> = None;
let mut head_match: Option<String> = None;
let mut first: Option<String> = None;
for line in stdout.lines() {
let mut parts = line.split('\t');
let sha = parts.next().unwrap_or("").trim();
let name = parts.next().unwrap_or("").trim();
if sha.is_empty() || name.is_empty() {
continue;
}
if first.is_none() {
first = Some(sha.to_string());
}
match name {
"HEAD" => head = Some(sha.to_string()),
"refs/heads/main" => main_branch = Some(sha.to_string()),
"refs/heads/master" => master_branch = Some(sha.to_string()),
_ => {}
}
if let Some(want) = committish {
if name == format!("refs/tags/{want}") || name == format!("refs/tags/{want}^{{}}") {
tag_match = Some(sha.to_string());
} else if name == format!("refs/heads/{want}") {
head_match = Some(sha.to_string());
}
}
}
if let Some(want) = committish {
if let Some(sha) = tag_match.or(head_match) {
return Ok(sha);
}
let looks_hex =
want.len() >= 4 && want.len() < 40 && want.chars().all(|c| c.is_ascii_hexdigit());
if looks_hex {
return Err(Error::Git(format!(
"git ls-remote {url}: `#{want}` looks like an abbreviated commit SHA — aube requires a full 40-character SHA, or a branch/tag name"
)));
}
Err(Error::Git(format!(
"git ls-remote {url}: no ref matched {want}"
)))
} else {
head.or(main_branch)
.or(master_branch)
.or(first)
.ok_or_else(|| Error::Git(format!("git ls-remote {url}: no refs advertised")))
}
}
pub fn git_host_in_list(url: &str, hosts: &[String]) -> bool {
let Some(host) = git_url_host(url) else {
return false;
};
hosts.iter().any(|h| h == host)
}
pub fn git_url_host(url: &str) -> Option<&str> {
let rest = url.strip_prefix("git+").unwrap_or(url);
let after_scheme = match rest.split_once("://") {
Some((_, r)) => r,
None => {
let (userhost, _) = rest.split_once(':')?;
let host = userhost
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(userhost);
if host.is_empty() || host.contains('/') {
return None;
}
return Some(host);
}
};
let authority = after_scheme
.split_once('/')
.map(|(a, _)| a)
.unwrap_or(after_scheme);
let host_with_port = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let host = if let Some(inner) = host_with_port.strip_prefix('[') {
inner.split_once(']').map(|(h, _)| h).unwrap_or(inner)
} else {
host_with_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_with_port)
};
if host.is_empty() { None } else { Some(host) }
}
pub fn git_shallow_clone(url: &str, commit: &str, shallow: bool) -> Result<PathBuf, Error> {
use std::process::Command;
validate_git_positional(url, "git url")?;
validate_git_positional(commit, "git commit")?;
let mut hasher = blake3::Hasher::new();
hasher.update(url.as_bytes());
hasher.update(b"\0");
hasher.update(commit.as_bytes());
let digest = hasher.finalize();
let key: String = digest
.as_bytes()
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect();
let commit_short = commit.get(..commit.len().min(12)).unwrap_or(commit);
let target = std::env::temp_dir().join(format!("aube-git-{key}-{commit_short}"));
if target.join(".git").is_dir()
&& let Ok(out) = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&target)
.output()
&& out.status.success()
&& String::from_utf8_lossy(&out.stdout).trim() == commit
{
return Ok(target);
}
let scratch = std::env::temp_dir().join(format!(
"aube-git-{key}-{commit_short}.tmp.{}",
std::process::id()
));
if scratch.exists() {
let _ = std::fs::remove_dir_all(&scratch);
}
std::fs::create_dir_all(&scratch).map_err(|e| Error::Io(scratch.clone(), e))?;
let run_in = |dir: &Path, args: &[&str]| -> Result<(), Error> {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.map_err(|e| Error::Git(format!("spawn git {args:?}: {e}")))?;
if !out.status.success() {
return Err(Error::Git(format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
};
let do_clone = || -> Result<(), Error> {
run_in(&scratch, &["init", "-q"])?;
run_in(&scratch, &["remote", "add", "--", "origin", url])?;
let shallow_ok = shallow
&& run_in(
&scratch,
&["fetch", "--depth", "1", "-q", "--", "origin", commit],
)
.is_ok();
if !shallow_ok {
run_in(&scratch, &["fetch", "-q", "--", "origin"])?;
}
run_in(&scratch, &["checkout", "-q", commit])?;
Ok(())
};
if let Err(e) = do_clone() {
let _ = std::fs::remove_dir_all(&scratch);
return Err(e);
}
match std::fs::rename(&scratch, &target) {
Ok(()) => Ok(target),
Err(_) => {
if target.join(".git").is_dir()
&& let Ok(out) = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&target)
.output()
&& out.status.success()
&& String::from_utf8_lossy(&out.stdout).trim() == commit
{
let _ = std::fs::remove_dir_all(&scratch);
return Ok(target);
}
let _ = std::fs::remove_dir_all(&target);
std::fs::rename(&scratch, &target).map_err(|e| {
let _ = std::fs::remove_dir_all(&scratch);
Error::Git(format!("rename clone into place: {e}"))
})?;
Ok(target)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_integrity_to_hex() {
let integrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
let result = integrity_to_hex(integrity);
assert!(result.is_some());
let hex = result.unwrap();
assert_eq!(hex.len(), 128);
assert!(hex.chars().all(|c| c == '0'));
}
#[test]
fn test_integrity_to_hex_invalid() {
assert!(integrity_to_hex("md5-abc").is_none());
assert!(integrity_to_hex("notahash").is_none());
assert!(integrity_to_hex("sha256-abc").is_none());
assert!(integrity_to_hex("").is_none());
}
#[test]
fn test_file_path_from_hex_sharding() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let path = store.file_path_from_hex("abcdef1234567890");
let sep = std::path::MAIN_SEPARATOR;
assert!(path.to_string_lossy().contains(&format!("{sep}ab{sep}")));
assert!(path.to_string_lossy().ends_with("cdef1234567890"));
}
#[test]
fn test_import_bytes() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let content = b"hello world";
let stored = store.import_bytes(content, false).unwrap();
assert!(stored.store_path.exists());
assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
assert!(!stored.executable);
let stored2 = store.import_bytes(content, false).unwrap();
assert_eq!(stored.hex_hash, stored2.hex_hash);
}
#[test]
fn test_verify_integrity_valid() {
let data = b"hello world";
let mut hasher = Sha512::new();
hasher.update(data);
let hash = hasher.finalize();
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
let integrity = format!("sha512-{b64}");
assert!(verify_integrity(data, &integrity).is_ok());
}
#[test]
fn test_verify_integrity_mismatch() {
let data = b"hello world";
let wrong = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
let result = verify_integrity(data, wrong);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("integrity mismatch")
);
}
#[test]
fn test_verify_integrity_unsupported_format() {
let result = verify_integrity(b"test", "md5-abc123");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsupported"));
}
#[test]
fn test_import_bytes_executable() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let content = b"#!/bin/sh\necho hello";
let stored = store.import_bytes(content, true).unwrap();
assert!(stored.executable);
let exec_marker = PathBuf::from(format!("{}-exec", stored.store_path.display()));
assert!(exec_marker.exists());
}
#[test]
fn test_import_bytes_different_content_different_hash() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let stored1 = store.import_bytes(b"content a", false).unwrap();
let stored2 = store.import_bytes(b"content b", false).unwrap();
assert_ne!(stored1.hex_hash, stored2.hex_hash);
}
#[test]
fn test_index_cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let content = b"test file";
let stored = store.import_bytes(content, false).unwrap();
let mut index = BTreeMap::new();
index.insert("index.js".to_string(), stored);
store.save_index("test-pkg", "1.0.0", &index).unwrap();
let loaded = store.load_index("test-pkg", "1.0.0");
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded.contains_key("index.js"));
}
#[test]
fn test_index_cache_scoped_package() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let stored = store.import_bytes(b"scoped content", false).unwrap();
let mut index = BTreeMap::new();
index.insert("index.js".to_string(), stored);
store.save_index("@scope/pkg", "1.0.0", &index).unwrap();
let loaded = store.load_index("@scope/pkg", "1.0.0");
assert!(loaded.is_some());
}
#[test]
fn test_index_cache_stale_detection() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let stored = store.import_bytes(b"content", false).unwrap();
let store_path = stored.store_path.clone();
let mut index = BTreeMap::new();
index.insert("index.js".to_string(), stored);
store.save_index("pkg", "1.0.0", &index).unwrap();
std::fs::remove_file(&store_path).unwrap();
let loaded = store.load_index("pkg", "1.0.0");
assert!(loaded.is_none());
}
#[test]
fn test_index_cache_miss() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
assert!(store.load_index("nonexistent", "1.0.0").is_none());
}
fn index_with_manifest(store: &Store, name: &str, version: &str) -> PackageIndex {
let manifest =
serde_json::json!({"name": name, "version": version, "main": "index.js"}).to_string();
let stored = store.import_bytes(manifest.as_bytes(), false).unwrap();
let mut index = BTreeMap::new();
index.insert("package.json".to_string(), stored);
index
}
#[test]
fn test_validate_pkg_content_match() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let index = index_with_manifest(&store, "lodash", "4.17.21");
assert!(validate_pkg_content(&index, "lodash", "4.17.21").is_ok());
}
#[test]
fn test_validate_pkg_content_name_mismatch() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let index = index_with_manifest(&store, "evil-pkg", "1.0.0");
let err = validate_pkg_content(&index, "lodash", "1.0.0").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("content mismatch"), "{msg}");
assert!(msg.contains("declares evil-pkg@1.0.0"), "{msg}");
}
#[test]
fn test_validate_pkg_content_version_mismatch() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let index = index_with_manifest(&store, "lodash", "9.9.9");
let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("content mismatch"), "{msg}");
assert!(msg.contains("declares lodash@9.9.9"), "{msg}");
}
#[test]
fn test_validate_pkg_content_tolerates_leading_v() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let index = index_with_manifest(&store, "@upstash/ratelimit", "v2.0.8");
assert!(validate_pkg_content(&index, "@upstash/ratelimit", "2.0.8").is_ok());
}
#[test]
fn test_validate_pkg_content_missing_manifest() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let stored = store.import_bytes(b"module.exports = 1;", false).unwrap();
let mut index = PackageIndex::new();
index.insert("index.js".to_string(), stored);
let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
assert!(err.to_string().contains("package.json missing"), "{err}",);
}
#[test]
fn test_validate_pkg_content_unparseable_manifest() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let stored = store.import_bytes(b"{not json", false).unwrap();
let mut index = PackageIndex::new();
index.insert("package.json".to_string(), stored);
let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
assert!(err.to_string().contains("invalid package.json"), "{err}");
}
#[test]
fn test_import_tarball() {
let mut builder = tar::Builder::new(Vec::new());
let content = b"module.exports = 42;\n";
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "package/index.js", &content[..])
.unwrap();
let bin_content = b"#!/usr/bin/env node\nconsole.log('hi');\n";
let mut bin_header = tar::Header::new_gnu();
bin_header.set_size(bin_content.len() as u64);
bin_header.set_mode(0o755);
bin_header.set_cksum();
builder
.append_data(&mut bin_header, "package/bin/cli.js", &bin_content[..])
.unwrap();
let tar_bytes = builder.into_inner().unwrap();
use flate2::write::GzEncoder;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(&tar_bytes).unwrap();
let tgz_bytes = encoder.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let index = store.import_tarball(&tgz_bytes).unwrap();
assert_eq!(index.len(), 2);
assert!(index.contains_key("index.js"));
assert!(index.contains_key("bin/cli.js"));
let idx_stored = &index["index.js"];
assert!(!idx_stored.executable);
assert_eq!(std::fs::read(&idx_stored.store_path).unwrap(), content);
let bin_stored = &index["bin/cli.js"];
assert!(bin_stored.executable);
assert_eq!(std::fs::read(&bin_stored.store_path).unwrap(), bin_content);
}
#[test]
fn test_git_url_host_https() {
assert_eq!(
git_url_host("https://github.com/user/repo.git"),
Some("github.com")
);
assert_eq!(
git_url_host("git+https://github.com/user/repo.git#main"),
Some("github.com")
);
assert_eq!(
git_url_host("git://git.example.com/repo.git"),
Some("git.example.com")
);
}
#[test]
fn test_git_url_host_ssh() {
assert_eq!(
git_url_host("git+ssh://git@github.com/user/repo.git"),
Some("github.com")
);
assert_eq!(
git_url_host("ssh://git@gitlab.com:2222/user/repo.git"),
Some("gitlab.com")
);
assert_eq!(
git_url_host("git@github.com:user/repo.git"),
Some("github.com")
);
}
#[test]
fn test_git_url_host_ipv6() {
assert_eq!(git_url_host("https://[::1]/repo.git"), Some("::1"));
assert_eq!(git_url_host("https://[::1]:8443/repo.git"), Some("::1"));
assert_eq!(
git_url_host("ssh://git@[2001:db8::1]:2222/user/repo.git"),
Some("2001:db8::1")
);
}
#[test]
fn test_git_url_host_rejects_garbage() {
assert_eq!(git_url_host(""), None);
assert_eq!(git_url_host("not a url"), None);
assert_eq!(git_url_host("/just/a/path"), None);
}
#[test]
fn test_git_host_in_list_exact_match() {
let hosts = vec![
"github.com".to_string(),
"gitlab.com".to_string(),
"bitbucket.org".to_string(),
];
assert!(git_host_in_list("https://github.com/user/repo.git", &hosts));
assert!(git_host_in_list(
"git+ssh://git@gitlab.com/user/repo.git",
&hosts
));
assert!(!git_host_in_list(
"https://api.github.com/user/repo.git",
&hosts
));
assert!(!git_host_in_list(
"https://self-hosted.example/user/repo.git",
&hosts
));
}
#[test]
fn test_git_host_in_list_empty_list() {
let hosts: Vec<String> = vec![];
assert!(!git_host_in_list(
"https://github.com/user/repo.git",
&hosts
));
}
#[test]
fn test_validate_git_positional_accepts_normal_values() {
validate_git_positional("https://github.com/u/r.git", "git url").unwrap();
validate_git_positional("git@github.com:u/r.git", "git url").unwrap();
validate_git_positional("main", "git commit").unwrap();
validate_git_positional("0123456789abcdef0123456789abcdef01234567", "git commit").unwrap();
}
#[test]
fn test_validate_git_positional_rejects_dash_prefix() {
let err = validate_git_positional("--upload-pack=/tmp/evil", "git url").unwrap_err();
assert!(matches!(err, Error::Git(_)));
let err = validate_git_positional("-oX", "git commit").unwrap_err();
assert!(matches!(err, Error::Git(_)));
}
#[test]
fn test_validate_git_positional_rejects_nul() {
let err = validate_git_positional("normal\0tail", "git url").unwrap_err();
assert!(matches!(err, Error::Git(_)));
}
#[test]
fn test_git_resolve_ref_rejects_dash_prefixed_url() {
let err = git_resolve_ref("--upload-pack=/tmp/evil", None).unwrap_err();
assert!(matches!(err, Error::Git(_)));
}
#[test]
fn test_git_shallow_clone_rejects_dash_prefixed_url() {
let err = git_shallow_clone("--upload-pack=/tmp/evil", "main", false).unwrap_err();
assert!(matches!(err, Error::Git(_)));
}
#[test]
fn test_git_shallow_clone_rejects_dash_prefixed_commit() {
let err = git_shallow_clone("https://github.com/u/r.git", "-X-evil", false).unwrap_err();
assert!(matches!(err, Error::Git(_)));
}
fn build_tarball(path: &str, content: &[u8]) -> Vec<u8> {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut ar = tar::Builder::new(gz);
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(content.len() as u64);
h.set_mode(0o644);
h.set_cksum();
ar.append(&h, content).unwrap();
ar.into_inner().unwrap().finish().unwrap()
}
#[test]
fn test_import_tarball_accepts_normal_sized_entry() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let tarball = build_tarball("package/index.js", b"console.log('hi');");
let index = store.import_tarball(&tarball).unwrap();
assert_eq!(index.len(), 1);
assert!(index.contains_key("index.js"));
}
#[test]
fn test_import_tarball_rejects_per_entry_cap_exceeded() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
let oversize = (MAX_TARBALL_ENTRY_BYTES + 1) as usize;
let mut h = tar::Header::new_gnu();
h.set_path("package/huge.bin").unwrap();
h.set_size(oversize as u64);
h.set_mode(0o644);
h.set_cksum();
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut ar = tar::Builder::new(gz);
ar.append(&h, &[][..]).ok();
let tarball = ar.into_inner().unwrap().finish().unwrap();
let err = store.import_tarball(&tarball).unwrap_err();
let msg = match err {
Error::Tar(m) => m,
other => panic!("expected Error::Tar, got {other:?}"),
};
assert!(msg.contains("per-entry cap"), "unexpected error: {msg}");
}
#[test]
fn test_import_tarball_rejects_archive_decompression_cap() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let half = ((MAX_TARBALL_DECOMPRESSED_BYTES / 2) + 1024) as usize;
let chunk = vec![0u8; half];
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut ar = tar::Builder::new(gz);
for i in 0..2 {
let mut h = tar::Header::new_gnu();
h.set_path(format!("package/chunk{i}.bin")).unwrap();
h.set_size(chunk.len() as u64);
h.set_mode(0o644);
h.set_cksum();
ar.append(&h, &chunk[..]).unwrap();
}
let tarball = ar.into_inner().unwrap().finish().unwrap();
let err = store.import_tarball(&tarball).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn test_import_tarball_rejects_entry_count_cap() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut ar = tar::Builder::new(gz);
for i in 0..=MAX_TARBALL_ENTRIES {
let mut h = tar::Header::new_gnu();
h.set_path(format!("package/f{i}.txt")).unwrap();
h.set_size(0);
h.set_mode(0o644);
h.set_cksum();
ar.append(&h, &[][..]).unwrap();
}
let tarball = ar.into_inner().unwrap().finish().unwrap();
let err = store.import_tarball(&tarball).unwrap_err();
let msg = match err {
Error::Tar(m) => m,
other => panic!("expected Error::Tar, got {other:?}"),
};
assert!(msg.contains("entry cap"), "unexpected error: {msg}");
}
fn build_raw_named_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut ar = tar::Builder::new(gz);
for (path, data) in entries {
let mut h = tar::Header::new_gnu();
h.set_path("placeholder").unwrap();
let name = &mut h.as_old_mut().name;
name.fill(0);
let bytes = path.as_bytes();
assert!(bytes.len() < 100, "path too long for ustar name field");
name[..bytes.len()].copy_from_slice(bytes);
h.set_size(data.len() as u64);
h.set_mode(0o644);
h.set_cksum();
ar.append(&h, *data).unwrap();
}
ar.into_inner().unwrap().finish().unwrap()
}
#[test]
fn normalize_tar_entry_path_accepts_plain_keys() {
assert_eq!(
normalize_tar_entry_path(Path::new("package/index.js")).unwrap(),
Some("index.js".to_string())
);
assert_eq!(
normalize_tar_entry_path(Path::new("package/lib/util/a.js")).unwrap(),
Some("lib/util/a.js".to_string())
);
}
#[test]
fn normalize_tar_entry_path_skips_wrapper_only_entry() {
assert_eq!(
normalize_tar_entry_path(Path::new("package")).unwrap(),
None
);
assert_eq!(
normalize_tar_entry_path(Path::new("package/")).unwrap(),
None
);
}
#[test]
fn normalize_tar_entry_path_collapses_cur_dir() {
assert_eq!(
normalize_tar_entry_path(Path::new("package/./foo.js")).unwrap(),
Some("foo.js".to_string())
);
}
#[test]
fn normalize_tar_entry_path_rejects_parent_dir() {
let err = normalize_tar_entry_path(Path::new("package/../etc/passwd")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn normalize_tar_entry_path_rejects_parent_dir_after_leading_cur_dir() {
let err = normalize_tar_entry_path(Path::new("./../file")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
let err = normalize_tar_entry_path(Path::new("././../etc/passwd")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn normalize_tar_entry_path_rejects_absolute_path() {
let err = normalize_tar_entry_path(Path::new("/etc/passwd")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn normalize_tar_entry_path_rejects_smuggled_backslash() {
let err = normalize_tar_entry_path(Path::new("package/a\\..\\etc")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn normalize_tar_entry_path_rejects_colon_smuggle() {
let err = normalize_tar_entry_path(Path::new("package/C:evil")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn normalize_tar_entry_path_rejects_nul() {
let err = normalize_tar_entry_path(Path::new("package/a\0b")).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn test_import_tarball_rejects_parent_dir_escape() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let tarball = build_raw_named_tarball(&[
("package/package.json", b"{}"),
("package/../../../etc/cron.d/evil", b"* * * * * root id\n"),
]);
let err = store.import_tarball(&tarball).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn test_import_tarball_rejects_absolute_entry() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let tarball = build_raw_named_tarball(&[
("package/package.json", b"{}"),
("/etc/passwd", b"root:x:0:0\n"),
]);
let err = store.import_tarball(&tarball).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn test_import_tarball_rejects_symlink_entry() {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut ar = tar::Builder::new(gz);
let mut h = tar::Header::new_gnu();
h.set_path("package/sneaky").unwrap();
h.set_size(0);
h.set_mode(0o644);
h.set_entry_type(tar::EntryType::Symlink);
h.set_link_name("/etc/passwd").unwrap();
h.set_cksum();
ar.append(&h, &[][..]).unwrap();
let tarball = ar.into_inner().unwrap().finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let err = store.import_tarball(&tarball).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn test_import_tarball_rejects_hardlink_entry() {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut ar = tar::Builder::new(gz);
let mut h = tar::Header::new_gnu();
h.set_path("package/clobber").unwrap();
h.set_size(0);
h.set_mode(0o644);
h.set_entry_type(tar::EntryType::Link);
h.set_link_name("../../../../home/victim/.ssh/authorized_keys")
.unwrap();
h.set_cksum();
ar.append(&h, &[][..]).unwrap();
let tarball = ar.into_inner().unwrap().finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let err = store.import_tarball(&tarball).unwrap_err();
assert!(matches!(err, Error::Tar(_)));
}
#[test]
fn test_import_tarball_skips_pax_global_header() {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut ar = tar::Builder::new(gz);
let pax_body = b"52 comment=867aa88a335a266b904e0b5d1a3b0b5d1a3b0b5d1\n";
let mut gh = tar::Header::new_ustar();
gh.set_path("pax_global_header").unwrap();
gh.set_size(pax_body.len() as u64);
gh.set_mode(0o644);
gh.set_entry_type(tar::EntryType::XGlobalHeader);
gh.set_cksum();
ar.append(&gh, &pax_body[..]).unwrap();
let body = b"// ok";
let mut fh = tar::Header::new_gnu();
fh.set_path("package/index.js").unwrap();
fh.set_size(body.len() as u64);
fh.set_mode(0o644);
fh.set_cksum();
ar.append(&fh, &body[..]).unwrap();
let tarball = ar.into_inner().unwrap().finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let index = store.import_tarball(&tarball).unwrap();
assert!(index.contains_key("index.js"));
assert!(!index.contains_key("pax_global_header"));
}
#[test]
fn test_import_tarball_still_accepts_normal_nested_paths() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let tarball = build_tarball("package/lib/sub/a.js", b"// hi");
let index = store.import_tarball(&tarball).unwrap();
assert!(index.contains_key("lib/sub/a.js"));
}
#[test]
fn test_capped_reader_surfaces_exhaustion_as_error() {
use std::io::Read;
let mut r = CappedReader::new(&b"hello world"[..], 5);
let mut first = [0u8; 5];
r.read_exact(&mut first).unwrap();
assert_eq!(&first, b"hello");
let mut rest = Vec::new();
let err = r.read_to_end(&mut rest).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn test_capped_reader_does_not_error_below_cap() {
use std::io::Read;
let mut r = CappedReader::new(&b"hi"[..], 10);
let mut buf = Vec::new();
r.read_to_end(&mut buf).unwrap();
assert_eq!(&buf, b"hi");
}
#[test]
fn test_capped_reader_empty_buf_is_ok_past_cap() {
use std::io::Read;
let mut r = CappedReader::new(&b"abcd"[..], 4);
let mut buf = [0u8; 4];
r.read_exact(&mut buf).unwrap();
assert_eq!(r.read(&mut []).unwrap(), 0);
}
#[test]
fn test_capped_reader_at_exact_boundary_still_errors() {
use std::io::Read;
let mut r = CappedReader::new(&b"abcd"[..], 4);
let mut buf = [0u8; 4];
r.read_exact(&mut buf).unwrap();
assert_eq!(&buf, b"abcd");
let mut rest = Vec::new();
let err = r.read_to_end(&mut rest).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn test_import_tarball_declared_size_does_not_overallocate() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path().join("files"));
store.ensure_shards_exist().unwrap();
let declared_near_cap = MAX_TARBALL_ENTRY_BYTES;
let actual_content = b"tiny";
let mut h = tar::Header::new_gnu();
h.set_path("package/lying.bin").unwrap();
h.set_size(declared_near_cap);
h.set_mode(0o644);
h.set_cksum();
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut ar = tar::Builder::new(gz);
ar.append(&h, &actual_content[..]).ok();
let tarball = ar.into_inner().unwrap().finish().unwrap();
let _ = store.import_tarball(&tarball);
}
}