1use std::fs;
32use std::io::Write;
33use std::path::{Path, PathBuf};
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use anyhow::{Context, Result};
37
38use crate::util::{self, FileLock};
39use serde::{Deserialize, Serialize};
40use walkdir::WalkDir;
41
42use crate::fingerprint::{self, ArtifactInfo, Unit};
43
44const PARITY_SEED: &str = "epicenter-parity-v2";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Toolchain {
52 pub release: String,
54 pub commit: String,
56 pub host: String,
58}
59
60impl Toolchain {
61 pub fn version_line(&self) -> String {
63 format!("rustc {} ({})", self.release, self.commit)
64 }
65}
66
67pub fn toolchain() -> Result<Toolchain> {
69 let mut command = util::command_for("rustc")?;
70 let output = command.arg("-vV").output().context("running `rustc -vV`")?;
71 if !output.status.success() {
72 anyhow::bail!("`rustc -vV` failed — is a toolchain installed?");
73 }
74 let text = String::from_utf8_lossy(&output.stdout);
75
76 let mut release = "unknown".to_string();
77 let mut commit = "unknown".to_string();
78 let mut host = "unknown".to_string();
79 for line in text.lines() {
80 if let Some(rest) = line.strip_prefix("rustc ") {
81 let mut parts = rest.split_whitespace();
82 if let Some(version) = parts.next() {
83 release = version.to_string();
84 }
85 if let Some(commit_part) = parts.next() {
87 let value = commit_part.trim_start_matches('(');
88 if !value.is_empty() {
89 commit = value.to_string();
90 }
91 }
92 } else if let Some(value) = line.strip_prefix("host: ") {
93 host = value.trim().to_string();
94 }
95 }
96 util::validate_component(&release, "rustc release")?;
97 util::validate_component(&commit, "rustc commit")?;
98 util::validate_component(&host, "rustc host")?;
99 Ok(Toolchain {
100 release,
101 commit,
102 host,
103 })
104}
105
106pub fn toolchain_install_time() -> Option<SystemTime> {
110 let mut command = util::command_for("rustc").ok()?;
111 let output = command.args(["--print", "sysroot"]).output().ok()?;
112 if !output.status.success() {
113 return None;
114 }
115 let sysroot = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
116 let rustc = sysroot
117 .join("bin")
118 .join(if cfg!(windows) { "rustc.exe" } else { "rustc" });
119 fs::metadata(&rustc)
120 .and_then(|meta| meta.modified())
121 .ok()
122 .or_else(|| fs::metadata(&sysroot).and_then(|meta| meta.modified()).ok())
123}
124
125pub fn parity_key() -> Result<String> {
130 let tc = toolchain()?;
131 let key = format!(
132 "{}-{}-{}-{}",
133 tc.release,
134 tc.commit,
135 tc.host,
136 flags_digest()
137 );
138 util::validate_component(&key, "parity key")?;
141 Ok(key)
142}
143
144fn flags_digest() -> String {
147 let mut input = String::from(PARITY_SEED);
148 for name in [
149 "RUSTFLAGS",
150 "RUSTDOCFLAGS",
151 "CARGO_BUILD_RUSTFLAGS",
152 "CARGO_BUILD_RUSTDOCFLAGS",
153 "CARGO_BUILD_TARGET",
154 "RUSTC_WRAPPER",
155 "CARGO_BUILD_RUSTC_WRAPPER",
156 "RUSTC",
157 ] {
158 input.push('\0');
159 input.push_str(name);
160 input.push('=');
161 input.push_str(&std::env::var(name).unwrap_or_default());
162 }
163 if let Ok(path) = crate::config::cargo_config_path() {
164 if let Ok(text) = fs::read_to_string(path) {
165 for key in ["rustflags", "rustdocflags", "target", "rustc-wrapper"] {
166 if let Some(value) = crate::config::read_table_key(&text, "build", key) {
167 input.push('\0');
168 input.push_str(key);
169 input.push('=');
170 input.push_str(&value);
171 }
172 }
173 }
174 }
175 if let Ok(mut cargo) = util::command_for("cargo") {
176 if let Ok(output) = cargo.arg("-V").output() {
177 if output.status.success() {
178 input.push('\0');
179 input.push_str("cargo=");
180 input.push_str(String::from_utf8_lossy(&output.stdout).trim());
181 }
182 }
183 }
184 format!("{:08x}", (fnv1a64(input.as_bytes()) >> 32) as u32)
185}
186
187fn fnv1a64(bytes: &[u8]) -> u64 {
188 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
189 for byte in bytes {
190 hash ^= *byte as u64;
191 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
192 }
193 hash
194}
195
196pub fn store_root(build_root: &Path) -> PathBuf {
201 build_root.parent().unwrap_or(build_root).join("store")
202}
203
204pub fn units_dir(store_root: &Path, parity: &str) -> PathBuf {
206 store_root.join(parity).join("units")
207}
208
209fn manifest_path(store_root: &Path, parity: &str) -> PathBuf {
210 store_root.join(parity).join("manifest.json")
211}
212
213fn store_lock_path(store_root: &Path, parity: &str) -> PathBuf {
216 store_root.join(format!(".lock-{parity}"))
217}
218
219fn ensure_store_root(store_root: &Path) -> Result<()> {
220 match fs::symlink_metadata(store_root) {
221 Ok(meta) => {
222 if !meta.is_dir() || meta.file_type().is_symlink() {
223 anyhow::bail!(
224 "store root {} is not a real directory",
225 store_root.display()
226 );
227 }
228 util::ensure_private_dir(store_root)?;
229 }
230 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
231 util::create_private_dir_all(store_root)?;
232 }
233 Err(err) => {
234 return Err(err).with_context(|| format!("inspecting {}", store_root.display()));
235 }
236 }
237 Ok(())
238}
239
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
244pub struct Manifest {
245 pub parity_key: String,
246 pub toolchain: String,
247 pub created: u64,
249 pub updated: u64,
251 pub units: Vec<UnitEntry>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct UnitEntry {
257 pub name: String,
259 pub profile: String,
260 pub memo: String,
262 pub hash: String,
264 pub stamp: u64,
266 pub files: Vec<FileEntry>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct FileEntry {
272 pub path: String,
274 pub dest: String,
276 pub size: u64,
277 #[serde(default)]
279 pub sha256: String,
280 pub role: String,
282}
283
284impl Manifest {
285 pub fn validate(&self) -> Result<()> {
288 for unit in &self.units {
289 util::validate_component(&unit.name, "unit name")?;
290 util::validate_component(&unit.profile, "profile")?;
291 util::validate_component(&unit.memo, "memo file")?;
292 if !unit.hash.is_empty() && !fingerprint::looks_like_hash(&unit.hash) {
293 anyhow::bail!("invalid memo hash {:?} in unit {}", unit.hash, unit.name);
294 }
295 for file in &unit.files {
296 util::validate_rel(&file.path, "stored file path")?;
297 util::validate_rel(&file.dest, "destination path")?;
298 if !file.sha256.is_empty()
299 && (file.sha256.len() != 64
300 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()))
301 {
302 anyhow::bail!("invalid SHA-256 for {} in unit {}", file.path, unit.name);
303 }
304 }
305 }
306 Ok(())
307 }
308}
309
310fn load_manifest(store_root: &Path, parity: &str) -> Result<Option<Manifest>> {
311 let path = manifest_path(store_root, parity);
312 if !path.is_file() {
313 return Ok(None);
314 }
315 let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
316 let manifest: Manifest =
317 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
318 manifest
319 .validate()
320 .with_context(|| format!("validating {}", path.display()))?;
321 Ok(Some(manifest))
322}
323
324fn save_manifest(store_root: &Path, parity: &str, manifest: &Manifest) -> Result<()> {
325 util::validate_component(parity, "generation")?;
326 let dir = store_root.join(parity);
327 util::create_private_dir_all(&dir)?;
328 let what = serde_json::to_string_pretty(manifest)?;
329 let mut tmp = None;
331 for attempt in 0..100u32 {
332 let candidate = dir.join(format!(
333 "manifest.json.tmp-{}-{attempt}",
334 std::process::id()
335 ));
336 match fs::OpenOptions::new()
337 .write(true)
338 .create_new(true)
339 .open(&candidate)
340 {
341 Ok(file) => {
342 tmp = Some((candidate, file));
343 break;
344 }
345 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
346 Err(err) => {
347 return Err(err).with_context(|| format!("creating {}", candidate.display()));
348 }
349 }
350 }
351 let Some((tmp, mut file)) = tmp else {
352 anyhow::bail!(
353 "could not create a unique manifest temp file in {}",
354 dir.display()
355 );
356 };
357 file.write_all(what.as_bytes())?;
358 file.write_all(b"\n")?;
359 file.sync_all()?;
360 drop(file);
361 fs::rename(&tmp, manifest_path(store_root, parity))?;
362 #[cfg(unix)]
363 if let Ok(parent) = fs::File::open(&dir) {
364 let _ = parent.sync_all();
365 }
366 Ok(())
367}
368
369pub fn generation_names(store_root: &Path) -> Result<Vec<String>> {
371 if !store_root.is_dir() {
372 return Ok(Vec::new());
373 }
374 let mut names = Vec::new();
375 for entry in
376 fs::read_dir(store_root).with_context(|| format!("reading {}", store_root.display()))?
377 {
378 let entry = entry?;
379 if !entry.file_type()?.is_dir() {
380 continue;
381 }
382 let name = entry.file_name().to_string_lossy().into_owned();
383 if !name.starts_with('.') {
384 names.push(name);
385 }
386 }
387 names.sort();
388 Ok(names)
389}
390
391#[derive(Debug, Clone)]
395pub struct StoredUnit {
396 pub generation: String,
398 pub entry: UnitEntry,
400 pub dir: PathBuf,
402}
403
404pub fn stored_units(store_root: &Path) -> Result<Vec<StoredUnit>> {
406 let mut out = Vec::new();
407 for generation in generation_names(store_root)? {
408 let Some(manifest) = load_manifest(store_root, &generation)? else {
409 continue;
410 };
411 let units = units_dir(store_root, &generation);
412 for entry in manifest.units {
413 let dir = units.join(&entry.name);
414 out.push(StoredUnit {
415 generation: generation.clone(),
416 entry,
417 dir,
418 });
419 }
420 }
421 Ok(out)
422}
423
424pub fn stored_unit_rustc(unit: &StoredUnit) -> Option<String> {
426 let dir_meta = fs::symlink_metadata(&unit.dir).ok()?;
427 if !dir_meta.is_dir() || dir_meta.file_type().is_symlink() {
428 return None;
429 }
430 let rel = PathBuf::from(format!("{}.json", unit.entry.memo));
431 util::check_regular_chain(&unit.dir, &rel).ok()?;
432 let text = fs::read_to_string(unit.dir.join(&rel)).ok()?;
433 let value: serde_json::Value = serde_json::from_str(&text).ok()?;
434 match value.get("rustc") {
435 Some(serde_json::Value::Number(number)) => Some(number.to_string()),
436 Some(serde_json::Value::String(text)) => Some(text.clone()),
437 _ => None,
438 }
439}
440
441pub fn drop_units(store_root: &Path, generation: &str, names: &[String]) -> Result<usize> {
445 util::validate_component(generation, "generation")?;
446 let _lock = FileLock::acquire(&store_lock_path(store_root, generation))?;
447 let Some(mut manifest) = load_manifest(store_root, generation)? else {
448 return Ok(0);
449 };
450 let units = units_dir(store_root, generation);
451 let mut kept = Vec::with_capacity(manifest.units.len());
452 let mut dropped = 0;
453 for entry in manifest.units.drain(..) {
454 if !names.contains(&entry.name) {
455 kept.push(entry);
456 continue;
457 }
458 util::validate_component(&entry.name, "unit name")?;
459 let dir = units.join(&entry.name);
460 match fs::symlink_metadata(&dir) {
461 Ok(meta) if meta.file_type().is_symlink() => {
462 eprintln!("could not remove symlinked unit {}: refused", dir.display());
463 kept.push(entry);
464 }
465 Ok(meta) if meta.is_dir() => match fs::remove_dir_all(&dir) {
466 Ok(()) => {
467 dropped += 1;
468 }
469 Err(err) => {
470 eprintln!("could not remove {}: {err}", dir.display());
471 kept.push(entry);
472 }
473 },
474 Ok(_) => {
475 eprintln!("could not remove {}: not a directory", dir.display());
476 kept.push(entry);
477 }
478 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
479 dropped += 1;
480 }
481 Err(err) => {
482 eprintln!("could not remove {}: {err}", dir.display());
483 kept.push(entry);
484 }
485 }
486 }
487 manifest.units = kept;
488 if manifest.units.is_empty() {
489 let _ = fs::remove_dir_all(store_root.join(generation));
490 } else if dropped > 0 {
491 manifest.updated = now_secs();
492 save_manifest(store_root, generation, &manifest)?;
493 }
494 Ok(dropped)
495}
496#[derive(Debug, Default)]
500pub struct HarvestReport {
501 pub parity: String,
502 pub toolchain: String,
503 pub stored: usize,
504 pub refreshed: usize,
505 pub unchanged: usize,
506 pub skipped_stale: usize,
507 pub skipped_incomplete: usize,
508 pub files: usize,
509 pub bytes: u64,
510 pub note: Option<String>,
511}
512
513pub fn harvest(
520 build_root: &Path,
521 store_root: &Path,
522 unit_filter: Option<&str>,
523 dry_run: bool,
524) -> Result<HarvestReport> {
525 let tc = toolchain()?;
526 let parity = parity_key()?;
527 let mut report = HarvestReport {
528 parity: parity.clone(),
529 toolchain: tc.version_line(),
530 ..Default::default()
531 };
532
533 let _lock = if dry_run {
536 None
537 } else {
538 ensure_store_root(store_root)?;
539 util::create_private_dir_all(&store_root.join(&parity))?;
540 util::create_private_dir_all(&units_dir(store_root, &parity))?;
541 let lock = FileLock::acquire(&store_lock_path(store_root, &parity))?;
542 sweep_staging(&units_dir(store_root, &parity));
545 Some(lock)
546 };
547
548 let rows: Vec<(Unit, ArtifactInfo)> = fingerprint::read_units(build_root)?
549 .into_iter()
550 .map(|unit| {
551 let arts = fingerprint::artifacts_for(build_root, &unit);
552 (unit, arts)
553 })
554 .collect();
555
556 let newest_activity = rows.iter().filter_map(|(_, arts)| arts.newest).max();
559 if let (Some(newest), Some(installed)) = (newest_activity, toolchain_install_time()) {
560 if newest < installed {
561 report.note = Some(format!(
562 "the shared cache predates the installed toolchain ({}) — build once, then harvest",
563 tc.version_line()
564 ));
565 return Ok(report);
566 }
567 }
568
569 let active_gen = rows
573 .iter()
574 .filter(|(unit, _)| crate::who::rustc_of(unit) != "unknown")
575 .max_by_key(|(_, arts)| arts.newest)
576 .map(|(unit, _)| crate::who::rustc_of(unit))
577 .unwrap_or_else(|| "unknown".into());
578
579 let mut manifest = load_manifest(store_root, &parity)?.unwrap_or_else(|| Manifest {
580 parity_key: parity.clone(),
581 toolchain: tc.version_line(),
582 created: now_secs(),
583 updated: 0,
584 units: Vec::new(),
585 });
586
587 struct GroupPlan {
589 unit: Unit,
590 files: Vec<(PathBuf, String, String, String)>,
591 memo: String,
592 hash: String,
593 base: u64,
594 }
595 let mut plans: Vec<GroupPlan> = Vec::new();
596
597 for (unit, arts) in &rows {
598 if let Some(query) = unit_filter {
599 if !unit_name_matches(&unit.dir_name, query) {
600 continue;
601 }
602 }
603 let cohort = crate::who::rustc_of(unit);
604 if cohort != "unknown" && active_gen != "unknown" && cohort != active_gen {
605 report.skipped_stale += 1;
606 continue;
607 }
608
609 let unit_dir = build_root
610 .join(&unit.profile)
611 .join(".fingerprint")
612 .join(&unit.dir_name);
613 let profile_dir = build_root.join(&unit.profile);
614 let memo = unit.memo_file.clone();
615 let mut files: Vec<(PathBuf, String, String, String)> = Vec::new();
616 for entry in
623 fs::read_dir(&unit_dir).with_context(|| format!("reading {}", unit_dir.display()))?
624 {
625 let entry = entry?;
626 if !entry.file_type()?.is_file() {
627 continue;
628 }
629 let name = entry.file_name().to_string_lossy().into_owned();
630 if is_volatile(&name) {
631 continue;
632 }
633 let role = if name.starts_with("dep-") {
634 "dep-info"
635 } else if name.ends_with(".json") {
636 "fingerprint"
637 } else if fs::read_to_string(entry.path())
638 .map(|text| crate::fingerprint::looks_like_hash(text.trim()))
639 .unwrap_or(false)
640 {
641 "memo"
642 } else {
643 "extra"
645 };
646 files.push((
647 entry.path(),
648 name.clone(),
649 format!(".fingerprint/{}/{name}", unit.dir_name),
650 role.into(),
651 ));
652 }
653
654 let mut artifact_count = 0;
657 for (path, _, _) in &arts.paths {
658 let name = path
659 .file_name()
660 .map(|name| name.to_string_lossy().into_owned())
661 .unwrap_or_default();
662 if is_volatile(&name) {
663 continue;
664 }
665 let Ok(rel) = path.strip_prefix(&profile_dir) else {
666 continue;
667 };
668 let rel = rel.to_string_lossy().replace('\\', "/");
669 if rel.starts_with("deps/") && rel.ends_with(".d") {
670 continue;
671 }
672 let role = if rel.starts_with("build/") {
673 "build-tree"
674 } else {
675 "artifact"
676 };
677 files.push((path.clone(), rel.clone(), rel, role.into()));
678 artifact_count += 1;
679 }
680 if artifact_count == 0 {
681 report.skipped_incomplete += 1;
682 continue;
683 }
684
685 plans.push(GroupPlan {
686 base: group_stamp(&files)?,
687 memo,
688 hash: unit.memo_hash.clone().unwrap_or_default(),
689 files,
690 unit: unit.clone(),
691 });
692 }
693
694 let stored_stamps: std::collections::HashMap<String, u64> = manifest
703 .units
704 .iter()
705 .map(|entry| (entry.hash.clone(), entry.stamp))
706 .collect();
707 let index: std::collections::HashMap<&str, usize> = plans
708 .iter()
709 .enumerate()
710 .map(|(position, plan)| (plan.hash.as_str(), position))
711 .collect();
712 let mut plan_deps: Vec<Vec<usize>> = Vec::with_capacity(plans.len());
713 let mut stamps: Vec<u64> = Vec::with_capacity(plans.len());
714 for plan in &plans {
715 let mut deps = Vec::new();
716 let mut stamp = plan.base;
717 for dep in plan
718 .unit
719 .json
720 .as_ref()
721 .map(|json| json.deps.as_slice())
722 .unwrap_or(&[])
723 {
724 let Some(hash) = dep.memo_hash() else {
725 continue;
726 };
727 if let Some(&at) = index.get(hash.as_str()) {
728 deps.push(at);
729 } else if let Some(known) = stored_stamps.get(hash.as_str()) {
730 stamp = stamp.max(*known);
731 }
732 }
733 plan_deps.push(deps);
734 stamps.push(stamp);
735 }
736 let mut order: Vec<usize> = Vec::with_capacity(plans.len());
739 let mut settled = vec![false; plans.len()];
740 loop {
741 let mut progressed = false;
742 for at in 0..plans.len() {
743 if settled[at] || !plan_deps[at].iter().all(|&dep| settled[dep]) {
744 continue;
745 }
746 settled[at] = true;
747 order.push(at);
748 progressed = true;
749 }
750 if !progressed {
751 break;
752 }
753 }
754 for at in 0..plans.len() {
755 if !settled[at] {
756 order.push(at);
757 }
758 }
759 for &at in &order {
760 for dep in &plan_deps[at] {
761 stamps[at] = stamps[at].max(stamps[*dep]);
762 }
763 }
764
765 for (at, plan) in plans.iter().enumerate() {
767 let unit = &plan.unit;
768 let files = &plan.files;
769 let stamp = stamps[at];
770 let group_dir = units_dir(store_root, &parity).join(&unit.dir_name);
771
772 let mut new_entries: Vec<(&str, &str)> = files
775 .iter()
776 .map(|(_, path, _, role)| (path.as_str(), role.as_str()))
777 .collect();
778 new_entries.sort_unstable();
779 let already = manifest
780 .units
781 .iter()
782 .find(|entry| entry.name == unit.dir_name)
783 .map(|entry| {
784 let mut stored: Vec<(&str, &str)> = entry
785 .files
786 .iter()
787 .map(|file| (file.path.as_str(), file.role.as_str()))
788 .collect();
789 stored.sort_unstable();
790 entry.hash == plan.hash
791 && entry.profile == unit.profile
792 && entry.stamp == stamp
793 && stored == new_entries
794 && entry
795 .files
796 .iter()
797 .all(|file| !file.sha256.is_empty() && group_dir.join(&file.path).is_file())
798 })
799 .unwrap_or(false);
800 if already {
801 report.unchanged += 1;
802 continue;
803 }
804
805 let bytes: u64 = files
806 .iter()
807 .filter_map(|(src, _, _, _)| fs::metadata(src).ok())
808 .map(|meta| meta.len())
809 .sum();
810 if dry_run {
811 report.stored += 1;
812 report.files += files.len();
813 report.bytes += bytes;
814 continue;
815 }
816
817 let mut entries = Vec::with_capacity(files.len());
818 store_group(
819 store_root,
820 &parity,
821 &unit.dir_name,
822 stamp,
823 files,
824 &mut entries,
825 )
826 .with_context(|| format!("storing unit {}", unit.dir_name))?;
827
828 let entry = UnitEntry {
829 name: unit.dir_name.clone(),
830 profile: unit.profile.clone(),
831 memo: plan.memo.clone(),
832 hash: plan.hash.clone(),
833 stamp,
834 files: entries,
835 };
836 match manifest
837 .units
838 .iter_mut()
839 .find(|existing| existing.name == entry.name)
840 {
841 Some(slot) => {
842 *slot = entry;
843 report.refreshed += 1;
844 }
845 None => {
846 manifest.units.push(entry);
847 report.stored += 1;
848 }
849 }
850 report.files += files.len();
851 report.bytes += bytes;
852 }
853
854 if !dry_run {
855 manifest.units.sort_by(|a, b| {
856 (a.profile.as_str(), a.name.as_str()).cmp(&(b.profile.as_str(), b.name.as_str()))
857 });
858 manifest.updated = now_secs();
859 save_manifest(store_root, &parity, &manifest)?;
860 }
861 Ok(report)
862}
863
864fn store_group(
868 store_root: &Path,
869 parity: &str,
870 name: &str,
871 stamp: u64,
872 files: &[(PathBuf, String, String, String)],
873 entries: &mut Vec<FileEntry>,
874) -> Result<()> {
875 util::validate_component(name, "unit name")?;
876 let units = units_dir(store_root, parity);
877 let final_dir = units.join(name);
878 let mut tmp = None;
881 for attempt in 0..100u32 {
882 let candidate = units.join(format!(".{name}.tmp-{}-{attempt}", std::process::id()));
883 match fs::create_dir(&candidate) {
884 Ok(()) => {
885 tmp = Some(candidate);
886 break;
887 }
888 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
889 Err(err) => {
890 return Err(err)
891 .with_context(|| format!("creating staging dir {}", candidate.display()));
892 }
893 }
894 }
895 let Some(tmp) = tmp else {
896 anyhow::bail!(
897 "could not create a unique staging dir in {}",
898 units.display()
899 );
900 };
901 #[cfg(unix)]
902 {
903 use std::os::unix::fs::PermissionsExt;
904 let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o700));
905 }
906
907 if let Err(err) = stage_group(&tmp, files, stamp, entries) {
908 let _ = crate::util::force_remove_dir(&tmp);
911 return Err(err);
912 }
913
914 let old = units.join(format!(".{name}.old-{}-{}", std::process::id(), now_secs()));
915 match fs::symlink_metadata(&final_dir) {
916 Ok(meta) if meta.file_type().is_symlink() => {
917 let _ = crate::util::force_remove_dir(&tmp);
918 anyhow::bail!("refusing symlinked store unit {}", final_dir.display());
919 }
920 Ok(meta) if meta.is_dir() => fs::rename(&final_dir, &old)?,
921 Ok(_) => {
922 let _ = crate::util::force_remove_dir(&tmp);
923 anyhow::bail!("store unit path {} is not a directory", final_dir.display());
924 }
925 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
926 Err(err) => return Err(err).context("inspecting store unit"),
927 }
928 fs::rename(&tmp, &final_dir)?;
929 if fs::symlink_metadata(&old)
930 .map(|meta| meta.is_dir() && !meta.file_type().is_symlink())
931 .unwrap_or(false)
932 {
933 let _ = fs::remove_dir_all(&old);
934 }
935 Ok(())
936}
937
938fn stage_group(
947 tmp: &Path,
948 files: &[(PathBuf, String, String, String)],
949 stamp: u64,
950 entries: &mut Vec<FileEntry>,
951) -> Result<()> {
952 for (src, path, dest, role) in files {
953 let rel = util::validate_rel(path, "stored file path")?;
954 let dst = tmp.join(&rel);
955 if let Some(parent) = dst.parent() {
956 util::create_private_dir_all(parent)?;
957 }
958 let (size, sha256) = util::copy_file_hashed(src, &dst)
959 .with_context(|| format!("copying {} -> {}", src.display(), dst.display()))?;
960 make_writable(&dst).with_context(|| format!("making {} writable", dst.display()))?;
961 set_stamp(&dst, stamp)?;
962 make_read_only(&dst)?;
963 entries.push(FileEntry {
964 path: path.clone(),
965 dest: dest.clone(),
966 size,
967 sha256,
968 role: role.clone(),
969 });
970 }
971 Ok(())
972}
973
974fn sweep_staging(units: &Path) {
978 let Ok(entries) = fs::read_dir(units) else {
979 return;
980 };
981 let now = SystemTime::now();
982 for entry in entries.flatten() {
983 let name = entry.file_name();
984 let name = name.to_string_lossy();
985 if !(name.contains(".tmp-") || name.contains(".old-")) {
986 continue;
987 }
988 let meta = match fs::symlink_metadata(entry.path()) {
989 Ok(meta) => meta,
990 Err(_) => continue,
991 };
992 if meta.file_type().is_symlink() {
994 let _ = fs::remove_file(entry.path());
995 continue;
996 }
997 if !meta.is_dir() {
998 continue;
999 }
1000 let stale = meta
1001 .modified()
1002 .ok()
1003 .and_then(|modified| now.duration_since(modified).ok())
1004 .is_some_and(|age| age > Duration::from_secs(600));
1005 if stale {
1006 let _ = crate::util::force_remove_dir(&entry.path());
1007 }
1008 }
1009}
1010
1011fn group_stamp(files: &[(PathBuf, String, String, String)]) -> Result<u64> {
1016 let mut newest: Option<SystemTime> = None;
1017 let mut metadata: Option<SystemTime> = None;
1018 for (src, path, _, _) in files {
1019 let modified = fs::metadata(src)
1020 .and_then(|meta| meta.modified())
1021 .with_context(|| format!("reading mtime of {}", src.display()))?;
1022 if newest.map_or(true, |current| modified > current) {
1023 newest = Some(modified);
1024 }
1025 if path.ends_with(".rmeta") && metadata.map_or(true, |current| modified > current) {
1026 metadata = Some(modified);
1027 }
1028 }
1029 Ok(match metadata.or(newest) {
1035 Some(time) => ceil_secs(time),
1036 None => now_secs(),
1037 })
1038}
1039
1040fn ceil_secs(time: SystemTime) -> u64 {
1042 match time.duration_since(UNIX_EPOCH) {
1043 Ok(duration) => {
1044 let secs = duration.as_secs();
1045 if duration.subsec_nanos() > 0 {
1046 secs + 1
1047 } else {
1048 secs
1049 }
1050 }
1051 Err(_) => 0,
1052 }
1053}
1054
1055fn now_secs() -> u64 {
1056 SystemTime::now()
1057 .duration_since(UNIX_EPOCH)
1058 .map(|duration| duration.as_secs())
1059 .unwrap_or(0)
1060}
1061
1062fn set_stamp(path: &Path, stamp: u64) -> Result<()> {
1063 let time = UNIX_EPOCH + Duration::from_secs(stamp);
1064 let file =
1065 fs::File::open(path).with_context(|| format!("opening {} to stamp it", path.display()))?;
1066 file.set_modified(time)
1067 .with_context(|| format!("stamping {}", path.display()))?;
1068 Ok(())
1069}
1070
1071#[cfg(unix)]
1073fn make_writable(path: &Path) -> std::io::Result<()> {
1074 use std::os::unix::fs::PermissionsExt;
1075 let mut perms = fs::metadata(path)?.permissions();
1076 perms.set_mode(0o644);
1077 fs::set_permissions(path, perms)
1078}
1079
1080#[cfg(not(unix))]
1081fn make_writable(path: &Path) -> std::io::Result<()> {
1082 let mut perms = fs::metadata(path)?.permissions();
1083 perms.set_readonly(false);
1084 fs::set_permissions(path, perms)
1085}
1086
1087fn make_read_only(path: &Path) -> Result<()> {
1091 #[cfg(unix)]
1092 {
1093 use std::os::unix::fs::PermissionsExt;
1094 fs::set_permissions(path, fs::Permissions::from_mode(0o444))?;
1095 }
1096 #[cfg(not(unix))]
1097 {
1098 let _ = path;
1099 }
1100 Ok(())
1101}
1102
1103fn is_volatile(name: &str) -> bool {
1107 name == "invoked.timestamp" || name.contains(".kache")
1108}
1109
1110#[derive(Debug, Default)]
1114pub struct RestoreReport {
1115 pub parity: String,
1116 pub target: PathBuf,
1117 pub units_linked: usize,
1118 pub units_already: usize,
1119 pub files_linked: usize,
1120 pub files_copied: usize,
1121 pub files_existing: usize,
1122 pub files_missing: usize,
1123 pub bytes: u64,
1124 pub errors: usize,
1125 pub fatal_errors: usize,
1128 pub note: Option<String>,
1129}
1130
1131pub fn restore(
1142 view_root: &Path,
1143 store_root: &Path,
1144 unit_filter: Option<&str>,
1145 dry_run: bool,
1146) -> Result<RestoreReport> {
1147 let parity = parity_key()?;
1148 let mut report = RestoreReport {
1149 parity: parity.clone(),
1150 target: view_root.to_path_buf(),
1151 ..Default::default()
1152 };
1153 let Some(manifest) = load_manifest(store_root, &parity)? else {
1154 report.note = Some(
1155 "the vault holds no units for this generation yet — run `epicenter harvest` after a build"
1156 .into(),
1157 );
1158 return Ok(report);
1159 };
1160 check_store_trust(store_root)?;
1161 if !dry_run {
1162 prepare_view_root(view_root)?;
1163 }
1164
1165 let units = units_dir(store_root, &parity);
1166 for entry in &manifest.units {
1167 if let Some(query) = unit_filter {
1168 if !unit_name_matches(&entry.name, query) {
1169 continue;
1170 }
1171 }
1172 let fp_dir = view_root
1173 .join(&entry.profile)
1174 .join(".fingerprint")
1175 .join(&entry.name);
1176 if read_small_regular(&fp_dir.join(&entry.memo))
1177 .map(|text| text.trim() == entry.hash)
1178 .unwrap_or(false)
1179 {
1180 report.units_already += 1;
1181 continue;
1182 }
1183
1184 let mut unit_touched = false;
1185 for file in &entry.files {
1186 let dest_rel = Path::new(&entry.profile).join(&file.dest);
1189 let dst = view_root.join(&dest_rel);
1190 match fs::symlink_metadata(&dst) {
1191 Ok(meta) => {
1192 if meta.file_type().is_symlink() {
1193 report.errors += 1;
1194 report.fatal_errors += 1;
1195 eprintln!("restore: refusing symlink destination {}", dst.display());
1196 } else {
1197 report.files_existing += 1;
1198 }
1199 continue;
1200 }
1201 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1202 Err(err) => {
1203 report.errors += 1;
1204 eprintln!("restore: inspecting {}: {err}", dst.display());
1205 continue;
1206 }
1207 }
1208 let shared = file.path.starts_with("deps/") && !file.path.ends_with(".rmeta");
1213 if dry_run {
1214 unit_touched = true;
1215 if shared {
1216 report.files_linked += 1;
1217 } else {
1218 report.files_copied += 1;
1219 }
1220 report.bytes += file.size;
1221 continue;
1222 }
1223 if let Some(parent) = dest_rel.parent() {
1224 if let Err(err) = util::create_dir_chain_nofollow(view_root, parent) {
1225 report.errors += 1;
1226 eprintln!("restore: creating {}: {err}", parent.display());
1227 continue;
1228 }
1229 }
1230 let src = units.join(&entry.name).join(&file.path);
1231 let rel = Path::new(&entry.name).join(&file.path);
1232 match fs::symlink_metadata(&src) {
1233 Ok(_) => {}
1234 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1235 report.files_missing += 1;
1237 continue;
1238 }
1239 Err(err) => {
1240 report.errors += 1;
1241 report.fatal_errors += 1;
1242 eprintln!("restore: inspecting {}: {err}", src.display());
1243 continue;
1244 }
1245 }
1246 let result = if shared {
1247 util::check_regular_chain(&units, &rel)
1248 .and_then(|_| verify_store_file(&src, file))
1249 .and_then(|_| fs::hard_link(&src, &dst).map_err(Into::into))
1250 } else {
1251 util::check_regular_chain(&units, &rel)
1252 .and_then(|_| install_private_copy(&src, &dst, entry.stamp, file))
1253 };
1254 match result {
1255 Ok(()) => {
1256 if shared {
1257 report.files_linked += 1;
1258 } else {
1259 report.files_copied += 1;
1260 }
1261 report.bytes += file.size;
1262 unit_touched = true;
1263 }
1264 Err(err) => {
1265 report.errors += 1;
1266 report.fatal_errors += 1;
1267 eprintln!("restore: writing {}: {err:#}", dst.display());
1268 continue;
1269 }
1270 }
1271 }
1272 if unit_touched {
1273 report.units_linked += 1;
1274 }
1275 }
1276 Ok(report)
1277}
1278
1279fn check_store_trust(store_root: &Path) -> Result<()> {
1283 let meta = fs::symlink_metadata(store_root)
1284 .with_context(|| format!("inspecting store {}", store_root.display()))?;
1285 if !meta.is_dir() || meta.file_type().is_symlink() {
1286 anyhow::bail!("store {} is not a real directory", store_root.display());
1287 }
1288 if !util::owned_by_us(&meta) {
1289 anyhow::bail!(
1290 "store {} is owned by another user — refusing to execute its artifacts",
1291 store_root.display()
1292 );
1293 }
1294 util::ensure_private_dir(store_root)?;
1298 Ok(())
1299}
1300
1301fn prepare_view_root(view_root: &Path) -> Result<()> {
1302 match fs::symlink_metadata(view_root) {
1303 Ok(meta) => {
1304 if !meta.is_dir() || meta.file_type().is_symlink() {
1305 anyhow::bail!("view {} is not a real directory", view_root.display());
1306 }
1307 if !util::owned_by_us(&meta) {
1308 anyhow::bail!(
1309 "view {} is owned by another user — refusing to write into it",
1310 view_root.display()
1311 );
1312 }
1313 }
1314 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1315 util::create_private_dir_all(view_root)?;
1316 }
1317 Err(err) => {
1318 return Err(err).with_context(|| format!("inspecting view {}", view_root.display()));
1319 }
1320 }
1321 Ok(())
1322}
1323
1324fn verify_store_file(src: &Path, file: &FileEntry) -> Result<()> {
1325 let meta = fs::symlink_metadata(src)
1326 .with_context(|| format!("inspecting stored file {}", src.display()))?;
1327 if !meta.file_type().is_file() || meta.file_type().is_symlink() {
1328 anyhow::bail!("{} is not a regular file", src.display());
1329 }
1330 if !util::owned_by_us(&meta) {
1331 anyhow::bail!("{} is owned by another user", src.display());
1332 }
1333 if meta.len() != file.size {
1334 anyhow::bail!(
1335 "{} size drift (recorded {}, found {})",
1336 src.display(),
1337 file.size,
1338 meta.len()
1339 );
1340 }
1341 if file.sha256.is_empty() {
1342 anyhow::bail!("{} has no recorded SHA-256", src.display());
1343 }
1344 let found = util::sha256_file(src)?;
1345 if found != file.sha256 {
1346 anyhow::bail!("{} failed its SHA-256 check", src.display());
1347 }
1348 Ok(())
1349}
1350
1351fn install_private_copy(src: &Path, dst: &Path, stamp: u64, file: &FileEntry) -> Result<()> {
1352 let parent = dst
1353 .parent()
1354 .with_context(|| format!("destination {} has no parent", dst.display()))?;
1355 let nonce = SystemTime::now()
1356 .duration_since(UNIX_EPOCH)
1357 .map(|duration| duration.as_nanos())
1358 .unwrap_or(0);
1359 let tmp = parent.join(format!(".epicenter-copy-{}-{nonce}", std::process::id()));
1360 util::copy_file_verified(src, &tmp, file.size, &file.sha256)?;
1361 make_writable(&tmp)?;
1362 set_stamp(&tmp, stamp)?;
1363 let installed = fs::hard_link(&tmp, dst);
1367 let _ = fs::remove_file(&tmp);
1368 installed.with_context(|| format!("installing {}", dst.display()))?;
1369 Ok(())
1370}
1371
1372fn read_small_regular(path: &Path) -> Option<String> {
1374 let meta = fs::symlink_metadata(path).ok()?;
1375 if !meta.file_type().is_file() || meta.file_type().is_symlink() || meta.len() > 4 * 1024 * 1024
1376 {
1377 return None;
1378 }
1379 fs::read_to_string(path).ok()
1380}
1381
1382#[derive(Debug, Default)]
1386pub struct VerifyReport {
1387 pub parity: String,
1388 pub units: usize,
1389 pub files: usize,
1390 pub bytes: u64,
1391 pub problems: Vec<String>,
1393 pub warnings: Vec<String>,
1395 pub ordering: Vec<String>,
1399}
1400
1401impl VerifyReport {
1402 pub fn is_clean(&self) -> bool {
1403 self.problems.is_empty()
1404 }
1405}
1406
1407pub fn verify(store_root: &Path) -> Result<Vec<VerifyReport>> {
1411 let mut reports = Vec::new();
1412 for parity in generation_names(store_root)? {
1413 let mut report = VerifyReport {
1414 parity: parity.clone(),
1415 ..Default::default()
1416 };
1417 if let Err(err) = check_store_trust(store_root) {
1418 report
1419 .problems
1420 .push(format!("unsafe store: {err:#} — no artifact was read"));
1421 reports.push(report);
1422 continue;
1423 }
1424 let Some(manifest) = load_manifest(store_root, &parity)? else {
1425 report.problems.push(format!(
1426 "generation {parity} has no readable manifest.json — it cannot be audited"
1427 ));
1428 reports.push(report);
1429 continue;
1430 };
1431 let units = units_dir(store_root, &parity);
1432 for entry in &manifest.units {
1433 for file in &entry.files {
1434 let path = units.join(&entry.name).join(&file.path);
1435 let rel = Path::new(&entry.name).join(&file.path);
1436 if let Err(err) = util::check_regular_chain(&units, &rel) {
1437 report.problems.push(format!(
1438 "unsafe path: {} ({err:#})",
1439 display_join(&path, &units)
1440 ));
1441 continue;
1442 }
1443 let meta = match fs::symlink_metadata(&path) {
1444 Ok(meta) => meta,
1445 Err(_) => {
1446 report
1447 .problems
1448 .push(format!("missing: {}", display_join(&path, units.as_path())));
1449 continue;
1450 }
1451 };
1452 if meta.file_type().is_symlink() {
1453 report.problems.push(format!(
1454 "symlink in store (refused): {}",
1455 display_join(&path, &units)
1456 ));
1457 continue;
1458 }
1459 if !meta.is_file() {
1460 report.problems.push(format!(
1461 "not a regular file: {}",
1462 display_join(&path, &units)
1463 ));
1464 continue;
1465 }
1466 if !util::owned_by_us(&meta) {
1467 report.problems.push(format!(
1468 "foreign-owned file: {}",
1469 display_join(&path, &units)
1470 ));
1471 }
1472 report.files += 1;
1473 report.bytes += meta.len();
1474 if meta.len() != file.size {
1475 report.problems.push(format!(
1476 "size drift: {} (recorded {}, found {})",
1477 display_join(&path, &units),
1478 file.size,
1479 meta.len()
1480 ));
1481 }
1482 let found_stamp = meta.modified().map(ceil_secs).unwrap_or(0);
1483 if found_stamp != entry.stamp {
1484 report.problems.push(format!(
1485 "stamp drift: {} (recorded {}, found {})",
1486 display_join(&path, &units),
1487 entry.stamp,
1488 found_stamp
1489 ));
1490 }
1491 if file.sha256.is_empty() {
1492 report.problems.push(format!(
1493 "no recorded SHA-256: {} (re-harvest this generation)",
1494 display_join(&path, &units)
1495 ));
1496 } else {
1497 match util::sha256_file(&path) {
1498 Ok(found) if found != file.sha256 => report.problems.push(format!(
1499 "content drift: {} (recorded {}, found {})",
1500 display_join(&path, &units),
1501 &file.sha256[..file.sha256.len().min(12)],
1502 &found[..found.len().min(12)]
1503 )),
1504 Ok(_) => {}
1505 Err(err) => report.problems.push(format!(
1506 "unreadable: {}: {err}",
1507 display_join(&path, &units)
1508 )),
1509 }
1510 }
1511 if file.role == "memo" {
1512 match read_small_regular(&path) {
1513 Some(text) if file.path == entry.memo && text.trim() != entry.hash => {
1514 let found = text.trim();
1515 let detail = if crate::fingerprint::looks_like_hash(found) {
1516 format!("found {found}")
1517 } else {
1518 "found non-hash content (not printed)".to_string()
1519 };
1520 report.problems.push(format!(
1521 "hash drift: {} (recorded {}; {detail})",
1522 display_join(&path, &units),
1523 entry.hash
1524 ));
1525 }
1526 Some(text)
1527 if file.path != entry.memo
1528 && !crate::fingerprint::looks_like_hash(text.trim()) =>
1529 {
1530 report.problems.push(format!(
1531 "malformed memo file: {} (content is not a 16-hex hash)",
1532 display_join(&path, &units)
1533 ));
1534 }
1535 Some(_) => {}
1536 None => report.problems.push(format!(
1537 "unreadable/symlinked memo file: {}",
1538 display_join(&path, &units)
1539 )),
1540 }
1541 }
1542 }
1543 }
1544 let by_hash: std::collections::HashMap<&str, usize> = manifest
1551 .units
1552 .iter()
1553 .enumerate()
1554 .map(|(index, entry)| (entry.hash.as_str(), index))
1555 .collect();
1556 for entry in &manifest.units {
1557 let json = entry
1558 .files
1559 .iter()
1560 .find(|file| file.path.ends_with(".json"))
1561 .and_then(|file| read_small_regular(&units.join(&entry.name).join(&file.path)))
1562 .and_then(|text| crate::fingerprint::parse_fingerprint(&text));
1563 let Some(json) = json else { continue };
1564 for dep in &json.deps {
1565 let Some(dep_index) = dep
1566 .memo_hash()
1567 .and_then(|hash| by_hash.get(hash.as_str()))
1568 .copied()
1569 else {
1570 continue;
1571 };
1572 let dep_entry = &manifest.units[dep_index];
1573 if dep_entry.stamp > entry.stamp {
1574 report.ordering.push(format!(
1575 "{} is older than its dependency {} ({} older) — cargo recompiles it after a restore",
1576 entry.name,
1577 dep_entry.name,
1578 crate::util::human_duration(Duration::from_secs(dep_entry.stamp - entry.stamp))
1579 ));
1580 }
1581 }
1582 }
1583 report.units = manifest.units.len();
1584
1585 if units.is_dir() {
1587 let known: std::collections::BTreeSet<&str> = manifest
1588 .units
1589 .iter()
1590 .map(|unit| unit.name.as_str())
1591 .collect();
1592 for entry in fs::read_dir(&units)? {
1593 let entry = entry?;
1594 if !entry.file_type()?.is_dir() {
1595 continue;
1596 }
1597 let name = entry.file_name().to_string_lossy().into_owned();
1598 if name.starts_with('.') {
1599 continue; }
1601 if !known.contains(name.as_str()) {
1602 report
1603 .warnings
1604 .push(format!("unrecorded unit group: units/{name}"));
1605 }
1606 }
1607 }
1608 reports.push(report);
1609 }
1610 Ok(reports)
1611}
1612
1613fn display_join(path: &Path, base: &Path) -> String {
1615 path.strip_prefix(base)
1616 .unwrap_or(path)
1617 .to_string_lossy()
1618 .replace('\\', "/")
1619}
1620
1621#[derive(Debug, Default)]
1625pub struct GenerationInfo {
1626 pub parity: String,
1627 pub active: bool,
1629 pub units: usize,
1630 pub files: usize,
1631 pub bytes: u64,
1633 pub linked: usize,
1635 pub created: Option<u64>,
1636 pub updated: Option<u64>,
1637}
1638
1639pub fn generations(store_root: &Path) -> Result<Vec<GenerationInfo>> {
1641 let active = parity_key().ok();
1642 let mut out = Vec::new();
1643 for parity in generation_names(store_root)? {
1644 let mut info = GenerationInfo {
1645 parity: parity.clone(),
1646 active: active.as_deref() == Some(parity.as_str()),
1647 ..Default::default()
1648 };
1649 if let Some(manifest) = load_manifest(store_root, &parity)? {
1650 info.units = manifest.units.len();
1651 info.created = Some(manifest.created);
1652 info.updated = Some(manifest.updated);
1653 }
1654 let units = units_dir(store_root, &parity);
1655 if units.is_dir() {
1656 for entry in WalkDir::new(&units).min_depth(1) {
1657 let entry = entry?;
1658 if !entry.file_type().is_file() {
1659 continue;
1660 }
1661 let meta = entry.metadata()?;
1662 info.files += 1;
1663 info.bytes += meta.len();
1664 if link_count(&meta) > 1 {
1665 info.linked += 1;
1666 }
1667 }
1668 }
1669 out.push(info);
1670 }
1671 Ok(out)
1672}
1673
1674pub(crate) fn link_count(meta: &fs::Metadata) -> u64 {
1676 #[cfg(unix)]
1677 {
1678 use std::os::unix::fs::MetadataExt;
1679 meta.nlink()
1680 }
1681 #[cfg(not(unix))]
1682 {
1683 let _ = meta;
1684 1
1685 }
1686}
1687
1688fn unit_name_matches(name: &str, query: &str) -> bool {
1690 name.contains(query)
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::*;
1696
1697 fn synthetic_build_dir(root: &Path) -> Result<()> {
1702 let jsons =
1703 r#"{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","deps":[]}"#;
1704
1705 let lib = root.join("debug/.fingerprint/serde-102a33f055a8d229");
1706 fs::create_dir_all(&lib)?;
1707 fs::write(lib.join("lib-serde"), "5d82713a7f2d05e2")?;
1708 fs::write(lib.join("lib-serde.json"), jsons)?;
1709 fs::write(lib.join("dep-lib-serde"), [0u8; 24])?;
1710 fs::write(lib.join("invoked.timestamp"), "")?;
1711 fs::create_dir_all(root.join("debug/deps"))?;
1712 fs::write(
1713 root.join("debug/deps/libserde-102a33f055a8d229.rlib"),
1714 [7u8; 64],
1715 )?;
1716 fs::write(
1717 root.join("debug/deps/libserde-102a33f055a8d229.rmeta"),
1718 [6u8; 48],
1719 )?;
1720 fs::write(
1721 root.join("debug/deps/serde-102a33f055a8d229.d"),
1722 b"side file",
1723 )?;
1724 fs::write(
1725 root.join("debug/deps/libserde-102a33f055a8d229.kache-real"),
1726 b"wrapper",
1727 )?;
1728
1729 let compile = root.join("debug/.fingerprint/x-1122334455667788");
1730 fs::create_dir_all(&compile)?;
1731 fs::write(
1732 compile.join("build-script-build-script-build"),
1733 "aaaabbbbccccdddd",
1734 )?;
1735 fs::write(compile.join("build-script-build-script-build.json"), jsons)?;
1736 fs::write(
1737 compile.join("dep-build-script-build-script-build"),
1738 [0u8; 8],
1739 )?;
1740 let compile_anchor = root.join("debug/build/x-1122334455667788");
1741 fs::create_dir_all(&compile_anchor)?;
1742 fs::write(
1743 compile_anchor.join("build_script_build-1122334455667788"),
1744 [3u8; 32],
1745 )?;
1746 fs::write(
1747 compile_anchor.join("build_script_build-1122334455667788.d"),
1748 b"dep",
1749 )?;
1750 fs::write(
1751 compile_anchor.join("build_script_build-1122334455667788.kache-real"),
1752 b"wrapper",
1753 )?;
1754 fs::write(compile_anchor.join("invoked.timestamp"), "")?;
1755
1756 let run = root.join("debug/.fingerprint/x-aabbccddeeff0011");
1758 fs::create_dir_all(&run)?;
1759 fs::write(
1760 run.join("run-build-script-build-script-build"),
1761 "eeeeffff00001111",
1762 )?;
1763 fs::write(run.join("run-build-script-build-script-build.json"), jsons)?;
1764 let run_anchor = root.join("debug/build/x-aabbccddeeff0011");
1765 fs::create_dir_all(&run_anchor)?;
1766 fs::write(
1767 run_anchor.join("output"),
1768 b"cargo:rerun-if-changed=build.rs",
1769 )?;
1770 fs::write(run_anchor.join("stderr"), b"")?;
1771 fs::write(run_anchor.join("invoked.timestamp"), "")?;
1772 Ok(())
1773 }
1774
1775 fn synthetic_husk(root: &Path) -> Result<()> {
1777 let unit = root.join("debug/.fingerprint/husk-aaaabbbbccccdddd");
1778 fs::create_dir_all(&unit)?;
1779 fs::write(unit.join("lib-husk"), "0000111122223333")?;
1780 Ok(())
1781 }
1782
1783 #[test]
1784 fn parity_key_is_stable_and_shaped() -> Result<()> {
1785 let first = parity_key()?;
1786 let second = parity_key()?;
1787 assert_eq!(first, second);
1788 let tc = toolchain()?;
1789 assert!(first.starts_with(&format!("{}-{}", tc.release, tc.commit)));
1790 assert!(first.ends_with(&flags_digest()));
1791 Ok(())
1792 }
1793
1794 #[test]
1795 fn harvest_stores_groups_with_one_canonical_stamp() -> Result<()> {
1796 let build = tempfile::tempdir()?;
1797 let store = tempfile::tempdir()?;
1798 synthetic_build_dir(build.path())?;
1799
1800 let report = harvest(build.path(), store.path(), None, false)?;
1801 assert_eq!(report.stored, 3, "lib + build-script compile + run");
1802 assert_eq!(report.skipped_incomplete, 0);
1803 assert!(report.files >= 10, "got {}", report.files);
1804 assert!(report.bytes > 0);
1805
1806 let parity = parity_key()?;
1807 let manifest = load_manifest(store.path(), &parity)?.expect("manifest written");
1808 assert_eq!(manifest.units.len(), 3);
1809 let lib_entry = manifest
1810 .units
1811 .iter()
1812 .find(|unit| unit.name == "serde-102a33f055a8d229")
1813 .expect("lib unit recorded");
1814 assert_eq!(lib_entry.hash, "5d82713a7f2d05e2");
1815 assert_eq!(lib_entry.profile, "debug");
1816
1817 let group = units_dir(store.path(), &parity).join(&lib_entry.name);
1819 for file in &lib_entry.files {
1820 let meta = fs::metadata(group.join(&file.path))?;
1821 assert_eq!(
1822 ceil_secs(meta.modified()?),
1823 lib_entry.stamp,
1824 "{}",
1825 file.path
1826 );
1827 assert_eq!(meta.len(), file.size);
1828 }
1829
1830 assert!(!group.join("invoked.timestamp").exists());
1832 for file in &lib_entry.files {
1833 assert!(!file.path.contains("kache"), "{}", file.path);
1834 assert!(!file.path.ends_with(".d"), "{}", file.path);
1835 }
1836
1837 #[cfg(unix)]
1838 {
1839 use std::os::unix::fs::PermissionsExt;
1840 let meta = fs::metadata(group.join(&lib_entry.files[0].path))?;
1841 assert_eq!(
1842 meta.permissions().mode() & 0o777,
1843 0o444,
1844 "store is read-only"
1845 );
1846 }
1847 Ok(())
1848 }
1849
1850 #[test]
1851 fn harvest_is_idempotent_and_skips_incomplete_units() -> Result<()> {
1852 let build = tempfile::tempdir()?;
1853 let store = tempfile::tempdir()?;
1854 synthetic_build_dir(build.path())?;
1855 synthetic_husk(build.path())?;
1856
1857 let first = harvest(build.path(), store.path(), None, false)?;
1858 assert_eq!(first.stored, 3);
1859 assert_eq!(first.skipped_incomplete, 1, "husk without artifacts");
1860
1861 let second = harvest(build.path(), store.path(), None, false)?;
1862 assert_eq!(second.stored, 0);
1863 assert_eq!(second.unchanged, 3);
1864 assert_eq!(second.skipped_incomplete, 1);
1865 Ok(())
1866 }
1867
1868 #[test]
1869 fn harvest_refreshes_when_the_file_set_grows() -> Result<()> {
1870 let build = tempfile::tempdir()?;
1871 let store = tempfile::tempdir()?;
1872 synthetic_build_dir(build.path())?;
1873 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1874
1875 fs::write(
1876 build.path().join("debug/deps/libserde-102a33f055a8d229.so"),
1877 [1u8; 16],
1878 )?;
1879 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1880 assert_eq!(report.refreshed, 1);
1881 assert_eq!(report.stored, 0);
1882
1883 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1884 let paths: Vec<&str> = manifest.units[0]
1885 .files
1886 .iter()
1887 .map(|file| file.path.as_str())
1888 .collect();
1889 assert!(
1890 paths.contains(&"deps/libserde-102a33f055a8d229.so"),
1891 "{paths:?}"
1892 );
1893 Ok(())
1894 }
1895
1896 #[test]
1897 fn harvest_stores_all_memos_of_shared_dirs() -> Result<()> {
1898 let build = tempfile::tempdir()?;
1899 let store = tempfile::tempdir()?;
1900 synthetic_build_dir(build.path())?;
1901 let lib = build
1902 .path()
1903 .join("debug/.fingerprint/serde-102a33f055a8d229");
1904 fs::write(lib.join("bin-serde"), "1111222233334444")?;
1905 fs::write(lib.join("dep-bin-serde"), [0u8; 8])?;
1906
1907 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1908 assert_eq!(report.stored, 1);
1909 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1910 let entry = &manifest.units[0];
1911 let paths: Vec<&str> = entry.files.iter().map(|file| file.path.as_str()).collect();
1912 assert!(paths.contains(&"bin-serde"), "{paths:?}");
1913 assert!(paths.contains(&"dep-bin-serde"), "{paths:?}");
1914
1915 let reports = verify(store.path())?;
1916 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1917 Ok(())
1918 }
1919
1920 #[test]
1921 fn harvest_dry_run_writes_nothing() -> Result<()> {
1922 let build = tempfile::tempdir()?;
1923 let store = tempfile::tempdir()?;
1924 synthetic_build_dir(build.path())?;
1925
1926 let report = harvest(build.path(), store.path(), None, true)?;
1927 assert_eq!(report.stored, 3);
1928 assert_eq!(generation_names(store.path())?.len(), 0, "no generation");
1929 Ok(())
1930 }
1931
1932 #[test]
1933 fn harvest_unit_filter_selects_one_group() -> Result<()> {
1934 let build = tempfile::tempdir()?;
1935 let store = tempfile::tempdir()?;
1936 synthetic_build_dir(build.path())?;
1937
1938 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1939 assert_eq!(report.stored, 1);
1940 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1941 assert_eq!(manifest.units.len(), 1);
1942 assert_eq!(manifest.units[0].name, "serde-102a33f055a8d229");
1943 Ok(())
1944 }
1945
1946 #[test]
1947 fn restore_links_hardlinks_and_never_overwrites() -> Result<()> {
1948 let build = tempfile::tempdir()?;
1949 let store = tempfile::tempdir()?;
1950 let view = tempfile::tempdir()?;
1951 synthetic_build_dir(build.path())?;
1952 harvest(build.path(), store.path(), None, false)?;
1953
1954 let occupied = view
1956 .path()
1957 .join("debug/deps/libserde-102a33f055a8d229.rlib");
1958 fs::create_dir_all(occupied.parent().unwrap())?;
1959 fs::write(&occupied, b"live artifact")?;
1960
1961 let report = restore(view.path(), store.path(), None, false)?;
1962 assert_eq!(report.units_linked, 3);
1963 assert_eq!(report.files_existing, 1, "the live artifact was skipped");
1964 assert_eq!(fs::read(&occupied)?, b"live artifact");
1965 assert_eq!(report.files_linked, 0, "no other deps/ artifact");
1969 assert!(report.files_copied >= 12, "got {}", report.files_copied);
1970
1971 let memo = view
1973 .path()
1974 .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
1975 assert_eq!(fs::read_to_string(&memo)?.trim(), "5d82713a7f2d05e2");
1976 assert!(
1977 view.path()
1978 .join("debug/build/x-aabbccddeeff0011/output")
1979 .is_file()
1980 );
1981
1982 #[cfg(unix)]
1986 {
1987 use std::os::unix::fs::MetadataExt;
1988 use std::os::unix::fs::PermissionsExt;
1989 let parity = parity_key()?;
1990 let stored = units_dir(store.path(), &parity).join("serde-102a33f055a8d229/lib-serde");
1991 let view_meta = fs::metadata(&memo)?;
1992 assert_ne!(
1993 view_meta.ino(),
1994 fs::metadata(&stored)?.ino(),
1995 "state not shared"
1996 );
1997 assert_eq!(view_meta.nlink(), 1, "state owns its inode");
1998 assert_ne!(view_meta.permissions().mode() & 0o200, 0, "state writable");
1999 }
2000
2001 let again = restore(view.path(), store.path(), None, false)?;
2003 assert_eq!(again.units_already, 3);
2004 assert_eq!(again.files_linked, 0);
2005 assert_eq!(again.files_copied, 0);
2006 Ok(())
2007 }
2008
2009 #[test]
2010 fn restore_into_missing_profile_dirs_creates_them() -> Result<()> {
2011 let build = tempfile::tempdir()?;
2012 let store = tempfile::tempdir()?;
2013 let view = tempfile::tempdir()?;
2014 synthetic_build_dir(build.path())?;
2015 harvest(build.path(), store.path(), None, false)?;
2016
2017 let report = restore(view.path(), store.path(), None, false)?;
2018 assert_eq!(report.errors, 0);
2019 assert!(view.path().join("debug/.fingerprint").is_dir());
2020 Ok(())
2021 }
2022
2023 #[test]
2024 fn restore_without_a_generation_reports_fail_open() -> Result<()> {
2025 let view = tempfile::tempdir()?;
2026 let store = tempfile::tempdir()?;
2027 let report = restore(view.path(), store.path(), None, false)?;
2028 assert!(report.note.is_some());
2029 assert_eq!(report.files_linked, 0);
2030 Ok(())
2031 }
2032
2033 #[test]
2034 fn verify_passes_clean_and_flags_drift() -> Result<()> {
2035 let build = tempfile::tempdir()?;
2036 let store = tempfile::tempdir()?;
2037 synthetic_build_dir(build.path())?;
2038 harvest(build.path(), store.path(), None, false)?;
2039
2040 let reports = verify(store.path())?;
2041 assert_eq!(reports.len(), 1);
2042 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2043 assert_eq!(reports[0].units, 3);
2044
2045 let parity = parity_key()?;
2047 let victim = units_dir(store.path(), &parity).join("serde-102a33f055a8d229/dep-lib-serde");
2048 #[cfg(unix)]
2049 {
2050 use std::os::unix::fs::PermissionsExt;
2051 fs::set_permissions(&victim, fs::Permissions::from_mode(0o644))?;
2052 }
2053 fs::write(&victim, [9u8; 32])?;
2054
2055 let reports = verify(store.path())?;
2056 assert!(!reports[0].is_clean());
2057 assert!(
2058 reports[0]
2059 .problems
2060 .iter()
2061 .any(|line| line.contains("size drift")),
2062 "{:?}",
2063 reports[0].problems
2064 );
2065 Ok(())
2066 }
2067
2068 #[test]
2069 fn verify_reports_unrecorded_unit_groups() -> Result<()> {
2070 let build = tempfile::tempdir()?;
2071 let store = tempfile::tempdir()?;
2072 synthetic_build_dir(build.path())?;
2073 harvest(build.path(), store.path(), None, false)?;
2074
2075 let stray = units_dir(store.path(), &parity_key()?).join("stray-0000000000000000");
2076 fs::create_dir_all(stray.join("deps"))?;
2077 fs::write(stray.join("deps/x.rlib"), [0u8; 4])?;
2078
2079 let reports = verify(store.path())?;
2080 assert!(
2081 reports[0]
2082 .warnings
2083 .iter()
2084 .any(|line| line.contains("stray-0000000000000000")),
2085 "{:?}",
2086 reports[0].warnings
2087 );
2088 Ok(())
2089 }
2090
2091 #[test]
2092 fn generations_summary_counts_store_files() -> Result<()> {
2093 let build = tempfile::tempdir()?;
2094 let store = tempfile::tempdir()?;
2095 synthetic_build_dir(build.path())?;
2096 harvest(build.path(), store.path(), None, false)?;
2097
2098 let generations = generations(store.path())?;
2099 assert_eq!(generations.len(), 1);
2100 assert!(generations[0].active, "current toolchain generation");
2101 assert_eq!(generations[0].units, 3);
2102 assert!(generations[0].files >= 10);
2103 assert!(generations[0].linked <= generations[0].files);
2104 Ok(())
2105 }
2106
2107 #[test]
2108 fn cached_diagnostics_are_stored_as_extra_and_verify_clean() -> Result<()> {
2109 let build = tempfile::tempdir()?;
2110 let store = tempfile::tempdir()?;
2111 synthetic_build_dir(build.path())?;
2112 fs::write(
2114 build
2115 .path()
2116 .join("debug/.fingerprint/serde-102a33f055a8d229/output-lib-serde"),
2117 r#"{"$message_type":"diagnostic","message":"warning: unused"}"#,
2118 )?;
2119
2120 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2121 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2122 let output = manifest.units[0]
2123 .files
2124 .iter()
2125 .find(|file| file.path == "output-lib-serde")
2126 .expect("cached diagnostics are stored");
2127 assert_eq!(output.role, "extra");
2128
2129 let reports = verify(store.path())?;
2130 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2131 Ok(())
2132 }
2133
2134 #[test]
2135 fn harvest_refreshes_when_a_role_drifted() -> Result<()> {
2136 let build = tempfile::tempdir()?;
2137 let store = tempfile::tempdir()?;
2138 synthetic_build_dir(build.path())?;
2139 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2140
2141 let parity = parity_key()?;
2144 let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2145 let memo_index = manifest.units[0]
2146 .files
2147 .iter()
2148 .position(|file| file.path == manifest.units[0].memo)
2149 .expect("memo recorded");
2150 manifest.units[0].files[memo_index].role = "extra".to_string();
2151 save_manifest(store.path(), &parity, &manifest)?;
2152
2153 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2154 assert_eq!(report.refreshed, 1, "role drift forces a refresh");
2155 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2156 let role = &manifest.units[0].files[memo_index].role;
2157 assert_eq!(role, "memo", "roles are rewritten");
2158 Ok(())
2159 }
2160
2161 #[test]
2162 fn restore_shares_artifacts_but_copies_state_writable() -> Result<()> {
2163 let build = tempfile::tempdir()?;
2164 let store = tempfile::tempdir()?;
2165 synthetic_build_dir(build.path())?;
2166 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2167
2168 let view = tempfile::tempdir()?;
2169 restore(view.path(), store.path(), Some("102a33f0"), false)?;
2170
2171 let state = view
2172 .path()
2173 .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
2174 let artifact = view
2175 .path()
2176 .join("debug/deps/libserde-102a33f055a8d229.rlib");
2177 let metadata = view
2178 .path()
2179 .join("debug/deps/libserde-102a33f055a8d229.rmeta");
2180 assert!(state.is_file() && artifact.is_file() && metadata.is_file());
2181
2182 let parity = parity_key()?;
2183 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2184 let entry = manifest
2185 .units
2186 .iter()
2187 .find(|entry| entry.name == "serde-102a33f055a8d229")
2188 .expect("stored unit");
2189 assert_eq!(ceil_secs(fs::metadata(&state)?.modified()?), entry.stamp);
2190
2191 fs::write(&state, "ffffffffffffffff")?;
2194 let stored = units_dir(store.path(), &parity)
2195 .join("serde-102a33f055a8d229")
2196 .join("lib-serde");
2197 assert_eq!(fs::read_to_string(&stored)?, "5d82713a7f2d05e2");
2198
2199 #[cfg(unix)]
2200 {
2201 use std::os::unix::fs::MetadataExt;
2202 assert!(
2203 fs::metadata(&artifact)?.nlink() >= 2,
2204 "artifact shares the store inode"
2205 );
2206 assert_eq!(fs::metadata(&state)?.nlink(), 1, "state is a private copy");
2207 assert_eq!(
2208 fs::metadata(&metadata)?.nlink(),
2209 1,
2210 ".rmeta is a private copy (rustc opens it for write)"
2211 );
2212 }
2213 Ok(())
2214 }
2215
2216 fn synthetic_edge_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
2220 fs::create_dir_all(root.join("debug/deps"))?;
2221 let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
2222 fs::create_dir_all(&core)?;
2223 fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
2224 fs::write(core.join("dep-lib-serde_core"), [0u8; 8])?;
2225 let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
2226 fs::write(&core_rlib, [1u8; 32])?;
2227
2228 let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
2229 fs::create_dir_all(&unit)?;
2230 fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
2231 fs::write(
2232 unit.join("lib-serde.json"),
2233 r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
2234 )?;
2235 fs::write(unit.join("dep-lib-serde"), [0u8; 8])?;
2236 let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
2237 fs::write(&unit_rlib, [1u8; 32])?;
2238
2239 set_stamp(&core.join("lib-serde_core"), dep_at)?;
2240 set_stamp(&core.join("dep-lib-serde_core"), dep_at)?;
2241 set_stamp(&core_rlib, dep_at)?;
2242 set_stamp(&unit.join("lib-serde"), unit_at)?;
2243 set_stamp(&unit.join("lib-serde.json"), unit_at)?;
2244 set_stamp(&unit.join("dep-lib-serde"), unit_at)?;
2245 set_stamp(&unit_rlib, unit_at)?;
2246 Ok(())
2247 }
2248
2249 #[test]
2250 fn harvest_clamps_a_dependent_to_its_dependency() -> Result<()> {
2251 let build = tempfile::tempdir()?;
2252 let store = tempfile::tempdir()?;
2253 let now = now_secs();
2254 synthetic_edge_dir(build.path(), now, now - 3600)?;
2257 harvest(build.path(), store.path(), None, false)?;
2258
2259 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2260 let dependent = manifest
2261 .units
2262 .iter()
2263 .find(|entry| entry.name == "serde-746d173581f1a314")
2264 .expect("dependent stored");
2265 let dependency = manifest
2266 .units
2267 .iter()
2268 .find(|entry| entry.name == "serde_core-b468be6fa8c1f936")
2269 .expect("dependency stored");
2270 assert!(
2271 dependent.stamp >= dependency.stamp,
2272 "clamped: {} >= {}",
2273 dependent.stamp,
2274 dependency.stamp
2275 );
2276
2277 let reports = verify(store.path())?;
2278 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2279 assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
2280 Ok(())
2281 }
2282
2283 #[test]
2284 fn harvest_stamps_groups_by_the_metadata_output() -> Result<()> {
2285 let build = tempfile::tempdir()?;
2286 let store = tempfile::tempdir()?;
2287 synthetic_build_dir(build.path())?;
2288 let now = now_secs();
2289 set_stamp(
2292 &build
2293 .path()
2294 .join("debug/deps/libserde-102a33f055a8d229.rmeta"),
2295 now - 30,
2296 )?;
2297 set_stamp(
2298 &build
2299 .path()
2300 .join("debug/deps/libserde-102a33f055a8d229.rlib"),
2301 now,
2302 )?;
2303 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2304
2305 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2306 assert_eq!(manifest.units[0].stamp, now - 30);
2307 Ok(())
2308 }
2309
2310 #[test]
2311 fn harvest_stores_read_only_sources() -> Result<()> {
2312 let build = tempfile::tempdir()?;
2313 let store = tempfile::tempdir()?;
2314 synthetic_build_dir(build.path())?;
2315 let source = build
2319 .path()
2320 .join("debug/.fingerprint/serde-102a33f055a8d229/dep-lib-serde");
2321 let mut perms = fs::metadata(&source)?.permissions();
2322 perms.set_readonly(true);
2323 fs::set_permissions(&source, perms)?;
2324
2325 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2326 assert_eq!(report.stored, 1);
2327
2328 let parity = parity_key()?;
2329 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2330 let stored = units_dir(store.path(), &parity)
2331 .join(&manifest.units[0].name)
2332 .join("dep-lib-serde");
2333 assert!(stored.is_file(), "the state file is stored");
2334 assert_eq!(
2335 ceil_secs(fs::metadata(&stored)?.modified()?),
2336 manifest.units[0].stamp,
2337 "the stored copy carries the canonical stamp"
2338 );
2339 Ok(())
2340 }
2341
2342 #[cfg(unix)]
2343 #[test]
2344 fn a_failed_store_drops_its_staging_dir() -> Result<()> {
2345 use std::os::unix::fs::MetadataExt;
2346
2347 let build = tempfile::tempdir()?;
2348 let store = tempfile::tempdir()?;
2349 synthetic_build_dir(build.path())?;
2350 if fs::metadata(build.path())?.uid() == 0 {
2351 return Ok(()); }
2353 let artifact = build
2354 .path()
2355 .join("debug/deps/libserde-102a33f055a8d229.rlib");
2356 {
2357 use std::os::unix::fs::PermissionsExt;
2358 fs::set_permissions(&artifact, fs::Permissions::from_mode(0o000))?;
2359 }
2360
2361 let result = harvest(build.path(), store.path(), Some("102a33f0"), false);
2362 assert!(result.is_err(), "an unreadable artifact aborts the store");
2363
2364 let units = units_dir(store.path(), &parity_key()?);
2365 if units.is_dir() {
2366 for entry in fs::read_dir(&units)? {
2367 let name = entry?.file_name().to_string_lossy().into_owned();
2368 assert!(!name.contains(".tmp-"), "staging leftover: {name}");
2369 }
2370 }
2371 Ok(())
2372 }
2373
2374 #[cfg(unix)]
2375 #[test]
2376 fn harvest_sweeps_stale_staging_dirs() -> Result<()> {
2377 use std::os::unix::fs::PermissionsExt;
2378
2379 let build = tempfile::tempdir()?;
2380 let store = tempfile::tempdir()?;
2381 synthetic_build_dir(build.path())?;
2382 let units = units_dir(store.path(), &parity_key()?);
2383
2384 let stale = units.join(".serde-102a33f055a8d229.tmp-99999");
2386 fs::create_dir_all(&stale)?;
2387 let leftover = stale.join("dep-lib-serde");
2388 fs::write(&leftover, b"x")?;
2389 fs::set_permissions(&leftover, fs::Permissions::from_mode(0o444))?;
2390 set_stamp(&stale, now_secs() - 7200)?;
2391
2392 let fresh = units.join(".serde-102a33f055a8d229.tmp-11111");
2394 fs::create_dir_all(&fresh)?;
2395
2396 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2397 assert!(!stale.exists(), "stale staging dir swept");
2398 assert!(fresh.exists(), "fresh staging dir left alone");
2399 Ok(())
2400 }
2401
2402 #[test]
2403 fn verify_flags_an_inverted_store_stamp() -> Result<()> {
2404 let build = tempfile::tempdir()?;
2405 let store = tempfile::tempdir()?;
2406 let now = now_secs();
2407 synthetic_edge_dir(build.path(), now - 3600, now)?;
2408 harvest(build.path(), store.path(), None, false)?;
2409
2410 let parity = parity_key()?;
2413 let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2414 let units = units_dir(store.path(), &parity);
2415 let entry = manifest
2416 .units
2417 .iter_mut()
2418 .find(|entry| entry.name == "serde-746d173581f1a314")
2419 .expect("dependent stored");
2420 entry.stamp = now - 7200;
2421 for file in &entry.files {
2422 set_stamp(&units.join(&entry.name).join(&file.path), now - 7200)?;
2423 }
2424 save_manifest(store.path(), &parity, &manifest)?;
2425
2426 let reports = verify(store.path())?;
2427 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2428 assert_eq!(reports[0].ordering.len(), 1, "{:?}", reports[0].ordering);
2429 assert!(reports[0].ordering[0].contains("serde-746d173581f1a314"));
2430 assert!(reports[0].ordering[0].contains("serde_core-b468be6fa8c1f936"));
2431 Ok(())
2432 }
2433
2434 #[test]
2435 fn manifest_validation_rejects_escapes() {
2436 let unit = |name: &str, profile: &str, path: &str, dest: &str| UnitEntry {
2437 name: name.into(),
2438 profile: profile.into(),
2439 memo: "lib-x".into(),
2440 hash: "0123456789abcdef".into(),
2441 stamp: 1,
2442 files: vec![FileEntry {
2443 path: path.into(),
2444 dest: dest.into(),
2445 size: 1,
2446 sha256: String::new(),
2447 role: "artifact".into(),
2448 }],
2449 };
2450 let base = Manifest {
2451 parity_key: "p".into(),
2452 toolchain: "t".into(),
2453 created: 1,
2454 updated: 1,
2455 units: vec![],
2456 };
2457 for unit in [
2458 unit("../escape", "debug", "deps/x", "deps/x"),
2459 unit("ok", "/etc", "deps/x", "deps/x"),
2460 unit("ok", "debug", "/etc/passwd", "deps/x"),
2461 unit("ok", "debug", "deps/x", "/root/.ssh/authorized_keys"),
2462 unit("ok", "debug", "deps/../../x", "deps/x"),
2463 ] {
2464 let manifest = Manifest {
2465 units: vec![unit],
2466 ..base.clone()
2467 };
2468 assert!(manifest.validate().is_err(), "must reject {manifest:?}");
2469 }
2470 }
2471
2472 #[test]
2473 fn harvest_records_sha256_and_verify_flags_content_drift() -> Result<()> {
2474 let build = tempfile::tempdir()?;
2475 let store = tempfile::tempdir()?;
2476 synthetic_build_dir(build.path())?;
2477 harvest(build.path(), store.path(), None, false)?;
2478
2479 let parity = parity_key()?;
2480 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2481 assert!(
2482 manifest
2483 .units
2484 .iter()
2485 .flat_map(|unit| &unit.files)
2486 .all(|file| !file.sha256.is_empty()),
2487 "every stored file must carry a hash"
2488 );
2489
2490 let entry = manifest
2491 .units
2492 .iter()
2493 .find(|entry| entry.name == "serde-102a33f055a8d229")
2494 .expect("unit");
2495 let file = entry
2496 .files
2497 .iter()
2498 .find(|file| file.path == "deps/libserde-102a33f055a8d229.rlib")
2499 .expect("rlib");
2500 let path = units_dir(store.path(), &parity)
2501 .join(&entry.name)
2502 .join(&file.path);
2503 let mut bytes = fs::read(&path)?;
2504 let last = bytes.len() - 1;
2505 bytes[last] ^= 0x01; #[cfg(unix)]
2507 {
2508 use std::os::unix::fs::PermissionsExt;
2509 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))?;
2510 }
2511 fs::write(&path, &bytes)?;
2512 let stamp = UNIX_EPOCH + Duration::from_secs(entry.stamp);
2513 fs::File::open(&path)?.set_modified(stamp)?;
2514 #[cfg(unix)]
2515 {
2516 use std::os::unix::fs::PermissionsExt;
2517 fs::set_permissions(&path, fs::Permissions::from_mode(0o444))?;
2518 }
2519
2520 let reports = verify(store.path())?;
2521 assert!(
2522 reports[0]
2523 .problems
2524 .iter()
2525 .any(|problem| problem.contains("content drift")),
2526 "{:?}",
2527 reports[0].problems
2528 );
2529
2530 let view = tempfile::tempdir()?;
2531 let report = restore(view.path(), store.path(), None, false)?;
2532 assert!(report.errors > 0, "tampered artifact must not install");
2533 assert!(
2534 !view.path().join(&file.dest).exists(),
2535 "tampered artifact must be absent from the view"
2536 );
2537 Ok(())
2538 }
2539
2540 #[test]
2541 fn concurrent_harvest_keeps_every_unit() -> Result<()> {
2542 let store = tempfile::tempdir()?;
2543 let mut builds = Vec::new();
2544 for index in 0..6u32 {
2545 let build = tempfile::tempdir()?;
2546 let hex = format!("{:016x}", index + 1);
2547 let unit = build
2548 .path()
2549 .join("debug/.fingerprint")
2550 .join(format!("u{index}-{hex}"));
2551 fs::create_dir_all(&unit)?;
2552 fs::write(unit.join(format!("lib-u{index}")), "0123456789abcdef")?;
2553 fs::write(
2554 unit.join(format!("lib-u{index}.json")),
2555 r#"{"rustc":1,"features":"[]","declared_features":"[]","deps":[]}"#,
2556 )?;
2557 fs::create_dir_all(build.path().join("debug/deps"))?;
2558 fs::write(
2559 build
2560 .path()
2561 .join("debug/deps")
2562 .join(format!("libu{index}-{hex}.rlib")),
2563 [index as u8; 8],
2564 )?;
2565 builds.push(build);
2566 }
2567 std::thread::scope(|scope| {
2568 for build in &builds {
2569 scope.spawn(|| harvest(build.path(), store.path(), None, false));
2570 }
2571 });
2572 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2573 assert_eq!(manifest.units.len(), builds.len(), "{:?}", manifest.units);
2574 Ok(())
2575 }
2576
2577 #[cfg(unix)]
2578 #[test]
2579 fn store_directories_are_private() -> Result<()> {
2580 use std::os::unix::fs::MetadataExt;
2581 let build = tempfile::tempdir()?;
2582 let store = tempfile::tempdir()?;
2583 synthetic_build_dir(build.path())?;
2584 harvest(build.path(), store.path(), None, false)?;
2585 assert_eq!(
2586 fs::metadata(store.path())?.mode() & 0o077,
2587 0,
2588 "store root 0700"
2589 );
2590 let generation = store.path().join(parity_key()?);
2591 assert_eq!(
2592 fs::metadata(&generation)?.mode() & 0o077,
2593 0,
2594 "generation 0700"
2595 );
2596 Ok(())
2597 }
2598
2599 #[test]
2600 fn verify_ordering_is_quiet_when_units_are_current() -> Result<()> {
2601 let build = tempfile::tempdir()?;
2602 let store = tempfile::tempdir()?;
2603 let now = now_secs();
2604 synthetic_edge_dir(build.path(), now - 3600, now)?;
2605 harvest(build.path(), store.path(), None, false)?;
2606
2607 let reports = verify(store.path())?;
2608 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2609 assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
2610 Ok(())
2611 }
2612}