1use std::fs::{self, File, OpenOptions};
4use std::io::{IsTerminal, Read, Write};
5use std::path::{Component, Path, PathBuf};
6use std::process::Command;
7use std::sync::OnceLock;
8use std::time::{Duration, SystemTime};
9
10use anyhow::{Context, Result};
11use sha2::{Digest, Sha256};
12use walkdir::WalkDir;
13
14const GREEN: &str = "38;2;146;210;147"; const WARN: &str = "38;2;230;180;85"; const FAIL: &str = "38;2;224;108;117"; const INFO: &str = "38;2;126;166;224"; const DIM: &str = "2"; fn colors_enabled() -> bool {
28 static ENABLED: OnceLock<bool> = OnceLock::new();
29 *ENABLED.get_or_init(|| {
30 if std::env::var_os("NO_COLOR").is_some() {
31 return false;
32 }
33 if let Some(force) = std::env::var_os("CLICOLOR_FORCE") {
34 if force != "0" && !force.is_empty() {
35 return true;
36 }
37 }
38 std::io::stdout().is_terminal()
39 })
40}
41
42fn paint(enabled: bool, code: &str, text: &str) -> String {
43 if enabled {
44 format!("\x1b[{code}m{text}\x1b[0m")
45 } else {
46 text.to_string()
47 }
48}
49
50pub fn green(text: &str) -> String {
52 paint(colors_enabled(), GREEN, text)
53}
54
55pub fn heading(text: &str) -> String {
57 paint(colors_enabled(), &format!("1;{GREEN}"), text)
58}
59
60pub fn warn(text: &str) -> String {
62 paint(colors_enabled(), WARN, text)
63}
64
65pub fn fail(text: &str) -> String {
67 paint(colors_enabled(), FAIL, text)
68}
69
70pub fn info(text: &str) -> String {
72 paint(colors_enabled(), INFO, text)
73}
74
75pub fn dim(text: &str) -> String {
77 paint(colors_enabled(), DIM, text)
78}
79
80pub const GLYPH_PASS: &str = "✓";
86pub const GLYPH_WARN: &str = "▲";
88pub const GLYPH_FAIL: &str = "✕";
90pub const GLYPH_INFO: &str = "◆";
92
93pub fn human_bytes(bytes: u64) -> String {
95 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
96 let mut value = bytes as f64;
97 let mut unit = 0;
98 while value >= 1024.0 && unit < UNITS.len() - 1 {
99 value /= 1024.0;
100 unit += 1;
101 }
102 if unit == 0 {
103 format!("{bytes} B")
104 } else {
105 format!("{value:.1} {}", UNITS[unit])
106 }
107}
108
109pub fn parse_duration(text: &str) -> Result<Duration> {
111 let text = text.trim();
112 let split = text
113 .find(|c: char| c.is_ascii_alphabetic())
114 .unwrap_or(text.len());
115 let (num, unit) = text.split_at(split);
116 let n: u64 = num
119 .parse()
120 .map_err(|_| anyhow::anyhow!("invalid duration {text:?} (expected e.g. 30d, 2w, 12h)"))?;
121 let secs = match unit.to_ascii_lowercase().as_str() {
122 "" | "s" => Some(n),
123 "m" => n.checked_mul(60),
124 "h" => n.checked_mul(3_600),
125 "d" => n.checked_mul(86_400),
126 "w" => n.checked_mul(604_800),
127 other => anyhow::bail!("unknown time unit {other:?} in {text:?} (use s, m, h, d or w)"),
128 }
129 .with_context(|| format!("duration {text:?} is too large"))?;
130 Ok(Duration::from_secs(secs))
131}
132
133pub fn human_age(at: SystemTime) -> String {
135 match SystemTime::now().duration_since(at) {
136 Ok(age) => human_duration(age),
137 Err(_) => "in the future".to_string(),
138 }
139}
140
141pub fn error_block(err: &anyhow::Error) -> String {
145 error_block_with(colors_enabled(), err)
146}
147
148fn error_block_with(enabled: bool, err: &anyhow::Error) -> String {
150 let mut chain = err.chain();
151 let headline = chain
152 .next()
153 .map(|cause| cause.to_string())
154 .unwrap_or_else(|| "unknown error".to_string());
155 let mut lines = headline.lines();
156 let mut out = format!(
157 "{} {}",
158 paint(enabled, FAIL, GLYPH_FAIL),
159 lines.next().unwrap_or("")
160 );
161 for line in lines {
162 out.push_str("\n ");
163 out.push_str(line);
164 }
165 for cause in chain {
166 out.push_str(&format!(
167 "\n {} {cause}",
168 paint(enabled, DIM, "caused by:")
169 ));
170 }
171 out
172}
173
174pub fn human_duration(duration: Duration) -> String {
176 let secs = duration.as_secs_f64();
177 if secs >= 86_400.0 {
178 format!("{:.1} days", secs / 86_400.0)
179 } else if secs >= 3_600.0 {
180 format!("{:.1} h", secs / 3_600.0)
181 } else if secs >= 60.0 {
182 format!("{:.1} min", secs / 60.0)
183 } else {
184 format!("{} s", duration.as_secs())
185 }
186}
187
188fn exe_candidates(program: &str) -> Vec<String> {
191 let mut names = vec![program.to_string()];
192 if cfg!(windows) {
193 names.push(format!("{program}.exe"));
194 }
195 names
196}
197
198pub fn on_path(program: &str) -> bool {
200 let names = exe_candidates(program);
201 let Some(path) = std::env::var_os("PATH") else {
202 return false;
203 };
204 std::env::split_paths(&path).any(|dir| names.iter().any(|name| dir.join(name).is_file()))
205}
206
207pub fn which_path(program: &str) -> Option<PathBuf> {
212 let names = exe_candidates(program);
213 if let Some(path) = std::env::var_os("PATH") {
214 for dir in std::env::split_paths(&path) {
215 for name in &names {
216 let candidate = dir.join(name);
217 if candidate.is_file() {
218 return Some(candidate);
219 }
220 }
221 }
222 }
223 let cargo_bin = crate::paths::cargo_home().ok()?.join("bin");
224 names
225 .iter()
226 .map(|name| cargo_bin.join(name))
227 .find(|candidate| candidate.is_file())
228}
229
230pub fn which(program: &str) -> bool {
232 which_path(program).is_some()
233}
234
235pub fn force_remove_dir(path: &Path) -> std::io::Result<()> {
239 let first = match std::fs::remove_dir_all(path) {
240 Ok(()) => return Ok(()),
241 Err(err) => err,
242 };
243 for entry in WalkDir::new(path) {
244 let Ok(entry) = entry else { continue };
245 if entry.file_type().is_symlink() {
248 continue;
249 }
250 let Ok(meta) = fs::symlink_metadata(entry.path()) else {
251 continue;
252 };
253 let mut perms = meta.permissions();
254 if perms.readonly() {
255 perms.set_readonly(false);
256 let _ = fs::set_permissions(entry.path(), perms);
257 }
258 }
259 fs::remove_dir_all(path).map_err(|_| first)
260}
261
262#[cfg(unix)]
267pub fn euid() -> u32 {
268 unsafe { libc::geteuid() }
270}
271
272#[cfg(not(unix))]
273pub fn euid() -> u32 {
274 0
275}
276
277pub fn owned_by_us(meta: &fs::Metadata) -> bool {
279 #[cfg(unix)]
280 {
281 use std::os::unix::fs::MetadataExt;
282 meta.uid() == euid()
283 }
284 #[cfg(not(unix))]
285 {
286 let _ = meta;
287 true
288 }
289}
290
291pub fn validate_component(value: &str, what: &str) -> Result<()> {
295 if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0', ':']) {
296 anyhow::bail!("invalid {what} {value:?}: expected one plain path component");
297 }
298 Ok(())
299}
300
301pub fn validate_rel(value: &str, what: &str) -> Result<PathBuf> {
305 if value.is_empty() || value.contains('\0') || value.ends_with(['/', '\\']) {
306 anyhow::bail!("invalid {what} {value:?}: empty or trailing separator");
307 }
308 let normalized = value.replace('\\', "/");
309 let mut out = PathBuf::new();
310 for component in Path::new(&normalized).components() {
311 match component {
312 Component::Normal(part) => {
313 let part = part.to_string_lossy();
314 if part.contains(['\0', ':']) {
315 anyhow::bail!("invalid {what} {value:?}: forbidden character");
316 }
317 out.push(&*part);
318 }
319 _ => anyhow::bail!("invalid {what} {value:?}: only relative components are allowed"),
320 }
321 }
322 if out.as_os_str().is_empty() {
323 anyhow::bail!("invalid {what} {value:?}: empty path");
324 }
325 Ok(out)
326}
327
328pub fn create_private_dir_all(path: &Path) -> Result<()> {
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
334 fs::DirBuilder::new()
335 .recursive(true)
336 .mode(0o700)
337 .create(path)
338 .with_context(|| format!("creating private directory {}", path.display()))?;
339 let meta =
340 fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
341 if !meta.is_dir() || meta.file_type().is_symlink() {
342 anyhow::bail!("{} is not a real directory", path.display());
343 }
344 if !owned_by_us(&meta) {
345 anyhow::bail!(
346 "{} is owned by uid {} — refusing to use a cache directory not owned by uid {}",
347 path.display(),
348 std::os::unix::fs::MetadataExt::uid(&meta),
349 euid()
350 );
351 }
352 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
353 .with_context(|| format!("securing {}", path.display()))?;
354 }
355 #[cfg(not(unix))]
356 {
357 fs::create_dir_all(path)
358 .with_context(|| format!("creating private directory {}", path.display()))?;
359 }
360 Ok(())
361}
362
363pub fn ensure_private_dir(path: &Path) -> Result<()> {
365 let meta =
366 fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
367 if !meta.is_dir() || meta.file_type().is_symlink() {
368 anyhow::bail!("{} is not a real directory", path.display());
369 }
370 if !owned_by_us(&meta) {
371 anyhow::bail!(
372 "{} is owned by another user — refusing a shared/untrusted cache",
373 path.display()
374 );
375 }
376 #[cfg(unix)]
377 {
378 use std::os::unix::fs::{MetadataExt, PermissionsExt};
379 if meta.mode() & 0o077 != 0 {
380 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
381 .with_context(|| format!("securing {}", path.display()))?;
382 }
383 }
384 Ok(())
385}
386
387pub fn check_regular_chain(base: &Path, rel: &Path) -> Result<()> {
391 let base_meta =
392 fs::symlink_metadata(base).with_context(|| format!("inspecting {}", base.display()))?;
393 if !base_meta.is_dir() || base_meta.file_type().is_symlink() {
394 anyhow::bail!("{} is not a real directory", base.display());
395 }
396 let mut current = base.to_path_buf();
397 let components: Vec<_> = rel.components().collect();
398 for (index, component) in components.iter().enumerate() {
399 let Component::Normal(part) = component else {
400 anyhow::bail!("refusing non-normal path component in {}", rel.display());
401 };
402 current.push(part);
403 let meta = fs::symlink_metadata(¤t)
404 .with_context(|| format!("inspecting {}", current.display()))?;
405 if meta.file_type().is_symlink() {
406 anyhow::bail!("symlink in store path: {}", current.display());
407 }
408 if index + 1 == components.len() {
409 if !meta.is_file() {
410 anyhow::bail!("not a regular file: {}", current.display());
411 }
412 } else if !meta.is_dir() {
413 anyhow::bail!("not a directory: {}", current.display());
414 }
415 }
416 Ok(())
417}
418
419pub fn create_dir_chain_nofollow(base: &Path, rel: &Path) -> Result<()> {
423 let base_meta =
424 fs::symlink_metadata(base).with_context(|| format!("inspecting {}", base.display()))?;
425 if !base_meta.is_dir() || base_meta.file_type().is_symlink() {
426 anyhow::bail!("{} is not a real directory", base.display());
427 }
428 let mut current = base.to_path_buf();
429 for component in rel.components() {
430 let Component::Normal(part) = component else {
431 anyhow::bail!("refusing non-normal path component in {}", rel.display());
432 };
433 current.push(part);
434 match fs::symlink_metadata(¤t) {
435 Ok(meta) => {
436 if meta.file_type().is_symlink() {
437 anyhow::bail!("symlink in destination path: {}", current.display());
438 }
439 if !meta.is_dir() {
440 anyhow::bail!("not a directory: {}", current.display());
441 }
442 }
443 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
444 #[cfg(unix)]
445 {
446 use std::os::unix::fs::DirBuilderExt;
447 fs::DirBuilder::new()
448 .mode(0o700)
449 .create(¤t)
450 .with_context(|| format!("creating {}", current.display()))?;
451 }
452 #[cfg(not(unix))]
453 fs::create_dir(¤t)
454 .with_context(|| format!("creating {}", current.display()))?;
455 }
456 Err(err) => {
457 return Err(err).with_context(|| format!("inspecting {}", current.display()));
458 }
459 }
460 }
461 Ok(())
462}
463
464pub fn sha256_file(path: &Path) -> std::io::Result<String> {
467 let mut options = OpenOptions::new();
468 options.read(true);
469 #[cfg(unix)]
470 {
471 use std::os::unix::fs::OpenOptionsExt;
472 options.custom_flags(libc::O_NOFOLLOW);
473 }
474 let mut file = options.open(path)?;
475 let mut hasher = Sha256::new();
476 let mut buf = [0u8; 64 * 1024];
477 loop {
478 let n = file.read(&mut buf)?;
479 if n == 0 {
480 break;
481 }
482 hasher.update(&buf[..n]);
483 }
484 Ok(format!("{:x}", hasher.finalize()))
485}
486
487pub fn read_small_text_follow(path: &Path, max_bytes: u64) -> std::io::Result<String> {
490 let meta = fs::metadata(path)?;
491 if !meta.is_file() || meta.len() > max_bytes {
492 return Err(std::io::Error::new(
493 std::io::ErrorKind::InvalidInput,
494 "not a small regular file",
495 ));
496 }
497 fs::read_to_string(path)
498}
499
500pub fn read_small_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
504 let mut options = OpenOptions::new();
505 options.read(true);
506 #[cfg(unix)]
507 {
508 use std::os::unix::fs::OpenOptionsExt;
509 options.custom_flags(libc::O_NOFOLLOW);
510 }
511 let mut file = options.open(path)?;
512 let meta = file.metadata()?;
513 if !meta.is_file() || meta.len() > max_bytes {
514 return Err(std::io::Error::new(
515 std::io::ErrorKind::InvalidInput,
516 "not a small regular file",
517 ));
518 }
519 let mut text = String::new();
520 file.read_to_string(&mut text)?;
521 Ok(text)
522}
523
524pub fn copy_file_hashed(src: &Path, dst: &Path) -> Result<(u64, String)> {
528 let meta = fs::symlink_metadata(src)
529 .with_context(|| format!("inspecting source {}", src.display()))?;
530 if !meta.file_type().is_file() {
531 anyhow::bail!(
532 "{} is not a regular file (symlink/special file refused)",
533 src.display()
534 );
535 }
536 if !owned_by_us(&meta) {
537 anyhow::bail!(
538 "{} is owned by another user — refusing to copy it into the vault",
539 src.display()
540 );
541 }
542 let mut options = OpenOptions::new();
543 options.read(true);
544 #[cfg(unix)]
545 {
546 use std::os::unix::fs::OpenOptionsExt;
547 options.custom_flags(libc::O_NOFOLLOW);
548 }
549 let mut input = options
550 .open(src)
551 .with_context(|| format!("opening source {}", src.display()))?;
552 if !input.metadata()?.is_file() {
553 anyhow::bail!("{} changed type while opening", src.display());
554 }
555 let mut output = OpenOptions::new()
556 .write(true)
557 .create_new(true)
558 .open(dst)
559 .with_context(|| format!("creating {}", dst.display()))?;
560 let mut hasher = Sha256::new();
561 let mut buf = [0u8; 64 * 1024];
562 let mut size = 0u64;
563 loop {
564 let n = input.read(&mut buf)?;
565 if n == 0 {
566 break;
567 }
568 hasher.update(&buf[..n]);
569 output.write_all(&buf[..n])?;
570 size += n as u64;
571 }
572 output.flush()?;
573 Ok((size, format!("{:x}", hasher.finalize())))
574}
575
576pub fn copy_file_verified(
579 src: &Path,
580 dst: &Path,
581 expected_size: u64,
582 expected_hash: &str,
583) -> Result<(u64, String)> {
584 if expected_hash.is_empty() {
585 anyhow::bail!(
586 "{} has no recorded SHA-256 (re-harvest the vault before restoring)",
587 src.display()
588 );
589 }
590 let (size, hash) = copy_file_hashed(src, dst)?;
591 if size != expected_size || hash != expected_hash {
592 let _ = fs::remove_file(dst);
593 anyhow::bail!(
594 "integrity check failed for {} (recorded {} bytes/{}, found {} bytes/{})",
595 src.display(),
596 expected_size,
597 &expected_hash[..expected_hash.len().min(12)],
598 size,
599 &hash[..hash.len().min(12)]
600 );
601 }
602 Ok((size, hash))
603}
604
605pub fn tool_path(program: &str) -> Result<PathBuf> {
608 let path = which_path(program)
609 .with_context(|| format!("{program} not found on PATH or in the cargo bin directory"))?;
610 if path.is_absolute() {
611 Ok(path)
612 } else {
613 Ok(fs::canonicalize(&path).unwrap_or(path))
614 }
615}
616
617pub fn command_for(program: &str) -> Result<Command> {
620 let path = if program.contains(['/', '\\']) {
621 PathBuf::from(program)
622 } else {
623 tool_path(program)?
624 };
625 let mut command = Command::new(path);
626 command.env("NoDefaultCurrentDirectoryInExePath", "1");
629 Ok(command)
630}
631
632pub struct FileLock {
634 file: File,
635}
636
637impl FileLock {
638 pub fn acquire(path: &Path) -> Result<Self> {
639 if let Some(parent) = path.parent() {
640 create_private_dir_all(parent)?;
641 }
642 if fs::symlink_metadata(path)
643 .map(|meta| meta.file_type().is_symlink())
644 .unwrap_or(false)
645 {
646 anyhow::bail!("refusing symlinked lock file {}", path.display());
647 }
648 let file = OpenOptions::new()
649 .read(true)
650 .write(true)
651 .create(true)
652 .open(path)
653 .with_context(|| format!("opening lock {}", path.display()))?;
654 file.lock()
655 .with_context(|| format!("locking {}", path.display()))?;
656 Ok(Self { file })
657 }
658
659 pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
660 if let Some(parent) = path.parent() {
661 create_private_dir_all(parent)?;
662 }
663 if fs::symlink_metadata(path)
664 .map(|meta| meta.file_type().is_symlink())
665 .unwrap_or(false)
666 {
667 anyhow::bail!("refusing symlinked lock file {}", path.display());
668 }
669 let file = OpenOptions::new()
670 .read(true)
671 .write(true)
672 .create(true)
673 .open(path)
674 .with_context(|| format!("opening lock {}", path.display()))?;
675 match file.try_lock() {
676 Ok(()) => Ok(Some(Self { file })),
677 Err(_) => Ok(None),
679 }
680 }
681}
682
683impl Drop for FileLock {
684 fn drop(&mut self) {
685 let _ = self.file.unlock();
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use anyhow::Context;
693
694 #[test]
695 fn parses_time_units() {
696 assert_eq!(
697 parse_duration("30d").unwrap(),
698 Duration::from_secs(30 * 86_400)
699 );
700 assert_eq!(
701 parse_duration("2w").unwrap(),
702 Duration::from_secs(2 * 604_800)
703 );
704 assert_eq!(
705 parse_duration("12h").unwrap(),
706 Duration::from_secs(12 * 3_600)
707 );
708 assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
709 assert!(parse_duration("abc").is_err());
710 assert!(parse_duration("5x").is_err());
711 }
712
713 #[test]
714 fn human_sizes() {
715 assert_eq!(human_bytes(0), "0 B");
716 assert_eq!(human_bytes(1023), "1023 B");
717 assert_eq!(human_bytes(1024), "1.0 KiB");
718 assert_eq!(human_bytes(3 * 1_048_576 + 200_000), "3.2 MiB");
719 }
720
721 #[test]
722 fn exe_candidates_is_platform_aware() {
723 let names = exe_candidates("kache");
724 assert!(names.contains(&"kache".to_string()));
725 #[cfg(windows)]
726 assert!(names.contains(&"kache.exe".to_string()));
727 #[cfg(not(windows))]
728 assert_eq!(names.len(), 1);
729 }
730
731 #[test]
732 fn paint_is_pure() {
733 assert_eq!(paint(false, "38;2;146;210;147", "hi"), "hi");
734 assert_eq!(paint(true, "1", "hi"), "\x1b[1mhi\x1b[0m");
735 }
736
737 #[test]
738 fn manifest_paths_stay_relative_and_normal() {
739 assert!(validate_rel("deps/libx-abc.rlib", "path").is_ok());
740 assert!(validate_rel(".fingerprint/x-abc/lib-x", "path").is_ok());
741 assert!(validate_rel("/etc/passwd", "path").is_err());
742 assert!(validate_rel("../escape", "path").is_err());
743 assert!(validate_rel("deps/../../escape", "path").is_err());
744 assert!(validate_rel("C:\\Windows\\x", "path").is_err());
745 assert!(validate_rel("deps\\x", "path").is_ok());
746 assert!(validate_rel("", "path").is_err());
747 assert!(validate_component("debug", "profile").is_ok());
748 assert!(validate_component("..", "profile").is_err());
749 assert!(validate_component("a/b", "profile").is_err());
750 assert!(validate_component("a\\b", "profile").is_err());
751 }
752
753 #[test]
754 fn copies_are_hashed_and_verified() -> Result<()> {
755 let dir = tempfile::tempdir()?;
756 let src = dir.path().join("src");
757 let dst = dir.path().join("dst");
758 fs::write(&src, b"hello")?;
759 let (size, hash) = copy_file_hashed(&src, &dst)?;
760 assert_eq!(size, 5);
761 assert_eq!(hash, sha256_file(&src)?);
762 assert_eq!(fs::read(&dst)?, b"hello");
763 let bad = dir.path().join("bad");
764 assert!(copy_file_verified(&src, &bad, 5, "00").is_err());
765 assert!(
766 !bad.exists(),
767 "a failed verify must not leave the destination"
768 );
769 Ok(())
770 }
771
772 #[test]
773 fn duration_overflow_is_rejected() {
774 assert!(parse_duration("18446744073709551615d").is_err());
775 assert!(parse_duration("18446744073709551615s").is_ok());
776 }
777
778 #[test]
779 fn error_blocks_render_a_glyph_indent_and_causes() {
780 let plain =
781 anyhow::anyhow!("nothing selected — pass one selector:\n--stale older toolchains");
782 let block = error_block_with(false, &plain);
783 assert_eq!(
784 block,
785 "✕ nothing selected — pass one selector:\n --stale older toolchains"
786 );
787 let colored = error_block_with(true, &plain);
788 assert!(colored.contains("\u{1b}[38;2;224;108;117m✕\u{1b}[0m"));
789
790 let chained = Err::<(), _>(anyhow::anyhow!("inner detail")).context("outer headline");
791 let block = error_block_with(false, &chained.unwrap_err());
792 assert_eq!(block, "✕ outer headline\n caused by: inner detail");
793 }
794}