1use std::fs;
17use std::io::Write;
18use std::path::{Path, PathBuf};
19use std::process::ExitStatus;
20use std::time::{Instant, SystemTime, UNIX_EPOCH};
21
22use walkdir::WalkDir;
23
24use anyhow::{Context, Result, bail};
25
26use crate::util::{self, GLYPH_FAIL, GLYPH_INFO, GLYPH_PASS, human_bytes, human_duration};
27use crate::vault;
28
29pub fn work_root(build_root: &Path) -> PathBuf {
32 build_root.parent().unwrap_or(build_root).join("work")
33}
34
35#[derive(Debug, Clone)]
37pub struct WorkDirInfo {
38 pub name: String,
39 pub files: u64,
40 pub bytes: u64,
41 pub newest: Option<SystemTime>,
43}
44
45#[derive(Debug, Default)]
47pub struct WorkTier {
48 pub dirs: usize,
49 pub files: u64,
50 pub bytes: u64,
51 pub newest: Option<SystemTime>,
53 pub entries: Vec<WorkDirInfo>,
55}
56
57pub fn work_tier(build_root: &Path) -> Result<WorkTier> {
59 let mut tier = WorkTier::default();
60 let Ok(entries) = std::fs::read_dir(work_root(build_root)) else {
61 return Ok(tier);
62 };
63 for entry in entries.flatten() {
64 if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
65 continue;
66 }
67 let name = entry.file_name().to_string_lossy().into_owned();
68 if name.starts_with('.') {
69 continue;
70 }
71 if std::fs::symlink_metadata(entry.path())
72 .map(|meta| util::is_reparse_point(&meta))
73 .unwrap_or(true)
74 {
75 continue;
76 }
77 let mut info = WorkDirInfo {
78 name,
79 files: 0,
80 bytes: 0,
81 newest: None,
82 };
83 for file in WalkDir::new(entry.path())
84 .min_depth(1)
85 .into_iter()
86 .flatten()
87 {
88 if !file.file_type().is_file() {
89 continue;
90 }
91 if let Ok(meta) = file.metadata() {
92 info.files += 1;
93 info.bytes += meta.len();
94 if let Ok(modified) = meta.modified() {
95 if info.newest.map_or(true, |current| modified > current) {
96 info.newest = Some(modified);
97 }
98 }
99 }
100 }
101 tier.dirs += 1;
102 tier.files += info.files;
103 tier.bytes += info.bytes;
104 if let Some(modified) = info.newest {
105 if tier.newest.map_or(true, |current| modified > current) {
106 tier.newest = Some(modified);
107 }
108 }
109 tier.entries.push(info);
110 }
111 tier.entries.sort_by_key(|info| info.newest);
112 Ok(tier)
113}
114
115pub(crate) fn cargo_locks(dir: &Path) -> Result<Option<Vec<fs::File>>> {
125 let entries = WalkDir::new(dir)
129 .min_depth(1)
130 .max_depth(3)
131 .into_iter()
132 .collect::<Result<Vec<_>, _>>()?;
133 let mut locks = Vec::new();
134 for entry in entries {
135 if entry.file_name().to_string_lossy() != ".cargo-build-lock" {
136 continue;
137 }
138 if !entry.file_type().is_file() {
140 return Ok(None);
141 }
142 let file = fs::OpenOptions::new()
143 .read(true)
144 .write(true)
145 .open(entry.path())
146 .or_else(|_| fs::File::open(entry.path()));
147 let Ok(file) = file else {
148 return Ok(None);
149 };
150 if file.try_lock().is_err() {
151 return Ok(None);
152 }
153 locks.push(file);
154 }
155 Ok(Some(locks))
156}
157
158pub(crate) fn dir_locked(dir: &Path) -> bool {
159 match cargo_locks(dir) {
160 Ok(Some(locks)) => {
161 drop(locks);
162 false
163 }
164 _ => true,
165 }
166}
167
168pub(crate) fn job_lock_path(work_root: &Path, job_id: &str) -> PathBuf {
171 work_root.join(format!(".lock-{job_id}"))
172}
173
174pub struct Options<'a> {
176 pub job_id: Option<&'a str>,
178 pub cwd: Option<PathBuf>,
180 pub harvest_back: bool,
182 pub remove_on_success: bool,
185 pub reuse: bool,
188 pub target_dir: Option<&'a str>,
191 pub command: &'a [String],
193}
194
195fn resolve_target_dir(raw: &str, work_dir: &Path, cwd: Option<&Path>) -> PathBuf {
199 if raw == "job" {
200 return work_dir.join("target");
201 }
202 let path = PathBuf::from(raw);
203 if path.is_absolute() {
204 return path;
205 }
206 let base = cwd
207 .map(Path::to_path_buf)
208 .or_else(|| std::env::current_dir().ok())
209 .unwrap_or_default();
210 base.join(path)
211}
212
213pub fn run(store_root: &Path, work_root: &Path, opts: &Options<'_>) -> Result<i32> {
215 let Some(program) = opts.command.first() else {
216 bail!("exec needs a command — `epicenter exec -- <cmd>`");
217 };
218 let job_id = match opts.job_id {
219 Some(id) => sanitize_job_id(id)?,
220 None => default_job_id(),
221 };
222 crate::util::ensure_windows_user_path(work_root, "work root")?;
225 crate::util::create_private_dir_all(work_root)?;
226 let _job_lock = match crate::util::FileLock::try_acquire(&job_lock_path(work_root, &job_id))? {
229 Some(lock) => lock,
230 None => bail!(
231 "job {} is in use by another epicenter process — wait for it, or use another --id",
232 job_id
233 ),
234 };
235 let work_dir = work_root.join(&job_id);
236 let mut reused = false;
237 let mut reuse_locks: Option<Vec<fs::File>> = None;
238 if fs::symlink_metadata(&work_dir).is_ok() {
239 if !opts.reuse {
240 bail!(
241 "job dir {} already exists — pick another --id, pass --reuse to continue in it, or remove it",
242 work_dir.display()
243 );
244 }
245 let meta = fs::symlink_metadata(&work_dir)
246 .with_context(|| format!("inspecting {}", work_dir.display()))?;
247 if !meta.is_dir() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) {
248 bail!("{} exists and is not a real directory", work_dir.display());
249 }
250 let Some(locks) = cargo_locks(&work_dir)? else {
251 bail!(
252 "job dir {} is in use by a live build — wait for it, or use another --id",
253 work_dir.display()
254 );
255 };
256 reuse_locks = Some(locks);
260 reused = true;
261 } else {
262 crate::util::create_private_dir_all(&work_dir)?;
263 }
264 let target_dir = opts
265 .target_dir
266 .map(|raw| resolve_target_dir(raw, &work_dir, opts.cwd.as_deref()));
267
268 eprintln!(
269 "{}",
270 util::heading(&format!("epicenter exec — job {job_id}"))
271 );
272 eprintln!(
273 " {} {}{}",
274 util::dim(&format!("{:<11}", "work dir")),
275 work_dir.display(),
276 if reused {
277 util::dim(" (reused)")
278 } else {
279 String::new()
280 }
281 );
282
283 let restore = vault::restore(&work_dir, store_root, None, false)?;
286 if restore.fatal_errors > 0 {
287 bail!(
288 "vault restore refused {} unverified file(s) — refusing to run the command over unverified state",
289 restore.fatal_errors
290 );
291 }
292 match &restore.note {
293 Some(_) if opts.harvest_back => eprintln!(
294 " {} cold start — the vault has nothing for this generation yet; --harvest will store what this job builds",
295 util::info(GLYPH_INFO)
296 ),
297 Some(_) => eprintln!(
298 " {} cold start — the vault has nothing for this generation yet; pass --harvest to seed it from this job",
299 util::info(GLYPH_INFO)
300 ),
301 None => eprintln!(
302 " {} {} unit(s) · {} file(s) · {}",
303 util::dim(&format!("{:<11}", "vault")),
304 restore.units_linked,
305 restore.files_linked + restore.files_copied,
306 util::green(&human_bytes(restore.bytes))
307 ),
308 }
309 eprintln!(
310 " {} CARGO_BUILD_BUILD_DIR (this job only; no config change)",
311 util::dim(&format!("{:<11}", "build-dir"))
312 );
313 if let Some(target) = &target_dir {
314 eprintln!(
315 " {} {}{}",
316 util::dim(&format!("{:<11}", "target dir")),
317 target.display(),
318 util::dim(" (CARGO_TARGET_DIR — finals live and die with this job)")
319 );
320 }
321 drop(reuse_locks);
322 eprintln!("{}", util::dim(" ── command ──"));
323 let _ = std::io::stderr().flush();
324
325 let started = Instant::now();
326 let mut child = crate::util::command_for(program)?;
327 child.args(&opts.command[1..]);
328 child.env("CARGO_BUILD_BUILD_DIR", &work_dir);
329 if let Some(target) = &target_dir {
330 child.env("CARGO_TARGET_DIR", target);
331 }
332 if let Some(dir) = &opts.cwd {
333 child.current_dir(dir);
334 }
335 let status = child
336 .status()
337 .with_context(|| format!("cannot run `{program}` — is it installed and on PATH?"))?;
338 let elapsed = started.elapsed();
339 let code = exit_code(&status);
340
341 let harvested = if opts.harvest_back {
342 Some(vault::harvest(&work_dir, store_root, None, false)?)
343 } else {
344 None
345 };
346 let mut removed = false;
347 let mut remove_error = None;
348 if opts.remove_on_success {
349 match util::force_remove_dir(&work_dir) {
350 Ok(()) => removed = true,
351 Err(err) => remove_error = Some(err),
352 }
353 }
354
355 eprintln!("{}", util::dim(" ── end ──"));
356 let mark = if code == 0 {
357 util::green(GLYPH_PASS)
358 } else {
359 util::fail(GLYPH_FAIL)
360 };
361 eprintln!(" {mark} exit {code} · {}", human_duration(elapsed));
362 if let Some(report) = &harvested {
363 if let Some(note) = &report.note {
364 eprintln!(" {} {note}", util::warn(util::GLYPH_WARN));
366 } else {
367 let mut line = format!(
368 "{} newly stored · {} refreshed · {} already in the vault",
369 report.stored, report.refreshed, report.unchanged
370 );
371 let skipped = report.skipped_stale + report.skipped_incomplete;
372 if skipped > 0 {
373 line.push_str(&format!(" · {skipped} skipped"));
374 }
375 eprintln!(" {} {line}", util::dim(&format!("{:<11}", "harvest")));
376 }
377 }
378 if removed {
379 eprintln!(
380 " {} {} (--rm)",
381 util::dim(&format!("{:<11}", "work dir")),
382 util::dim("removed")
383 );
384 } else if let Some(err) = &remove_error {
385 eprintln!(
386 " {} {} — could not remove it: {err}",
387 util::dim(&format!("{:<11}", "work dir")),
388 work_dir.display()
389 );
390 } else {
391 eprintln!(
392 " {} {} {}",
393 util::dim(&format!("{:<11}", "work dir")),
394 work_dir.display(),
395 util::dim("(kept — pass --rm to remove)")
396 );
397 }
398 Ok(code)
399}
400
401fn exit_code(status: &ExitStatus) -> i32 {
403 if let Some(code) = status.code() {
404 return code;
405 }
406 #[cfg(unix)]
407 {
408 use std::os::unix::process::ExitStatusExt;
409 if let Some(signal) = status.signal() {
410 return 128 + signal;
411 }
412 }
413 1
414}
415
416fn default_job_id() -> String {
417 let secs = SystemTime::now()
418 .duration_since(UNIX_EPOCH)
419 .map(|duration| duration.as_secs())
420 .unwrap_or(0);
421 format!("job-{}-{}", stamp_from_unix(secs), std::process::id())
422}
423
424fn stamp_from_unix(secs: u64) -> String {
426 let days = secs / 86_400;
427 let rem = secs % 86_400;
428 let (year, month, day) = civil_from_days(days as i64);
429 format!(
430 "{year:04}{month:02}{day:02}-{:02}{:02}{:02}",
431 rem / 3_600,
432 (rem % 3_600) / 60,
433 rem % 60
434 )
435}
436
437fn civil_from_days(days: i64) -> (i64, u32, u32) {
440 let z = days + 719_468;
441 let era = z.div_euclid(146_097);
442 let doe = z.rem_euclid(146_097) as u64; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u32; let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let year = yoe as i64 + era * 400 + if month <= 2 { 1 } else { 0 };
449 (year, month, day)
450}
451
452fn sanitize_job_id(id: &str) -> Result<String> {
454 let trimmed = id.trim();
455 if trimmed.is_empty() {
456 bail!("--id must not be empty");
457 }
458 if trimmed == "." || trimmed == ".." || trimmed.starts_with('.') {
459 bail!("--id {id:?} is not a usable directory name");
460 }
461 if !trimmed
462 .chars()
463 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
464 {
465 bail!("--id {id:?}: use letters, digits, `-`, `_` or `.`");
466 }
467 if trimmed.ends_with(['.', ' ']) {
468 bail!("--id {id:?} must not end with '.' or ' ' (Windows strips it)");
469 }
470 if crate::util::is_windows_reserved(trimmed) {
471 bail!("--id {id:?} is a reserved Windows device name");
472 }
473 Ok(trimmed.to_string())
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use std::fs;
480
481 #[test]
482 fn utc_stamp_is_correct_and_sortable() {
483 assert_eq!(stamp_from_unix(0), "19700101-000000");
484 assert_eq!(stamp_from_unix(86_399), "19700101-235959");
485 assert_eq!(stamp_from_unix(86_400), "19700102-000000");
486 assert_eq!(stamp_from_unix(1_800_000_000), "20270115-080000");
487 }
488
489 #[test]
490 fn job_ids_are_path_safe() {
491 assert_eq!(sanitize_job_id(" ci-1234_ok ").unwrap(), "ci-1234_ok");
492 assert!(sanitize_job_id("").is_err());
493 assert!(sanitize_job_id("..").is_err());
494 assert!(sanitize_job_id("../x").is_err());
495 assert!(sanitize_job_id("a/b").is_err());
496 assert!(sanitize_job_id(".hidden").is_err());
497 assert!(sanitize_job_id("has space").is_err());
498 assert!(sanitize_job_id("CON").is_err());
499 assert!(sanitize_job_id("com1").is_err());
500 assert!(sanitize_job_id("trailing.").is_err());
501 assert!(sanitize_job_id("CON.txt").is_err());
502 assert!(sanitize_job_id("NUL.log").is_err());
503 assert!(sanitize_job_id("COM1.txt").is_err());
504 }
505
506 fn seeded_store() -> Result<(tempfile::TempDir, tempfile::TempDir)> {
508 let build = tempfile::tempdir()?;
509 let store = tempfile::tempdir()?;
510 let unit = build.path().join("debug/.fingerprint/x-1122334455667788");
511 fs::create_dir_all(&unit)?;
512 fs::write(unit.join("lib-x"), "aaaabbbbccccdddd")?;
513 fs::write(unit.join("dep-lib-x"), [0u8; 4])?;
514 fs::create_dir_all(build.path().join("debug/deps"))?;
515 fs::write(
516 build.path().join("debug/deps/libx-1122334455667788.rlib"),
517 [1u8; 8],
518 )?;
519 vault::harvest(build.path(), store.path(), None, false)?;
520 Ok((build, store))
521 }
522
523 #[cfg(unix)]
524 #[test]
525 fn exec_seeds_the_job_and_propagates_the_exit_code() -> Result<()> {
526 let (_build, store) = seeded_store()?;
527 let work = tempfile::tempdir()?;
528 let job_dir = work.path().join("jobtest");
529 let command = vec![
530 "sh".to_string(),
531 "-c".to_string(),
532 format!(
533 "test \"$CARGO_BUILD_BUILD_DIR\" = '{}' && exit 7",
534 job_dir.display()
535 ),
536 ];
537 let opts = Options {
538 job_id: Some("jobtest"),
539 cwd: None,
540 harvest_back: false,
541 remove_on_success: false,
542 reuse: false,
543 target_dir: None,
544 command: &command,
545 };
546 let code = run(store.path(), work.path(), &opts)?;
547 assert_eq!(code, 7, "the command's exit code is the job's exit code");
548 assert!(
549 job_dir
550 .join("debug/.fingerprint/x-1122334455667788/lib-x")
551 .is_file(),
552 "the vault seeded the job"
553 );
554 Ok(())
555 }
556
557 #[cfg(unix)]
558 #[test]
559 fn exec_rm_removes_the_job_dir_either_way() -> Result<()> {
560 let (_build, store) = seeded_store()?;
561 let work = tempfile::tempdir()?;
562
563 let ok: Vec<String> = vec!["true".into()];
564 let opts = Options {
565 job_id: Some("j-ok"),
566 cwd: None,
567 harvest_back: false,
568 remove_on_success: true,
569 reuse: false,
570 target_dir: None,
571 command: &ok,
572 };
573 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
574 assert!(!work.path().join("j-ok").exists(), "success -> removed");
575
576 let bad: Vec<String> = vec!["false".into()];
579 let opts = Options {
580 job_id: Some("j-bad"),
581 cwd: None,
582 harvest_back: false,
583 remove_on_success: true,
584 reuse: false,
585 target_dir: None,
586 command: &bad,
587 };
588 assert_eq!(run(store.path(), work.path(), &opts)?, 1);
589 assert!(
590 !work.path().join("j-bad").exists(),
591 "--rm -> removed on failure too"
592 );
593
594 let kept: Vec<String> = vec!["true".into()];
596 let opts = Options {
597 job_id: Some("j-keep"),
598 cwd: None,
599 harvest_back: false,
600 remove_on_success: false,
601 reuse: false,
602 target_dir: None,
603 command: &kept,
604 };
605 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
606 assert!(
607 work.path().join("j-keep").exists(),
608 "no --rm -> kept for inspection"
609 );
610 Ok(())
611 }
612
613 #[test]
614 fn work_tier_summarizes_job_dirs() -> Result<()> {
615 let home = tempfile::tempdir()?;
616 let build = home.path().join("build");
617 fs::create_dir_all(&build)?;
618 let debug = home.path().join("work/ci-1/debug");
619 fs::create_dir_all(&debug)?;
620 fs::write(debug.join("a.bin"), [0u8; 100])?;
621 fs::write(debug.join("b.bin"), [0u8; 50])?;
622
623 let tier = work_tier(&build)?;
624 assert_eq!(tier.dirs, 1);
625 assert_eq!(tier.files, 2);
626 assert_eq!(tier.bytes, 150);
627 assert!(tier.newest.is_some());
628 assert_eq!(tier.entries.len(), 1);
629 assert_eq!(tier.entries[0].name, "ci-1");
630 assert_eq!(tier.entries[0].bytes, 150);
631 assert_eq!(tier.entries[0].files, 2);
632 Ok(())
633 }
634
635 #[test]
636 fn exec_refuses_a_used_job_dir() -> Result<()> {
637 let (_build, store) = seeded_store()?;
638 let work = tempfile::tempdir()?;
639 fs::create_dir_all(work.path().join("taken"))?;
640 let command: Vec<String> = vec!["true".into()];
641 let opts = Options {
642 job_id: Some("taken"),
643 cwd: None,
644 harvest_back: false,
645 remove_on_success: false,
646 reuse: false,
647 target_dir: None,
648 command: &command,
649 };
650 let err = run(store.path(), work.path(), &opts).unwrap_err();
651 assert!(err.to_string().contains("already exists"), "{err}");
652 assert!(err.to_string().contains("--reuse"), "mentions the way out");
653 Ok(())
654 }
655
656 #[cfg(unix)]
657 #[test]
658 fn target_dir_resolution() {
659 let work = Path::new("/cache/work/ci-1");
660 let cwd = Path::new("/project");
661 assert_eq!(
662 resolve_target_dir("job", work, Some(cwd)),
663 PathBuf::from("/cache/work/ci-1/target")
664 );
665 assert_eq!(
666 resolve_target_dir("/abs/finals", work, Some(cwd)),
667 PathBuf::from("/abs/finals")
668 );
669 assert_eq!(
670 resolve_target_dir("rel/finals", work, Some(cwd)),
671 PathBuf::from("/project/rel/finals")
672 );
673 }
674
675 #[cfg(unix)]
676 #[test]
677 fn exec_reuse_continues_in_an_existing_dir() -> Result<()> {
678 let (_build, store) = seeded_store()?;
679 let work = tempfile::tempdir()?;
680 let ok: Vec<String> = vec!["true".into()];
681 let fresh = Options {
682 job_id: Some("j1"),
683 cwd: None,
684 harvest_back: false,
685 remove_on_success: false,
686 reuse: false,
687 target_dir: None,
688 command: &ok,
689 };
690 assert_eq!(run(store.path(), work.path(), &fresh)?, 0);
691
692 let err = run(store.path(), work.path(), &fresh).unwrap_err();
693 assert!(err.to_string().contains("already exists"), "{err}");
694
695 let again = Options {
696 reuse: true,
697 ..fresh
698 };
699 assert_eq!(run(store.path(), work.path(), &again)?, 0);
700 Ok(())
701 }
702
703 #[test]
704 fn exec_reuse_refuses_a_locked_dir() -> Result<()> {
705 let (_build, store) = seeded_store()?;
706 let work = tempfile::tempdir()?;
707 let debug = work.path().join("busy/debug");
708 fs::create_dir_all(&debug)?;
709 let lock = debug.join(".cargo-build-lock");
710 fs::write(&lock, b"")?;
711 let held = fs::OpenOptions::new().write(true).open(&lock)?;
712 held.try_lock().context("test holds the lock")?;
713
714 let cmd: Vec<String> = vec!["true".into()];
715 let opts = Options {
716 job_id: Some("busy"),
717 cwd: None,
718 harvest_back: false,
719 remove_on_success: false,
720 reuse: true,
721 target_dir: None,
722 command: &cmd,
723 };
724 let err = run(store.path(), work.path(), &opts).unwrap_err();
725 assert!(err.to_string().contains("in use"), "{err}");
726 Ok(())
727 }
728
729 #[test]
730 fn nested_target_lock_is_detected() -> Result<()> {
731 let home = tempfile::tempdir()?;
732 let job = home.path().join("job/debug");
733 fs::create_dir_all(&job)?;
734 let lock = job.join(".cargo-build-lock");
735 fs::write(&lock, b"")?;
736 let held = fs::OpenOptions::new().write(true).open(&lock)?;
737 held.try_lock().context("test holds the lock")?;
738 assert!(dir_locked(&home.path().join("job")), "direct profile lock");
739
740 let nested = home.path().join("cross/x86_64-unknown-linux-gnu/debug");
741 fs::create_dir_all(&nested)?;
742 let lock = nested.join(".cargo-build-lock");
743 fs::write(&lock, b"")?;
744 let held = fs::OpenOptions::new().write(true).open(&lock)?;
745 held.try_lock().context("test holds the nested lock")?;
746 assert!(
747 dir_locked(&home.path().join("cross")),
748 "target-triple lock must be found"
749 );
750 Ok(())
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn exec_exports_a_per_job_target_dir() -> Result<()> {
756 let (_build, store) = seeded_store()?;
757 let work = tempfile::tempdir()?;
758 let job_dir = work.path().join("t1");
759 let command = vec![
760 "sh".to_string(),
761 "-c".to_string(),
762 format!(
763 "test \"$CARGO_TARGET_DIR\" = '{}'",
764 job_dir.join("target").display()
765 ),
766 ];
767 let opts = Options {
768 job_id: Some("t1"),
769 cwd: None,
770 harvest_back: false,
771 remove_on_success: false,
772 reuse: false,
773 target_dir: Some("job"),
774 command: &command,
775 };
776 assert_eq!(run(store.path(), work.path(), &opts)?, 0);
777 Ok(())
778 }
779}