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