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};
12#[cfg(unix)]
13use std::os::unix::io::AsRawFd;
14use walkdir::WalkDir;
15
16const 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 {
30 static ENABLED: OnceLock<bool> = OnceLock::new();
31 *ENABLED.get_or_init(|| {
32 if std::env::var_os("NO_COLOR").is_some() {
33 return false;
34 }
35 if let Some(force) = std::env::var_os("CLICOLOR_FORCE") {
36 if force != "0" && !force.is_empty() {
37 return true;
38 }
39 }
40 std::io::stdout().is_terminal()
41 })
42}
43
44fn paint(enabled: bool, code: &str, text: &str) -> String {
45 if enabled {
46 format!("\x1b[{code}m{text}\x1b[0m")
47 } else {
48 text.to_string()
49 }
50}
51
52pub fn green(text: &str) -> String {
54 paint(colors_enabled(), GREEN, text)
55}
56
57pub fn heading(text: &str) -> String {
59 paint(colors_enabled(), &format!("1;{GREEN}"), text)
60}
61
62pub fn warn(text: &str) -> String {
64 paint(colors_enabled(), WARN, text)
65}
66
67pub fn fail(text: &str) -> String {
69 paint(colors_enabled(), FAIL, text)
70}
71
72pub fn info(text: &str) -> String {
74 paint(colors_enabled(), INFO, text)
75}
76
77pub fn dim(text: &str) -> String {
79 paint(colors_enabled(), DIM, text)
80}
81
82pub const GLYPH_PASS: &str = "✓";
88pub const GLYPH_WARN: &str = "▲";
90pub const GLYPH_FAIL: &str = "✕";
92pub const GLYPH_INFO: &str = "◆";
94
95pub fn human_bytes(bytes: u64) -> String {
97 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
98 let mut value = bytes as f64;
99 let mut unit = 0;
100 while value >= 1024.0 && unit < UNITS.len() - 1 {
101 value /= 1024.0;
102 unit += 1;
103 }
104 if unit == 0 {
105 format!("{bytes} B")
106 } else {
107 format!("{value:.1} {}", UNITS[unit])
108 }
109}
110
111pub fn parse_duration(text: &str) -> Result<Duration> {
113 let text = text.trim();
114 let split = text
115 .find(|c: char| c.is_ascii_alphabetic())
116 .unwrap_or(text.len());
117 let (num, unit) = text.split_at(split);
118 let n: u64 = num
121 .parse()
122 .map_err(|_| anyhow::anyhow!("invalid duration {text:?} (expected e.g. 30d, 2w, 12h)"))?;
123 let secs = match unit.to_ascii_lowercase().as_str() {
124 "" | "s" => Some(n),
125 "m" => n.checked_mul(60),
126 "h" => n.checked_mul(3_600),
127 "d" => n.checked_mul(86_400),
128 "w" => n.checked_mul(604_800),
129 other => anyhow::bail!("unknown time unit {other:?} in {text:?} (use s, m, h, d or w)"),
130 }
131 .with_context(|| format!("duration {text:?} is too large"))?;
132 Ok(Duration::from_secs(secs))
133}
134
135pub fn human_age(at: SystemTime) -> String {
137 match SystemTime::now().duration_since(at) {
138 Ok(age) => human_duration(age),
139 Err(_) => "in the future".to_string(),
140 }
141}
142
143pub fn error_block(err: &anyhow::Error) -> String {
147 error_block_with(colors_enabled(), err)
148}
149
150fn error_block_with(enabled: bool, err: &anyhow::Error) -> String {
152 let mut chain = err.chain();
153 let headline = chain
154 .next()
155 .map(|cause| cause.to_string())
156 .unwrap_or_else(|| "unknown error".to_string());
157 let mut lines = headline.lines();
158 let mut out = format!(
159 "{} {}",
160 paint(enabled, FAIL, GLYPH_FAIL),
161 lines.next().unwrap_or("")
162 );
163 for line in lines {
164 out.push_str("\n ");
165 out.push_str(line);
166 }
167 for cause in chain {
168 out.push_str(&format!(
169 "\n {} {cause}",
170 paint(enabled, DIM, "caused by:")
171 ));
172 }
173 out
174}
175
176pub fn human_duration(duration: Duration) -> String {
178 let secs = duration.as_secs_f64();
179 if secs >= 86_400.0 {
180 format!("{:.1} days", secs / 86_400.0)
181 } else if secs >= 3_600.0 {
182 format!("{:.1} h", secs / 3_600.0)
183 } else if secs >= 60.0 {
184 format!("{:.1} min", secs / 60.0)
185 } else {
186 format!("{} s", duration.as_secs())
187 }
188}
189
190fn exe_candidates(program: &str) -> Vec<String> {
193 let mut names = vec![program.to_string()];
194 if cfg!(windows) {
195 names.push(format!("{program}.exe"));
196 }
197 names
198}
199
200pub fn on_path(program: &str) -> bool {
202 let names = exe_candidates(program);
203 let Some(path) = std::env::var_os("PATH") else {
204 return false;
205 };
206 std::env::split_paths(&path).any(|dir| names.iter().any(|name| dir.join(name).is_file()))
207}
208
209pub fn which_path(program: &str) -> Option<PathBuf> {
214 let names = exe_candidates(program);
215 if let Some(path) = std::env::var_os("PATH") {
216 for dir in std::env::split_paths(&path) {
217 if dir.as_os_str().is_empty() || !dir.is_absolute() {
220 continue;
221 }
222 for name in &names {
223 let candidate = dir.join(name);
224 if candidate.is_file() {
225 return Some(candidate);
226 }
227 }
228 }
229 }
230 let cargo_bin = crate::paths::cargo_home().ok()?.join("bin");
231 if !cargo_bin.is_absolute() {
232 return None;
233 }
234 names
235 .iter()
236 .map(|name| cargo_bin.join(name))
237 .find(|candidate| candidate.is_file())
238}
239
240pub fn which(program: &str) -> bool {
242 which_path(program).is_some()
243}
244
245pub fn force_remove_dir(path: &Path) -> std::io::Result<()> {
249 let first = match std::fs::remove_dir_all(path) {
250 Ok(()) => return Ok(()),
251 Err(err) => err,
252 };
253 for entry in WalkDir::new(path) {
254 let Ok(entry) = entry else { continue };
255 if entry.file_type().is_symlink() {
258 continue;
259 }
260 let Ok(meta) = fs::symlink_metadata(entry.path()) else {
261 continue;
262 };
263 let mut perms = meta.permissions();
264 if perms.readonly() {
265 perms.set_readonly(false);
266 let _ = fs::set_permissions(entry.path(), perms);
267 }
268 }
269 fs::remove_dir_all(path).map_err(|_| first)
270}
271
272#[cfg(unix)]
277pub fn euid() -> u32 {
278 unsafe { libc::geteuid() }
280}
281
282#[cfg(not(unix))]
283pub fn euid() -> u32 {
284 0
285}
286
287pub fn owned_by_us(meta: &fs::Metadata) -> bool {
289 #[cfg(unix)]
290 {
291 use std::os::unix::fs::MetadataExt;
292 meta.uid() == euid()
293 }
294 #[cfg(not(unix))]
295 {
296 let _ = meta;
297 true
298 }
299}
300
301pub fn is_windows_reserved(value: &str) -> bool {
304 let stem = value
305 .trim_end_matches([' ', '.'])
306 .split('.')
307 .next()
308 .unwrap_or("")
309 .trim()
310 .to_ascii_uppercase();
311 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
312 || (stem.len() == 4
313 && (stem.starts_with("COM") || stem.starts_with("LPT"))
314 && stem.as_bytes()[3].is_ascii_digit())
315}
316
317#[cfg(windows)]
322fn strip_verbatim(path: &str) -> String {
323 let path = path.replace('/', "\\");
324 if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
325 format!(r"\\{rest}")
326 } else if let Some(rest) = path.strip_prefix(r"\\?\") {
327 rest.to_string()
328 } else {
329 path
330 }
331}
332
333#[cfg(windows)]
334fn normalize_windows_path_text(path: &str) -> String {
335 strip_verbatim(path)
336 .trim_end_matches('\\')
337 .to_ascii_lowercase()
338}
339
340#[cfg(windows)]
341pub fn ensure_windows_user_path(path: &Path, what: &str) -> Result<()> {
342 let profile = std::env::var_os("USERPROFILE")
343 .with_context(|| format!("{what}: USERPROFILE is not set"))?;
344 let profile = fs::canonicalize(&profile).unwrap_or_else(|_| PathBuf::from(profile));
345 let profile_text = normalize_windows_path_text(&profile.to_string_lossy());
346
347 let full = if path.is_absolute() {
348 path.to_path_buf()
349 } else {
350 std::env::current_dir()
351 .context("resolving the current directory")?
352 .join(path)
353 };
354 for component in full.components() {
357 if matches!(component, std::path::Component::ParentDir) {
358 anyhow::bail!(
359 "{what} {} contains `..`; use a normalized path",
360 full.display()
361 );
362 }
363 }
364
365 let mut current = PathBuf::new();
368 for component in full.components() {
369 current.push(component.as_os_str());
370 if let Ok(meta) = fs::symlink_metadata(¤t) {
371 if is_reparse_point(&meta) {
372 anyhow::bail!(
373 "{what} path contains a reparse point: {}",
374 current.display()
375 );
376 }
377 }
378 }
379
380 let full_text = normalize_windows_path_text(&full.to_string_lossy());
381 let inside = |candidate: &str| {
382 candidate == profile_text || candidate.starts_with(&format!("{profile_text}\\"))
383 };
384 if !inside(&full_text) {
385 anyhow::bail!(
386 "{what} {} is outside %USERPROFILE% ({}); Windows shared caches are unsupported — use a per-user cache root",
387 full.display(),
388 profile.display()
389 );
390 }
391 let canonical = fs::canonicalize(&full).unwrap_or_else(|_| full.clone());
394 let canonical_text = normalize_windows_path_text(&canonical.to_string_lossy());
395 if !inside(&canonical_text) {
396 anyhow::bail!(
397 "{what} {} resolves outside %USERPROFILE% ({}); Windows shared caches are unsupported",
398 full.display(),
399 profile.display()
400 );
401 }
402 if full_text == profile_text || canonical_text == profile_text {
407 anyhow::bail!(
408 "{what} {} is %USERPROFILE% itself — choose a dedicated subdirectory",
409 full.display()
410 );
411 }
412 Ok(())
413}
414
415#[cfg(not(windows))]
416pub fn ensure_windows_user_path(path: &Path, what: &str) -> Result<()> {
417 let _ = (path, what);
418 Ok(())
419}
420
421pub fn is_reparse_point(meta: &fs::Metadata) -> bool {
424 #[cfg(windows)]
425 {
426 use std::os::windows::fs::MetadataExt;
427 meta.file_attributes() & 0x400 != 0
428 }
429 #[cfg(not(windows))]
430 {
431 let _ = meta;
432 false
433 }
434}
435
436pub fn validate_component(value: &str, what: &str) -> Result<()> {
441 if value.is_empty()
442 || value != value.trim_end_matches([' ', '.'])
443 || value.trim_end_matches([' ', '.']) == "."
444 || value.trim_end_matches([' ', '.']) == ".."
445 || value.contains(['/', '\\', '\0', ':'])
446 || is_windows_reserved(value)
447 {
448 anyhow::bail!("invalid {what} {value:?}: expected one plain path component");
449 }
450 Ok(())
451}
452
453pub fn validate_rel(value: &str, what: &str) -> Result<PathBuf> {
457 if value.is_empty() || value.contains('\0') || value.ends_with(['/', '\\']) {
458 anyhow::bail!("invalid {what} {value:?}: empty or trailing separator");
459 }
460 let normalized = value.replace('\\', "/");
461 let mut out = PathBuf::new();
462 for component in Path::new(&normalized).components() {
463 match component {
464 Component::Normal(part) => {
465 let part = part.to_string_lossy();
466 validate_component(&part, what)?;
467 out.push(&*part);
468 }
469 _ => anyhow::bail!("invalid {what} {value:?}: only relative components are allowed"),
470 }
471 }
472 if out.as_os_str().is_empty() {
473 anyhow::bail!("invalid {what} {value:?}: empty path");
474 }
475 Ok(out)
476}
477
478fn refuse_destructive_chmod(path: &Path) -> Result<()> {
482 let is_root = path.parent().is_none() || path == Path::new("/");
483 let is_home = crate::paths::home()
484 .map(|home| home == path)
485 .unwrap_or(false);
486 let is_temp = std::env::temp_dir() == path;
487 if is_root || is_home || is_temp {
488 anyhow::bail!(
489 "refusing to change permissions of protected directory {} — choose a dedicated cache subdirectory",
490 path.display()
491 );
492 }
493 Ok(())
494}
495
496pub fn create_private_dir_all(path: &Path) -> Result<()> {
499 refuse_destructive_chmod(path)?;
500 #[cfg(unix)]
501 {
502 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
503 fs::DirBuilder::new()
504 .recursive(true)
505 .mode(0o700)
506 .create(path)
507 .with_context(|| format!("creating private directory {}", path.display()))?;
508 let meta =
509 fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
510 if !meta.is_dir() || meta.file_type().is_symlink() || is_reparse_point(&meta) {
511 anyhow::bail!("{} is not a real directory", path.display());
512 }
513 if !owned_by_us(&meta) {
514 anyhow::bail!(
515 "{} is owned by uid {} — refusing to use a cache directory not owned by uid {}",
516 path.display(),
517 std::os::unix::fs::MetadataExt::uid(&meta),
518 euid()
519 );
520 }
521 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
522 .with_context(|| format!("securing {}", path.display()))?;
523 }
524 #[cfg(not(unix))]
525 {
526 fs::create_dir_all(path)
527 .with_context(|| format!("creating private directory {}", path.display()))?;
528 }
529 Ok(())
530}
531
532pub fn ensure_private_dir(path: &Path) -> Result<()> {
534 refuse_destructive_chmod(path)?;
535 let meta =
536 fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
537 if !meta.is_dir() || meta.file_type().is_symlink() || is_reparse_point(&meta) {
538 anyhow::bail!("{} is not a real directory", path.display());
539 }
540 if !owned_by_us(&meta) {
541 anyhow::bail!(
542 "{} is owned by another user — refusing a shared/untrusted cache",
543 path.display()
544 );
545 }
546 #[cfg(unix)]
547 {
548 use std::os::unix::fs::{MetadataExt, PermissionsExt};
549 if meta.mode() & 0o077 != 0 {
550 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
551 .with_context(|| format!("securing {}", path.display()))?;
552 }
553 }
554 Ok(())
555}
556
557pub fn check_regular_chain(base: &Path, rel: &Path) -> Result<()> {
561 let base_meta =
562 fs::symlink_metadata(base).with_context(|| format!("inspecting {}", base.display()))?;
563 if !base_meta.is_dir() || base_meta.file_type().is_symlink() || is_reparse_point(&base_meta) {
564 anyhow::bail!("{} is not a real directory", base.display());
565 }
566 let mut current = base.to_path_buf();
567 let components: Vec<_> = rel.components().collect();
568 for (index, component) in components.iter().enumerate() {
569 let Component::Normal(part) = component else {
570 anyhow::bail!("refusing non-normal path component in {}", rel.display());
571 };
572 current.push(part);
573 let meta = fs::symlink_metadata(¤t)
574 .with_context(|| format!("inspecting {}", current.display()))?;
575 if meta.file_type().is_symlink() || is_reparse_point(&meta) {
576 anyhow::bail!("symlink/reparse in store path: {}", current.display());
577 }
578 if index + 1 == components.len() {
579 if !meta.is_file() {
580 anyhow::bail!("not a regular file: {}", current.display());
581 }
582 } else if !meta.is_dir() {
583 anyhow::bail!("not a directory: {}", current.display());
584 }
585 }
586 Ok(())
587}
588
589pub fn create_dir_chain_nofollow(base: &Path, rel: &Path) -> Result<()> {
593 let base_meta =
594 fs::symlink_metadata(base).with_context(|| format!("inspecting {}", base.display()))?;
595 if !base_meta.is_dir() || base_meta.file_type().is_symlink() || is_reparse_point(&base_meta) {
596 anyhow::bail!("{} is not a real directory", base.display());
597 }
598 let mut current = base.to_path_buf();
599 for component in rel.components() {
600 let Component::Normal(part) = component else {
601 anyhow::bail!("refusing non-normal path component in {}", rel.display());
602 };
603 current.push(part);
604 match fs::symlink_metadata(¤t) {
605 Ok(meta) => {
606 if meta.file_type().is_symlink() || is_reparse_point(&meta) {
607 anyhow::bail!("symlink/reparse in destination path: {}", current.display());
608 }
609 if !meta.is_dir() {
610 anyhow::bail!("not a directory: {}", current.display());
611 }
612 }
613 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
614 #[cfg(unix)]
615 {
616 use std::os::unix::fs::DirBuilderExt;
617 fs::DirBuilder::new()
618 .mode(0o700)
619 .create(¤t)
620 .with_context(|| format!("creating {}", current.display()))?;
621 }
622 #[cfg(not(unix))]
623 fs::create_dir(¤t)
624 .with_context(|| format!("creating {}", current.display()))?;
625 }
626 Err(err) => {
627 return Err(err).with_context(|| format!("inspecting {}", current.display()));
628 }
629 }
630 }
631 Ok(())
632}
633
634#[cfg(unix)]
639pub(crate) struct AtDir {
640 file: File,
641}
642
643#[cfg(unix)]
644impl AtDir {
645 pub(crate) fn open(path: &Path) -> Result<Self> {
646 use std::ffi::CString;
647 use std::os::unix::ffi::OsStrExt;
648 use std::os::unix::io::FromRawFd;
649 let name = CString::new(path.as_os_str().as_bytes())
650 .with_context(|| format!("path contains NUL: {}", path.display()))?;
651 let fd = unsafe {
652 libc::open(
653 name.as_ptr(),
654 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
655 )
656 };
657 if fd < 0 {
658 return Err(std::io::Error::last_os_error())
659 .with_context(|| format!("opening directory {}", path.display()));
660 }
661 Ok(Self {
662 file: unsafe { File::from_raw_fd(fd) },
663 })
664 }
665
666 pub(crate) fn open_or_create_child(&self, name: &std::ffi::OsStr) -> Result<Self> {
667 use std::ffi::CString;
668 use std::os::unix::ffi::OsStrExt;
669 use std::os::unix::io::FromRawFd;
670 let cname = CString::new(name.as_bytes())
671 .with_context(|| format!("path component contains NUL: {:?}", name))?;
672 loop {
673 let fd = unsafe {
674 libc::openat(
675 self.file.as_raw_fd(),
676 cname.as_ptr(),
677 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
678 )
679 };
680 if fd >= 0 {
681 return Ok(Self {
682 file: unsafe { File::from_raw_fd(fd) },
683 });
684 }
685 let err = std::io::Error::last_os_error();
686 if err.kind() != std::io::ErrorKind::NotFound {
687 return Err(err).with_context(|| format!("opening child {:?}", name));
688 }
689 let made = unsafe { libc::mkdirat(self.file.as_raw_fd(), cname.as_ptr(), 0o700) };
690 if made != 0 {
691 let mkerr = std::io::Error::last_os_error();
692 if mkerr.kind() != std::io::ErrorKind::AlreadyExists {
694 return Err(mkerr).with_context(|| format!("creating child {:?}", name));
695 }
696 }
697 }
698 }
699
700 pub(crate) fn exists(&self, name: &std::ffi::OsStr) -> Result<bool> {
702 use std::ffi::CString;
703 use std::os::unix::ffi::OsStrExt;
704 use std::os::unix::io::FromRawFd;
705 let cname = CString::new(name.as_bytes())
706 .with_context(|| format!("path component contains NUL: {:?}", name))?;
707 #[cfg(target_os = "linux")]
708 let flags = libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC;
709 #[cfg(not(target_os = "linux"))]
710 let flags = libc::O_RDONLY | libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC;
711 let fd = unsafe { libc::openat(self.file.as_raw_fd(), cname.as_ptr(), flags) };
712 if fd < 0 {
713 let err = std::io::Error::last_os_error();
714 if err.kind() == std::io::ErrorKind::NotFound {
715 return Ok(false);
716 }
717 return Err(err).with_context(|| format!("inspecting child {:?}", name));
718 }
719 let file = unsafe { File::from_raw_fd(fd) };
720 let meta = file
721 .metadata()
722 .with_context(|| format!("inspecting child {:?}", name))?;
723 if meta.file_type().is_symlink() || is_reparse_point(&meta) {
724 anyhow::bail!("symlink/reparse in destination path: {:?}", name);
725 }
726 Ok(true)
727 }
728
729 pub(crate) fn link_path(&self, source: &Path, dst_name: &std::ffi::OsStr) -> Result<()> {
733 use std::ffi::CString;
734 use std::os::unix::ffi::OsStrExt;
735 let src = CString::new(source.as_os_str().as_bytes())
736 .with_context(|| format!("source path contains NUL: {}", source.display()))?;
737 let dst = CString::new(dst_name.as_bytes())
738 .with_context(|| format!("destination name contains NUL: {:?}", dst_name))?;
739 let rc = unsafe {
740 libc::linkat(
741 libc::AT_FDCWD,
742 src.as_ptr(),
743 self.file.as_raw_fd(),
744 dst.as_ptr(),
745 libc::AT_SYMLINK_FOLLOW,
746 )
747 };
748 if rc != 0 {
749 return Err(std::io::Error::last_os_error())
750 .with_context(|| format!("linking {:?}", dst_name));
751 }
752 Ok(())
753 }
754
755 pub(crate) fn create_excl(&self, name: &std::ffi::OsStr, mode: u32) -> Result<File> {
756 use std::ffi::CString;
757 use std::os::unix::ffi::OsStrExt;
758 use std::os::unix::io::FromRawFd;
759 let cname = CString::new(name.as_bytes())
760 .with_context(|| format!("temporary name contains NUL: {:?}", name))?;
761 let fd = unsafe {
762 libc::openat(
763 self.file.as_raw_fd(),
764 cname.as_ptr(),
765 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
766 mode as libc::c_uint,
767 )
768 };
769 if fd < 0 {
770 return Err(std::io::Error::last_os_error())
771 .with_context(|| format!("creating temporary {:?}", name));
772 }
773 Ok(unsafe { File::from_raw_fd(fd) })
774 }
775
776 pub(crate) fn unlink(&self, name: &std::ffi::OsStr) -> std::io::Result<()> {
777 use std::ffi::CString;
778 use std::os::unix::ffi::OsStrExt;
779 let cname = CString::new(name.as_bytes())
780 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "NUL in name"))?;
781 let rc = unsafe { libc::unlinkat(self.file.as_raw_fd(), cname.as_ptr(), 0) };
782 if rc != 0 {
783 return Err(std::io::Error::last_os_error());
784 }
785 Ok(())
786 }
787}
788
789#[cfg(unix)]
790pub(crate) fn open_dir_chain_at(base: &Path, rel: &Path) -> Result<AtDir> {
791 let mut current = AtDir::open(base)?;
792 for component in rel.components() {
793 let Component::Normal(name) = component else {
794 anyhow::bail!(
795 "refusing non-normal destination component in {}",
796 rel.display()
797 );
798 };
799 current = current.open_or_create_child(name)?;
800 }
801 Ok(current)
802}
803
804#[cfg(unix)]
805pub(crate) fn open_file_nofollow(path: &Path) -> Result<File> {
806 use std::ffi::CString;
807 use std::os::unix::ffi::OsStrExt;
808 use std::os::unix::io::FromRawFd;
809 let name = CString::new(path.as_os_str().as_bytes())
810 .with_context(|| format!("path contains NUL: {}", path.display()))?;
811 let fd = unsafe {
812 libc::open(
813 name.as_ptr(),
814 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
815 )
816 };
817 if fd < 0 {
818 return Err(std::io::Error::last_os_error())
819 .with_context(|| format!("opening {}", path.display()));
820 }
821 Ok(unsafe { File::from_raw_fd(fd) })
822}
823
824#[cfg(unix)]
825pub(crate) fn set_stamp_fd(file: &File, stamp: u64) -> std::io::Result<()> {
826 let times = [
827 libc::timespec {
828 tv_sec: stamp as libc::time_t,
829 tv_nsec: 0,
830 },
831 libc::timespec {
832 tv_sec: stamp as libc::time_t,
833 tv_nsec: 0,
834 },
835 ];
836 let rc = unsafe { libc::futimens(file.as_raw_fd(), times.as_ptr()) };
837 if rc != 0 {
838 return Err(std::io::Error::last_os_error());
839 }
840 Ok(())
841}
842
843#[cfg(unix)]
844pub(crate) fn sha256_file_fd(file: &mut File) -> std::io::Result<String> {
845 use std::io::{Seek, SeekFrom};
846 file.seek(SeekFrom::Start(0))?;
847 let mut hasher = Sha256::new();
848 let mut buf = [0u8; 64 * 1024];
849 loop {
850 let n = file.read(&mut buf)?;
851 if n == 0 {
852 break;
853 }
854 hasher.update(&buf[..n]);
855 }
856 Ok(format!("{:x}", hasher.finalize()))
857}
858
859#[cfg(unix)]
860pub(crate) fn copy_fd_verified(
861 src: &mut File,
862 dst: &mut File,
863 expected_size: u64,
864 expected_hash: &str,
865) -> Result<(u64, String)> {
866 use std::io::{Seek, SeekFrom};
867 src.seek(SeekFrom::Start(0))?;
868 let mut hasher = Sha256::new();
869 let mut buf = [0u8; 64 * 1024];
870 let mut size = 0u64;
871 loop {
872 let n = src.read(&mut buf)?;
873 if n == 0 {
874 break;
875 }
876 hasher.update(&buf[..n]);
877 dst.write_all(&buf[..n])?;
878 size += n as u64;
879 }
880 dst.flush()?;
881 let hash = format!("{:x}", hasher.finalize());
882 if size != expected_size || hash != expected_hash {
883 anyhow::bail!(
884 "integrity check failed (recorded {expected_size} bytes/{}, found {size} bytes/{})",
885 &expected_hash[..expected_hash.len().min(12)],
886 &hash[..hash.len().min(12)]
887 );
888 }
889 Ok((size, hash))
890}
891
892pub fn sha256_file(path: &Path) -> std::io::Result<String> {
895 let mut options = OpenOptions::new();
896 options.read(true);
897 #[cfg(unix)]
898 {
899 use std::os::unix::fs::OpenOptionsExt;
900 options.custom_flags(libc::O_NOFOLLOW);
901 }
902 let mut file = options.open(path)?;
903 if is_reparse_point(&file.metadata()?) {
904 return Err(std::io::Error::new(
905 std::io::ErrorKind::InvalidInput,
906 "reparse point refused",
907 ));
908 }
909 let mut hasher = Sha256::new();
910 let mut buf = [0u8; 64 * 1024];
911 loop {
912 let n = file.read(&mut buf)?;
913 if n == 0 {
914 break;
915 }
916 hasher.update(&buf[..n]);
917 }
918 Ok(format!("{:x}", hasher.finalize()))
919}
920
921pub fn read_small_text_follow(path: &Path, max_bytes: u64) -> std::io::Result<String> {
924 let meta = fs::metadata(path)?;
925 if !meta.is_file() || meta.len() > max_bytes {
926 return Err(std::io::Error::new(
927 std::io::ErrorKind::InvalidInput,
928 "not a small regular file",
929 ));
930 }
931 fs::read_to_string(path)
932}
933
934pub fn read_small_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
938 let mut options = OpenOptions::new();
939 options.read(true);
940 #[cfg(unix)]
941 {
942 use std::os::unix::fs::OpenOptionsExt;
943 options.custom_flags(libc::O_NOFOLLOW);
944 }
945 let mut file = options.open(path)?;
946 let meta = file.metadata()?;
947 if !meta.is_file() || is_reparse_point(&meta) || meta.len() > max_bytes {
948 return Err(std::io::Error::new(
949 std::io::ErrorKind::InvalidInput,
950 "not a small regular file",
951 ));
952 }
953 let mut text = String::new();
954 file.read_to_string(&mut text)?;
955 Ok(text)
956}
957
958pub fn read_trusted_config(path: &Path, max_bytes: u64) -> std::io::Result<String> {
964 let mut options = OpenOptions::new();
965 options.read(true);
966 #[cfg(unix)]
967 {
968 use std::os::unix::fs::OpenOptionsExt;
969 options.custom_flags(libc::O_NOFOLLOW);
970 }
971 let mut file = options.open(path)?;
972 let meta = file.metadata()?;
973 if !meta.is_file() || is_reparse_point(&meta) || meta.len() > max_bytes {
974 return Err(std::io::Error::new(
975 std::io::ErrorKind::InvalidInput,
976 "not a small regular config file",
977 ));
978 }
979 if !owned_by_us(&meta) {
980 return Err(std::io::Error::new(
981 std::io::ErrorKind::PermissionDenied,
982 "config file is owned by another user",
983 ));
984 }
985 #[cfg(unix)]
986 {
987 use std::os::unix::fs::MetadataExt;
988 if meta.mode() & 0o022 != 0 {
989 return Err(std::io::Error::new(
990 std::io::ErrorKind::PermissionDenied,
991 "config file is group/world-writable",
992 ));
993 }
994 }
995 let mut text = String::new();
996 file.read_to_string(&mut text)?;
997 Ok(text)
998}
999
1000pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> Result<()> {
1005 let parent = path
1006 .parent()
1007 .with_context(|| format!("{} has no parent directory", path.display()))?;
1008 create_private_dir_all(parent)?;
1009 let base = path
1010 .file_name()
1011 .map(|name| name.to_string_lossy().into_owned())
1012 .unwrap_or_else(|| "config".to_string());
1013 let mut last_error = None;
1014 for attempt in 0..100u32 {
1015 let tmp = parent.join(format!(".{base}.tmp-{}-{attempt}", std::process::id()));
1016 let mut options = OpenOptions::new();
1017 options.write(true).create_new(true);
1018 #[cfg(unix)]
1019 {
1020 use std::os::unix::fs::OpenOptionsExt;
1021 options.mode(0o600);
1022 }
1023 let mut file = match options.open(&tmp) {
1024 Ok(file) => file,
1025 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1026 Err(err) => {
1027 return Err(err).with_context(|| format!("creating {}", tmp.display()));
1028 }
1029 };
1030 if let Err(err) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1031 let _ = fs::remove_file(&tmp);
1032 return Err(err).with_context(|| format!("writing {}", tmp.display()));
1033 }
1034 drop(file);
1035 #[cfg(unix)]
1036 {
1037 use std::os::unix::fs::PermissionsExt;
1038 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))
1039 .with_context(|| format!("securing {}", tmp.display()))?;
1040 }
1041 match fs::rename(&tmp, path) {
1042 Ok(()) => return Ok(()),
1043 #[cfg(windows)]
1044 Err(err)
1045 if matches!(
1046 err.kind(),
1047 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
1048 ) =>
1049 {
1050 let _ = fs::remove_file(path);
1054 if let Err(err2) = fs::rename(&tmp, path) {
1055 let _ = fs::remove_file(&tmp);
1056 return Err(err2).with_context(|| format!("installing {}", path.display()));
1057 }
1058 return Ok(());
1059 }
1060 Err(err) => {
1061 last_error = Some(err);
1062 let _ = fs::remove_file(&tmp);
1063 if attempt < 99 {
1064 continue;
1065 }
1066 }
1067 }
1068 }
1069 Err(last_error
1070 .map(anyhow::Error::from)
1071 .unwrap_or_else(|| anyhow::anyhow!("could not atomically write {}", path.display())))
1072 .with_context(|| format!("writing {}", path.display()))
1073}
1074
1075pub fn copy_file_hashed(src: &Path, dst: &Path) -> Result<(u64, String)> {
1079 let meta = fs::symlink_metadata(src)
1080 .with_context(|| format!("inspecting source {}", src.display()))?;
1081 if !meta.file_type().is_file() || is_reparse_point(&meta) {
1082 anyhow::bail!(
1083 "{} is not a regular file (symlink/special file refused)",
1084 src.display()
1085 );
1086 }
1087 if !owned_by_us(&meta) {
1088 anyhow::bail!(
1089 "{} is owned by another user — refusing to copy it into the vault",
1090 src.display()
1091 );
1092 }
1093 let mut options = OpenOptions::new();
1094 options.read(true);
1095 #[cfg(unix)]
1096 {
1097 use std::os::unix::fs::OpenOptionsExt;
1098 options.custom_flags(libc::O_NOFOLLOW);
1099 }
1100 let mut input = options
1101 .open(src)
1102 .with_context(|| format!("opening source {}", src.display()))?;
1103 if !input.metadata()?.is_file() {
1104 anyhow::bail!("{} changed type while opening", src.display());
1105 }
1106 let mut output = OpenOptions::new()
1107 .write(true)
1108 .create_new(true)
1109 .open(dst)
1110 .with_context(|| format!("creating {}", dst.display()))?;
1111 let mut hasher = Sha256::new();
1112 let mut buf = [0u8; 64 * 1024];
1113 let mut size = 0u64;
1114 loop {
1115 let n = input.read(&mut buf)?;
1116 if n == 0 {
1117 break;
1118 }
1119 hasher.update(&buf[..n]);
1120 output.write_all(&buf[..n])?;
1121 size += n as u64;
1122 }
1123 output.flush()?;
1124 Ok((size, format!("{:x}", hasher.finalize())))
1125}
1126
1127pub fn copy_file_verified(
1130 src: &Path,
1131 dst: &Path,
1132 expected_size: u64,
1133 expected_hash: &str,
1134) -> Result<(u64, String)> {
1135 if expected_hash.is_empty() {
1136 anyhow::bail!(
1137 "{} has no recorded SHA-256 (re-harvest the vault before restoring)",
1138 src.display()
1139 );
1140 }
1141 let (size, hash) = copy_file_hashed(src, dst)?;
1142 if size != expected_size || hash != expected_hash {
1143 let _ = fs::remove_file(dst);
1144 anyhow::bail!(
1145 "integrity check failed for {} (recorded {} bytes/{}, found {} bytes/{})",
1146 src.display(),
1147 expected_size,
1148 &expected_hash[..expected_hash.len().min(12)],
1149 size,
1150 &hash[..hash.len().min(12)]
1151 );
1152 }
1153 Ok((size, hash))
1154}
1155
1156pub fn tool_path(program: &str) -> Result<PathBuf> {
1159 let path = which_path(program)
1160 .with_context(|| format!("{program} not found on PATH or in the cargo bin directory"))?;
1161 if path.is_absolute() {
1162 Ok(path)
1163 } else {
1164 Ok(fs::canonicalize(&path).unwrap_or(path))
1165 }
1166}
1167
1168pub fn command_for(program: &str) -> Result<Command> {
1171 let path = if program.contains(['/', '\\']) {
1172 PathBuf::from(program)
1173 } else {
1174 tool_path(program)?
1175 };
1176 let mut command = Command::new(path);
1177 command.env("NoDefaultCurrentDirectoryInExePath", "1");
1180 Ok(command)
1181}
1182
1183pub struct FileLock {
1185 file: File,
1186}
1187
1188impl FileLock {
1189 pub fn acquire(path: &Path) -> Result<Self> {
1190 if let Some(parent) = path.parent() {
1191 create_private_dir_all(parent)?;
1192 }
1193 if fs::symlink_metadata(path)
1194 .map(|meta| meta.file_type().is_symlink())
1195 .unwrap_or(false)
1196 {
1197 anyhow::bail!("refusing symlinked lock file {}", path.display());
1198 }
1199 let file = OpenOptions::new()
1200 .read(true)
1201 .write(true)
1202 .create(true)
1203 .open(path)
1204 .with_context(|| format!("opening lock {}", path.display()))?;
1205 file.lock()
1206 .with_context(|| format!("locking {}", path.display()))?;
1207 Ok(Self { file })
1208 }
1209
1210 pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
1211 if let Some(parent) = path.parent() {
1212 create_private_dir_all(parent)?;
1213 }
1214 if fs::symlink_metadata(path)
1215 .map(|meta| meta.file_type().is_symlink())
1216 .unwrap_or(false)
1217 {
1218 anyhow::bail!("refusing symlinked lock file {}", path.display());
1219 }
1220 let file = OpenOptions::new()
1221 .read(true)
1222 .write(true)
1223 .create(true)
1224 .open(path)
1225 .with_context(|| format!("opening lock {}", path.display()))?;
1226 match file.try_lock() {
1227 Ok(()) => Ok(Some(Self { file })),
1228 Err(_) => Ok(None),
1230 }
1231 }
1232}
1233
1234impl Drop for FileLock {
1235 fn drop(&mut self) {
1236 let _ = self.file.unlock();
1237 }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243 use anyhow::Context;
1244
1245 #[test]
1246 fn parses_time_units() {
1247 assert_eq!(
1248 parse_duration("30d").unwrap(),
1249 Duration::from_secs(30 * 86_400)
1250 );
1251 assert_eq!(
1252 parse_duration("2w").unwrap(),
1253 Duration::from_secs(2 * 604_800)
1254 );
1255 assert_eq!(
1256 parse_duration("12h").unwrap(),
1257 Duration::from_secs(12 * 3_600)
1258 );
1259 assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
1260 assert!(parse_duration("abc").is_err());
1261 assert!(parse_duration("5x").is_err());
1262 }
1263
1264 #[test]
1265 fn human_sizes() {
1266 assert_eq!(human_bytes(0), "0 B");
1267 assert_eq!(human_bytes(1023), "1023 B");
1268 assert_eq!(human_bytes(1024), "1.0 KiB");
1269 assert_eq!(human_bytes(3 * 1_048_576 + 200_000), "3.2 MiB");
1270 }
1271
1272 #[test]
1273 fn exe_candidates_is_platform_aware() {
1274 let names = exe_candidates("kache");
1275 assert!(names.contains(&"kache".to_string()));
1276 #[cfg(windows)]
1277 assert!(names.contains(&"kache.exe".to_string()));
1278 #[cfg(not(windows))]
1279 assert_eq!(names.len(), 1);
1280 }
1281
1282 #[test]
1283 fn paint_is_pure() {
1284 assert_eq!(paint(false, "38;2;146;210;147", "hi"), "hi");
1285 assert_eq!(paint(true, "1", "hi"), "\x1b[1mhi\x1b[0m");
1286 }
1287
1288 #[test]
1289 fn manifest_paths_stay_relative_and_normal() {
1290 assert!(validate_rel("deps/libx-abc.rlib", "path").is_ok());
1291 assert!(validate_rel(".fingerprint/x-abc/lib-x", "path").is_ok());
1292 assert!(validate_rel("/etc/passwd", "path").is_err());
1293 assert!(validate_rel("../escape", "path").is_err());
1294 assert!(validate_rel("deps/../../escape", "path").is_err());
1295 assert!(validate_rel("C:\\Windows\\x", "path").is_err());
1296 assert!(validate_rel("deps\\x", "path").is_ok());
1297 assert!(validate_rel("", "path").is_err());
1298 assert!(validate_component("debug", "profile").is_ok());
1299 assert!(validate_component("..", "profile").is_err());
1300 assert!(validate_component("a/b", "profile").is_err());
1301 assert!(validate_component("a\\b", "profile").is_err());
1302 assert!(validate_component(".. ", "profile").is_err());
1304 assert!(validate_component("...", "profile").is_err());
1305 assert!(validate_component("foo.", "profile").is_err());
1306 assert!(validate_component("foo ", "profile").is_err());
1307 assert!(validate_component("CON", "profile").is_err());
1308 assert!(validate_component("CON.txt", "profile").is_err());
1309 assert!(validate_component("COM1.log", "profile").is_err());
1310 assert!(validate_component("foo.bar", "profile").is_ok());
1311 assert!(validate_rel(".. /x", "path").is_err());
1312 }
1313
1314 #[test]
1315 fn copies_are_hashed_and_verified() -> Result<()> {
1316 let dir = tempfile::tempdir()?;
1317 let src = dir.path().join("src");
1318 let dst = dir.path().join("dst");
1319 fs::write(&src, b"hello")?;
1320 let (size, hash) = copy_file_hashed(&src, &dst)?;
1321 assert_eq!(size, 5);
1322 assert_eq!(hash, sha256_file(&src)?);
1323 assert_eq!(fs::read(&dst)?, b"hello");
1324 let bad = dir.path().join("bad");
1325 assert!(copy_file_verified(&src, &bad, 5, "00").is_err());
1326 assert!(
1327 !bad.exists(),
1328 "a failed verify must not leave the destination"
1329 );
1330 Ok(())
1331 }
1332
1333 #[test]
1334 fn duration_overflow_is_rejected() {
1335 assert!(parse_duration("18446744073709551615d").is_err());
1336 assert!(parse_duration("18446744073709551615s").is_ok());
1337 }
1338
1339 #[test]
1340 fn error_blocks_render_a_glyph_indent_and_causes() {
1341 let plain =
1342 anyhow::anyhow!("nothing selected — pass one selector:\n--stale older toolchains");
1343 let block = error_block_with(false, &plain);
1344 assert_eq!(
1345 block,
1346 "✕ nothing selected — pass one selector:\n --stale older toolchains"
1347 );
1348 let colored = error_block_with(true, &plain);
1349 assert!(colored.contains("\u{1b}[38;2;224;108;117m✕\u{1b}[0m"));
1350
1351 let chained = Err::<(), _>(anyhow::anyhow!("inner detail")).context("outer headline");
1352 let block = error_block_with(false, &chained.unwrap_err());
1353 assert_eq!(block, "✕ outer headline\n caused by: inner detail");
1354 }
1355}
1356
1357#[cfg(all(test, windows))]
1358mod windows_path_tests {
1359 use super::*;
1360
1361 #[test]
1362 fn non_existent_in_profile_paths_are_accepted() {
1363 let root = tempfile::tempdir().unwrap();
1366 let target = root.path().join("cache").join("build");
1367 ensure_windows_user_path(&target, "cache root").unwrap();
1368 }
1369
1370 #[test]
1371 fn profile_aliases_are_rejected() {
1372 let profile = std::env::var_os("USERPROFILE").expect("USERPROFILE");
1373 let profile = std::path::PathBuf::from(profile);
1374 let mut trailing = profile.as_os_str().to_os_string();
1375 trailing.push("\\");
1376 for target in [
1377 std::path::PathBuf::from(trailing),
1378 profile.join("."),
1379 profile.join(".").join("."),
1380 profile.join(".").join(".."),
1381 ] {
1382 assert!(
1383 ensure_windows_user_path(&target, "cache root").is_err(),
1384 "profile alias was accepted: {:?}",
1385 target
1386 );
1387 }
1388 }
1389
1390 #[test]
1391 fn parent_dir_cannot_lexically_escape_the_profile() {
1392 let root = tempfile::tempdir().unwrap();
1393 let target = root.path().join("..").join("Public").join("ec");
1394 assert!(ensure_windows_user_path(&target, "cache root").is_err());
1395 }
1396}