Skip to main content

epicenter_cli/
exec.rs

1//! `epicenter exec` — run one command against a fresh per-job build dir
2//! seeded from the vault.
3//!
4//! A job gets its own directory under `{cargo-cache-home}/epicenter/work/<id>/`
5//! that plays the role of Cargo's build dir for the child process
6//! (`CARGO_BUILD_BUILD_DIR` — no config file is written). The vault's unit
7//! groups are hard-linked in first, so the build starts warm; the units the
8//! job produces can be harvested back. Because every job owns its build dir,
9//! jobs never contend on Cargo's per-build-dir lock, and the job dir itself
10//! is a disposable view: deleting it removes no state the vault still holds.
11//!
12//! stdout and stderr of the command pass through untouched; epicenter's own
13//! frame lines go to stderr, and the command's exit code becomes epicenter's
14//! exit code.
15
16use std::collections::HashSet;
17use std::fs;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::process::ExitStatus;
21use std::time::{Instant, SystemTime, UNIX_EPOCH};
22
23use walkdir::WalkDir;
24
25use anyhow::{Context, Result, bail};
26
27use crate::fingerprint;
28use crate::util::{self, GLYPH_FAIL, GLYPH_INFO, GLYPH_PASS, human_bytes, human_duration};
29use crate::vault;
30
31/// `{cargo-cache-home}/epicenter/work` — the home of per-job working dirs,
32/// a sibling of `build/` and `store/`.
33pub fn work_root(build_root: &Path) -> PathBuf {
34    build_root.parent().unwrap_or(build_root).join("work")
35}
36
37/// One job dir as the read-only summary sees it.
38#[derive(Debug, Clone)]
39pub struct WorkDirInfo {
40    pub name: String,
41    pub files: u64,
42    pub bytes: u64,
43    /// Newest file mtime — the dir's last activity.
44    pub newest: Option<SystemTime>,
45}
46
47/// What the `work/` tier currently holds (read-only summary).
48#[derive(Debug, Default)]
49pub struct WorkTier {
50    pub dirs: usize,
51    pub files: u64,
52    pub bytes: u64,
53    /// Newest file mtime across all job dirs — the tier's last activity.
54    pub newest: Option<SystemTime>,
55    /// Per-dir detail, oldest activity first (the most collectible on top).
56    pub entries: Vec<WorkDirInfo>,
57}
58
59/// Summarize the job dirs under `work/`.
60pub fn work_tier(build_root: &Path) -> Result<WorkTier> {
61    let mut tier = WorkTier::default();
62    let Ok(entries) = std::fs::read_dir(work_root(build_root)) else {
63        return Ok(tier);
64    };
65    for entry in entries.flatten() {
66        if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
67            continue;
68        }
69        let name = entry.file_name().to_string_lossy().into_owned();
70        if name.starts_with('.') {
71            continue;
72        }
73        if std::fs::symlink_metadata(entry.path())
74            .map(|meta| util::is_reparse_point(&meta))
75            .unwrap_or(true)
76        {
77            continue;
78        }
79        let mut info = WorkDirInfo {
80            name,
81            files: 0,
82            bytes: 0,
83            newest: None,
84        };
85        for file in WalkDir::new(entry.path())
86            .min_depth(1)
87            .into_iter()
88            .flatten()
89        {
90            if !file.file_type().is_file() {
91                continue;
92            }
93            if let Ok(meta) = file.metadata() {
94                info.files += 1;
95                info.bytes += meta.len();
96                if let Ok(modified) = meta.modified() {
97                    if info.newest.map_or(true, |current| modified > current) {
98                        info.newest = Some(modified);
99                    }
100                }
101            }
102        }
103        tier.dirs += 1;
104        tier.files += info.files;
105        tier.bytes += info.bytes;
106        if let Some(modified) = info.newest {
107            if tier.newest.map_or(true, |current| modified > current) {
108                tier.newest = Some(modified);
109            }
110        }
111        tier.entries.push(info);
112    }
113    tier.entries.sort_by_key(|info| info.newest);
114    Ok(tier)
115}
116
117/// Does a live build hold this build dir? Cargo takes an exclusive advisory
118/// lock on `<build-dir>/<profile>/.cargo-build-lock` for the duration of a
119/// build; the lock file itself survives idle builds, so only lock contention
120/// means "in use". Shared by `exec` (reuse refusal) and `evict` (view guards).
121///
122/// `cargo_locks` returns the held descriptors so a caller that is about to
123/// delete/enter a view can keep Cargo out for the whole operation. A check
124/// that drops the locks immediately is only advisory and races a build that
125/// starts one instruction later.
126pub(crate) fn cargo_locks(dir: &Path) -> Result<Option<Vec<fs::File>>> {
127    // Cargo puts the lock at `<build>/<profile>/.cargo-build-lock` or, with
128    // `--target`, at `<build>/<triple>/<profile>/.cargo-build-lock`. Walk a
129    // bounded depth and take every lock, not just direct children.
130    let entries = WalkDir::new(dir)
131        .min_depth(1)
132        .max_depth(3)
133        .into_iter()
134        .collect::<Result<Vec<_>, _>>()?;
135    let mut locks = Vec::new();
136    for entry in entries {
137        if entry.file_name().to_string_lossy() != ".cargo-build-lock" {
138            continue;
139        }
140        // A symlink/non-regular lock is treated as held (fail closed).
141        if !entry.file_type().is_file() {
142            return Ok(None);
143        }
144        let file = fs::OpenOptions::new()
145            .read(true)
146            .write(true)
147            .open(entry.path())
148            .or_else(|_| fs::File::open(entry.path()));
149        let Ok(file) = file else {
150            return Ok(None);
151        };
152        if file.try_lock().is_err() {
153            return Ok(None);
154        }
155        locks.push(file);
156    }
157    Ok(Some(locks))
158}
159
160pub(crate) fn dir_locked(dir: &Path) -> bool {
161    match cargo_locks(dir) {
162        Ok(Some(locks)) => {
163            drop(locks);
164            false
165        }
166        _ => true,
167    }
168}
169
170/// Path of the per-job epicenter lock (outside the job dir, so `--rm` can
171/// delete the dir while the lock is held).
172pub(crate) fn job_lock_path(work_root: &Path, job_id: &str) -> PathBuf {
173    work_root.join(format!(".lock-{job_id}"))
174}
175
176/// Remove the per-job lock file once the job dir is gone and no process holds
177/// the lock. A held lock (a live job) or an existing dir keeps it.
178pub(crate) fn remove_job_lock(work_root: &Path, job_id: &str) {
179    let dir = work_root.join(job_id);
180    let path = job_lock_path(work_root, job_id);
181    if dir.exists() || !path.exists() {
182        return;
183    }
184    let Ok(Some(_lock)) = crate::util::FileLock::try_acquire(&path) else {
185        return;
186    };
187    if !dir.exists() {
188        let _ = fs::remove_file(&path);
189    }
190}
191
192/// Clear `.lock-*` debris whose job dir is gone (a crash, or a removal from
193/// before this cleanup existed). Locks of live or kept jobs are left alone.
194pub(crate) fn sweep_job_locks(work_root: &Path) {
195    let Ok(entries) = fs::read_dir(work_root) else {
196        return;
197    };
198    for entry in entries.flatten() {
199        let name = entry.file_name().to_string_lossy().into_owned();
200        let Some(job_id) = name.strip_prefix(".lock-") else {
201            continue;
202        };
203        if !job_id.is_empty() {
204            remove_job_lock(work_root, job_id);
205        }
206    }
207}
208
209/// What the caller wants.
210pub struct Options<'a> {
211    /// Job id / work-dir name; `None` mints one.
212    pub job_id: Option<&'a str>,
213    /// Directory the command runs in (default: the current directory).
214    pub cwd: Option<PathBuf>,
215    /// Store the units the job produced back into the vault.
216    pub harvest_back: bool,
217    /// Remove the work dir after the run — success or failure. Without it
218    /// the dir is kept for inspection.
219    pub remove_on_success: bool,
220    /// Continue in an existing job dir instead of refusing (restore is
221    /// idempotent; a dir a live build holds is still refused).
222    pub reuse: bool,
223    /// The child's `CARGO_TARGET_DIR`: `Some("job")` means `<work dir>/target`
224    /// (per-job finals — the fully parallel case); any other value is a path.
225    pub target_dir: Option<&'a str>,
226    /// The command, argv-style.
227    pub command: &'a [String],
228}
229
230/// Resolve `--target-dir`: the bare flag (`"job"`) picks `<work dir>/target`,
231/// so finals live and die with the job; a value is a path, resolved against
232/// the command's working directory when relative.
233fn resolve_target_dir(raw: &str, work_dir: &Path, cwd: Option<&Path>) -> PathBuf {
234    if raw == "job" {
235        return work_dir.join("target");
236    }
237    let path = PathBuf::from(raw);
238    if path.is_absolute() {
239        return path;
240    }
241    let base = cwd
242        .map(Path::to_path_buf)
243        .or_else(|| std::env::current_dir().ok())
244        .unwrap_or_default();
245    base.join(path)
246}
247
248/// The directory the child will run in, as an absolute path when possible.
249fn project_dir(opts: &Options<'_>) -> Option<PathBuf> {
250    match &opts.cwd {
251        Some(dir) if dir.is_absolute() => Some(dir.clone()),
252        Some(dir) => std::env::current_dir().ok().map(|cwd| cwd.join(dir)),
253        None => std::env::current_dir().ok(),
254    }
255}
256
257/// `+toolchain` and `cargo --version` of the child, when it is cargo.
258/// `(None, None)` for any other program (fail-open: the default generation).
259fn child_cargo(command: &[String]) -> (Option<String>, Option<String>) {
260    let Some(program) = command.first() else {
261        return (None, None);
262    };
263    let is_cargo = Path::new(program)
264        .file_stem()
265        .and_then(|stem| stem.to_str())
266        .map(|stem| stem.eq_ignore_ascii_case("cargo"))
267        .unwrap_or(false);
268    if !is_cargo {
269        return (None, None);
270    }
271    // rustup's `+toolchain` must be the first argument after the program.
272    let selector = command
273        .get(1)
274        .and_then(|arg| arg.strip_prefix('+'))
275        .filter(|value| !value.is_empty())
276        .map(str::to_string);
277    let mut probe = std::process::Command::new(program);
278    if let Some(selector) = &selector {
279        probe.arg(format!("+{selector}"));
280    }
281    let version = probe
282        .arg("--version")
283        .output()
284        .ok()
285        .filter(|output| output.status.success())
286        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
287        .filter(|text| text.starts_with("cargo "));
288    (selector, version)
289}
290
291/// Packages the project resolves to, from the nearest `Cargo.lock` (the
292/// workspace root). `None` means "cannot tell" — no lock yet, unreadable, or
293/// empty — and the caller keeps the whole-generation seed (fail-open).
294fn project_packages(cwd: &Path, command: &[String]) -> Option<HashSet<String>> {
295    let mut dir = manifest_arg(command)
296        .map(|manifest| {
297            let manifest = if manifest.is_absolute() {
298                manifest
299            } else {
300                cwd.join(manifest)
301            };
302            manifest
303                .parent()
304                .map(Path::to_path_buf)
305                .unwrap_or_else(|| cwd.to_path_buf())
306        })
307        .unwrap_or_else(|| cwd.to_path_buf());
308    loop {
309        let lock = dir.join("Cargo.lock");
310        if lock.is_file() {
311            return parse_lock(&lock);
312        }
313        dir = dir.parent()?.to_path_buf();
314    }
315}
316
317/// `--manifest-path <p>` or `--manifest-path=<p>` from the child's argv.
318fn manifest_arg(command: &[String]) -> Option<PathBuf> {
319    let mut args = command.iter();
320    while let Some(arg) = args.next() {
321        if let Some(value) = arg.strip_prefix("--manifest-path=") {
322            return Some(PathBuf::from(value));
323        }
324        if arg == "--manifest-path" {
325            return args.next().map(PathBuf::from);
326        }
327    }
328    None
329}
330
331/// Package names from a `Cargo.lock` (all formats: `[[package]] name = …`).
332fn parse_lock(path: &Path) -> Option<HashSet<String>> {
333    let lock: toml::Value = fs::read_to_string(path).ok()?.parse().ok()?;
334    let names: HashSet<String> = lock
335        .get("package")?
336        .as_array()?
337        .iter()
338        .filter_map(|package| package.get("name")?.as_str().map(str::to_owned))
339        .collect();
340    (!names.is_empty()).then_some(names)
341}
342
343/// Run the job; returns the command's exit code.
344pub fn run(store_root: &Path, work_root: &Path, opts: &Options<'_>) -> Result<i32> {
345    let Some(program) = opts.command.first() else {
346        bail!("exec needs a command — `epicenter exec -- <cmd>`");
347    };
348    let job_id = match opts.job_id {
349        Some(id) => sanitize_job_id(id)?,
350        None => default_job_id(),
351    };
352    // Private work root + per-job epicenter lock (outside the job dir, so
353    // --rm can remove the dir while the lock is held).
354    crate::util::ensure_windows_user_path(work_root, "work root")?;
355    crate::util::create_private_dir_all(work_root)?;
356    // Acquire the per-job lock BEFORE looking at/creating the dir: the bulk
357    // work-dir GC takes the same lock and must never see an unlocked empty dir.
358    let job_lock = match crate::util::FileLock::try_acquire(&job_lock_path(work_root, &job_id))? {
359        Some(lock) => lock,
360        None => bail!(
361            "job {} is in use by another epicenter process — wait for it, or use another --id",
362            job_id
363        ),
364    };
365    let work_dir = work_root.join(&job_id);
366    let mut reused = false;
367    let mut reuse_locks: Option<Vec<fs::File>> = None;
368    if fs::symlink_metadata(&work_dir).is_ok() {
369        if !opts.reuse {
370            bail!(
371                "job dir {} already exists — pick another --id, pass --reuse to continue in it, or remove it",
372                work_dir.display()
373            );
374        }
375        let meta = fs::symlink_metadata(&work_dir)
376            .with_context(|| format!("inspecting {}", work_dir.display()))?;
377        if !meta.is_dir() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) {
378            bail!("{} exists and is not a real directory", work_dir.display());
379        }
380        let Some(locks) = cargo_locks(&work_dir)? else {
381            bail!(
382                "job dir {} is in use by a live build — wait for it, or use another --id",
383                work_dir.display()
384            );
385        };
386        // Keep Cargo out while restoring into the reused view. The locks must
387        // be released before the child starts (otherwise its own Cargo would
388        // block), so this is a best-effort narrowing, not a full guarantee.
389        reuse_locks = Some(locks);
390        reused = true;
391    } else {
392        crate::util::create_private_dir_all(&work_dir)?;
393    }
394    let target_dir = opts
395        .target_dir
396        .map(|raw| resolve_target_dir(raw, &work_dir, opts.cwd.as_deref()));
397
398    eprintln!(
399        "{}",
400        util::heading(&format!("epicenter exec — job {job_id}"))
401    );
402    eprintln!(
403        "  {} {}{}",
404        util::dim(&format!("{:<11}", "work dir")),
405        work_dir.display(),
406        if reused {
407            util::dim("  (reused)")
408        } else {
409            String::new()
410        }
411    );
412
413    // An empty vault is a cold build (fail-open), but a corrupt/tampered
414    // vault is not: never run the command over unverified artifacts.
415    // The store is one global generation, so seed only the packages this
416    // project resolves to: restoring all of it puts every other project's
417    // units in the job dir (214 fingerprints for a one-dependency project).
418    // A missing or unreadable lock keeps the whole-generation seed.
419    let packages = project_dir(opts).and_then(|cwd| project_packages(&cwd, opts.command));
420    // The child may select another toolchain (`cargo +nightly`), and Cargo
421    // 1.99-nightly+ writes the new build-dir layout. Both decide which vault
422    // generation/layout can actually be reused.
423    let (selector, cargo_version) = child_cargo(opts.command);
424    let layout = cargo_version
425        .as_deref()
426        .map(|version| fingerprint::layout_for_cargo(version, opts.command));
427    let restore = vault::restore_with(
428        &work_dir,
429        store_root,
430        None,
431        packages.as_ref(),
432        layout,
433        selector.as_deref(),
434        false,
435    )?;
436    if restore.fatal_errors > 0 {
437        bail!(
438            "vault restore refused {} unverified file(s) — refusing to run the command over unverified state",
439            restore.fatal_errors
440        );
441    }
442    match &restore.note {
443        Some(_) if opts.harvest_back => eprintln!(
444            "  {} cold start — the vault has nothing for this generation yet; --harvest will store what this job builds",
445            util::info(GLYPH_INFO)
446        ),
447        Some(_) => eprintln!(
448            "  {} cold start — the vault has nothing for this generation yet; pass --harvest to seed it from this job",
449            util::info(GLYPH_INFO)
450        ),
451        None => eprintln!(
452            "  {} {} unit(s) · {} file(s) · {}",
453            util::dim(&format!("{:<11}", "vault")),
454            restore.units_linked,
455            restore.files_linked + restore.files_copied,
456            util::green(&human_bytes(restore.bytes))
457        ),
458    }
459    eprintln!(
460        "  {} CARGO_BUILD_BUILD_DIR (this job only; no config change)",
461        util::dim(&format!("{:<11}", "build-dir"))
462    );
463    if let (Some(layout), Some(version)) = (layout, cargo_version.as_deref()) {
464        eprintln!(
465            "  {} {} · {}",
466            util::dim(&format!("{:<11}", "layout")),
467            layout.label(),
468            util::dim(version)
469        );
470    }
471    if let Some(target) = &target_dir {
472        eprintln!(
473            "  {} {}{}",
474            util::dim(&format!("{:<11}", "target dir")),
475            target.display(),
476            util::dim("  (CARGO_TARGET_DIR — finals live and die with this job)")
477        );
478    }
479    drop(reuse_locks);
480    eprintln!("{}", util::dim("  ── command ──"));
481    let _ = std::io::stderr().flush();
482
483    let started = Instant::now();
484    let mut child = crate::util::command_for(program)?;
485    child.args(&opts.command[1..]);
486    child.env("CARGO_BUILD_BUILD_DIR", &work_dir);
487    if let Some(target) = &target_dir {
488        child.env("CARGO_TARGET_DIR", target);
489    }
490    if let Some(dir) = &opts.cwd {
491        child.current_dir(dir);
492    }
493    let status = child
494        .status()
495        .with_context(|| format!("cannot run `{program}` — is it installed and on PATH?"))?;
496    let elapsed = started.elapsed();
497    let code = exit_code(&status);
498
499    let harvested = if opts.harvest_back {
500        Some(vault::harvest_with(
501            &work_dir,
502            store_root,
503            None,
504            false,
505            selector.as_deref(),
506            layout,
507        )?)
508    } else {
509        None
510    };
511    let mut removed = false;
512    let mut remove_error = None;
513    if opts.remove_on_success {
514        match util::force_remove_dir(&work_dir) {
515            Ok(()) => removed = true,
516            Err(err) => remove_error = Some(err),
517        }
518    }
519
520    eprintln!("{}", util::dim("  ── end ──"));
521    let mark = if code == 0 {
522        util::green(GLYPH_PASS)
523    } else {
524        util::fail(GLYPH_FAIL)
525    };
526    eprintln!("  {mark} exit {code} · {}", human_duration(elapsed));
527    if let Some(report) = &harvested {
528        if let Some(note) = &report.note {
529            // Harvest refused wholesale (e.g. the job predates the toolchain).
530            eprintln!("  {} {note}", util::warn(util::GLYPH_WARN));
531        } else {
532            let mut line = format!(
533                "{} newly stored · {} refreshed · {} already in the vault",
534                report.stored, report.refreshed, report.unchanged
535            );
536            let skipped = report.skipped_stale + report.skipped_incomplete;
537            if skipped > 0 {
538                line.push_str(&format!(" · {skipped} skipped"));
539            }
540            eprintln!("  {} {line}", util::dim(&format!("{:<11}", "harvest")));
541        }
542    }
543    if removed {
544        eprintln!(
545            "  {} {} (--rm)",
546            util::dim(&format!("{:<11}", "work dir")),
547            util::dim("removed")
548        );
549    } else if let Some(err) = &remove_error {
550        eprintln!(
551            "  {} {} — could not remove it: {err}",
552            util::dim(&format!("{:<11}", "work dir")),
553            work_dir.display()
554        );
555    } else {
556        eprintln!(
557            "  {} {} {}",
558            util::dim(&format!("{:<11}", "work dir")),
559            work_dir.display(),
560            util::dim("(kept — pass --rm to remove)")
561        );
562    }
563    // The job dir is gone: drop its outside lock and clear old lock debris.
564    // A kept dir keeps its lock (the next `--reuse` takes the same file).
565    drop(job_lock);
566    if removed {
567        remove_job_lock(work_root, &job_id);
568        sweep_job_locks(work_root);
569    }
570    Ok(code)
571}
572
573/// Signals use the shell convention: 128 + signal number.
574fn exit_code(status: &ExitStatus) -> i32 {
575    if let Some(code) = status.code() {
576        return code;
577    }
578    #[cfg(unix)]
579    {
580        use std::os::unix::process::ExitStatusExt;
581        if let Some(signal) = status.signal() {
582            return 128 + signal;
583        }
584    }
585    1
586}
587
588fn default_job_id() -> String {
589    let secs = SystemTime::now()
590        .duration_since(UNIX_EPOCH)
591        .map(|duration| duration.as_secs())
592        .unwrap_or(0);
593    format!("job-{}-{}", stamp_from_unix(secs), std::process::id())
594}
595
596/// `YYYYMMDD-HHMMSS` in UTC — sortable, readable, filesystem-safe.
597fn stamp_from_unix(secs: u64) -> String {
598    let days = secs / 86_400;
599    let rem = secs % 86_400;
600    let (year, month, day) = civil_from_days(days as i64);
601    format!(
602        "{year:04}{month:02}{day:02}-{:02}{:02}{:02}",
603        rem / 3_600,
604        (rem % 3_600) / 60,
605        rem % 60
606    )
607}
608
609/// Days since the Unix epoch to a civil date — the standard shift-and-correct
610/// integer algorithm, no lookup tables.
611fn civil_from_days(days: i64) -> (i64, u32, u32) {
612    let z = days + 719_468;
613    let era = z.div_euclid(146_097);
614    let doe = z.rem_euclid(146_097) as u64; // [0, 146096]
615    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
616    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
617    let mp = (5 * doy + 2) / 153; // [0, 11]
618    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
619    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
620    let year = yoe as i64 + era * 400 + if month <= 2 { 1 } else { 0 };
621    (year, month, day)
622}
623
624/// Job ids become directory names: plain characters only.
625fn sanitize_job_id(id: &str) -> Result<String> {
626    let trimmed = id.trim();
627    if trimmed.is_empty() {
628        bail!("--id must not be empty");
629    }
630    if trimmed == "." || trimmed == ".." || trimmed.starts_with('.') {
631        bail!("--id {id:?} is not a usable directory name");
632    }
633    if !trimmed
634        .chars()
635        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
636    {
637        bail!("--id {id:?}: use letters, digits, `-`, `_` or `.`");
638    }
639    if trimmed.ends_with(['.', ' ']) {
640        bail!("--id {id:?} must not end with '.' or ' ' (Windows strips it)");
641    }
642    if crate::util::is_windows_reserved(trimmed) {
643        bail!("--id {id:?} is a reserved Windows device name");
644    }
645    Ok(trimmed.to_string())
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::fs;
652
653    #[test]
654    fn utc_stamp_is_correct_and_sortable() {
655        assert_eq!(stamp_from_unix(0), "19700101-000000");
656        assert_eq!(stamp_from_unix(86_399), "19700101-235959");
657        assert_eq!(stamp_from_unix(86_400), "19700102-000000");
658        assert_eq!(stamp_from_unix(1_800_000_000), "20270115-080000");
659    }
660
661    #[test]
662    fn job_ids_are_path_safe() {
663        assert_eq!(sanitize_job_id(" ci-1234_ok ").unwrap(), "ci-1234_ok");
664        assert!(sanitize_job_id("").is_err());
665        assert!(sanitize_job_id("..").is_err());
666        assert!(sanitize_job_id("../x").is_err());
667        assert!(sanitize_job_id("a/b").is_err());
668        assert!(sanitize_job_id(".hidden").is_err());
669        assert!(sanitize_job_id("has space").is_err());
670        assert!(sanitize_job_id("CON").is_err());
671        assert!(sanitize_job_id("com1").is_err());
672        assert!(sanitize_job_id("trailing.").is_err());
673        assert!(sanitize_job_id("CON.txt").is_err());
674        assert!(sanitize_job_id("NUL.log").is_err());
675        assert!(sanitize_job_id("COM1.txt").is_err());
676    }
677
678    /// One synthetic unit in a build dir, harvested into a temp vault.
679    fn seeded_store() -> Result<(tempfile::TempDir, tempfile::TempDir)> {
680        let build = tempfile::tempdir()?;
681        let store = tempfile::tempdir()?;
682        let unit = build.path().join("debug/.fingerprint/x-1122334455667788");
683        fs::create_dir_all(&unit)?;
684        fs::write(unit.join("lib-x"), "aaaabbbbccccdddd")?;
685        fs::write(unit.join("dep-lib-x"), [0u8; 4])?;
686        fs::create_dir_all(build.path().join("debug/deps"))?;
687        fs::write(
688            build.path().join("debug/deps/libx-1122334455667788.rlib"),
689            [1u8; 8],
690        )?;
691        vault::harvest(build.path(), store.path(), None, false)?;
692        Ok((build, store))
693    }
694
695    #[cfg(unix)]
696    #[test]
697    fn exec_seeds_the_job_and_propagates_the_exit_code() -> Result<()> {
698        let (_build, store) = seeded_store()?;
699        let work = tempfile::tempdir()?;
700        // The synthetic store unit is for package `x`: name it in a lock so
701        // this test does not depend on whatever Cargo.lock exists above /tmp.
702        let project = tempfile::tempdir()?;
703        fs::write(
704            project.path().join("Cargo.lock"),
705            "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n",
706        )?;
707        let job_dir = work.path().join("jobtest");
708        let command = vec![
709            "sh".to_string(),
710            "-c".to_string(),
711            format!(
712                "test \"$CARGO_BUILD_BUILD_DIR\" = '{}' && exit 7",
713                job_dir.display()
714            ),
715        ];
716        let opts = Options {
717            job_id: Some("jobtest"),
718            cwd: Some(project.path().to_path_buf()),
719            harvest_back: false,
720            remove_on_success: false,
721            reuse: false,
722            target_dir: None,
723            command: &command,
724        };
725        let code = run(store.path(), work.path(), &opts)?;
726        assert_eq!(code, 7, "the command's exit code is the job's exit code");
727        assert!(
728            job_dir
729                .join("debug/.fingerprint/x-1122334455667788/lib-x")
730                .is_file(),
731            "the vault seeded the job"
732        );
733        Ok(())
734    }
735
736    #[test]
737    fn exec_seeds_only_the_packages_in_the_project_lock() -> Result<()> {
738        let (_build, store) = seeded_store()?;
739        let work = tempfile::tempdir()?;
740        let project = tempfile::tempdir()?;
741        let command: Vec<String> = vec!["true".into()];
742        let lock = project.path().join("Cargo.lock");
743
744        // The project's lock does not name the stored unit: do not seed it.
745        fs::write(
746            &lock,
747            "version = 4\n\n[[package]]\nname = \"unrelated\"\nversion = \"1.0.0\"\n",
748        )?;
749        let opts = Options {
750            job_id: Some("filtered"),
751            cwd: Some(project.path().to_path_buf()),
752            harvest_back: false,
753            remove_on_success: false,
754            reuse: false,
755            target_dir: None,
756            command: &command,
757        };
758        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
759        assert!(
760            !work
761                .path()
762                .join("filtered/debug/.fingerprint/x-1122334455667788")
763                .exists(),
764            "a unit outside the lock is not seeded"
765        );
766
767        // Naming the package in the lock seeds exactly that unit.
768        fs::write(
769            &lock,
770            "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n",
771        )?;
772        let opts = Options {
773            job_id: Some("seeded"),
774            ..opts
775        };
776        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
777        assert!(
778            work.path()
779                .join("seeded/debug/.fingerprint/x-1122334455667788/lib-x")
780                .is_file(),
781            "a unit inside the lock is seeded"
782        );
783        Ok(())
784    }
785
786    #[cfg(unix)]
787    #[test]
788    fn exec_rm_removes_the_job_dir_either_way() -> Result<()> {
789        let (_build, store) = seeded_store()?;
790        let work = tempfile::tempdir()?;
791        // Old lock debris from a job dir that no longer exists (or a crash).
792        fs::write(work.path().join(".lock-orphan"), b"")?;
793
794        let ok: Vec<String> = vec!["true".into()];
795        let opts = Options {
796            job_id: Some("j-ok"),
797            cwd: None,
798            harvest_back: false,
799            remove_on_success: true,
800            reuse: false,
801            target_dir: None,
802            command: &ok,
803        };
804        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
805        assert!(!work.path().join("j-ok").exists(), "success -> removed");
806        assert!(
807            !work.path().join(".lock-j-ok").exists(),
808            "success -> its job lock removed"
809        );
810        assert!(
811            !work.path().join(".lock-orphan").exists(),
812            "orphan lock debris swept"
813        );
814
815        // `--rm` is explicit: a failing build is removed too (the fixed
816        // rmeta-era failure mode used to leave dirs piling up here).
817        let bad: Vec<String> = vec!["false".into()];
818        let opts = Options {
819            job_id: Some("j-bad"),
820            cwd: None,
821            harvest_back: false,
822            remove_on_success: true,
823            reuse: false,
824            target_dir: None,
825            command: &bad,
826        };
827        assert_eq!(run(store.path(), work.path(), &opts)?, 1);
828        assert!(
829            !work.path().join("j-bad").exists(),
830            "--rm -> removed on failure too"
831        );
832        assert!(
833            !work.path().join(".lock-j-bad").exists(),
834            "--rm -> its job lock removed on failure too"
835        );
836
837        // Without --rm the dir stays for inspection.
838        let kept: Vec<String> = vec!["true".into()];
839        let opts = Options {
840            job_id: Some("j-keep"),
841            cwd: None,
842            harvest_back: false,
843            remove_on_success: false,
844            reuse: false,
845            target_dir: None,
846            command: &kept,
847        };
848        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
849        assert!(
850            work.path().join("j-keep").exists(),
851            "no --rm -> kept for inspection"
852        );
853        assert!(
854            work.path().join(".lock-j-keep").exists(),
855            "a kept job keeps its lock for the next --reuse"
856        );
857        Ok(())
858    }
859
860    #[test]
861    fn work_tier_summarizes_job_dirs() -> Result<()> {
862        let home = tempfile::tempdir()?;
863        let build = home.path().join("build");
864        fs::create_dir_all(&build)?;
865        let debug = home.path().join("work/ci-1/debug");
866        fs::create_dir_all(&debug)?;
867        fs::write(debug.join("a.bin"), [0u8; 100])?;
868        fs::write(debug.join("b.bin"), [0u8; 50])?;
869
870        let tier = work_tier(&build)?;
871        assert_eq!(tier.dirs, 1);
872        assert_eq!(tier.files, 2);
873        assert_eq!(tier.bytes, 150);
874        assert!(tier.newest.is_some());
875        assert_eq!(tier.entries.len(), 1);
876        assert_eq!(tier.entries[0].name, "ci-1");
877        assert_eq!(tier.entries[0].bytes, 150);
878        assert_eq!(tier.entries[0].files, 2);
879        Ok(())
880    }
881
882    #[test]
883    fn exec_refuses_a_used_job_dir() -> Result<()> {
884        let (_build, store) = seeded_store()?;
885        let work = tempfile::tempdir()?;
886        fs::create_dir_all(work.path().join("taken"))?;
887        let command: Vec<String> = vec!["true".into()];
888        let opts = Options {
889            job_id: Some("taken"),
890            cwd: None,
891            harvest_back: false,
892            remove_on_success: false,
893            reuse: false,
894            target_dir: None,
895            command: &command,
896        };
897        let err = run(store.path(), work.path(), &opts).unwrap_err();
898        assert!(err.to_string().contains("already exists"), "{err}");
899        assert!(err.to_string().contains("--reuse"), "mentions the way out");
900        Ok(())
901    }
902
903    #[cfg(unix)]
904    #[test]
905    fn target_dir_resolution() {
906        let work = Path::new("/cache/work/ci-1");
907        let cwd = Path::new("/project");
908        assert_eq!(
909            resolve_target_dir("job", work, Some(cwd)),
910            PathBuf::from("/cache/work/ci-1/target")
911        );
912        assert_eq!(
913            resolve_target_dir("/abs/finals", work, Some(cwd)),
914            PathBuf::from("/abs/finals")
915        );
916        assert_eq!(
917            resolve_target_dir("rel/finals", work, Some(cwd)),
918            PathBuf::from("/project/rel/finals")
919        );
920    }
921
922    #[cfg(unix)]
923    #[test]
924    fn exec_reuse_continues_in_an_existing_dir() -> Result<()> {
925        let (_build, store) = seeded_store()?;
926        let work = tempfile::tempdir()?;
927        let ok: Vec<String> = vec!["true".into()];
928        let fresh = Options {
929            job_id: Some("j1"),
930            cwd: None,
931            harvest_back: false,
932            remove_on_success: false,
933            reuse: false,
934            target_dir: None,
935            command: &ok,
936        };
937        assert_eq!(run(store.path(), work.path(), &fresh)?, 0);
938
939        let err = run(store.path(), work.path(), &fresh).unwrap_err();
940        assert!(err.to_string().contains("already exists"), "{err}");
941
942        let again = Options {
943            reuse: true,
944            ..fresh
945        };
946        assert_eq!(run(store.path(), work.path(), &again)?, 0);
947        Ok(())
948    }
949
950    #[test]
951    fn exec_reuse_refuses_a_locked_dir() -> Result<()> {
952        let (_build, store) = seeded_store()?;
953        let work = tempfile::tempdir()?;
954        let debug = work.path().join("busy/debug");
955        fs::create_dir_all(&debug)?;
956        let lock = debug.join(".cargo-build-lock");
957        fs::write(&lock, b"")?;
958        let held = fs::OpenOptions::new().write(true).open(&lock)?;
959        held.try_lock().context("test holds the lock")?;
960
961        let cmd: Vec<String> = vec!["true".into()];
962        let opts = Options {
963            job_id: Some("busy"),
964            cwd: None,
965            harvest_back: false,
966            remove_on_success: false,
967            reuse: true,
968            target_dir: None,
969            command: &cmd,
970        };
971        let err = run(store.path(), work.path(), &opts).unwrap_err();
972        assert!(err.to_string().contains("in use"), "{err}");
973        Ok(())
974    }
975
976    #[test]
977    fn nested_target_lock_is_detected() -> Result<()> {
978        let home = tempfile::tempdir()?;
979        let job = home.path().join("job/debug");
980        fs::create_dir_all(&job)?;
981        let lock = job.join(".cargo-build-lock");
982        fs::write(&lock, b"")?;
983        let held = fs::OpenOptions::new().write(true).open(&lock)?;
984        held.try_lock().context("test holds the lock")?;
985        assert!(dir_locked(&home.path().join("job")), "direct profile lock");
986
987        let nested = home.path().join("cross/x86_64-unknown-linux-gnu/debug");
988        fs::create_dir_all(&nested)?;
989        let lock = nested.join(".cargo-build-lock");
990        fs::write(&lock, b"")?;
991        let held = fs::OpenOptions::new().write(true).open(&lock)?;
992        held.try_lock().context("test holds the nested lock")?;
993        assert!(
994            dir_locked(&home.path().join("cross")),
995            "target-triple lock must be found"
996        );
997        Ok(())
998    }
999
1000    #[cfg(unix)]
1001    #[test]
1002    fn exec_exports_a_per_job_target_dir() -> Result<()> {
1003        let (_build, store) = seeded_store()?;
1004        let work = tempfile::tempdir()?;
1005        let job_dir = work.path().join("t1");
1006        let command = vec![
1007            "sh".to_string(),
1008            "-c".to_string(),
1009            format!(
1010                "test \"$CARGO_TARGET_DIR\" = '{}'",
1011                job_dir.join("target").display()
1012            ),
1013        ];
1014        let opts = Options {
1015            job_id: Some("t1"),
1016            cwd: None,
1017            harvest_back: false,
1018            remove_on_success: false,
1019            reuse: false,
1020            target_dir: Some("job"),
1021            command: &command,
1022        };
1023        assert_eq!(run(store.path(), work.path(), &opts)?, 0);
1024        Ok(())
1025    }
1026}