1use std::collections::{HashMap, HashSet, VecDeque};
2use std::fs;
3use std::path::Path;
4use std::time::{Duration, SystemTime};
5
6use anyhow::{Context as _, Result};
7
8pub fn static_regex(pattern: &str) -> regex::Regex {
16 regex::Regex::new(pattern)
17 .unwrap_or_else(|e| panic!("invalid static regex literal `{}`: {}", pattern, e))
18}
19
20pub fn topological_sort(items: &[(impl AsRef<str>, impl AsRef<[String]>)]) -> Vec<String> {
34 let names: HashSet<&str> = items.iter().map(|(n, _)| n.as_ref()).collect();
35
36 let mut in_degree: HashMap<&str, usize> = items
37 .iter()
38 .map(|(n, deps)| {
39 let deg = deps
40 .as_ref()
41 .iter()
42 .filter(|d| names.contains(d.as_str()))
43 .count();
44 (n.as_ref(), deg)
45 })
46 .collect();
47
48 let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
50 for (n, deps) in items {
51 for dep in deps.as_ref() {
52 if names.contains(dep.as_str()) {
53 edges.entry(dep.as_str()).or_default().push(n.as_ref());
54 }
55 }
56 }
57
58 let mut queue: VecDeque<&str> = {
60 let mut v: Vec<&str> = in_degree
61 .iter()
62 .filter(|(_, d)| **d == 0)
63 .map(|(&n, _)| n)
64 .collect();
65 v.sort_unstable();
66 VecDeque::from(v)
67 };
68
69 let mut result = Vec::with_capacity(items.len());
70 while let Some(node) = queue.pop_front() {
71 result.push(node.to_string());
72 if let Some(dependents) = edges.get(node) {
73 let mut next: Vec<&str> = dependents
74 .iter()
75 .filter_map(|&dep| {
76 let deg = in_degree.get_mut(dep)?;
77 *deg -= 1;
78 if *deg == 0 { Some(dep) } else { None }
79 })
80 .collect();
81 next.sort_unstable();
82 for n in next {
83 queue.push_back(n);
84 }
85 }
86 }
87
88 if result.len() < items.len() {
90 let in_result: HashSet<String> = result.iter().cloned().collect();
91 for (n, _) in items {
92 if !in_result.contains(n.as_ref()) {
93 result.push(n.as_ref().to_string());
94 }
95 }
96 }
97
98 result
99}
100
101pub fn parse_mod_timestamp(raw: &str) -> Result<SystemTime> {
115 if let Ok(epoch_secs) = raw.parse::<u64>() {
117 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
118 }
119 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
121 let epoch_secs = dt.timestamp() as u64;
122 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
123 }
124 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
126 let epoch_secs = dt.and_utc().timestamp() as u64;
127 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
128 }
129 anyhow::bail!(
130 "mod_timestamp value '{raw}' is not a valid timestamp. \
131 Accepted formats: Unix epoch seconds (e.g. \"1704067200\") or \
132 RFC 3339 datetime (e.g. \"2024-01-01T00:00:00Z\")"
133 )
134}
135
136pub fn apply_mod_timestamp(dir: &Path, raw: &str, log: &crate::log::StageLogger) -> Result<()> {
146 let mtime = parse_mod_timestamp(raw)?;
147
148 let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
149 while let Some(p) = stack.pop() {
150 for entry in
151 fs::read_dir(&p).with_context(|| format!("read staging dir {}", p.display()))?
152 {
153 let entry = entry?;
154 let path = entry.path();
155 let ft = entry.file_type()?;
156 if ft.is_dir() {
157 stack.push(path);
158 } else if ft.is_file() {
159 set_file_mtime(&path, mtime)?;
160 }
161 }
162 }
163
164 log.status(&format!("applied mod_timestamp={raw} to staging files"));
165 Ok(())
166}
167
168pub fn set_file_mtime(path: &Path, mtime: SystemTime) -> Result<()> {
170 let file = std::fs::OpenOptions::new()
171 .write(true)
172 .open(path)
173 .with_context(|| format!("open {} for mtime update", path.display()))?;
174 file.set_times(
175 std::fs::FileTimes::new()
176 .set_accessed(mtime)
177 .set_modified(mtime),
178 )
179 .with_context(|| format!("set mtime on {}", path.display()))?;
180 Ok(())
181}
182
183pub fn set_file_mtime_epoch(path: &Path, epoch_secs: i64) -> Result<()> {
188 let mtime = if epoch_secs >= 0 {
189 SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs as u64)
190 } else {
191 SystemTime::UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64)
192 };
193 set_file_mtime(path, mtime)
194}
195
196pub fn pin_dir_mtimes_epoch(dir: &Path, epoch_secs: i64) -> Result<()> {
205 let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
206 while let Some(p) = stack.pop() {
207 for entry in
208 fs::read_dir(&p).with_context(|| format!("read_dir {} for mtime pin", p.display()))?
209 {
210 let entry = entry?;
211 let path = entry.path();
212 let ft = entry.file_type()?;
213 if ft.is_dir() {
214 stack.push(path);
215 } else if ft.is_file() {
216 set_file_mtime_epoch(&path, epoch_secs)
217 .with_context(|| format!("pin mtime on {}", path.display()))?;
218 }
219 }
220 }
221 Ok(())
222}
223
224pub fn copy_dir_tree(src: &Path, dst: &Path) -> Result<()> {
236 fs::create_dir_all(dst).with_context(|| format!("create dir {}", dst.display()))?;
237 for entry in fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
238 let entry = entry.with_context(|| format!("read entry under {}", src.display()))?;
239 let from = entry.path();
240 let to = dst.join(entry.file_name());
241 let file_type = entry
244 .file_type()
245 .with_context(|| format!("stat {}", from.display()))?;
246 if file_type.is_symlink() {
247 #[cfg(unix)]
248 {
249 let target = fs::read_link(&from)
250 .with_context(|| format!("read symlink {}", from.display()))?;
251 std::os::unix::fs::symlink(&target, &to).with_context(|| {
252 format!("recreate symlink {} -> {}", to.display(), target.display())
253 })?;
254 }
255 #[cfg(not(unix))]
256 {
257 if from.is_dir() {
258 copy_dir_tree(&from, &to)?;
259 } else {
260 fs::copy(&from, &to)
261 .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
262 }
263 }
264 } else if file_type.is_dir() {
265 copy_dir_tree(&from, &to)?;
266 } else {
267 fs::copy(&from, &to)
268 .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
269 }
270 }
271 Ok(())
272}
273
274pub fn collect_replace_archives(
280 artifacts: &crate::artifact::ArtifactRegistry,
281 crate_name: &str,
282 target: Option<&str>,
283) -> Vec<std::path::PathBuf> {
284 artifacts
285 .by_kind_and_crate(crate::artifact::ArtifactKind::Archive, crate_name)
286 .iter()
287 .filter(|a| a.target.as_deref() == target)
288 .map(|a| a.path.clone())
289 .collect()
290}
291
292pub fn collect_if_replace(
299 replace: Option<bool>,
300 artifacts: &crate::artifact::ArtifactRegistry,
301 crate_name: &str,
302 target: Option<&str>,
303) -> Vec<std::path::PathBuf> {
304 if replace.unwrap_or(false) {
305 collect_replace_archives(artifacts, crate_name, target)
306 } else {
307 Vec::new()
308 }
309}
310
311pub fn normalize_path_separators(s: &str) -> String {
316 s.replace('\\', "/")
317}
318
319pub fn apply_minimal_env(command: &mut std::process::Command) {
330 const PASSTHROUGH: &[&str] = &[
331 "HOME",
332 "USER",
333 "USERPROFILE",
334 "TMPDIR",
335 "TMP",
336 "TEMP",
337 "PATH",
338 "LOCALAPPDATA",
339 ];
340 for key in PASSTHROUGH {
341 if let Ok(val) = std::env::var(key) {
342 command.env(key, val);
343 }
344 }
345}
346
347const CARGO_BUILD_INTERMEDIATE_DIRS: &[&str] = &["deps", "build", "incremental", ".fingerprint"];
357
358pub fn free_cargo_build_intermediates(
378 profile_dir: &Path,
379 log: &crate::log::StageLogger,
380) -> Vec<&'static str> {
381 let is_cargo_profile_dir = profile_dir
382 .file_name()
383 .and_then(|n| n.to_str())
384 .is_some_and(|n| n == "release" || n == "debug");
385 if !is_cargo_profile_dir {
386 log.verbose(&format!(
387 "refusing to free build intermediates under non-profile dir {}",
388 profile_dir.display()
389 ));
390 return Vec::new();
391 }
392 let mut freed = Vec::new();
393 for sub in CARGO_BUILD_INTERMEDIATE_DIRS {
394 let path = profile_dir.join(sub);
395 if !path.exists() {
396 continue;
397 }
398 match fs::remove_dir_all(&path) {
399 Ok(()) => freed.push(*sub),
400 Err(err) => log.verbose(&format!(
401 "could not free build intermediate {}: {err}",
402 path.display()
403 )),
404 }
405 }
406 freed
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
418 fn test_topo_sort_simple_chain() {
419 let items = vec![
420 ("c".to_string(), vec!["b".to_string()]),
421 ("b".to_string(), vec!["a".to_string()]),
422 ("a".to_string(), vec![]),
423 ];
424 let sorted = topological_sort(&items);
425 assert_eq!(sorted, vec!["a", "b", "c"]);
426 }
427
428 #[test]
429 fn test_topo_sort_no_deps() {
430 let items = vec![("b".to_string(), vec![]), ("a".to_string(), vec![])];
431 let sorted = topological_sort(&items);
433 assert_eq!(sorted, vec!["a", "b"]);
434 }
435
436 #[test]
437 fn test_topo_sort_ignores_external_deps() {
438 let items = vec![
439 (
440 "b".to_string(),
441 vec!["a".to_string(), "external".to_string()],
442 ),
443 ("a".to_string(), vec![]),
444 ];
445 let sorted = topological_sort(&items);
446 assert_eq!(sorted, vec!["a", "b"]);
447 }
448
449 #[test]
450 fn test_topo_sort_diamond() {
451 let items = vec![
452 ("d".to_string(), vec!["b".to_string(), "c".to_string()]),
453 ("b".to_string(), vec!["a".to_string()]),
454 ("c".to_string(), vec!["a".to_string()]),
455 ("a".to_string(), vec![]),
456 ];
457 let sorted = topological_sort(&items);
458 assert_eq!(sorted[0], "a");
460 assert_eq!(sorted[3], "d");
461 }
462
463 #[test]
464 fn test_topo_sort_cycle_appends_remaining() {
465 let items = vec![
466 ("a".to_string(), vec!["b".to_string()]),
467 ("b".to_string(), vec!["a".to_string()]),
468 ("c".to_string(), vec![]),
469 ];
470 let sorted = topological_sort(&items);
471 assert_eq!(sorted.len(), 3);
472 assert_eq!(sorted[0], "c");
474 }
475
476 #[test]
477 fn test_topo_sort_empty() {
478 let items: Vec<(String, Vec<String>)> = vec![];
479 let sorted = topological_sort(&items);
480 assert!(sorted.is_empty());
481 }
482
483 #[test]
488 fn test_parse_mod_timestamp_epoch_integer() {
489 let t = parse_mod_timestamp("1704067200").unwrap();
490 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
491 assert_eq!(epoch, 1704067200);
492 }
493
494 #[test]
495 fn test_parse_mod_timestamp_rfc3339() {
496 let t = parse_mod_timestamp("2024-01-01T00:00:00Z").unwrap();
497 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
498 assert_eq!(epoch, 1704067200);
499 }
500
501 #[test]
502 fn test_parse_mod_timestamp_rfc3339_with_offset() {
503 let t = parse_mod_timestamp("2024-01-01T01:00:00+01:00").unwrap();
504 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
505 assert_eq!(epoch, 1704067200);
507 }
508
509 #[test]
510 fn test_parse_mod_timestamp_naive_datetime() {
511 let t = parse_mod_timestamp("2024-01-01T00:00:00").unwrap();
512 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
513 assert_eq!(epoch, 1704067200);
514 }
515
516 #[test]
517 fn test_parse_mod_timestamp_invalid() {
518 let err = parse_mod_timestamp("not-a-timestamp").unwrap_err();
519 let msg = err.to_string();
520 assert!(
521 msg.contains("not a valid timestamp"),
522 "unexpected error: {msg}"
523 );
524 assert!(
527 msg.contains("not-a-timestamp"),
528 "error must include the bad value, got: {msg}"
529 );
530 }
531
532 #[test]
533 fn test_parse_mod_timestamp_zero() {
534 let t = parse_mod_timestamp("0").unwrap();
535 assert_eq!(t, SystemTime::UNIX_EPOCH);
536 }
537
538 #[test]
543 fn test_set_file_mtime_sets_both_atime_and_mtime() {
544 let dir = tempfile::tempdir().unwrap();
545 let dir = dir.path();
546
547 let file_path = dir.join("test.txt");
548 std::fs::write(&file_path, "hello").unwrap();
549
550 let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
552 set_file_mtime(&file_path, target).unwrap();
553
554 let meta = std::fs::metadata(&file_path).unwrap();
555 let actual_mtime = meta.modified().unwrap();
556
557 let diff = if actual_mtime > target {
559 actual_mtime.duration_since(target).unwrap()
560 } else {
561 target.duration_since(actual_mtime).unwrap()
562 };
563 assert!(
564 diff.as_secs() <= 1,
565 "mtime should be within 1s of target, diff={:?}",
566 diff
567 );
568
569 let actual_atime = meta.accessed().unwrap();
571 let diff_a = if actual_atime > target {
572 actual_atime.duration_since(target).unwrap()
573 } else {
574 target.duration_since(actual_atime).unwrap()
575 };
576 assert!(
577 diff_a.as_secs() <= 1,
578 "atime should be within 1s of target, diff={:?}",
579 diff_a
580 );
581 }
582
583 #[test]
584 fn test_pin_dir_mtimes_epoch_recurses_into_subdirs() {
585 let dir = tempfile::tempdir().unwrap();
586 let dir = dir.path();
587 let sub = dir.join("nested");
588 std::fs::create_dir_all(&sub).unwrap();
589
590 let top = dir.join("top.txt");
591 let nested = sub.join("nested.txt");
592 std::fs::write(&top, "top").unwrap();
593 std::fs::write(&nested, "nested").unwrap();
594
595 let epoch: i64 = 1704067200;
596 pin_dir_mtimes_epoch(dir, epoch).unwrap();
597
598 let target = SystemTime::UNIX_EPOCH + Duration::from_secs(epoch as u64);
599 for path in [&top, &nested] {
600 let mtime = std::fs::metadata(path).unwrap().modified().unwrap();
601 assert_eq!(
602 mtime,
603 target,
604 "{}: mtime must equal the pinned epoch exactly",
605 path.display()
606 );
607 }
608 }
609
610 #[test]
611 fn test_set_file_mtime_nonexistent_file() {
612 let result = set_file_mtime(Path::new("/nonexistent/file.txt"), SystemTime::UNIX_EPOCH);
613 assert!(result.is_err());
614 }
615
616 #[test]
621 fn test_apply_mod_timestamp_sets_mtime_on_regular_files() {
622 let dir = tempfile::tempdir().unwrap();
623 let dir = dir.path();
624
625 std::fs::write(dir.join("a.txt"), "aaa").unwrap();
627 std::fs::write(dir.join("b.txt"), "bbb").unwrap();
628 std::fs::create_dir(dir.join("subdir")).unwrap();
629
630 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
631 apply_mod_timestamp(dir, "1704067200", &log).unwrap();
632
633 let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
634 for name in &["a.txt", "b.txt"] {
635 let meta = std::fs::metadata(dir.join(name)).unwrap();
636 let mtime = meta.modified().unwrap();
637 let diff = if mtime > target {
638 mtime.duration_since(target).unwrap()
639 } else {
640 target.duration_since(mtime).unwrap()
641 };
642 assert!(
643 diff.as_secs() <= 1,
644 "{name}: mtime should be within 1s of target, diff={:?}",
645 diff
646 );
647 }
648 }
649
650 #[test]
651 fn test_apply_mod_timestamp_recurses_into_subdirs() {
652 let dir = tempfile::tempdir().unwrap();
653 let dir = dir.path();
654 let sub = dir.join("docs");
655 std::fs::create_dir_all(&sub).unwrap();
656
657 let top = dir.join("top.txt");
658 let nested = sub.join("README.txt");
659 std::fs::write(&top, "top").unwrap();
660 std::fs::write(&nested, "nested").unwrap();
661
662 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
663 apply_mod_timestamp(dir, "1704067200", &log).unwrap();
664
665 let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
666 for path in [&top, &nested] {
667 let mtime = std::fs::metadata(path).unwrap().modified().unwrap();
668 let diff = if mtime > target {
669 mtime.duration_since(target).unwrap()
670 } else {
671 target.duration_since(mtime).unwrap()
672 };
673 assert!(
674 diff.as_secs() <= 1,
675 "{}: nested file must receive mod_timestamp, diff={:?}",
676 path.display(),
677 diff
678 );
679 }
680 }
681
682 #[test]
683 fn test_apply_mod_timestamp_invalid_timestamp_errors() {
684 let dir = tempfile::tempdir().unwrap();
685
686 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
687 let result = apply_mod_timestamp(dir.path(), "not-valid", &log);
688 assert!(result.is_err());
689 }
690
691 fn mk_release_dir(root: &Path) -> std::path::PathBuf {
699 let profile = root
700 .join("target")
701 .join("x86_64-unknown-linux-gnu")
702 .join("release");
703 std::fs::create_dir_all(&profile).unwrap();
704 profile
705 }
706
707 #[test]
708 fn test_free_cargo_build_intermediates_removes_transient_keeps_binary() {
709 let tmp = tempfile::tempdir().unwrap();
710 let profile = mk_release_dir(tmp.path());
711
712 for sub in ["deps", "build", "incremental", ".fingerprint"] {
716 let d = profile.join(sub);
717 std::fs::create_dir_all(&d).unwrap();
718 std::fs::write(d.join("scratch.o"), "obj").unwrap();
719 }
720 std::fs::write(profile.join("myapp"), b"\x7fELF binary").unwrap();
721 std::fs::write(profile.join("myapp.d"), "depinfo").unwrap();
722
723 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
724 let mut freed = free_cargo_build_intermediates(&profile, &log);
725 freed.sort_unstable();
726 assert_eq!(freed, vec![".fingerprint", "build", "deps", "incremental"]);
727
728 for sub in ["deps", "build", "incremental", ".fingerprint"] {
729 assert!(
730 !profile.join(sub).exists(),
731 "transient subdir {sub} should be removed"
732 );
733 }
734 assert!(profile.join("myapp").exists(), "binary must be retained");
735 assert_eq!(
736 std::fs::read(profile.join("myapp")).unwrap(),
737 b"\x7fELF binary"
738 );
739 assert!(
740 profile.join("myapp.d").exists(),
741 "sibling regular file must be retained"
742 );
743 }
744
745 #[test]
746 fn test_free_cargo_build_intermediates_missing_dirs_is_noop() {
747 let tmp = tempfile::tempdir().unwrap();
748 let profile = mk_release_dir(tmp.path());
749 std::fs::write(profile.join("myapp"), "bin").unwrap();
751
752 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
753 let freed = free_cargo_build_intermediates(&profile, &log);
754 assert!(freed.is_empty(), "nothing to free when no subdirs exist");
755 assert!(profile.join("myapp").exists());
756 }
757
758 #[test]
759 fn test_free_cargo_build_intermediates_partial_subset() {
760 let tmp = tempfile::tempdir().unwrap();
761 let profile = mk_release_dir(tmp.path());
762 std::fs::create_dir_all(profile.join("deps")).unwrap();
765 std::fs::create_dir_all(profile.join("incremental")).unwrap();
766 std::fs::write(profile.join("myapp"), "bin").unwrap();
767
768 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
769 let mut freed = free_cargo_build_intermediates(&profile, &log);
770 freed.sort_unstable();
771 assert_eq!(freed, vec!["deps", "incremental"]);
772 assert!(profile.join("myapp").exists());
773 }
774
775 #[test]
779 fn test_free_cargo_build_intermediates_non_profile_dir_is_noop() {
780 let tmp = tempfile::tempdir().unwrap();
781 let not_profile = tmp.path().join("target");
783 std::fs::create_dir_all(not_profile.join("deps")).unwrap();
784 std::fs::create_dir_all(not_profile.join("build")).unwrap();
785
786 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
787 let freed = free_cargo_build_intermediates(¬_profile, &log);
788 assert!(
789 freed.is_empty(),
790 "non-profile dir must free nothing (guard)"
791 );
792 assert!(
793 not_profile.join("deps").exists() && not_profile.join("build").exists(),
794 "guard must leave a non-profile dir's contents untouched"
795 );
796 }
797}