Skip to main content

epicenter_cli/
util.rs

1//! Small formatting/parsing helpers shared by the other modules.
2
3use 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
16// ── terminal colors ─────────────────────────────────────────────────────────
17//
18// Palette: one brand green (#92D293) for headers/accents, dim gray for labels,
19// amber strictly for warnings, red only for failures — calm by default, loud
20// only when something is wrong. Colors auto-disable when stdout is not a
21// terminal, when `NO_COLOR` is set, and force-enable with `CLICOLOR_FORCE`.
22
23const GREEN: &str = "38;2;146;210;147"; // #92D293 — headers, success, accents
24const WARN: &str = "38;2;230;180;85"; // #E6B455 — warnings only
25const FAIL: &str = "38;2;224;108;117"; // #E06C75 — failures only
26const INFO: &str = "38;2;126;166;224"; // #7EA6E0 — neutral tips
27const DIM: &str = "2"; // labels, legends, secondary detail
28
29fn 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
52/// Brand-green text (`#92D293`) — section titles and success accents.
53pub fn green(text: &str) -> String {
54    paint(colors_enabled(), GREEN, text)
55}
56
57/// Bold brand green — top-level headings.
58pub fn heading(text: &str) -> String {
59    paint(colors_enabled(), &format!("1;{GREEN}"), text)
60}
61
62/// Amber — warnings.
63pub fn warn(text: &str) -> String {
64    paint(colors_enabled(), WARN, text)
65}
66
67/// Red — failures.
68pub fn fail(text: &str) -> String {
69    paint(colors_enabled(), FAIL, text)
70}
71
72/// Calm blue — neutral info.
73pub fn info(text: &str) -> String {
74    paint(colors_enabled(), INFO, text)
75}
76
77/// Dimmed — labels, legends, secondary detail.
78pub fn dim(text: &str) -> String {
79    paint(colors_enabled(), DIM, text)
80}
81
82// ── status glyphs ───────────────────────────────────────────────────────────
83//
84// Geometric, single-width, emoji-free: ✓ pass · ▲ warning · ✕ failure · ◆ info.
85
86/// `✓`
87pub const GLYPH_PASS: &str = "✓";
88/// `▲`
89pub const GLYPH_WARN: &str = "▲";
90/// `✕`
91pub const GLYPH_FAIL: &str = "✕";
92/// `◆`
93pub const GLYPH_INFO: &str = "◆";
94
95/// `1234567` -> `"1.2 MiB"` style, compact.
96pub 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
111/// Parse `"30d"`, `"2w"`, `"12h"`, `"90m"`, `"45s"` or plain seconds `"45"`.
112pub 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    // Build the message explicitly: the raw integer-parse error ("cannot parse
119    // integer from empty string") is noise for a CLI user.
120    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
135/// Age of a timestamp, human-readable ("3.4 days").
136pub 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
143/// One failure rendered in the tool's visual language: a red `✕` line, the
144/// message's own continuation lines indented beneath it, then any `anyhow`
145/// causes as dim rows. Plain glyphs when colors are off.
146pub fn error_block(err: &anyhow::Error) -> String {
147    error_block_with(colors_enabled(), err)
148}
149
150/// Pure form of [`error_block`] so tests never depend on a TTY.
151fn 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
176/// Compact duration for humans ("3.4 s", "1.2 min", "2.1 h").
177pub 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
190/// Candidate executables for `program`. Cargo installs `kache` as
191/// `kache.exe` on Windows, so the suffix must be considered there.
192fn 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
200/// Is `program` reachable through `PATH`? (bare `which`, no extra crate)
201pub 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
209/// Absolute path of `program` if discoverable — first on `PATH`, then as a
210/// fallback in Cargo's own bin dir (`$CARGO_HOME/bin`, e.g.
211/// `C:\Users\<user>\.cargo\bin`), which rustup tools live in but isn't on
212/// PATH in every shell. Returns `None` when not found anywhere.
213pub 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            // Never let an empty/relative PATH element (or `.`) make the
218            // process search the current directory before the real tool.
219            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
240/// Is `program` installed at all (PATH or cargo bin dir)?
241pub fn which(program: &str) -> bool {
242    which_path(program).is_some()
243}
244
245/// Remove a tree robustly: read-only files (a wrapper's store, copies that
246/// inherited the read-only attribute) block `remove_dir_all` on Windows, so
247/// clear the flag and retry once; report the original error if it still fails.
248pub 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        // Never follow a symlink here: `set_permissions` on a symlink path
256        // changes the *target* (possibly outside this tree).
257        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// ── security: identity, paths, private dirs, hashes ────────────────────────
273
274/// Effective uid. Only meaningful on Unix; 0 elsewhere (permission checks
275/// are skipped there and documented as such).
276#[cfg(unix)]
277pub fn euid() -> u32 {
278    // SAFETY: geteuid(2) has no preconditions and never fails.
279    unsafe { libc::geteuid() }
280}
281
282#[cfg(not(unix))]
283pub fn euid() -> u32 {
284    0
285}
286
287/// Is this file owned by the current effective user? (always true off Unix)
288pub 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
301/// Windows reserved device names (case-insensitive, before the first dot).
302/// `CON`, `NUL`, `COM1`, `CON.txt`, `LPT9.log`, ...
303pub 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/// Windows single-user policy: the cache/store/work must live under the
318/// current `%USERPROFILE%`, and no path component may be a reparse point.
319/// This does not inspect the DACL, but it rejects the shared `C:\Users\Public`
320/// class of cache and junction redirection. No-op off Windows.
321#[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    // `..` must never be allowed to look like an in-profile path lexically and
355    // resolve outside after creation. Reject it before prefix/canonical checks.
356    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    // Reparse check BEFORE canonicalize: canonicalize resolves a junction to
366    // its target, which would hide the junction from the component loop.
367    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(&current) {
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    // A path that already exists can contain a junction deeper than the
392    // lexical components; the canonical target must also stay inside.
393    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    // The profile itself is not a cache root. Compare both the lexical and
403    // canonical forms: `%USERPROFILE%\`, `%USERPROFILE%\.`, and
404    // `%USERPROFILE%\.\.` all resolve to the profile even though their raw
405    // strings differ.
406    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
421/// Is this a Windows reparse point (symlink/junction/mount point)? On Unix
422/// symlinks are handled by `file_type().is_symlink`.
423pub 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
436/// One path component: no separators, no `.`/`..`, no NUL, no Windows
437/// drive/ADS separator, no trailing dot/space, no reserved device stem.
438/// Use for manifest fields that must be one directory or file name
439/// (`name`, `profile`, `memo`, parity keys).
440pub 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
453/// A **relative** path with only normal components. Rejects absolute paths,
454/// `..`, `.`, NUL, drive prefixes, and backslash/forward-slash tricks. This is
455/// the single gate for every manifest path that reaches the filesystem.
456pub 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
478/// Directories this process must never chmod as a "private cache": the
479/// filesystem root, the user's home, and the system temp directory. A
480/// mistyped `--cache-root /tmp` must not turn /tmp into 0700.
481fn 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
496/// Create a directory tree with mode 0700 (Unix), refusing a symlinked or
497/// foreign-owned final directory. Existing directories are tightened to 0700.
498pub 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
532/// Verify (and, when possible, repair) an existing private directory.
533pub 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
557/// Check that `base/rel` is a chain of real directories ending in a regular
558/// file, with no symlink at any component. Read-only; used before hashing or
559/// restoring a store file.
560pub 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(&current)
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
589/// Create a directory chain under an existing `base` without following any
590/// symlink component. `rel` must already have been validated (only normal
591/// components). New components are 0700.
592pub 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(&current) {
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(&current)
620                        .with_context(|| format!("creating {}", current.display()))?;
621                }
622                #[cfg(not(unix))]
623                fs::create_dir(&current)
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/// Capability-style directory handle for Unix restore. All child lookups
635/// use openat()/mkdirat() with O_NOFOLLOW, and final installs use linkat(),
636/// so a directory swapped for a symlink between a path check and the write
637/// cannot redirect the operation.
638#[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                // Another process may have created it; retry the open.
693                if mkerr.kind() != std::io::ErrorKind::AlreadyExists {
694                    return Err(mkerr).with_context(|| format!("creating child {:?}", name));
695                }
696            }
697        }
698    }
699
700    /// Does `name` exist? A symlink/reparse is an error, never "existing".
701    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    /// Portable fallback for platforms without /proc/self/fd: linkat with a
730    /// source path but a held destination directory fd. The destination is
731    /// still race-free; the source is store-owned and pre-checked.
732    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
892/// SHA-256 of a regular file (lowercase hex). Callers that accept untrusted
893/// paths must reject symlinks with `symlink_metadata` first.
894pub 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
921/// Like [`read_small_text`] but follows a user's config symlink (dotfiles);
922/// still refuses FIFOs/devices and oversized inputs.
923pub 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
934/// Read a small regular file without following a symlink and without
935/// blocking on FIFOs/devices. Returns an error on non-regular or oversized
936/// inputs.
937pub 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
958/// Read a global config file from an already-open-like trusted path:
959/// O_NOFOLLOW, regular-file-only, owned by the current euid, and not
960/// group/world-writable. The ownership/mode checks use fstat on the opened
961/// descriptor, so a symlink/file swap between a path check and this read
962/// cannot redirect the trust anchor.
963pub 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
1000/// Atomically replace a private config file. The temp is created with
1001/// `O_EXCL` and mode 0600 in the same directory, then renamed over the
1002/// target. Renaming replaces a planted symlink instead of following it (on
1003/// Unix); on Windows a pre-existing symlink is removed first, never followed.
1004pub 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                // Windows does not replace an existing file with rename().
1051                // Removing the link itself (never following it) is safe; the
1052                // subsequent rename replaces whatever appears next.
1053                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
1075/// Copy a regular file into a **new** destination (`O_EXCL`) while hashing it.
1076/// Refuses symlinks/FIFOs/devices and never follows a destination symlink.
1077/// Returns (bytes, sha256).
1078pub 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
1127/// Copy + verify a recorded size/hash. On mismatch the new destination is
1128/// removed and the operation fails (never installs unverified bytes).
1129pub 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
1156/// Resolve an executable to an **absolute** path. This is the Windows
1157/// CWD-search fix: `Command::new("cargo")` must never be used directly.
1158pub 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
1168/// A `Command` for a trusted tool (`cargo`, `rustc`) or a user program:
1169/// relative names are resolved through [`tool_path`] to an absolute path.
1170pub 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    // Windows CreateProcess otherwise searches the CWD before PATH. The
1178    // variable is ignored on Unix but harmless and keeps the code uniform.
1179    command.env("NoDefaultCurrentDirectoryInExePath", "1");
1180    Ok(command)
1181}
1182
1183/// Advisory exclusive lock on a file. Held for the lifetime of the value.
1184pub 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            // Contention (or an unsupported lock): fail closed as "in use".
1229            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        // Windows aliases / device stems must never pass a manifest.
1303        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        // tempdir is under %TEMP% (inside %USERPROFILE%); these children do
1364        // not exist yet, which is exactly the fresh setup/harvest/exec case.
1365        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}