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};
12use walkdir::WalkDir;
13
14// ── terminal colors ─────────────────────────────────────────────────────────
15//
16// Palette: one brand green (#92D293) for headers/accents, dim gray for labels,
17// amber strictly for warnings, red only for failures — calm by default, loud
18// only when something is wrong. Colors auto-disable when stdout is not a
19// terminal, when `NO_COLOR` is set, and force-enable with `CLICOLOR_FORCE`.
20
21const GREEN: &str = "38;2;146;210;147"; // #92D293 — headers, success, accents
22const WARN: &str = "38;2;230;180;85"; // #E6B455 — warnings only
23const FAIL: &str = "38;2;224;108;117"; // #E06C75 — failures only
24const INFO: &str = "38;2;126;166;224"; // #7EA6E0 — neutral tips
25const DIM: &str = "2"; // labels, legends, secondary detail
26
27fn colors_enabled() -> bool {
28    static ENABLED: OnceLock<bool> = OnceLock::new();
29    *ENABLED.get_or_init(|| {
30        if std::env::var_os("NO_COLOR").is_some() {
31            return false;
32        }
33        if let Some(force) = std::env::var_os("CLICOLOR_FORCE") {
34            if force != "0" && !force.is_empty() {
35                return true;
36            }
37        }
38        std::io::stdout().is_terminal()
39    })
40}
41
42fn paint(enabled: bool, code: &str, text: &str) -> String {
43    if enabled {
44        format!("\x1b[{code}m{text}\x1b[0m")
45    } else {
46        text.to_string()
47    }
48}
49
50/// Brand-green text (`#92D293`) — section titles and success accents.
51pub fn green(text: &str) -> String {
52    paint(colors_enabled(), GREEN, text)
53}
54
55/// Bold brand green — top-level headings.
56pub fn heading(text: &str) -> String {
57    paint(colors_enabled(), &format!("1;{GREEN}"), text)
58}
59
60/// Amber — warnings.
61pub fn warn(text: &str) -> String {
62    paint(colors_enabled(), WARN, text)
63}
64
65/// Red — failures.
66pub fn fail(text: &str) -> String {
67    paint(colors_enabled(), FAIL, text)
68}
69
70/// Calm blue — neutral info.
71pub fn info(text: &str) -> String {
72    paint(colors_enabled(), INFO, text)
73}
74
75/// Dimmed — labels, legends, secondary detail.
76pub fn dim(text: &str) -> String {
77    paint(colors_enabled(), DIM, text)
78}
79
80// ── status glyphs ───────────────────────────────────────────────────────────
81//
82// Geometric, single-width, emoji-free: ✓ pass · ▲ warning · ✕ failure · ◆ info.
83
84/// `✓`
85pub const GLYPH_PASS: &str = "✓";
86/// `▲`
87pub const GLYPH_WARN: &str = "▲";
88/// `✕`
89pub const GLYPH_FAIL: &str = "✕";
90/// `◆`
91pub const GLYPH_INFO: &str = "◆";
92
93/// `1234567` -> `"1.2 MiB"` style, compact.
94pub fn human_bytes(bytes: u64) -> String {
95    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
96    let mut value = bytes as f64;
97    let mut unit = 0;
98    while value >= 1024.0 && unit < UNITS.len() - 1 {
99        value /= 1024.0;
100        unit += 1;
101    }
102    if unit == 0 {
103        format!("{bytes} B")
104    } else {
105        format!("{value:.1} {}", UNITS[unit])
106    }
107}
108
109/// Parse `"30d"`, `"2w"`, `"12h"`, `"90m"`, `"45s"` or plain seconds `"45"`.
110pub fn parse_duration(text: &str) -> Result<Duration> {
111    let text = text.trim();
112    let split = text
113        .find(|c: char| c.is_ascii_alphabetic())
114        .unwrap_or(text.len());
115    let (num, unit) = text.split_at(split);
116    // Build the message explicitly: the raw integer-parse error ("cannot parse
117    // integer from empty string") is noise for a CLI user.
118    let n: u64 = num
119        .parse()
120        .map_err(|_| anyhow::anyhow!("invalid duration {text:?} (expected e.g. 30d, 2w, 12h)"))?;
121    let secs = match unit.to_ascii_lowercase().as_str() {
122        "" | "s" => Some(n),
123        "m" => n.checked_mul(60),
124        "h" => n.checked_mul(3_600),
125        "d" => n.checked_mul(86_400),
126        "w" => n.checked_mul(604_800),
127        other => anyhow::bail!("unknown time unit {other:?} in {text:?} (use s, m, h, d or w)"),
128    }
129    .with_context(|| format!("duration {text:?} is too large"))?;
130    Ok(Duration::from_secs(secs))
131}
132
133/// Age of a timestamp, human-readable ("3.4 days").
134pub fn human_age(at: SystemTime) -> String {
135    match SystemTime::now().duration_since(at) {
136        Ok(age) => human_duration(age),
137        Err(_) => "in the future".to_string(),
138    }
139}
140
141/// One failure rendered in the tool's visual language: a red `✕` line, the
142/// message's own continuation lines indented beneath it, then any `anyhow`
143/// causes as dim rows. Plain glyphs when colors are off.
144pub fn error_block(err: &anyhow::Error) -> String {
145    error_block_with(colors_enabled(), err)
146}
147
148/// Pure form of [`error_block`] so tests never depend on a TTY.
149fn error_block_with(enabled: bool, err: &anyhow::Error) -> String {
150    let mut chain = err.chain();
151    let headline = chain
152        .next()
153        .map(|cause| cause.to_string())
154        .unwrap_or_else(|| "unknown error".to_string());
155    let mut lines = headline.lines();
156    let mut out = format!(
157        "{} {}",
158        paint(enabled, FAIL, GLYPH_FAIL),
159        lines.next().unwrap_or("")
160    );
161    for line in lines {
162        out.push_str("\n  ");
163        out.push_str(line);
164    }
165    for cause in chain {
166        out.push_str(&format!(
167            "\n  {} {cause}",
168            paint(enabled, DIM, "caused by:")
169        ));
170    }
171    out
172}
173
174/// Compact duration for humans ("3.4 s", "1.2 min", "2.1 h").
175pub fn human_duration(duration: Duration) -> String {
176    let secs = duration.as_secs_f64();
177    if secs >= 86_400.0 {
178        format!("{:.1} days", secs / 86_400.0)
179    } else if secs >= 3_600.0 {
180        format!("{:.1} h", secs / 3_600.0)
181    } else if secs >= 60.0 {
182        format!("{:.1} min", secs / 60.0)
183    } else {
184        format!("{} s", duration.as_secs())
185    }
186}
187
188/// Candidate executables for `program`. Cargo installs `kache` as
189/// `kache.exe` on Windows, so the suffix must be considered there.
190fn exe_candidates(program: &str) -> Vec<String> {
191    let mut names = vec![program.to_string()];
192    if cfg!(windows) {
193        names.push(format!("{program}.exe"));
194    }
195    names
196}
197
198/// Is `program` reachable through `PATH`? (bare `which`, no extra crate)
199pub fn on_path(program: &str) -> bool {
200    let names = exe_candidates(program);
201    let Some(path) = std::env::var_os("PATH") else {
202        return false;
203    };
204    std::env::split_paths(&path).any(|dir| names.iter().any(|name| dir.join(name).is_file()))
205}
206
207/// Absolute path of `program` if discoverable — first on `PATH`, then as a
208/// fallback in Cargo's own bin dir (`$CARGO_HOME/bin`, e.g.
209/// `C:\Users\<user>\.cargo\bin`), which rustup tools live in but isn't on
210/// PATH in every shell. Returns `None` when not found anywhere.
211pub fn which_path(program: &str) -> Option<PathBuf> {
212    let names = exe_candidates(program);
213    if let Some(path) = std::env::var_os("PATH") {
214        for dir in std::env::split_paths(&path) {
215            for name in &names {
216                let candidate = dir.join(name);
217                if candidate.is_file() {
218                    return Some(candidate);
219                }
220            }
221        }
222    }
223    let cargo_bin = crate::paths::cargo_home().ok()?.join("bin");
224    names
225        .iter()
226        .map(|name| cargo_bin.join(name))
227        .find(|candidate| candidate.is_file())
228}
229
230/// Is `program` installed at all (PATH or cargo bin dir)?
231pub fn which(program: &str) -> bool {
232    which_path(program).is_some()
233}
234
235/// Remove a tree robustly: read-only files (a wrapper's store, copies that
236/// inherited the read-only attribute) block `remove_dir_all` on Windows, so
237/// clear the flag and retry once; report the original error if it still fails.
238pub fn force_remove_dir(path: &Path) -> std::io::Result<()> {
239    let first = match std::fs::remove_dir_all(path) {
240        Ok(()) => return Ok(()),
241        Err(err) => err,
242    };
243    for entry in WalkDir::new(path) {
244        let Ok(entry) = entry else { continue };
245        // Never follow a symlink here: `set_permissions` on a symlink path
246        // changes the *target* (possibly outside this tree).
247        if entry.file_type().is_symlink() {
248            continue;
249        }
250        let Ok(meta) = fs::symlink_metadata(entry.path()) else {
251            continue;
252        };
253        let mut perms = meta.permissions();
254        if perms.readonly() {
255            perms.set_readonly(false);
256            let _ = fs::set_permissions(entry.path(), perms);
257        }
258    }
259    fs::remove_dir_all(path).map_err(|_| first)
260}
261
262// ── security: identity, paths, private dirs, hashes ────────────────────────
263
264/// Effective uid. Only meaningful on Unix; 0 elsewhere (permission checks
265/// are skipped there and documented as such).
266#[cfg(unix)]
267pub fn euid() -> u32 {
268    // SAFETY: geteuid(2) has no preconditions and never fails.
269    unsafe { libc::geteuid() }
270}
271
272#[cfg(not(unix))]
273pub fn euid() -> u32 {
274    0
275}
276
277/// Is this file owned by the current effective user? (always true off Unix)
278pub fn owned_by_us(meta: &fs::Metadata) -> bool {
279    #[cfg(unix)]
280    {
281        use std::os::unix::fs::MetadataExt;
282        meta.uid() == euid()
283    }
284    #[cfg(not(unix))]
285    {
286        let _ = meta;
287        true
288    }
289}
290
291/// One path component: no separators, no `.`/`..`, no NUL, no Windows
292/// drive/ADS separator. Use for manifest fields that must be one directory
293/// or file name (`name`, `profile`, `memo`, parity keys).
294pub fn validate_component(value: &str, what: &str) -> Result<()> {
295    if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0', ':']) {
296        anyhow::bail!("invalid {what} {value:?}: expected one plain path component");
297    }
298    Ok(())
299}
300
301/// A **relative** path with only normal components. Rejects absolute paths,
302/// `..`, `.`, NUL, drive prefixes, and backslash/forward-slash tricks. This is
303/// the single gate for every manifest path that reaches the filesystem.
304pub fn validate_rel(value: &str, what: &str) -> Result<PathBuf> {
305    if value.is_empty() || value.contains('\0') || value.ends_with(['/', '\\']) {
306        anyhow::bail!("invalid {what} {value:?}: empty or trailing separator");
307    }
308    let normalized = value.replace('\\', "/");
309    let mut out = PathBuf::new();
310    for component in Path::new(&normalized).components() {
311        match component {
312            Component::Normal(part) => {
313                let part = part.to_string_lossy();
314                if part.contains(['\0', ':']) {
315                    anyhow::bail!("invalid {what} {value:?}: forbidden character");
316                }
317                out.push(&*part);
318            }
319            _ => anyhow::bail!("invalid {what} {value:?}: only relative components are allowed"),
320        }
321    }
322    if out.as_os_str().is_empty() {
323        anyhow::bail!("invalid {what} {value:?}: empty path");
324    }
325    Ok(out)
326}
327
328/// Create a directory tree with mode 0700 (Unix), refusing a symlinked or
329/// foreign-owned final directory. Existing directories are tightened to 0700.
330pub fn create_private_dir_all(path: &Path) -> Result<()> {
331    #[cfg(unix)]
332    {
333        use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
334        fs::DirBuilder::new()
335            .recursive(true)
336            .mode(0o700)
337            .create(path)
338            .with_context(|| format!("creating private directory {}", path.display()))?;
339        let meta =
340            fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
341        if !meta.is_dir() || meta.file_type().is_symlink() {
342            anyhow::bail!("{} is not a real directory", path.display());
343        }
344        if !owned_by_us(&meta) {
345            anyhow::bail!(
346                "{} is owned by uid {} — refusing to use a cache directory not owned by uid {}",
347                path.display(),
348                std::os::unix::fs::MetadataExt::uid(&meta),
349                euid()
350            );
351        }
352        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
353            .with_context(|| format!("securing {}", path.display()))?;
354    }
355    #[cfg(not(unix))]
356    {
357        fs::create_dir_all(path)
358            .with_context(|| format!("creating private directory {}", path.display()))?;
359    }
360    Ok(())
361}
362
363/// Verify (and, when possible, repair) an existing private directory.
364pub fn ensure_private_dir(path: &Path) -> Result<()> {
365    let meta =
366        fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
367    if !meta.is_dir() || meta.file_type().is_symlink() {
368        anyhow::bail!("{} is not a real directory", path.display());
369    }
370    if !owned_by_us(&meta) {
371        anyhow::bail!(
372            "{} is owned by another user — refusing a shared/untrusted cache",
373            path.display()
374        );
375    }
376    #[cfg(unix)]
377    {
378        use std::os::unix::fs::{MetadataExt, PermissionsExt};
379        if meta.mode() & 0o077 != 0 {
380            fs::set_permissions(path, fs::Permissions::from_mode(0o700))
381                .with_context(|| format!("securing {}", path.display()))?;
382        }
383    }
384    Ok(())
385}
386
387/// Check that `base/rel` is a chain of real directories ending in a regular
388/// file, with no symlink at any component. Read-only; used before hashing or
389/// restoring a store file.
390pub fn check_regular_chain(base: &Path, rel: &Path) -> Result<()> {
391    let base_meta =
392        fs::symlink_metadata(base).with_context(|| format!("inspecting {}", base.display()))?;
393    if !base_meta.is_dir() || base_meta.file_type().is_symlink() {
394        anyhow::bail!("{} is not a real directory", base.display());
395    }
396    let mut current = base.to_path_buf();
397    let components: Vec<_> = rel.components().collect();
398    for (index, component) in components.iter().enumerate() {
399        let Component::Normal(part) = component else {
400            anyhow::bail!("refusing non-normal path component in {}", rel.display());
401        };
402        current.push(part);
403        let meta = fs::symlink_metadata(&current)
404            .with_context(|| format!("inspecting {}", current.display()))?;
405        if meta.file_type().is_symlink() {
406            anyhow::bail!("symlink in store path: {}", current.display());
407        }
408        if index + 1 == components.len() {
409            if !meta.is_file() {
410                anyhow::bail!("not a regular file: {}", current.display());
411            }
412        } else if !meta.is_dir() {
413            anyhow::bail!("not a directory: {}", current.display());
414        }
415    }
416    Ok(())
417}
418
419/// Create a directory chain under an existing `base` without following any
420/// symlink component. `rel` must already have been validated (only normal
421/// components). New components are 0700.
422pub fn create_dir_chain_nofollow(base: &Path, rel: &Path) -> Result<()> {
423    let base_meta =
424        fs::symlink_metadata(base).with_context(|| format!("inspecting {}", base.display()))?;
425    if !base_meta.is_dir() || base_meta.file_type().is_symlink() {
426        anyhow::bail!("{} is not a real directory", base.display());
427    }
428    let mut current = base.to_path_buf();
429    for component in rel.components() {
430        let Component::Normal(part) = component else {
431            anyhow::bail!("refusing non-normal path component in {}", rel.display());
432        };
433        current.push(part);
434        match fs::symlink_metadata(&current) {
435            Ok(meta) => {
436                if meta.file_type().is_symlink() {
437                    anyhow::bail!("symlink in destination path: {}", current.display());
438                }
439                if !meta.is_dir() {
440                    anyhow::bail!("not a directory: {}", current.display());
441                }
442            }
443            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
444                #[cfg(unix)]
445                {
446                    use std::os::unix::fs::DirBuilderExt;
447                    fs::DirBuilder::new()
448                        .mode(0o700)
449                        .create(&current)
450                        .with_context(|| format!("creating {}", current.display()))?;
451                }
452                #[cfg(not(unix))]
453                fs::create_dir(&current)
454                    .with_context(|| format!("creating {}", current.display()))?;
455            }
456            Err(err) => {
457                return Err(err).with_context(|| format!("inspecting {}", current.display()));
458            }
459        }
460    }
461    Ok(())
462}
463
464/// SHA-256 of a regular file (lowercase hex). Callers that accept untrusted
465/// paths must reject symlinks with `symlink_metadata` first.
466pub fn sha256_file(path: &Path) -> std::io::Result<String> {
467    let mut options = OpenOptions::new();
468    options.read(true);
469    #[cfg(unix)]
470    {
471        use std::os::unix::fs::OpenOptionsExt;
472        options.custom_flags(libc::O_NOFOLLOW);
473    }
474    let mut file = options.open(path)?;
475    let mut hasher = Sha256::new();
476    let mut buf = [0u8; 64 * 1024];
477    loop {
478        let n = file.read(&mut buf)?;
479        if n == 0 {
480            break;
481        }
482        hasher.update(&buf[..n]);
483    }
484    Ok(format!("{:x}", hasher.finalize()))
485}
486
487/// Like [`read_small_text`] but follows a user's config symlink (dotfiles);
488/// still refuses FIFOs/devices and oversized inputs.
489pub fn read_small_text_follow(path: &Path, max_bytes: u64) -> std::io::Result<String> {
490    let meta = fs::metadata(path)?;
491    if !meta.is_file() || meta.len() > max_bytes {
492        return Err(std::io::Error::new(
493            std::io::ErrorKind::InvalidInput,
494            "not a small regular file",
495        ));
496    }
497    fs::read_to_string(path)
498}
499
500/// Read a small regular file without following a symlink and without
501/// blocking on FIFOs/devices. Returns an error on non-regular or oversized
502/// inputs.
503pub fn read_small_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
504    let mut options = OpenOptions::new();
505    options.read(true);
506    #[cfg(unix)]
507    {
508        use std::os::unix::fs::OpenOptionsExt;
509        options.custom_flags(libc::O_NOFOLLOW);
510    }
511    let mut file = options.open(path)?;
512    let meta = file.metadata()?;
513    if !meta.is_file() || meta.len() > max_bytes {
514        return Err(std::io::Error::new(
515            std::io::ErrorKind::InvalidInput,
516            "not a small regular file",
517        ));
518    }
519    let mut text = String::new();
520    file.read_to_string(&mut text)?;
521    Ok(text)
522}
523
524/// Copy a regular file into a **new** destination (`O_EXCL`) while hashing it.
525/// Refuses symlinks/FIFOs/devices and never follows a destination symlink.
526/// Returns (bytes, sha256).
527pub fn copy_file_hashed(src: &Path, dst: &Path) -> Result<(u64, String)> {
528    let meta = fs::symlink_metadata(src)
529        .with_context(|| format!("inspecting source {}", src.display()))?;
530    if !meta.file_type().is_file() {
531        anyhow::bail!(
532            "{} is not a regular file (symlink/special file refused)",
533            src.display()
534        );
535    }
536    if !owned_by_us(&meta) {
537        anyhow::bail!(
538            "{} is owned by another user — refusing to copy it into the vault",
539            src.display()
540        );
541    }
542    let mut options = OpenOptions::new();
543    options.read(true);
544    #[cfg(unix)]
545    {
546        use std::os::unix::fs::OpenOptionsExt;
547        options.custom_flags(libc::O_NOFOLLOW);
548    }
549    let mut input = options
550        .open(src)
551        .with_context(|| format!("opening source {}", src.display()))?;
552    if !input.metadata()?.is_file() {
553        anyhow::bail!("{} changed type while opening", src.display());
554    }
555    let mut output = OpenOptions::new()
556        .write(true)
557        .create_new(true)
558        .open(dst)
559        .with_context(|| format!("creating {}", dst.display()))?;
560    let mut hasher = Sha256::new();
561    let mut buf = [0u8; 64 * 1024];
562    let mut size = 0u64;
563    loop {
564        let n = input.read(&mut buf)?;
565        if n == 0 {
566            break;
567        }
568        hasher.update(&buf[..n]);
569        output.write_all(&buf[..n])?;
570        size += n as u64;
571    }
572    output.flush()?;
573    Ok((size, format!("{:x}", hasher.finalize())))
574}
575
576/// Copy + verify a recorded size/hash. On mismatch the new destination is
577/// removed and the operation fails (never installs unverified bytes).
578pub fn copy_file_verified(
579    src: &Path,
580    dst: &Path,
581    expected_size: u64,
582    expected_hash: &str,
583) -> Result<(u64, String)> {
584    if expected_hash.is_empty() {
585        anyhow::bail!(
586            "{} has no recorded SHA-256 (re-harvest the vault before restoring)",
587            src.display()
588        );
589    }
590    let (size, hash) = copy_file_hashed(src, dst)?;
591    if size != expected_size || hash != expected_hash {
592        let _ = fs::remove_file(dst);
593        anyhow::bail!(
594            "integrity check failed for {} (recorded {} bytes/{}, found {} bytes/{})",
595            src.display(),
596            expected_size,
597            &expected_hash[..expected_hash.len().min(12)],
598            size,
599            &hash[..hash.len().min(12)]
600        );
601    }
602    Ok((size, hash))
603}
604
605/// Resolve an executable to an **absolute** path. This is the Windows
606/// CWD-search fix: `Command::new("cargo")` must never be used directly.
607pub fn tool_path(program: &str) -> Result<PathBuf> {
608    let path = which_path(program)
609        .with_context(|| format!("{program} not found on PATH or in the cargo bin directory"))?;
610    if path.is_absolute() {
611        Ok(path)
612    } else {
613        Ok(fs::canonicalize(&path).unwrap_or(path))
614    }
615}
616
617/// A `Command` for a trusted tool (`cargo`, `rustc`) or a user program:
618/// relative names are resolved through [`tool_path`] to an absolute path.
619pub fn command_for(program: &str) -> Result<Command> {
620    let path = if program.contains(['/', '\\']) {
621        PathBuf::from(program)
622    } else {
623        tool_path(program)?
624    };
625    let mut command = Command::new(path);
626    // Windows CreateProcess otherwise searches the CWD before PATH. The
627    // variable is ignored on Unix but harmless and keeps the code uniform.
628    command.env("NoDefaultCurrentDirectoryInExePath", "1");
629    Ok(command)
630}
631
632/// Advisory exclusive lock on a file. Held for the lifetime of the value.
633pub struct FileLock {
634    file: File,
635}
636
637impl FileLock {
638    pub fn acquire(path: &Path) -> Result<Self> {
639        if let Some(parent) = path.parent() {
640            create_private_dir_all(parent)?;
641        }
642        if fs::symlink_metadata(path)
643            .map(|meta| meta.file_type().is_symlink())
644            .unwrap_or(false)
645        {
646            anyhow::bail!("refusing symlinked lock file {}", path.display());
647        }
648        let file = OpenOptions::new()
649            .read(true)
650            .write(true)
651            .create(true)
652            .open(path)
653            .with_context(|| format!("opening lock {}", path.display()))?;
654        file.lock()
655            .with_context(|| format!("locking {}", path.display()))?;
656        Ok(Self { file })
657    }
658
659    pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
660        if let Some(parent) = path.parent() {
661            create_private_dir_all(parent)?;
662        }
663        if fs::symlink_metadata(path)
664            .map(|meta| meta.file_type().is_symlink())
665            .unwrap_or(false)
666        {
667            anyhow::bail!("refusing symlinked lock file {}", path.display());
668        }
669        let file = OpenOptions::new()
670            .read(true)
671            .write(true)
672            .create(true)
673            .open(path)
674            .with_context(|| format!("opening lock {}", path.display()))?;
675        match file.try_lock() {
676            Ok(()) => Ok(Some(Self { file })),
677            // Contention (or an unsupported lock): fail closed as "in use".
678            Err(_) => Ok(None),
679        }
680    }
681}
682
683impl Drop for FileLock {
684    fn drop(&mut self) {
685        let _ = self.file.unlock();
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use anyhow::Context;
693
694    #[test]
695    fn parses_time_units() {
696        assert_eq!(
697            parse_duration("30d").unwrap(),
698            Duration::from_secs(30 * 86_400)
699        );
700        assert_eq!(
701            parse_duration("2w").unwrap(),
702            Duration::from_secs(2 * 604_800)
703        );
704        assert_eq!(
705            parse_duration("12h").unwrap(),
706            Duration::from_secs(12 * 3_600)
707        );
708        assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
709        assert!(parse_duration("abc").is_err());
710        assert!(parse_duration("5x").is_err());
711    }
712
713    #[test]
714    fn human_sizes() {
715        assert_eq!(human_bytes(0), "0 B");
716        assert_eq!(human_bytes(1023), "1023 B");
717        assert_eq!(human_bytes(1024), "1.0 KiB");
718        assert_eq!(human_bytes(3 * 1_048_576 + 200_000), "3.2 MiB");
719    }
720
721    #[test]
722    fn exe_candidates_is_platform_aware() {
723        let names = exe_candidates("kache");
724        assert!(names.contains(&"kache".to_string()));
725        #[cfg(windows)]
726        assert!(names.contains(&"kache.exe".to_string()));
727        #[cfg(not(windows))]
728        assert_eq!(names.len(), 1);
729    }
730
731    #[test]
732    fn paint_is_pure() {
733        assert_eq!(paint(false, "38;2;146;210;147", "hi"), "hi");
734        assert_eq!(paint(true, "1", "hi"), "\x1b[1mhi\x1b[0m");
735    }
736
737    #[test]
738    fn manifest_paths_stay_relative_and_normal() {
739        assert!(validate_rel("deps/libx-abc.rlib", "path").is_ok());
740        assert!(validate_rel(".fingerprint/x-abc/lib-x", "path").is_ok());
741        assert!(validate_rel("/etc/passwd", "path").is_err());
742        assert!(validate_rel("../escape", "path").is_err());
743        assert!(validate_rel("deps/../../escape", "path").is_err());
744        assert!(validate_rel("C:\\Windows\\x", "path").is_err());
745        assert!(validate_rel("deps\\x", "path").is_ok());
746        assert!(validate_rel("", "path").is_err());
747        assert!(validate_component("debug", "profile").is_ok());
748        assert!(validate_component("..", "profile").is_err());
749        assert!(validate_component("a/b", "profile").is_err());
750        assert!(validate_component("a\\b", "profile").is_err());
751    }
752
753    #[test]
754    fn copies_are_hashed_and_verified() -> Result<()> {
755        let dir = tempfile::tempdir()?;
756        let src = dir.path().join("src");
757        let dst = dir.path().join("dst");
758        fs::write(&src, b"hello")?;
759        let (size, hash) = copy_file_hashed(&src, &dst)?;
760        assert_eq!(size, 5);
761        assert_eq!(hash, sha256_file(&src)?);
762        assert_eq!(fs::read(&dst)?, b"hello");
763        let bad = dir.path().join("bad");
764        assert!(copy_file_verified(&src, &bad, 5, "00").is_err());
765        assert!(
766            !bad.exists(),
767            "a failed verify must not leave the destination"
768        );
769        Ok(())
770    }
771
772    #[test]
773    fn duration_overflow_is_rejected() {
774        assert!(parse_duration("18446744073709551615d").is_err());
775        assert!(parse_duration("18446744073709551615s").is_ok());
776    }
777
778    #[test]
779    fn error_blocks_render_a_glyph_indent_and_causes() {
780        let plain =
781            anyhow::anyhow!("nothing selected — pass one selector:\n--stale   older toolchains");
782        let block = error_block_with(false, &plain);
783        assert_eq!(
784            block,
785            "✕ nothing selected — pass one selector:\n  --stale   older toolchains"
786        );
787        let colored = error_block_with(true, &plain);
788        assert!(colored.contains("\u{1b}[38;2;224;108;117m✕\u{1b}[0m"));
789
790        let chained = Err::<(), _>(anyhow::anyhow!("inner detail")).context("outer headline");
791        let block = error_block_with(false, &chained.unwrap_err());
792        assert_eq!(block, "✕ outer headline\n  caused by: inner detail");
793    }
794}