1use std::collections::HashMap;
2use std::env;
3use std::fs::{self, File, OpenOptions};
4use std::io::{Read, Result, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Output, Stdio};
7use std::thread;
8use std::time::Duration;
9use chrono::Local;
10use sha2::{Sha256, Digest};
11
12pub const LOG_FILE: &str = "logs.txt";
13pub const NPM_SHOW_TIMEOUT_SECS: u64 = 15;
14pub const NPM_INSTALL_TIMEOUT_SECS: u64 = 120;
15pub const CACHE_MANIFEST_NAME: &str = "manifest.json";
16pub const CACHE_MANIFEST_SIG: &str = "manifest.sig";
17
18pub fn get_cache_dir() -> String {
21 if let Ok(dir) = env::var("JHOL_CACHE_DIR") {
22 return dir;
23 }
24 let base = if cfg!(target_os = "windows") {
25 env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())
26 } else {
27 env::var("HOME").unwrap_or_else(|_| ".".to_string())
28 };
29 let sep = if cfg!(target_os = "windows") { "\\" } else { "/" };
30 format!("{}{}.jhol-cache", base, sep)
31}
32
33pub fn init_cache() -> Result<()> {
34 let cache_dir = get_cache_dir();
35 fs::create_dir_all(&cache_dir)?;
36
37 let log_path = PathBuf::from(format!("{}/{}", cache_dir, LOG_FILE));
38 if !log_path.exists() {
39 File::create(&log_path)?;
40 }
41
42 Ok(())
43}
44
45fn is_quiet() -> bool {
46 if env::var("JHOL_QUIET").map(|v| v == "1" || v == "true").unwrap_or(false) {
47 return true;
48 }
49 env::var("JHOL_LOG")
50 .map(|v| v.to_lowercase() == "quiet" || v.to_lowercase() == "error")
51 .unwrap_or(false)
52}
53
54pub fn log(message: &str) {
55 let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
56 let log_message = format!("[{}] {}", timestamp, message);
57
58 if !is_quiet() {
60 println!("{}", log_message);
61 }
62
63 let log_path = format!("{}/{}", get_cache_dir(), LOG_FILE);
64
65 let mut should_write = true;
66 if let Ok(contents) = fs::read_to_string(&log_path) {
67 if let Some(last_line) = contents.lines().last() {
68 if last_line == log_message {
69 should_write = false;
70 }
71 }
72 }
73
74 if should_write {
75 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
76 let _ = writeln!(file, "{}", log_message);
77 }
78 }
79}
80
81pub fn log_error(message: &str) {
82 eprintln!("{}", message);
83 log(message);
84}
85
86pub fn format_cache_name(package: &str) -> String {
87 package.replace('@', "-")
88}
89
90fn stem_to_spec(stem: &str) -> String {
92 if let Some(pos) = stem.rfind('-') {
93 let suffix = &stem[pos + 1..];
94 if suffix.chars().any(|c| c.is_ascii_digit()) {
95 return format!("{}@{}", &stem[..pos], suffix);
96 }
97 }
98 stem.replace('-', "@")
99}
100
101fn cache_dir_path() -> PathBuf {
102 PathBuf::from(get_cache_dir())
103}
104
105fn store_dir() -> PathBuf {
107 cache_dir_path().join("store")
108}
109
110pub fn store_unpacked_dir() -> PathBuf {
112 cache_dir_path().join("store_unpacked")
113}
114
115fn use_link() -> bool {
117 env::var("JHOL_LINK")
118 .map(|v| v != "0" && !v.is_empty())
119 .unwrap_or(true)
120}
121
122pub fn link_package_from_store(
126 store_unpacked_path: &Path,
127 node_modules: &Path,
128 package_name: &str,
129) -> std::result::Result<(), String> {
130 if !use_link() {
131 return Err("JHOL_LINK=0".to_string());
132 }
133 let link_path = if package_name.starts_with('@') {
134 let scope_and_name = package_name.splitn(2, '/').collect::<Vec<_>>();
136 if scope_and_name.len() != 2 {
137 return Err(format!("invalid scoped package name: {}", package_name));
138 }
139 node_modules.join(scope_and_name[0]).join(scope_and_name[1])
140 } else {
141 node_modules.join(package_name)
142 };
143 if link_path.exists() {
144 fs::remove_dir_all(&link_path).or_else(|_| fs::remove_file(&link_path)).ok();
145 }
146 if let Some(parent) = link_path.parent() {
147 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
148 }
149 #[cfg(unix)]
150 {
151 std::os::unix::fs::symlink(store_unpacked_path, &link_path).map_err(|e| e.to_string())?;
152 }
153 #[cfg(windows)]
154 {
155 std::os::windows::fs::symlink_dir(store_unpacked_path, &link_path).map_err(|e| e.to_string())?;
156 }
157 Ok(())
158}
159
160fn store_index_path() -> PathBuf {
162 cache_dir_path().join("store_index.json")
163}
164
165pub fn read_store_index() -> HashMap<String, String> {
166 let path = store_index_path();
167 if !path.exists() {
168 return HashMap::new();
169 }
170 let s = match fs::read_to_string(&path) {
171 Ok(x) => x,
172 Err(_) => return HashMap::new(),
173 };
174 serde_json::from_str(&s).unwrap_or_default()
175}
176
177pub fn write_store_index(map: &HashMap<String, String>) -> Result<()> {
178 let path = store_index_path();
179 let s = serde_json::to_string_pretty(map).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
180 fs::write(path, s)?;
181 Ok(())
182}
183
184pub fn manifest_signature(manifest_json: &str) -> Result<String> {
185 let key = env::var("JHOL_CACHE_SIGNING_KEY").map_err(|_| {
186 std::io::Error::new(std::io::ErrorKind::Other, "JHOL_CACHE_SIGNING_KEY not set")
187 })?;
188 let mut hasher = Sha256::new();
189 hasher.update(key.as_bytes());
190 hasher.update(manifest_json.as_bytes());
191 Ok(format!("{:x}", hasher.finalize()))
192}
193
194pub fn verify_manifest_signature(manifest_json: &str, signature: &str) -> Result<bool> {
195 let expected = manifest_signature(manifest_json)?;
196 Ok(expected == signature.trim())
197}
198
199pub fn verify_sri(path: &Path, sri: &str) -> bool {
201 let sri = sri.trim();
202 let Some((algo, rest)) = sri.split_once('-') else { return false };
203 let digest_b64 = rest.split_once('?').map(|(d, _)| d).unwrap_or(rest);
204 use base64::Engine;
205 let expected = match base64::engine::general_purpose::STANDARD.decode(digest_b64.as_bytes()) {
206 Ok(b) => b,
207 Err(_) => return false,
208 };
209 let mut f = match File::open(path) {
210 Ok(x) => x,
211 Err(_) => return false,
212 };
213 let mut buf = [0u8; 8192];
214 match algo.to_lowercase().as_str() {
215 "sha512" => {
216 use sha2::{Digest, Sha512};
217 let mut hasher = Sha512::new();
218 loop {
219 let n = match f.read(&mut buf) {
220 Ok(0) => break,
221 Ok(n) => n,
222 Err(_) => return false,
223 };
224 hasher.update(&buf[..n]);
225 }
226 let got = hasher.finalize();
227 got.as_slice() == expected.as_slice()
228 }
229 "sha384" => {
230 use sha2::{Digest, Sha384};
231 let mut hasher = Sha384::new();
232 loop {
233 let n = match f.read(&mut buf) {
234 Ok(0) => break,
235 Ok(n) => n,
236 Err(_) => return false,
237 };
238 hasher.update(&buf[..n]);
239 }
240 let got = hasher.finalize();
241 got.as_slice() == expected.as_slice()
242 }
243 _ => false,
244 }
245}
246
247pub fn content_hash(path: &Path) -> Result<String> {
249 let mut f = File::open(path)?;
250 let mut hasher = Sha256::new();
251 let mut buf = [0u8; 8192];
252 loop {
253 let n = f.read(&mut buf)?;
254 if n == 0 {
255 break;
256 }
257 hasher.update(&buf[..n]);
258 }
259 Ok(format!("{:x}", hasher.finalize()))
260}
261
262pub fn lockfile_content_hash(dir: &Path) -> Option<String> {
264 let bun = dir.join("bun.lock");
265 let npm = dir.join("package-lock.json");
266 let path = if bun.exists() {
267 bun
268 } else if npm.exists() {
269 npm
270 } else {
271 return None;
272 };
273 content_hash(&path).ok()
274}
275
276pub fn get_cached_tarball(package: &str) -> Option<PathBuf> {
280 let cache_dir = cache_dir_path();
281 if !cache_dir.exists() {
282 return None;
283 }
284 let base_name = package.split('@').next().unwrap_or(package);
285 let versioned_key = format_cache_name(package);
286
287 let index = read_store_index();
289 let key = if package.contains('@') {
290 package.to_string()
291 } else {
292 for (k, hash) in &index {
294 if k.starts_with(&format!("{}@", base_name)) {
295 let store_file = store_dir().join(format!("{}.tgz", hash));
296 if store_file.exists() {
297 return Some(store_file);
298 }
299 }
300 }
301 String::new()
302 };
303 if !key.is_empty() {
304 if let Some(hash) = index.get(&key) {
305 let store_file = store_dir().join(format!("{}.tgz", hash));
306 if store_file.exists() {
307 return Some(store_file);
308 }
309 }
310 }
311
312 let exact = cache_dir.join(format!("{}.tgz", versioned_key));
314 if exact.exists() {
315 return Some(exact);
316 }
317
318 if !package.contains('@') {
320 if let Ok(entries) = fs::read_dir(&cache_dir) {
321 for e in entries.flatten() {
322 let name = e.file_name().to_string_lossy().into_owned();
323 if name.starts_with(&format!("{}-", base_name)) && name.ends_with(".tgz") && !name.contains("store") {
324 return Some(e.path());
325 }
326 }
327 }
328 }
329
330 None
331}
332
333#[allow(dead_code)]
334pub fn is_package_cached(package: &str) -> bool {
335 get_cached_tarball(package).is_some()
336}
337
338#[deprecated(
341 since = "0.1.0",
342 note = "Use registry::fill_store_from_registry to populate store without npm pack"
343)]
344pub fn cache_package_tarball(base_name: &str, version: &str) -> Result<PathBuf> {
345 let cache_dir = cache_dir_path();
346 fs::create_dir_all(&cache_dir)?;
347 fs::create_dir_all(store_dir())?;
348
349 let output = run_command_timeout(
350 "npm",
351 &["pack", &format!("{}@{}", base_name, version), "--silent"],
352 NPM_SHOW_TIMEOUT_SECS,
353 )?;
354
355 if !output.status.success() {
356 let stderr = String::from_utf8_lossy(&output.stderr);
357 return Err(std::io::Error::new(
358 std::io::ErrorKind::Other,
359 format!("npm pack failed: {}", stderr),
360 ));
361 }
362
363 let tgz_name = format!("{}-{}.tgz", base_name, version);
364 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
365 let from = cwd.join(&tgz_name);
366
367 if !from.exists() {
368 return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "npm pack did not create tarball"));
369 }
370
371 let hash = content_hash(&from)?;
373 let store_file = store_dir().join(format!("{}.tgz", hash));
374 fs::copy(&from, &store_file).map(|_| ()).or_else(|_| fs::rename(&from, &store_file))?;
375 let _ = fs::remove_file(&from);
376
377 let pkg_key = format!("{}@{}", base_name, version);
378 let mut index = read_store_index();
379 index.insert(pkg_key, hash.clone());
380 write_store_index(&index)?;
381
382 let key = format!("{}-{}", base_name, version.replace('@', "-"));
384 let dest = cache_dir.join(format!("{}.tgz", key));
385 let _ = fs::hard_link(&store_file, &dest).or_else(|_| fs::copy(&store_file, &dest).map(|_| ()));
386
387 log(&format!("Cached package: {}@{}", base_name, version));
388 Ok(store_file)
389}
390
391pub fn list_cached_packages() -> Result<Vec<String>> {
393 let mut names: Vec<String> = read_store_index().into_keys().collect();
394 let cache_dir = cache_dir_path();
395 if cache_dir.exists() {
396 for e in fs::read_dir(&cache_dir)? {
397 let e = e?;
398 let name = e.file_name().to_string_lossy().into_owned();
399 if name.ends_with(".tgz") && name != "store_index.json" {
400 let base = name.trim_end_matches(".tgz");
401 if !names.contains(&base.to_string()) && !base.contains("/") {
402 names.push(base.to_string());
403 }
404 }
405 }
406 }
407 names.sort();
408 Ok(names)
409}
410
411pub fn cache_clean() -> Result<usize> {
413 let cache_dir = cache_dir_path();
414 if !cache_dir.exists() {
415 return Ok(0);
416 }
417 let mut removed = 0;
418 for e in fs::read_dir(&cache_dir)? {
419 let e = e?;
420 let name = e.file_name().to_string_lossy().into_owned();
421 if name.ends_with(".tgz") {
422 if fs::remove_file(e.path()).is_ok() {
423 removed += 1;
424 }
425 }
426 }
427 let store = store_dir();
428 if store.exists() {
429 for e in fs::read_dir(&store)? {
430 let e = e?;
431 if e.path().extension().map(|x| x == "tgz").unwrap_or(false) && fs::remove_file(e.path()).is_ok() {
432 removed += 1;
433 }
434 }
435 }
436 let _ = fs::remove_file(store_index_path());
437 Ok(removed)
438}
439
440pub fn cache_size_bytes() -> Result<(u64, usize)> {
442 let cache_dir = cache_dir_path();
443 let mut total: u64 = 0;
444 let mut count = 0;
445 if cache_dir.exists() {
446 for e in fs::read_dir(&cache_dir)? {
447 let e = e?;
448 let path = e.path();
449 if path.extension().map(|x| x == "tgz").unwrap_or(false) {
450 if let Ok(m) = fs::metadata(&path) {
451 total += m.len();
452 count += 1;
453 }
454 }
455 }
456 }
457 let store = store_dir();
458 if store.exists() {
459 for e in fs::read_dir(&store)? {
460 let e = e?;
461 let path = e.path();
462 if path.extension().map(|x| x == "tgz").unwrap_or(false) {
463 if let Ok(m) = fs::metadata(&path) {
464 total += m.len();
465 count += 1;
466 }
467 }
468 }
469 }
470 Ok((total, count))
471}
472
473pub fn cache_prune(keep_recent: Option<usize>) -> Result<usize> {
475 let mut index = read_store_index();
476 let store = store_dir();
477 if !store.exists() {
478 return Ok(0);
479 }
480 let mut removed = 0;
481 let mut entries: Vec<(PathBuf, std::time::SystemTime, String)> = Vec::new();
482 for e in fs::read_dir(&store)? {
483 let e = e?;
484 let path = e.path();
485 if path.extension().map(|x| x == "tgz").unwrap_or(false) {
486 let hash = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
487 let in_index = index.values().any(|v| v == &hash);
488 if !in_index {
489 if fs::remove_file(&path).is_ok() {
490 removed += 1;
491 }
492 } else if let Ok(meta) = fs::metadata(&path) {
493 if let Ok(mtime) = meta.modified() {
494 entries.push((path, mtime, hash));
495 }
496 }
497 }
498 }
499 if let Some(n) = keep_recent {
500 if entries.len() > n {
501 entries.sort_by(|a, b| b.1.cmp(&a.1));
502 let to_remove = entries.split_off(n);
503 let keep_hashes: std::collections::HashSet<String> = entries.into_iter().map(|(_, _, h)| h).collect();
504 index.retain(|_, hash| keep_hashes.contains(hash));
505 write_store_index(&index)?;
506 for (path, _, _) in to_remove {
507 if fs::remove_file(&path).is_ok() {
508 removed += 1;
509 }
510 }
511 }
512 }
513 Ok(removed)
514}
515
516pub fn cache_export(dir: &Path) -> Result<usize> {
518 let pj = Path::new("package.json");
519 if !pj.exists() {
520 return Err(std::io::Error::new(
521 std::io::ErrorKind::NotFound,
522 "No package.json in current directory",
523 ));
524 }
525 let deps = crate::lockfile::read_package_json_deps(pj).ok_or_else(|| {
526 std::io::Error::new(std::io::ErrorKind::InvalidData, "Could not read package.json deps")
527 })?;
528 if deps.is_empty() {
529 return Ok(0);
530 }
531 let specs = crate::lockfile::read_all_resolved_specs_from_dir(Path::new("."))
532 .unwrap_or_else(|| crate::lockfile::resolve_deps_for_install(&deps, None));
533 fs::create_dir_all(dir)?;
534 let mut count = 0;
535 let mut manifest: Vec<(String, String)> = Vec::new();
536 for spec in specs {
537 if let Some(path) = get_cached_tarball(&spec) {
538 let name = format!("{}.tgz", format_cache_name(&spec));
539 let dest = dir.join(&name);
540 fs::copy(&path, &dest)?;
541 manifest.push((spec, name));
542 count += 1;
543 }
544 }
545 let manifest_json: Vec<serde_json::Value> = manifest
546 .iter()
547 .map(|(spec, file)| {
548 serde_json::json!({ "spec": spec, "file": file })
549 })
550 .collect();
551 let manifest_path = dir.join(CACHE_MANIFEST_NAME);
552 let s = serde_json::to_string_pretty(&manifest_json)
553 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
554 fs::write(manifest_path, s)?;
555 if let Ok(sig) = manifest_signature(&serde_json::to_string(&manifest_json).unwrap_or_default()) {
556 let sig_path = dir.join(CACHE_MANIFEST_SIG);
557 fs::write(sig_path, sig)?;
558 }
559 Ok(count)
560}
561
562pub fn cache_import(dir: &Path) -> Result<usize> {
565 if !dir.exists() {
566 return Err(std::io::Error::new(
567 std::io::ErrorKind::NotFound,
568 "Export directory does not exist",
569 ));
570 }
571 fs::create_dir_all(store_dir())?;
572 let mut index = read_store_index();
573 let mut count = 0;
574 let manifest_path = dir.join(CACHE_MANIFEST_NAME);
575 if manifest_path.exists() {
576 let s = fs::read_to_string(&manifest_path)?;
577 if let Ok(sig) = fs::read_to_string(dir.join(CACHE_MANIFEST_SIG)) {
578 if !verify_manifest_signature(&s, &sig).unwrap_or(false) {
579 return Err(std::io::Error::new(
580 std::io::ErrorKind::Other,
581 "Manifest signature verification failed",
582 ));
583 }
584 } else if env::var("JHOL_CACHE_SIGNING_KEY").is_ok() {
585 return Err(std::io::Error::new(
586 std::io::ErrorKind::Other,
587 "Manifest signature missing",
588 ));
589 }
590 let entries: Vec<serde_json::Value> = serde_json::from_str(&s).unwrap_or_default();
591 for entry in entries {
592 let spec = entry.get("spec").and_then(|v| v.as_str()).unwrap_or("");
593 let file = entry.get("file").and_then(|v| v.as_str()).unwrap_or("");
594 if spec.is_empty() || file.is_empty() {
595 continue;
596 }
597 let path = dir.join(file);
598 if !path.exists() {
599 continue;
600 }
601 let hash = content_hash(&path)?;
602 let store_file = store_dir().join(format!("{}.tgz", hash));
603 if !store_file.exists() {
604 fs::copy(&path, &store_file)?;
605 }
606 let content_hash = content_hash(&store_file)?;
608 if content_hash != hash {
609 return Err(std::io::Error::new(
610 std::io::ErrorKind::Other,
611 format!("Cache import hash mismatch for {}", spec),
612 ));
613 }
614 index.insert(spec.to_string(), hash);
615 count += 1;
616 }
617 } else {
618 for e in fs::read_dir(dir)? {
619 let e = e?;
620 let path = e.path();
621 if path.extension().map(|x| x == "tgz").unwrap_or(false) {
622 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
623 if stem.is_empty() {
624 continue;
625 }
626 let hash = content_hash(&path)?;
627 let store_file = store_dir().join(format!("{}.tgz", hash));
628 if !store_file.exists() {
629 fs::copy(&path, &store_file)?;
630 }
631 let pkg_key = stem_to_spec(stem);
632 index.insert(pkg_key, hash);
633 count += 1;
634 }
635 }
636 }
637 write_store_index(&index)?;
638 Ok(count)
639}
640
641pub fn run_command_timeout(program: &str, args: &[&str], timeout_secs: u64) -> Result<Output> {
643 let child = Command::new(program)
644 .args(args)
645 .stdout(Stdio::piped())
646 .stderr(Stdio::piped())
647 .spawn()?;
648
649 let pid = child.id();
650 let kill_handle = thread::spawn(move || {
651 thread::sleep(Duration::from_secs(timeout_secs));
652 #[cfg(unix)]
653 {
654 let _ = Command::new("kill").arg("-9").arg(pid.to_string()).output();
655 }
656 #[cfg(windows)]
657 {
658 let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).output();
659 }
660 });
661
662 let out = child.wait_with_output();
663 let _ = kill_handle.join();
664 out
665}
666
667pub fn npm_install_timeout(args: &[&str], timeout_secs: u64) -> Result<Output> {
669 let mut a = vec!["install"];
670 a.extend(args);
671 run_command_timeout("npm", &a, timeout_secs)
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn test_format_cache_name() {
680 assert_eq!(format_cache_name("lodash"), "lodash");
681 assert_eq!(format_cache_name("lodash@4.17.21"), "lodash-4.17.21");
682 assert_eq!(format_cache_name("@scope/pkg@1.0.0"), "-scope/pkg-1.0.0");
683 }
684
685 #[test]
686 fn test_get_cache_dir_non_empty() {
687 let dir = get_cache_dir();
688 assert!(!dir.is_empty());
689 assert!(dir.contains("jhol-cache") || dir.contains(".jhol-cache"));
690 }
691
692 #[test]
693 fn test_is_package_cached_no_dir() {
694 assert!(!is_package_cached("nonexistent-package-xyz-123"));
696 }
697}