use std::collections::HashMap;
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Result, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::thread;
use std::time::Duration;
use chrono::Local;
use sha2::{Sha256, Digest};
pub const LOG_FILE: &str = "logs.txt";
pub const NPM_SHOW_TIMEOUT_SECS: u64 = 15;
pub const NPM_INSTALL_TIMEOUT_SECS: u64 = 120;
pub const CACHE_MANIFEST_NAME: &str = "manifest.json";
pub const CACHE_MANIFEST_SIG: &str = "manifest.sig";
pub fn get_cache_dir() -> String {
if let Ok(dir) = env::var("JHOL_CACHE_DIR") {
return dir;
}
let base = if cfg!(target_os = "windows") {
env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())
} else {
env::var("HOME").unwrap_or_else(|_| ".".to_string())
};
let sep = if cfg!(target_os = "windows") { "\\" } else { "/" };
format!("{}{}.jhol-cache", base, sep)
}
pub fn init_cache() -> Result<()> {
let cache_dir = get_cache_dir();
fs::create_dir_all(&cache_dir)?;
let log_path = PathBuf::from(format!("{}/{}", cache_dir, LOG_FILE));
if !log_path.exists() {
File::create(&log_path)?;
}
Ok(())
}
fn is_quiet() -> bool {
if env::var("JHOL_QUIET").map(|v| v == "1" || v == "true").unwrap_or(false) {
return true;
}
env::var("JHOL_LOG")
.map(|v| v.to_lowercase() == "quiet" || v.to_lowercase() == "error")
.unwrap_or(false)
}
pub fn log(message: &str) {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let log_message = format!("[{}] {}", timestamp, message);
if !is_quiet() {
println!("{}", log_message);
}
let log_path = format!("{}/{}", get_cache_dir(), LOG_FILE);
let mut should_write = true;
if let Ok(contents) = fs::read_to_string(&log_path) {
if let Some(last_line) = contents.lines().last() {
if last_line == log_message {
should_write = false;
}
}
}
if should_write {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = writeln!(file, "{}", log_message);
}
}
}
pub fn log_error(message: &str) {
eprintln!("{}", message);
log(message);
}
pub fn format_cache_name(package: &str) -> String {
package.replace('@', "-")
}
fn stem_to_spec(stem: &str) -> String {
if let Some(pos) = stem.rfind('-') {
let suffix = &stem[pos + 1..];
if suffix.chars().any(|c| c.is_ascii_digit()) {
return format!("{}@{}", &stem[..pos], suffix);
}
}
stem.replace('-', "@")
}
fn cache_dir_path() -> PathBuf {
PathBuf::from(get_cache_dir())
}
fn store_dir() -> PathBuf {
cache_dir_path().join("store")
}
pub fn store_unpacked_dir() -> PathBuf {
cache_dir_path().join("store_unpacked")
}
fn use_link() -> bool {
env::var("JHOL_LINK")
.map(|v| v != "0" && !v.is_empty())
.unwrap_or(true)
}
pub fn link_package_from_store(
store_unpacked_path: &Path,
node_modules: &Path,
package_name: &str,
) -> std::result::Result<(), String> {
if !use_link() {
return Err("JHOL_LINK=0".to_string());
}
let link_path = if package_name.starts_with('@') {
let scope_and_name = package_name.splitn(2, '/').collect::<Vec<_>>();
if scope_and_name.len() != 2 {
return Err(format!("invalid scoped package name: {}", package_name));
}
node_modules.join(scope_and_name[0]).join(scope_and_name[1])
} else {
node_modules.join(package_name)
};
if link_path.exists() {
fs::remove_dir_all(&link_path).or_else(|_| fs::remove_file(&link_path)).ok();
}
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(store_unpacked_path, &link_path).map_err(|e| e.to_string())?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(store_unpacked_path, &link_path).map_err(|e| e.to_string())?;
}
Ok(())
}
fn store_index_path() -> PathBuf {
cache_dir_path().join("store_index.json")
}
pub fn read_store_index() -> HashMap<String, String> {
let path = store_index_path();
if !path.exists() {
return HashMap::new();
}
let s = match fs::read_to_string(&path) {
Ok(x) => x,
Err(_) => return HashMap::new(),
};
serde_json::from_str(&s).unwrap_or_default()
}
pub fn write_store_index(map: &HashMap<String, String>) -> Result<()> {
let path = store_index_path();
let s = serde_json::to_string_pretty(map).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
fs::write(path, s)?;
Ok(())
}
pub fn manifest_signature(manifest_json: &str) -> Result<String> {
let key = env::var("JHOL_CACHE_SIGNING_KEY").map_err(|_| {
std::io::Error::new(std::io::ErrorKind::Other, "JHOL_CACHE_SIGNING_KEY not set")
})?;
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
hasher.update(manifest_json.as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
pub fn verify_manifest_signature(manifest_json: &str, signature: &str) -> Result<bool> {
let expected = manifest_signature(manifest_json)?;
Ok(expected == signature.trim())
}
pub fn verify_sri(path: &Path, sri: &str) -> bool {
let sri = sri.trim();
let Some((algo, rest)) = sri.split_once('-') else { return false };
let digest_b64 = rest.split_once('?').map(|(d, _)| d).unwrap_or(rest);
use base64::Engine;
let expected = match base64::engine::general_purpose::STANDARD.decode(digest_b64.as_bytes()) {
Ok(b) => b,
Err(_) => return false,
};
let mut f = match File::open(path) {
Ok(x) => x,
Err(_) => return false,
};
let mut buf = [0u8; 8192];
match algo.to_lowercase().as_str() {
"sha512" => {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
loop {
let n = match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => return false,
};
hasher.update(&buf[..n]);
}
let got = hasher.finalize();
got.as_slice() == expected.as_slice()
}
"sha384" => {
use sha2::{Digest, Sha384};
let mut hasher = Sha384::new();
loop {
let n = match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => return false,
};
hasher.update(&buf[..n]);
}
let got = hasher.finalize();
got.as_slice() == expected.as_slice()
}
_ => false,
}
}
pub fn content_hash(path: &Path) -> Result<String> {
let mut f = File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn lockfile_content_hash(dir: &Path) -> Option<String> {
let bun = dir.join("bun.lock");
let npm = dir.join("package-lock.json");
let path = if bun.exists() {
bun
} else if npm.exists() {
npm
} else {
return None;
};
content_hash(&path).ok()
}
pub fn get_cached_tarball(package: &str) -> Option<PathBuf> {
let cache_dir = cache_dir_path();
if !cache_dir.exists() {
return None;
}
let base_name = package.split('@').next().unwrap_or(package);
let versioned_key = format_cache_name(package);
let index = read_store_index();
let key = if package.contains('@') {
package.to_string()
} else {
for (k, hash) in &index {
if k.starts_with(&format!("{}@", base_name)) {
let store_file = store_dir().join(format!("{}.tgz", hash));
if store_file.exists() {
return Some(store_file);
}
}
}
String::new()
};
if !key.is_empty() {
if let Some(hash) = index.get(&key) {
let store_file = store_dir().join(format!("{}.tgz", hash));
if store_file.exists() {
return Some(store_file);
}
}
}
let exact = cache_dir.join(format!("{}.tgz", versioned_key));
if exact.exists() {
return Some(exact);
}
if !package.contains('@') {
if let Ok(entries) = fs::read_dir(&cache_dir) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with(&format!("{}-", base_name)) && name.ends_with(".tgz") && !name.contains("store") {
return Some(e.path());
}
}
}
}
None
}
#[allow(dead_code)]
pub fn is_package_cached(package: &str) -> bool {
get_cached_tarball(package).is_some()
}
#[deprecated(
since = "0.1.0",
note = "Use registry::fill_store_from_registry to populate store without npm pack"
)]
pub fn cache_package_tarball(base_name: &str, version: &str) -> Result<PathBuf> {
let cache_dir = cache_dir_path();
fs::create_dir_all(&cache_dir)?;
fs::create_dir_all(store_dir())?;
let output = run_command_timeout(
"npm",
&["pack", &format!("{}@{}", base_name, version), "--silent"],
NPM_SHOW_TIMEOUT_SECS,
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("npm pack failed: {}", stderr),
));
}
let tgz_name = format!("{}-{}.tgz", base_name, version);
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let from = cwd.join(&tgz_name);
if !from.exists() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "npm pack did not create tarball"));
}
let hash = content_hash(&from)?;
let store_file = store_dir().join(format!("{}.tgz", hash));
fs::copy(&from, &store_file).map(|_| ()).or_else(|_| fs::rename(&from, &store_file))?;
let _ = fs::remove_file(&from);
let pkg_key = format!("{}@{}", base_name, version);
let mut index = read_store_index();
index.insert(pkg_key, hash.clone());
write_store_index(&index)?;
let key = format!("{}-{}", base_name, version.replace('@', "-"));
let dest = cache_dir.join(format!("{}.tgz", key));
let _ = fs::hard_link(&store_file, &dest).or_else(|_| fs::copy(&store_file, &dest).map(|_| ()));
log(&format!("Cached package: {}@{}", base_name, version));
Ok(store_file)
}
pub fn list_cached_packages() -> Result<Vec<String>> {
let mut names: Vec<String> = read_store_index().into_keys().collect();
let cache_dir = cache_dir_path();
if cache_dir.exists() {
for e in fs::read_dir(&cache_dir)? {
let e = e?;
let name = e.file_name().to_string_lossy().into_owned();
if name.ends_with(".tgz") && name != "store_index.json" {
let base = name.trim_end_matches(".tgz");
if !names.contains(&base.to_string()) && !base.contains("/") {
names.push(base.to_string());
}
}
}
}
names.sort();
Ok(names)
}
pub fn cache_clean() -> Result<usize> {
let cache_dir = cache_dir_path();
if !cache_dir.exists() {
return Ok(0);
}
let mut removed = 0;
for e in fs::read_dir(&cache_dir)? {
let e = e?;
let name = e.file_name().to_string_lossy().into_owned();
if name.ends_with(".tgz") {
if fs::remove_file(e.path()).is_ok() {
removed += 1;
}
}
}
let store = store_dir();
if store.exists() {
for e in fs::read_dir(&store)? {
let e = e?;
if e.path().extension().map(|x| x == "tgz").unwrap_or(false) && fs::remove_file(e.path()).is_ok() {
removed += 1;
}
}
}
let _ = fs::remove_file(store_index_path());
Ok(removed)
}
pub fn cache_size_bytes() -> Result<(u64, usize)> {
let cache_dir = cache_dir_path();
let mut total: u64 = 0;
let mut count = 0;
if cache_dir.exists() {
for e in fs::read_dir(&cache_dir)? {
let e = e?;
let path = e.path();
if path.extension().map(|x| x == "tgz").unwrap_or(false) {
if let Ok(m) = fs::metadata(&path) {
total += m.len();
count += 1;
}
}
}
}
let store = store_dir();
if store.exists() {
for e in fs::read_dir(&store)? {
let e = e?;
let path = e.path();
if path.extension().map(|x| x == "tgz").unwrap_or(false) {
if let Ok(m) = fs::metadata(&path) {
total += m.len();
count += 1;
}
}
}
}
Ok((total, count))
}
pub fn cache_prune(keep_recent: Option<usize>) -> Result<usize> {
let mut index = read_store_index();
let store = store_dir();
if !store.exists() {
return Ok(0);
}
let mut removed = 0;
let mut entries: Vec<(PathBuf, std::time::SystemTime, String)> = Vec::new();
for e in fs::read_dir(&store)? {
let e = e?;
let path = e.path();
if path.extension().map(|x| x == "tgz").unwrap_or(false) {
let hash = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let in_index = index.values().any(|v| v == &hash);
if !in_index {
if fs::remove_file(&path).is_ok() {
removed += 1;
}
} else if let Ok(meta) = fs::metadata(&path) {
if let Ok(mtime) = meta.modified() {
entries.push((path, mtime, hash));
}
}
}
}
if let Some(n) = keep_recent {
if entries.len() > n {
entries.sort_by(|a, b| b.1.cmp(&a.1));
let to_remove = entries.split_off(n);
let keep_hashes: std::collections::HashSet<String> = entries.into_iter().map(|(_, _, h)| h).collect();
index.retain(|_, hash| keep_hashes.contains(hash));
write_store_index(&index)?;
for (path, _, _) in to_remove {
if fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
}
Ok(removed)
}
pub fn cache_export(dir: &Path) -> Result<usize> {
let pj = Path::new("package.json");
if !pj.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No package.json in current directory",
));
}
let deps = crate::lockfile::read_package_json_deps(pj).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "Could not read package.json deps")
})?;
if deps.is_empty() {
return Ok(0);
}
let specs = crate::lockfile::read_all_resolved_specs_from_dir(Path::new("."))
.unwrap_or_else(|| crate::lockfile::resolve_deps_for_install(&deps, None));
fs::create_dir_all(dir)?;
let mut count = 0;
let mut manifest: Vec<(String, String)> = Vec::new();
for spec in specs {
if let Some(path) = get_cached_tarball(&spec) {
let name = format!("{}.tgz", format_cache_name(&spec));
let dest = dir.join(&name);
fs::copy(&path, &dest)?;
manifest.push((spec, name));
count += 1;
}
}
let manifest_json: Vec<serde_json::Value> = manifest
.iter()
.map(|(spec, file)| {
serde_json::json!({ "spec": spec, "file": file })
})
.collect();
let manifest_path = dir.join(CACHE_MANIFEST_NAME);
let s = serde_json::to_string_pretty(&manifest_json)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
fs::write(manifest_path, s)?;
if let Ok(sig) = manifest_signature(&serde_json::to_string(&manifest_json).unwrap_or_default()) {
let sig_path = dir.join(CACHE_MANIFEST_SIG);
fs::write(sig_path, sig)?;
}
Ok(count)
}
pub fn cache_import(dir: &Path) -> Result<usize> {
if !dir.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Export directory does not exist",
));
}
fs::create_dir_all(store_dir())?;
let mut index = read_store_index();
let mut count = 0;
let manifest_path = dir.join(CACHE_MANIFEST_NAME);
if manifest_path.exists() {
let s = fs::read_to_string(&manifest_path)?;
if let Ok(sig) = fs::read_to_string(dir.join(CACHE_MANIFEST_SIG)) {
if !verify_manifest_signature(&s, &sig).unwrap_or(false) {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Manifest signature verification failed",
));
}
} else if env::var("JHOL_CACHE_SIGNING_KEY").is_ok() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Manifest signature missing",
));
}
let entries: Vec<serde_json::Value> = serde_json::from_str(&s).unwrap_or_default();
for entry in entries {
let spec = entry.get("spec").and_then(|v| v.as_str()).unwrap_or("");
let file = entry.get("file").and_then(|v| v.as_str()).unwrap_or("");
if spec.is_empty() || file.is_empty() {
continue;
}
let path = dir.join(file);
if !path.exists() {
continue;
}
let hash = content_hash(&path)?;
let store_file = store_dir().join(format!("{}.tgz", hash));
if !store_file.exists() {
fs::copy(&path, &store_file)?;
}
let content_hash = content_hash(&store_file)?;
if content_hash != hash {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Cache import hash mismatch for {}", spec),
));
}
index.insert(spec.to_string(), hash);
count += 1;
}
} else {
for e in fs::read_dir(dir)? {
let e = e?;
let path = e.path();
if path.extension().map(|x| x == "tgz").unwrap_or(false) {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if stem.is_empty() {
continue;
}
let hash = content_hash(&path)?;
let store_file = store_dir().join(format!("{}.tgz", hash));
if !store_file.exists() {
fs::copy(&path, &store_file)?;
}
let pkg_key = stem_to_spec(stem);
index.insert(pkg_key, hash);
count += 1;
}
}
}
write_store_index(&index)?;
Ok(count)
}
pub fn run_command_timeout(program: &str, args: &[&str], timeout_secs: u64) -> Result<Output> {
let child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let pid = child.id();
let kill_handle = thread::spawn(move || {
thread::sleep(Duration::from_secs(timeout_secs));
#[cfg(unix)]
{
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).output();
}
#[cfg(windows)]
{
let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).output();
}
});
let out = child.wait_with_output();
let _ = kill_handle.join();
out
}
pub fn npm_install_timeout(args: &[&str], timeout_secs: u64) -> Result<Output> {
let mut a = vec!["install"];
a.extend(args);
run_command_timeout("npm", &a, timeout_secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_cache_name() {
assert_eq!(format_cache_name("lodash"), "lodash");
assert_eq!(format_cache_name("lodash@4.17.21"), "lodash-4.17.21");
assert_eq!(format_cache_name("@scope/pkg@1.0.0"), "-scope/pkg-1.0.0");
}
#[test]
fn test_get_cache_dir_non_empty() {
let dir = get_cache_dir();
assert!(!dir.is_empty());
assert!(dir.contains("jhol-cache") || dir.contains(".jhol-cache"));
}
#[test]
fn test_is_package_cached_no_dir() {
assert!(!is_package_cached("nonexistent-package-xyz-123"));
}
}