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 sha2::{Digest, Sha256};
41use walkdir::WalkDir;
42
43use crate::fingerprint::{self, ArtifactInfo, Unit};
44
45const PARITY_SEED: &str = "epicenter-parity-v3";
47
48#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Toolchain {
53 pub release: String,
55 pub commit: String,
57 pub host: String,
59}
60
61impl Toolchain {
62 pub fn version_line(&self) -> String {
64 format!("rustc {} ({})", self.release, self.commit)
65 }
66}
67
68pub fn toolchain() -> Result<Toolchain> {
70 toolchain_of(None)
71}
72
73pub fn toolchain_of(selector: Option<&str>) -> Result<Toolchain> {
76 let mut command = util::command_for("rustc")?;
77 if let Some(selector) = selector {
78 command.arg(format!("+{selector}"));
79 }
80 let output = command.arg("-vV").output().context("running `rustc -vV`")?;
81 if !output.status.success() {
82 anyhow::bail!("`rustc -vV` failed — is a toolchain installed?");
83 }
84 let text = String::from_utf8_lossy(&output.stdout);
85
86 let mut release = "unknown".to_string();
87 let mut commit = "unknown".to_string();
88 let mut host = "unknown".to_string();
89 for line in text.lines() {
90 if let Some(rest) = line.strip_prefix("rustc ") {
91 let mut parts = rest.split_whitespace();
92 if let Some(version) = parts.next() {
93 release = version.to_string();
94 }
95 if let Some(commit_part) = parts.next() {
97 let value = commit_part.trim_start_matches('(');
98 if !value.is_empty() {
99 commit = value.to_string();
100 }
101 }
102 } else if let Some(value) = line.strip_prefix("host: ") {
103 host = value.trim().to_string();
104 }
105 }
106 util::validate_component(&release, "rustc release")?;
107 util::validate_component(&commit, "rustc commit")?;
108 util::validate_component(&host, "rustc host")?;
109 Ok(Toolchain {
110 release,
111 commit,
112 host,
113 })
114}
115
116pub fn toolchain_install_time() -> Option<SystemTime> {
120 toolchain_install_time_of(None)
121}
122
123pub fn toolchain_install_time_of(selector: Option<&str>) -> Option<SystemTime> {
125 let mut command = util::command_for("rustc").ok()?;
126 if let Some(selector) = selector {
127 command.arg(format!("+{selector}"));
128 }
129 let output = command.args(["--print", "sysroot"]).output().ok()?;
130 if !output.status.success() {
131 return None;
132 }
133 let sysroot = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
134 let rustc = sysroot
135 .join("bin")
136 .join(if cfg!(windows) { "rustc.exe" } else { "rustc" });
137 fs::metadata(&rustc)
138 .and_then(|meta| meta.modified())
139 .ok()
140 .or_else(|| fs::metadata(&sysroot).and_then(|meta| meta.modified()).ok())
141}
142
143pub fn parity_key() -> Result<String> {
148 parity_key_of(None)
149}
150
151pub fn parity_key_of(selector: Option<&str>) -> Result<String> {
153 let tc = toolchain_of(selector)?;
154 parity_key_with(&tc)
155}
156
157fn parity_key_with(tc: &Toolchain) -> Result<String> {
159 let key = format!(
160 "{}-{}-{}-{}",
161 tc.release,
162 tc.commit,
163 tc.host,
164 flags_digest(&tc.host)
165 );
166 util::validate_component(&key, "parity key")?;
169 Ok(key)
170}
171
172fn flags_digest(host: &str) -> String {
175 let mut input = String::from(PARITY_SEED);
176 for name in [
177 "RUSTFLAGS",
178 "RUSTDOCFLAGS",
179 "CARGO_BUILD_RUSTFLAGS",
180 "CARGO_BUILD_RUSTDOCFLAGS",
181 "CARGO_BUILD_TARGET",
182 "RUSTC_WRAPPER",
183 "CARGO_BUILD_RUSTC_WRAPPER",
184 "RUSTC",
185 ] {
186 input.push('\0');
187 input.push_str(name);
188 input.push('=');
189 input.push_str(&std::env::var(name).unwrap_or_default());
190 }
191 if let Ok(path) = crate::config::cargo_config_path() {
192 if let Ok(text) = crate::util::read_trusted_config(&path, 1024 * 1024) {
193 for key in ["rustflags", "rustdocflags", "target", "rustc-wrapper"] {
194 if let Some(value) = crate::config::read_table_key(&text, "build", key) {
195 input.push('\0');
196 input.push_str(key);
197 input.push('=');
198 input.push_str(&value);
199 }
200 }
201 let target_table = format!("target.{host}");
204 for key in ["rustflags", "rustdocflags", "linker"] {
205 if let Some(value) = crate::config::read_table_key(&text, &target_table, key) {
206 input.push('\0');
207 input.push_str(&target_table);
208 input.push('.');
209 input.push_str(key);
210 input.push('=');
211 input.push_str(&value);
212 }
213 }
214 }
215 }
216 if let Ok(mut cargo) = util::command_for("cargo") {
217 if let Ok(output) = cargo.arg("-V").output() {
218 if output.status.success() {
219 input.push('\0');
220 input.push_str("cargo=");
221 input.push_str(String::from_utf8_lossy(&output.stdout).trim());
222 }
223 }
224 }
225 format!("{:08x}", (fnv1a64(input.as_bytes()) >> 32) as u32)
226}
227
228fn fnv1a64(bytes: &[u8]) -> u64 {
229 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
230 for byte in bytes {
231 hash ^= *byte as u64;
232 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
233 }
234 hash
235}
236
237pub fn store_root(build_root: &Path) -> PathBuf {
242 build_root.parent().unwrap_or(build_root).join("store")
243}
244
245pub fn units_dir(store_root: &Path, parity: &str) -> PathBuf {
247 store_root.join(parity).join("units")
248}
249
250fn manifest_path(store_root: &Path, parity: &str) -> PathBuf {
251 store_root.join(parity).join("manifest.json")
252}
253
254fn store_lock_path(store_root: &Path, parity: &str) -> PathBuf {
257 store_root.join(format!(".lock-{parity}"))
258}
259
260pub(crate) fn remove_generation_lock(store_root: &Path, generation: &str) {
263 let dir = store_root.join(generation);
264 let path = store_lock_path(store_root, generation);
265 if dir.exists() || !path.exists() {
266 return;
267 }
268 let Ok(Some(_lock)) = FileLock::try_acquire(&path) else {
269 return; };
271 if !dir.exists() {
272 let _ = fs::remove_file(&path);
273 }
274}
275
276pub fn sweep_store_debris(store_root: &Path) {
280 let Ok(entries) = fs::read_dir(store_root) else {
281 return;
282 };
283 for entry in entries.flatten() {
284 let name = entry.file_name().to_string_lossy().into_owned();
285 if let Some(generation) = name.strip_prefix(".lock-") {
286 if !generation.is_empty() {
287 remove_generation_lock(store_root, generation);
288 }
289 }
290 }
291 let Ok(names) = generation_names(store_root) else {
292 return;
293 };
294 for generation in names {
295 let units = units_dir(store_root, &generation);
296 let empty = fs::read_dir(&units)
297 .map(|mut entries| entries.next().is_none())
298 .unwrap_or(true);
299 if !empty || manifest_path(store_root, &generation).is_file() {
302 continue;
303 }
304 let Ok(Some(_lock)) = FileLock::try_acquire(&store_lock_path(store_root, &generation))
305 else {
306 continue;
307 };
308 let still_empty = fs::read_dir(&units)
309 .map(|mut entries| entries.next().is_none())
310 .unwrap_or(true);
311 if still_empty {
312 let _ = crate::util::force_remove_dir(&store_root.join(&generation));
313 drop(_lock);
314 remove_generation_lock(store_root, &generation);
315 }
316 }
317}
318
319pub fn drop_generation(store_root: &Path, parity: &str) -> Result<bool> {
322 util::validate_component(parity, "generation")?;
323 if store_root.exists() {
324 check_store_trust(store_root)?;
325 }
326 let dir = store_root.join(parity);
327 let meta = match fs::symlink_metadata(&dir) {
328 Ok(meta) => meta,
329 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
330 Err(err) => return Err(err).context("inspecting generation"),
331 };
332 if !meta.is_dir() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) {
333 anyhow::bail!("refusing symlinked/reparse generation {}", dir.display());
334 }
335 let lock_path = store_lock_path(store_root, parity);
336 let Ok(Some(lock)) = FileLock::try_acquire(&lock_path) else {
337 return Ok(false);
338 };
339 crate::util::force_remove_dir(&dir)?;
340 drop(lock);
341 remove_generation_lock(store_root, parity);
342 Ok(true)
343}
344
345pub fn generation_unit_names(store_root: &Path, parity: &str) -> Result<Vec<String>> {
348 util::validate_component(parity, "generation")?;
349 if let Ok(Some(manifest)) = load_manifest(store_root, parity) {
350 return Ok(manifest
351 .units
352 .iter()
353 .map(|entry| entry.name.clone())
354 .collect());
355 }
356 let mut names = Vec::new();
357 if let Ok(entries) = fs::read_dir(units_dir(store_root, parity)) {
358 for entry in entries.flatten() {
359 if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
360 continue;
361 }
362 let name = entry.file_name().to_string_lossy().into_owned();
363 if util::validate_component(&name, "unit name").is_ok() {
364 names.push(name);
365 }
366 }
367 }
368 names.sort();
369 Ok(names)
370}
371
372fn ensure_store_root(store_root: &Path) -> Result<()> {
373 util::ensure_windows_user_path(store_root, "store")?;
374 match fs::symlink_metadata(store_root) {
375 Ok(meta) => {
376 if !meta.is_dir() || meta.file_type().is_symlink() {
377 anyhow::bail!(
378 "store root {} is not a real directory",
379 store_root.display()
380 );
381 }
382 util::ensure_private_dir(store_root)?;
383 }
384 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
385 util::create_private_dir_all(store_root)?;
386 }
387 Err(err) => {
388 return Err(err).with_context(|| format!("inspecting {}", store_root.display()));
389 }
390 }
391 Ok(())
392}
393
394#[derive(Debug, Clone, Default, Serialize, Deserialize)]
398pub struct Manifest {
399 pub parity_key: String,
400 pub toolchain: String,
401 pub created: u64,
403 pub updated: u64,
405 pub units: Vec<UnitEntry>,
406 #[serde(default, skip_serializing_if = "String::is_empty")]
409 pub mac: String,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct UnitEntry {
415 pub name: String,
417 pub profile: String,
418 pub memo: String,
420 pub hash: String,
422 pub stamp: u64,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub layout: Option<fingerprint::UnitLayout>,
428 pub files: Vec<FileEntry>,
429}
430
431impl UnitEntry {
432 pub fn layout(&self) -> fingerprint::UnitLayout {
434 self.layout.unwrap_or_else(|| {
435 if self
436 .files
437 .iter()
438 .any(|file| file.dest.starts_with(".fingerprint/"))
439 {
440 fingerprint::UnitLayout::Classic
441 } else if self
442 .files
443 .iter()
444 .any(|file| file.dest.contains("/fingerprint/"))
445 {
446 fingerprint::UnitLayout::New
447 } else {
448 fingerprint::UnitLayout::Classic
449 }
450 })
451 }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct FileEntry {
457 pub path: String,
459 pub dest: String,
461 pub size: u64,
462 #[serde(default)]
464 pub sha256: String,
465 pub role: String,
467}
468
469impl Manifest {
470 pub fn validate(&self) -> Result<()> {
473 for unit in &self.units {
474 util::validate_component(&unit.name, "unit name")?;
475 util::validate_component(&unit.profile, "profile")?;
476 util::validate_component(&unit.memo, "memo file")?;
477 if !unit.hash.is_empty() && !fingerprint::looks_like_hash(&unit.hash) {
478 anyhow::bail!("invalid memo hash {:?} in unit {}", unit.hash, unit.name);
479 }
480 for file in &unit.files {
481 util::validate_rel(&file.path, "stored file path")?;
482 util::validate_rel(&file.dest, "destination path")?;
483 if !file.sha256.is_empty()
484 && (file.sha256.len() != 64
485 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()))
486 {
487 anyhow::bail!("invalid SHA-256 for {} in unit {}", file.path, unit.name);
488 }
489 }
490 }
491 Ok(())
492 }
493}
494
495fn manifest_key_path() -> Result<PathBuf> {
498 let path = crate::paths::cargo_home()?
499 .join("epicenter")
500 .join("manifest.key");
501 #[cfg(windows)]
502 crate::util::ensure_windows_user_path(&path, "vault key")?;
503 Ok(path)
504}
505
506fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
507 let mut block = [0u8; 64];
508 if key.len() > block.len() {
509 let digest = Sha256::digest(key);
510 block[..32].copy_from_slice(&digest);
511 } else {
512 block[..key.len()].copy_from_slice(key);
513 }
514 let mut inner = Sha256::new();
515 let mut outer = Sha256::new();
516 let mut ipad = [0x36u8; 64];
517 let mut opad = [0x5cu8; 64];
518 for index in 0..64 {
519 ipad[index] ^= block[index];
520 opad[index] ^= block[index];
521 }
522 inner.update(ipad);
523 inner.update(data);
524 let inner = inner.finalize();
525 outer.update(opad);
526 outer.update(inner);
527 let out = outer.finalize();
528 let mut result = [0u8; 32];
529 result.copy_from_slice(&out);
530 result
531}
532
533fn hex32(bytes: &[u8; 32]) -> String {
534 let mut out = String::with_capacity(64);
535 for byte in bytes {
536 out.push_str(&format!("{byte:02x}"));
537 }
538 out
539}
540
541fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
542 if left.len() != right.len() {
543 return false;
544 }
545 let mut diff = 0u8;
546 for (a, b) in left.iter().zip(right) {
547 diff |= a ^ b;
548 }
549 diff == 0
550}
551
552fn read_key_file(path: &Path) -> Result<Vec<u8>> {
553 let mut options = fs::OpenOptions::new();
554 options.read(true);
555 #[cfg(unix)]
556 {
557 use std::os::unix::fs::OpenOptionsExt;
558 options.custom_flags(libc::O_NOFOLLOW);
559 }
560 let mut file = options
561 .open(path)
562 .with_context(|| format!("opening vault key {}", path.display()))?;
563 let meta = file.metadata()?;
564 if !meta.is_file() || util::is_reparse_point(&meta) {
565 anyhow::bail!("vault key {} is not a regular file", path.display());
566 }
567 if !util::owned_by_us(&meta) {
568 anyhow::bail!("vault key {} is owned by another user", path.display());
569 }
570 #[cfg(unix)]
571 {
572 use std::os::unix::fs::MetadataExt;
573 if meta.mode() & 0o077 != 0 {
574 anyhow::bail!(
575 "vault key {} is accessible by other users (mode {:o})",
576 path.display(),
577 meta.mode() & 0o777
578 );
579 }
580 }
581 let mut key = Vec::new();
582 std::io::Read::read_to_end(&mut file, &mut key)?;
583 if key.len() != 32 {
584 anyhow::bail!("vault key {} is malformed", path.display());
585 }
586 Ok(key)
587}
588
589fn manifest_key() -> Result<Vec<u8>> {
590 let path = manifest_key_path()?;
591 if let Ok(key) = read_key_file(&path) {
592 return Ok(key);
593 }
594 let parent = path
595 .parent()
596 .with_context(|| format!("{} has no parent", path.display()))?;
597 util::create_private_dir_all(parent)?;
598 let mut key = [0u8; 32];
599 getrandom::fill(&mut key).map_err(|err| anyhow::anyhow!("generating vault key: {err}"))?;
600 let mut options = fs::OpenOptions::new();
601 options.write(true).create_new(true);
602 #[cfg(unix)]
603 {
604 use std::os::unix::fs::OpenOptionsExt;
605 options.mode(0o600);
606 }
607 match options.open(&path) {
608 Ok(mut file) => {
609 file.write_all(&key)?;
610 file.sync_all()?;
611 Ok(key.to_vec())
612 }
613 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => read_key_file(&path),
614 Err(err) => Err(err).with_context(|| format!("creating vault key {}", path.display())),
615 }
616}
617
618fn manifest_mac(manifest: &Manifest) -> Result<String> {
619 let mut unsigned = manifest.clone();
620 unsigned.mac.clear();
621 let body = serde_json::to_vec(&unsigned)?;
622 Ok(hex32(&hmac_sha256(&manifest_key()?, &body)))
623}
624
625fn load_manifest(store_root: &Path, parity: &str) -> Result<Option<Manifest>> {
626 let path = manifest_path(store_root, parity);
627 let meta = match fs::symlink_metadata(&path) {
628 Ok(meta) => meta,
629 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
630 Err(err) => {
631 return Err(err).with_context(|| format!("inspecting {}", path.display()));
632 }
633 };
634 if !meta.is_file() || meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
637 anyhow::bail!("{} is not a regular manifest file", path.display());
638 }
639 if !util::owned_by_us(&meta) {
640 anyhow::bail!("{} is owned by another user", path.display());
641 }
642 #[cfg(unix)]
643 {
644 use std::os::unix::fs::MetadataExt;
645 if meta.mode() & 0o022 != 0 {
646 anyhow::bail!(
647 "{} is group/world-writable (mode {:o})",
648 path.display(),
649 meta.mode() & 0o777
650 );
651 }
652 }
653 let text = crate::util::read_trusted_config(&path, 16 * 1024 * 1024)
654 .with_context(|| format!("reading {}", path.display()))?;
655 let manifest: Manifest =
656 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
657 if manifest.mac.len() != 64 {
658 anyhow::bail!(
659 "manifest {} is unsigned (re-harvest this generation)",
660 path.display()
661 );
662 }
663 let expected = manifest_mac(&manifest)?;
664 if !constant_time_eq(manifest.mac.as_bytes(), expected.as_bytes()) {
665 anyhow::bail!("manifest {} failed its MAC check", path.display());
666 }
667 manifest
668 .validate()
669 .with_context(|| format!("validating {}", path.display()))?;
670 Ok(Some(manifest))
671}
672
673pub(crate) fn save_manifest(store_root: &Path, parity: &str, manifest: &Manifest) -> Result<()> {
674 util::validate_component(parity, "generation")?;
675 let dir = store_root.join(parity);
676 util::create_private_dir_all(&dir)?;
677 let mut signed = manifest.clone();
678 signed.mac.clear();
679 signed.mac = manifest_mac(&signed)?;
680 let what = serde_json::to_string_pretty(&signed)?;
681 let mut tmp = None;
683 for attempt in 0..100u32 {
684 let candidate = dir.join(format!(
685 "manifest.json.tmp-{}-{attempt}",
686 std::process::id()
687 ));
688 match fs::OpenOptions::new()
689 .write(true)
690 .create_new(true)
691 .open(&candidate)
692 {
693 Ok(file) => {
694 #[cfg(unix)]
695 {
696 use std::os::unix::fs::PermissionsExt;
697 let _ = file.set_permissions(fs::Permissions::from_mode(0o600));
698 }
699 tmp = Some((candidate, file));
700 break;
701 }
702 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
703 Err(err) => {
704 return Err(err).with_context(|| format!("creating {}", candidate.display()));
705 }
706 }
707 }
708 let Some((tmp, mut file)) = tmp else {
709 anyhow::bail!(
710 "could not create a unique manifest temp file in {}",
711 dir.display()
712 );
713 };
714 file.write_all(what.as_bytes())?;
715 file.write_all(b"\n")?;
716 file.sync_all()?;
717 drop(file);
718 fs::rename(&tmp, manifest_path(store_root, parity))?;
719 #[cfg(unix)]
720 if let Ok(parent) = fs::File::open(&dir) {
721 let _ = parent.sync_all();
722 }
723 Ok(())
724}
725
726pub fn generation_names(store_root: &Path) -> Result<Vec<String>> {
728 if !store_root.is_dir() {
729 return Ok(Vec::new());
730 }
731 let mut names = Vec::new();
732 for entry in
733 fs::read_dir(store_root).with_context(|| format!("reading {}", store_root.display()))?
734 {
735 let entry = entry?;
736 if !entry.file_type()?.is_dir() {
737 continue;
738 }
739 let name = entry.file_name().to_string_lossy().into_owned();
740 if !name.starts_with('.') {
741 names.push(name);
742 }
743 }
744 names.sort();
745 Ok(names)
746}
747
748#[derive(Debug, Clone)]
752pub struct StoredUnit {
753 pub generation: String,
755 pub entry: UnitEntry,
757 pub dir: PathBuf,
759}
760
761pub fn stored_units(store_root: &Path) -> Result<Vec<StoredUnit>> {
763 if store_root.exists() {
764 check_store_trust(store_root)?;
765 }
766 let mut out = Vec::new();
767 for generation in generation_names(store_root)? {
768 let Some(manifest) = load_manifest(store_root, &generation)? else {
769 continue;
770 };
771 let units = units_dir(store_root, &generation);
772 for entry in manifest.units {
773 let dir = units.join(&entry.name);
774 out.push(StoredUnit {
775 generation: generation.clone(),
776 entry,
777 dir,
778 });
779 }
780 }
781 Ok(out)
782}
783
784pub fn stored_unit_rustc(unit: &StoredUnit) -> Option<String> {
786 let dir_meta = fs::symlink_metadata(&unit.dir).ok()?;
787 if !dir_meta.is_dir() || dir_meta.file_type().is_symlink() || util::is_reparse_point(&dir_meta)
788 {
789 return None;
790 }
791 let rel = PathBuf::from(format!("{}.json", unit.entry.memo));
792 util::check_regular_chain(&unit.dir, &rel).ok()?;
793 let text = fs::read_to_string(unit.dir.join(&rel)).ok()?;
794 let value: serde_json::Value = serde_json::from_str(&text).ok()?;
795 match value.get("rustc") {
796 Some(serde_json::Value::Number(number)) => Some(number.to_string()),
797 Some(serde_json::Value::String(text)) => Some(text.clone()),
798 _ => None,
799 }
800}
801
802pub fn drop_units(store_root: &Path, generation: &str, names: &[String]) -> Result<usize> {
806 util::validate_component(generation, "generation")?;
807 if store_root.exists() {
808 check_store_trust(store_root)?;
809 }
810 let lock = FileLock::acquire(&store_lock_path(store_root, generation))?;
811 let Some(mut manifest) = load_manifest(store_root, generation)? else {
812 return Ok(0);
813 };
814 let units = units_dir(store_root, generation);
815 match fs::symlink_metadata(&units) {
816 Ok(meta)
817 if meta.is_dir()
818 && !meta.file_type().is_symlink()
819 && !util::is_reparse_point(&meta) => {}
820 Ok(_) => {
821 anyhow::bail!(
822 "units dir {} is a symlink/reparse or not a directory",
823 units.display()
824 );
825 }
826 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
827 }
829 Err(err) => {
830 return Err(err).with_context(|| format!("inspecting {}", units.display()));
831 }
832 }
833 let mut kept = Vec::with_capacity(manifest.units.len());
834 let mut dropped = 0;
835 for entry in manifest.units.drain(..) {
836 if !names.contains(&entry.name) {
837 kept.push(entry);
838 continue;
839 }
840 util::validate_component(&entry.name, "unit name")?;
841 let dir = units.join(&entry.name);
842 match fs::symlink_metadata(&dir) {
843 Ok(meta) if meta.file_type().is_symlink() || util::is_reparse_point(&meta) => {
844 eprintln!(
845 "could not remove symlinked/reparse unit {}: refused",
846 dir.display()
847 );
848 kept.push(entry);
849 }
850 Ok(meta) if meta.is_dir() => {
851 if let Ok(base) = fs::canonicalize(&units) {
854 match fs::canonicalize(&dir) {
855 Ok(canon) if canon.parent() == Some(base.as_path()) && canon != base => {}
856 _ => {
857 eprintln!(
858 "could not remove {}: escapes {}",
859 dir.display(),
860 units.display()
861 );
862 kept.push(entry);
863 continue;
864 }
865 }
866 }
867 match fs::remove_dir_all(&dir) {
868 Ok(()) => {
869 dropped += 1;
870 }
871 Err(err) => {
872 eprintln!("could not remove {}: {err}", dir.display());
873 kept.push(entry);
874 }
875 }
876 }
877 Ok(_) => {
878 eprintln!("could not remove {}: not a directory", dir.display());
879 kept.push(entry);
880 }
881 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
882 dropped += 1;
883 }
884 Err(err) => {
885 eprintln!("could not remove {}: {err}", dir.display());
886 kept.push(entry);
887 }
888 }
889 }
890 manifest.units = kept;
891 let empty = manifest.units.is_empty();
892 if empty {
893 let _ = crate::util::force_remove_dir(&store_root.join(generation));
894 } else if dropped > 0 {
895 manifest.updated = now_secs();
896 save_manifest(store_root, generation, &manifest)?;
897 }
898 drop(lock);
899 if empty {
900 remove_generation_lock(store_root, generation);
902 }
903 Ok(dropped)
904}
905#[derive(Debug, Default)]
909pub struct HarvestReport {
910 pub parity: String,
911 pub toolchain: String,
912 pub stored: usize,
913 pub refreshed: usize,
914 pub unchanged: usize,
915 pub skipped_stale: usize,
916 pub skipped_incomplete: usize,
917 pub files: usize,
918 pub bytes: u64,
919 pub note: Option<String>,
920}
921
922pub fn harvest(
929 build_root: &Path,
930 store_root: &Path,
931 unit_filter: Option<&str>,
932 dry_run: bool,
933) -> Result<HarvestReport> {
934 harvest_with(build_root, store_root, unit_filter, dry_run, None, None)
935}
936
937pub fn harvest_with(
941 build_root: &Path,
942 store_root: &Path,
943 unit_filter: Option<&str>,
944 dry_run: bool,
945 selector: Option<&str>,
946 layout: Option<fingerprint::UnitLayout>,
947) -> Result<HarvestReport> {
948 let tc = toolchain_of(selector)?;
949 let parity = parity_key_with(&tc)?;
950 let mut report = HarvestReport {
951 parity: parity.clone(),
952 toolchain: tc.version_line(),
953 ..Default::default()
954 };
955
956 let _lock = if dry_run {
959 None
960 } else {
961 ensure_store_root(store_root)?;
962 util::create_private_dir_all(&store_root.join(&parity))?;
963 util::create_private_dir_all(&units_dir(store_root, &parity))?;
964 let lock = FileLock::acquire(&store_lock_path(store_root, &parity))?;
965 sweep_staging(&units_dir(store_root, &parity));
968 Some(lock)
969 };
970
971 let rows: Vec<(Unit, ArtifactInfo)> = fingerprint::read_units(build_root)?
972 .into_iter()
973 .map(|unit| {
974 let arts = fingerprint::artifacts_for(build_root, &unit);
975 (unit, arts)
976 })
977 .collect();
978
979 let mixed_layouts = rows
984 .iter()
985 .any(|(unit, _)| unit.layout == fingerprint::UnitLayout::Classic)
986 && rows
987 .iter()
988 .any(|(unit, _)| unit.layout == fingerprint::UnitLayout::New);
989 let expected_layout =
990 layout.or_else(|| mixed_layouts.then(|| fingerprint::layout_for_cargo(&tc.release, &[])));
991 let rows: Vec<(Unit, ArtifactInfo)> = match expected_layout {
992 Some(wanted) => {
993 let before = rows.len();
994 let kept: Vec<(Unit, ArtifactInfo)> = rows
995 .into_iter()
996 .filter(|(unit, _)| unit.layout == wanted)
997 .collect();
998 report.skipped_stale += before - kept.len();
999 kept
1000 }
1001 None => rows,
1002 };
1003
1004 let newest_activity = rows.iter().filter_map(|(_, arts)| arts.newest).max();
1007 if let (Some(newest), Some(installed)) = (newest_activity, toolchain_install_time_of(selector))
1008 {
1009 if newest < installed {
1010 report.note = Some(format!(
1011 "the shared cache predates the installed toolchain ({}) — build once, then harvest",
1012 tc.version_line()
1013 ));
1014 return Ok(report);
1015 }
1016 }
1017
1018 let active_gen = rows
1022 .iter()
1023 .filter(|(unit, _)| crate::who::rustc_of(unit) != "unknown")
1024 .max_by_key(|(_, arts)| arts.newest)
1025 .map(|(unit, _)| crate::who::rustc_of(unit))
1026 .unwrap_or_else(|| "unknown".into());
1027
1028 let mut manifest = load_manifest(store_root, &parity)?.unwrap_or_else(|| Manifest {
1029 parity_key: parity.clone(),
1030 toolchain: tc.version_line(),
1031 created: now_secs(),
1032 updated: 0,
1033 units: Vec::new(),
1034 mac: String::new(),
1035 });
1036
1037 struct GroupPlan {
1039 unit: Unit,
1040 files: Vec<(PathBuf, String, String, String)>,
1041 memo: String,
1042 hash: String,
1043 base: u64,
1044 }
1045 let mut plans: Vec<GroupPlan> = Vec::new();
1046
1047 for (unit, arts) in &rows {
1048 if let Some(query) = unit_filter {
1049 if !unit_name_matches(&unit.dir_name, query) {
1050 continue;
1051 }
1052 }
1053 let cohort = crate::who::rustc_of(unit);
1054 if cohort != "unknown" && active_gen != "unknown" && cohort != active_gen {
1055 report.skipped_stale += 1;
1056 continue;
1057 }
1058
1059 let unit_dir = &unit.fingerprint_dir;
1060 let profile_dir = build_root.join(&unit.profile);
1061 let memo = unit.memo_file.clone();
1062 let mut files: Vec<(PathBuf, String, String, String)> = Vec::new();
1063 for entry in
1070 fs::read_dir(&unit_dir).with_context(|| format!("reading {}", unit_dir.display()))?
1071 {
1072 let entry = entry?;
1073 if !entry.file_type()?.is_file() {
1074 continue;
1075 }
1076 let name = entry.file_name().to_string_lossy().into_owned();
1077 if is_volatile(&name) {
1078 continue;
1079 }
1080 let role = if name.starts_with("dep-") {
1081 "dep-info"
1082 } else if name.ends_with(".json") {
1083 "fingerprint"
1084 } else if fs::read_to_string(entry.path())
1085 .map(|text| crate::fingerprint::looks_like_hash(text.trim()))
1086 .unwrap_or(false)
1087 {
1088 "memo"
1089 } else {
1090 "extra"
1092 };
1093 files.push((
1094 entry.path(),
1095 name.clone(),
1096 format!("{}/{name}", unit.fingerprint_rel()),
1097 role.into(),
1098 ));
1099 }
1100
1101 let mut artifact_count = 0;
1104 for (path, _, _) in &arts.paths {
1105 let name = path
1106 .file_name()
1107 .map(|name| name.to_string_lossy().into_owned())
1108 .unwrap_or_default();
1109 if is_volatile(&name) {
1110 continue;
1111 }
1112 let Ok(rel) = path.strip_prefix(&profile_dir) else {
1113 continue;
1114 };
1115 let rel = rel.to_string_lossy().replace('\\', "/");
1116 if rel.ends_with(".d")
1119 && (rel.starts_with("deps/") || unit.layout == fingerprint::UnitLayout::New)
1120 {
1121 continue;
1122 }
1123 let role = if rel.starts_with("deps/") || rel.contains("/out/") {
1124 "artifact"
1125 } else if rel.starts_with("build/") {
1126 "build-tree"
1127 } else {
1128 "artifact"
1129 };
1130 files.push((path.clone(), rel.clone(), rel, role.into()));
1131 artifact_count += 1;
1132 }
1133 if artifact_count == 0 {
1134 report.skipped_incomplete += 1;
1135 continue;
1136 }
1137
1138 plans.push(GroupPlan {
1139 base: group_stamp(&files)?,
1140 memo,
1141 hash: unit.memo_hash.clone().unwrap_or_default(),
1142 files,
1143 unit: unit.clone(),
1144 });
1145 }
1146
1147 let stored_stamps: std::collections::HashMap<String, u64> = manifest
1156 .units
1157 .iter()
1158 .map(|entry| (entry.hash.clone(), entry.stamp))
1159 .collect();
1160 let index: std::collections::HashMap<&str, usize> = plans
1161 .iter()
1162 .enumerate()
1163 .map(|(position, plan)| (plan.hash.as_str(), position))
1164 .collect();
1165 let mut plan_deps: Vec<Vec<usize>> = Vec::with_capacity(plans.len());
1166 let mut stamps: Vec<u64> = Vec::with_capacity(plans.len());
1167 for plan in &plans {
1168 let mut deps = Vec::new();
1169 let mut stamp = plan.base;
1170 for dep in plan
1171 .unit
1172 .json
1173 .as_ref()
1174 .map(|json| json.deps.as_slice())
1175 .unwrap_or(&[])
1176 {
1177 let Some(hash) = dep.memo_hash() else {
1178 continue;
1179 };
1180 if let Some(&at) = index.get(hash.as_str()) {
1181 deps.push(at);
1182 } else if let Some(known) = stored_stamps.get(hash.as_str()) {
1183 stamp = stamp.max(*known);
1184 }
1185 }
1186 plan_deps.push(deps);
1187 stamps.push(stamp);
1188 }
1189 let mut order: Vec<usize> = Vec::with_capacity(plans.len());
1192 let mut settled = vec![false; plans.len()];
1193 loop {
1194 let mut progressed = false;
1195 for at in 0..plans.len() {
1196 if settled[at] || !plan_deps[at].iter().all(|&dep| settled[dep]) {
1197 continue;
1198 }
1199 settled[at] = true;
1200 order.push(at);
1201 progressed = true;
1202 }
1203 if !progressed {
1204 break;
1205 }
1206 }
1207 for at in 0..plans.len() {
1208 if !settled[at] {
1209 order.push(at);
1210 }
1211 }
1212 for &at in &order {
1213 for dep in &plan_deps[at] {
1214 stamps[at] = stamps[at].max(stamps[*dep]);
1215 }
1216 }
1217
1218 for (at, plan) in plans.iter().enumerate() {
1220 let unit = &plan.unit;
1221 let files = &plan.files;
1222 let stamp = stamps[at];
1223 let group_dir = units_dir(store_root, &parity).join(&unit.dir_name);
1224
1225 let mut new_entries: Vec<(&str, &str, &str)> = files
1228 .iter()
1229 .map(|(_, path, dest, role)| (path.as_str(), dest.as_str(), role.as_str()))
1230 .collect();
1231 new_entries.sort_unstable();
1232 let already = manifest
1233 .units
1234 .iter()
1235 .find(|entry| entry.name == unit.dir_name)
1236 .map(|entry| {
1237 let mut stored: Vec<(&str, &str, &str)> = entry
1238 .files
1239 .iter()
1240 .map(|file| (file.path.as_str(), file.dest.as_str(), file.role.as_str()))
1241 .collect();
1242 stored.sort_unstable();
1243 entry.hash == plan.hash
1244 && entry.profile == unit.profile
1245 && entry.stamp == stamp
1246 && stored == new_entries
1247 && entry
1248 .files
1249 .iter()
1250 .all(|file| !file.sha256.is_empty() && group_dir.join(&file.path).is_file())
1251 })
1252 .unwrap_or(false);
1253 if already {
1254 report.unchanged += 1;
1255 continue;
1256 }
1257
1258 let bytes: u64 = files
1259 .iter()
1260 .filter_map(|(src, _, _, _)| fs::metadata(src).ok())
1261 .map(|meta| meta.len())
1262 .sum();
1263 if dry_run {
1264 report.stored += 1;
1265 report.files += files.len();
1266 report.bytes += bytes;
1267 continue;
1268 }
1269
1270 let mut entries = Vec::with_capacity(files.len());
1271 store_group(
1272 store_root,
1273 &parity,
1274 &unit.dir_name,
1275 stamp,
1276 files,
1277 &mut entries,
1278 )
1279 .with_context(|| format!("storing unit {}", unit.dir_name))?;
1280
1281 let entry = UnitEntry {
1282 name: unit.dir_name.clone(),
1283 profile: unit.profile.clone(),
1284 memo: plan.memo.clone(),
1285 hash: plan.hash.clone(),
1286 stamp,
1287 layout: Some(unit.layout),
1288 files: entries,
1289 };
1290 match manifest
1291 .units
1292 .iter_mut()
1293 .find(|existing| existing.name == entry.name)
1294 {
1295 Some(slot) => {
1296 *slot = entry;
1297 report.refreshed += 1;
1298 }
1299 None => {
1300 manifest.units.push(entry);
1301 report.stored += 1;
1302 }
1303 }
1304 report.files += files.len();
1305 report.bytes += bytes;
1306 }
1307
1308 if !dry_run {
1309 manifest.units.sort_by(|a, b| {
1310 (a.profile.as_str(), a.name.as_str()).cmp(&(b.profile.as_str(), b.name.as_str()))
1311 });
1312 manifest.updated = now_secs();
1313 save_manifest(store_root, &parity, &manifest)?;
1314 }
1315 Ok(report)
1316}
1317
1318fn store_group(
1322 store_root: &Path,
1323 parity: &str,
1324 name: &str,
1325 stamp: u64,
1326 files: &[(PathBuf, String, String, String)],
1327 entries: &mut Vec<FileEntry>,
1328) -> Result<()> {
1329 util::validate_component(name, "unit name")?;
1330 let units = units_dir(store_root, parity);
1331 let final_dir = units.join(name);
1332 let mut tmp = None;
1335 for attempt in 0..100u32 {
1336 let candidate = units.join(format!(".{name}.tmp-{}-{attempt}", std::process::id()));
1337 match fs::create_dir(&candidate) {
1338 Ok(()) => {
1339 tmp = Some(candidate);
1340 break;
1341 }
1342 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1343 Err(err) => {
1344 return Err(err)
1345 .with_context(|| format!("creating staging dir {}", candidate.display()));
1346 }
1347 }
1348 }
1349 let Some(tmp) = tmp else {
1350 anyhow::bail!(
1351 "could not create a unique staging dir in {}",
1352 units.display()
1353 );
1354 };
1355 #[cfg(unix)]
1356 {
1357 use std::os::unix::fs::PermissionsExt;
1358 let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o700));
1359 }
1360
1361 if let Err(err) = stage_group(&tmp, files, stamp, entries) {
1362 let _ = crate::util::force_remove_dir(&tmp);
1365 return Err(err);
1366 }
1367
1368 let old = units.join(format!(".{name}.old-{}-{}", std::process::id(), now_secs()));
1369 match fs::symlink_metadata(&final_dir) {
1370 Ok(meta) if meta.file_type().is_symlink() || util::is_reparse_point(&meta) => {
1371 let _ = crate::util::force_remove_dir(&tmp);
1372 anyhow::bail!(
1373 "refusing symlinked/reparse store unit {}",
1374 final_dir.display()
1375 );
1376 }
1377 Ok(meta) if meta.is_dir() => fs::rename(&final_dir, &old)?,
1378 Ok(_) => {
1379 let _ = crate::util::force_remove_dir(&tmp);
1380 anyhow::bail!("store unit path {} is not a directory", final_dir.display());
1381 }
1382 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1383 Err(err) => return Err(err).context("inspecting store unit"),
1384 }
1385 fs::rename(&tmp, &final_dir)?;
1386 if fs::symlink_metadata(&old)
1387 .map(|meta| {
1388 meta.is_dir() && !meta.file_type().is_symlink() && !util::is_reparse_point(&meta)
1389 })
1390 .unwrap_or(false)
1391 {
1392 let _ = fs::remove_dir_all(&old);
1393 }
1394 Ok(())
1395}
1396
1397fn stage_group(
1406 tmp: &Path,
1407 files: &[(PathBuf, String, String, String)],
1408 stamp: u64,
1409 entries: &mut Vec<FileEntry>,
1410) -> Result<()> {
1411 for (src, path, dest, role) in files {
1412 let rel = util::validate_rel(path, "stored file path")?;
1413 let dst = tmp.join(&rel);
1414 if let Some(parent) = dst.parent() {
1415 util::create_private_dir_all(parent)?;
1416 }
1417 let (size, sha256) = util::copy_file_hashed(src, &dst)
1418 .with_context(|| format!("copying {} -> {}", src.display(), dst.display()))?;
1419 make_writable(&dst).with_context(|| format!("making {} writable", dst.display()))?;
1420 set_stamp(&dst, stamp)?;
1421 make_read_only(&dst)?;
1422 entries.push(FileEntry {
1423 path: path.clone(),
1424 dest: dest.clone(),
1425 size,
1426 sha256,
1427 role: role.clone(),
1428 });
1429 }
1430 Ok(())
1431}
1432
1433fn sweep_staging(units: &Path) {
1437 let Ok(entries) = fs::read_dir(units) else {
1438 return;
1439 };
1440 let now = SystemTime::now();
1441 for entry in entries.flatten() {
1442 let name = entry.file_name();
1443 let name = name.to_string_lossy();
1444 if !(name.contains(".tmp-") || name.contains(".old-")) {
1445 continue;
1446 }
1447 let meta = match fs::symlink_metadata(entry.path()) {
1448 Ok(meta) => meta,
1449 Err(_) => continue,
1450 };
1451 if meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1453 let _ = fs::remove_file(entry.path());
1454 continue;
1455 }
1456 if !meta.is_dir() {
1457 continue;
1458 }
1459 let stale = meta
1460 .modified()
1461 .ok()
1462 .and_then(|modified| now.duration_since(modified).ok())
1463 .is_some_and(|age| age > Duration::from_secs(600));
1464 if stale {
1465 let _ = crate::util::force_remove_dir(&entry.path());
1466 }
1467 }
1468}
1469
1470fn group_stamp(files: &[(PathBuf, String, String, String)]) -> Result<u64> {
1475 let mut newest: Option<SystemTime> = None;
1476 let mut metadata: Option<SystemTime> = None;
1477 for (src, path, _, _) in files {
1478 let modified = fs::metadata(src)
1479 .and_then(|meta| meta.modified())
1480 .with_context(|| format!("reading mtime of {}", src.display()))?;
1481 if newest.map_or(true, |current| modified > current) {
1482 newest = Some(modified);
1483 }
1484 if path.ends_with(".rmeta") && metadata.map_or(true, |current| modified > current) {
1485 metadata = Some(modified);
1486 }
1487 }
1488 Ok(match metadata.or(newest) {
1494 Some(time) => ceil_secs(time),
1495 None => now_secs(),
1496 })
1497}
1498
1499fn ceil_secs(time: SystemTime) -> u64 {
1501 match time.duration_since(UNIX_EPOCH) {
1502 Ok(duration) => {
1503 let secs = duration.as_secs();
1504 if duration.subsec_nanos() > 0 {
1505 secs + 1
1506 } else {
1507 secs
1508 }
1509 }
1510 Err(_) => 0,
1511 }
1512}
1513
1514fn now_secs() -> u64 {
1515 SystemTime::now()
1516 .duration_since(UNIX_EPOCH)
1517 .map(|duration| duration.as_secs())
1518 .unwrap_or(0)
1519}
1520
1521fn set_stamp(path: &Path, stamp: u64) -> Result<()> {
1522 let time = UNIX_EPOCH + Duration::from_secs(stamp);
1523 #[cfg(windows)]
1527 let file = {
1528 use std::os::windows::fs::OpenOptionsExt;
1529 fs::OpenOptions::new()
1530 .access_mode(0x0100) .open(path)
1532 .or_else(|_| fs::OpenOptions::new().write(true).open(path))
1533 .with_context(|| format!("opening {} to stamp it", path.display()))?
1534 };
1535 #[cfg(not(windows))]
1536 let file =
1537 fs::File::open(path).with_context(|| format!("opening {} to stamp it", path.display()))?;
1538 file.set_modified(time)
1539 .with_context(|| format!("stamping {}", path.display()))?;
1540 Ok(())
1541}
1542
1543#[cfg(unix)]
1545fn make_writable(path: &Path) -> std::io::Result<()> {
1546 use std::os::unix::fs::PermissionsExt;
1547 let mut perms = fs::metadata(path)?.permissions();
1548 perms.set_mode(0o644);
1549 fs::set_permissions(path, perms)
1550}
1551
1552#[cfg(not(unix))]
1553fn make_writable(path: &Path) -> std::io::Result<()> {
1554 let mut perms = fs::metadata(path)?.permissions();
1555 perms.set_readonly(false);
1556 fs::set_permissions(path, perms)
1557}
1558
1559fn make_read_only(path: &Path) -> Result<()> {
1563 #[cfg(unix)]
1564 {
1565 use std::os::unix::fs::PermissionsExt;
1566 fs::set_permissions(path, fs::Permissions::from_mode(0o444))?;
1567 }
1568 #[cfg(not(unix))]
1569 {
1570 let _ = path;
1571 }
1572 Ok(())
1573}
1574
1575fn is_volatile(name: &str) -> bool {
1579 name == "invoked.timestamp" || name.contains(".kache")
1580}
1581
1582#[derive(Debug, Default)]
1586pub struct RestoreReport {
1587 pub parity: String,
1588 pub target: PathBuf,
1589 pub units_linked: usize,
1590 pub units_already: usize,
1591 pub files_linked: usize,
1592 pub files_copied: usize,
1593 pub files_existing: usize,
1594 pub files_missing: usize,
1595 pub bytes: u64,
1596 pub errors: usize,
1597 pub fatal_errors: usize,
1600 pub note: Option<String>,
1601}
1602
1603#[cfg(unix)]
1604enum RestoreOutcome {
1605 Existing,
1606 Missing,
1607 Linked,
1608 Copied,
1609}
1610
1611#[cfg(unix)]
1616fn restore_file_at(
1617 view_root: &Path,
1618 dest_rel: &Path,
1619 src: &Path,
1620 stamp: u64,
1621 file: &FileEntry,
1622 shared: bool,
1623) -> Result<RestoreOutcome> {
1624 let parent_rel = dest_rel.parent().unwrap_or_else(|| Path::new(""));
1625 let dst_name = dest_rel
1626 .file_name()
1627 .with_context(|| format!("destination {} has no file name", dest_rel.display()))?;
1628 let parent = util::open_dir_chain_at(view_root, parent_rel)?;
1629 if parent.exists(dst_name)? {
1630 return Ok(RestoreOutcome::Existing);
1631 }
1632 let mut source = match util::open_file_nofollow(src) {
1633 Ok(file) => file,
1634 Err(err)
1635 if err
1636 .root_cause()
1637 .downcast_ref::<std::io::Error>()
1638 .map_or(false, |io| io.kind() == std::io::ErrorKind::NotFound) =>
1639 {
1640 return Ok(RestoreOutcome::Missing);
1641 }
1642 Err(err)
1643 if err
1644 .downcast_ref::<std::io::Error>()
1645 .map_or(false, |io| io.kind() == std::io::ErrorKind::NotFound) =>
1646 {
1647 return Ok(RestoreOutcome::Missing);
1648 }
1649 Err(err) => return Err(err),
1650 };
1651 let meta = source.metadata()?;
1652 if !meta.is_file() || !util::owned_by_us(&meta) {
1653 anyhow::bail!("stored file {} is not a regular owned file", src.display());
1654 }
1655 if meta.len() != file.size {
1656 anyhow::bail!(
1657 "stored file {} size drift (recorded {}, found {})",
1658 src.display(),
1659 file.size,
1660 meta.len()
1661 );
1662 }
1663 if shared {
1664 if file.sha256.is_empty() {
1665 anyhow::bail!("{} has no recorded SHA-256", src.display());
1666 }
1667 let found = util::sha256_file_fd(&mut source)?;
1668 if found != file.sha256 {
1669 anyhow::bail!("{} failed its SHA-256 check", src.display());
1670 }
1671 parent.link_path(src, dst_name)?;
1672 Ok(RestoreOutcome::Linked)
1673 } else {
1674 let mut tmp = parent.create_excl(dst_name, 0o600)?;
1679 let result = util::copy_fd_verified(&mut source, &mut tmp, file.size, &file.sha256)
1680 .and_then(|_| util::set_stamp_fd(&tmp, stamp).map_err(anyhow::Error::from));
1681 if let Err(err) = result {
1682 let _ = parent.unlink(dst_name);
1683 return Err(err);
1684 }
1685 Ok(RestoreOutcome::Copied)
1686 }
1687}
1688
1689pub fn restore(
1705 view_root: &Path,
1706 store_root: &Path,
1707 unit_filter: Option<&str>,
1708 packages: Option<&std::collections::HashSet<String>>,
1709 dry_run: bool,
1710) -> Result<RestoreReport> {
1711 restore_with(
1712 view_root,
1713 store_root,
1714 unit_filter,
1715 packages,
1716 None,
1717 None,
1718 dry_run,
1719 )
1720}
1721
1722pub fn restore_with(
1726 view_root: &Path,
1727 store_root: &Path,
1728 unit_filter: Option<&str>,
1729 packages: Option<&std::collections::HashSet<String>>,
1730 layout: Option<fingerprint::UnitLayout>,
1731 selector: Option<&str>,
1732 dry_run: bool,
1733) -> Result<RestoreReport> {
1734 let parity = parity_key_of(selector)?;
1735 let mut report = RestoreReport {
1736 parity: parity.clone(),
1737 target: view_root.to_path_buf(),
1738 ..Default::default()
1739 };
1740 if store_root.exists() {
1743 check_store_trust(store_root)?;
1744 }
1745 let Some(manifest) = load_manifest(store_root, &parity)? else {
1746 report.note = Some(
1747 "the vault holds no units for this generation yet — run `epicenter harvest` after a build"
1748 .into(),
1749 );
1750 return Ok(report);
1751 };
1752 if !dry_run {
1753 prepare_view_root(view_root)?;
1754 }
1755
1756 let units = units_dir(store_root, &parity);
1757 for entry in &manifest.units {
1758 if let Some(query) = unit_filter {
1759 if !unit_name_matches(&entry.name, query) {
1760 continue;
1761 }
1762 }
1763 if let Some(packages) = packages {
1764 if let Some(package) = unit_package(&entry.name) {
1765 if !packages.contains(package) {
1766 continue;
1767 }
1768 }
1769 }
1770 if let Some(layout) = layout {
1771 if entry.layout() != layout {
1772 continue;
1773 }
1774 }
1775 let already = entry
1777 .files
1778 .iter()
1779 .find(|file| file.path == entry.memo)
1780 .and_then(|file| read_small_regular(&view_root.join(&entry.profile).join(&file.dest)))
1781 .map(|text| text.trim() == entry.hash)
1782 .unwrap_or(false);
1783 if already {
1784 report.units_already += 1;
1785 continue;
1786 }
1787
1788 let mut unit_touched = false;
1789 for file in &entry.files {
1790 let dest_rel = Path::new(&entry.profile).join(&file.dest);
1793 let dst = view_root.join(&dest_rel);
1794 match fs::symlink_metadata(&dst) {
1795 Ok(meta) => {
1796 if meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1797 report.errors += 1;
1798 report.fatal_errors += 1;
1799 eprintln!(
1800 "restore: refusing symlink/reparse destination {}",
1801 dst.display()
1802 );
1803 } else {
1804 report.files_existing += 1;
1805 }
1806 continue;
1807 }
1808 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1809 Err(err) => {
1810 report.errors += 1;
1811 eprintln!("restore: inspecting {}: {err}", dst.display());
1812 continue;
1813 }
1814 }
1815 let shared = if file.path.starts_with("deps/") {
1821 !file.path.ends_with(".rmeta")
1822 } else if file.path.contains("/out/") {
1823 file.path.ends_with(".rlib") || file.path.ends_with(".so")
1824 } else {
1825 false
1826 };
1827 if dry_run {
1828 unit_touched = true;
1829 if shared {
1830 report.files_linked += 1;
1831 } else {
1832 report.files_copied += 1;
1833 }
1834 report.bytes += file.size;
1835 continue;
1836 }
1837 let src = units.join(&entry.name).join(&file.path);
1838 let rel = Path::new(&entry.name).join(&file.path);
1839 #[cfg(unix)]
1840 {
1841 if let Err(err) = util::check_regular_chain(&units, &rel) {
1842 report.errors += 1;
1843 report.fatal_errors += 1;
1844 eprintln!("restore: writing {}: {err:#}", dst.display());
1845 continue;
1846 }
1847 match restore_file_at(view_root, &dest_rel, &src, entry.stamp, file, shared) {
1848 Ok(RestoreOutcome::Existing) => {
1849 report.files_existing += 1;
1850 }
1851 Ok(RestoreOutcome::Missing) => {
1852 report.files_missing += 1;
1853 }
1854 Ok(RestoreOutcome::Linked) => {
1855 report.files_linked += 1;
1856 report.bytes += file.size;
1857 unit_touched = true;
1858 }
1859 Ok(RestoreOutcome::Copied) => {
1860 report.files_copied += 1;
1861 report.bytes += file.size;
1862 unit_touched = true;
1863 }
1864 Err(err) => {
1865 report.errors += 1;
1866 report.fatal_errors += 1;
1867 eprintln!("restore: writing {}: {err:#}", dst.display());
1868 }
1869 }
1870 continue;
1871 }
1872 #[cfg(not(unix))]
1873 {
1874 if let Some(parent) = dest_rel.parent() {
1875 if let Err(err) = util::create_dir_chain_nofollow(view_root, parent) {
1876 report.errors += 1;
1877 report.fatal_errors += 1;
1878 eprintln!("restore: creating {}: {err}", parent.display());
1879 continue;
1880 }
1881 }
1882 match fs::symlink_metadata(&src) {
1883 Ok(_) => {}
1884 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1885 report.files_missing += 1;
1887 continue;
1888 }
1889 Err(err) => {
1890 report.errors += 1;
1891 report.fatal_errors += 1;
1892 eprintln!("restore: inspecting {}: {err}", src.display());
1893 continue;
1894 }
1895 }
1896 let result = if shared {
1897 util::check_regular_chain(&units, &rel)
1898 .and_then(|_| verify_store_file(&src, file))
1899 .and_then(|_| fs::hard_link(&src, &dst).map_err(Into::into))
1900 } else {
1901 util::check_regular_chain(&units, &rel)
1902 .and_then(|_| install_private_copy(&src, &dst, entry.stamp, file))
1903 };
1904 match result {
1905 Ok(()) => {
1906 if shared {
1907 report.files_linked += 1;
1908 } else {
1909 report.files_copied += 1;
1910 }
1911 report.bytes += file.size;
1912 unit_touched = true;
1913 }
1914 Err(err) => {
1915 report.errors += 1;
1916 report.fatal_errors += 1;
1917 eprintln!("restore: writing {}: {err:#}", dst.display());
1918 }
1919 }
1920 }
1921 }
1922 if unit_touched {
1923 report.units_linked += 1;
1924 }
1925 }
1926 Ok(report)
1927}
1928
1929pub(crate) fn check_store_trust(store_root: &Path) -> Result<()> {
1933 util::ensure_windows_user_path(store_root, "store")?;
1934 let meta = fs::symlink_metadata(store_root)
1935 .with_context(|| format!("inspecting store {}", store_root.display()))?;
1936 if !meta.is_dir() || meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1937 anyhow::bail!("store {} is not a real directory", store_root.display());
1938 }
1939 if !util::owned_by_us(&meta) {
1940 anyhow::bail!(
1941 "store {} is owned by another user — refusing to execute its artifacts",
1942 store_root.display()
1943 );
1944 }
1945 util::ensure_private_dir(store_root)?;
1949 Ok(())
1950}
1951
1952fn prepare_view_root(view_root: &Path) -> Result<()> {
1953 match fs::symlink_metadata(view_root) {
1954 Ok(meta) => {
1955 if !meta.is_dir() || meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1956 anyhow::bail!("view {} is not a real directory", view_root.display());
1957 }
1958 if !util::owned_by_us(&meta) {
1959 anyhow::bail!(
1960 "view {} is owned by another user — refusing to write into it",
1961 view_root.display()
1962 );
1963 }
1964 #[cfg(unix)]
1965 {
1966 use std::os::unix::fs::MetadataExt;
1967 if meta.mode() & 0o022 != 0 {
1968 anyhow::bail!(
1969 "view {} is group/world-writable (mode {:o}) — refusing a shared restore target; use a 0700 dir",
1970 view_root.display(),
1971 meta.mode() & 0o777
1972 );
1973 }
1974 }
1975 }
1976 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1977 util::create_private_dir_all(view_root)?;
1978 }
1979 Err(err) => {
1980 return Err(err).with_context(|| format!("inspecting view {}", view_root.display()));
1981 }
1982 }
1983 Ok(())
1989}
1990
1991#[cfg(not(unix))]
1992fn verify_store_file(src: &Path, file: &FileEntry) -> Result<()> {
1993 let meta = fs::symlink_metadata(src)
1994 .with_context(|| format!("inspecting stored file {}", src.display()))?;
1995 if !meta.file_type().is_file() || meta.file_type().is_symlink() || util::is_reparse_point(&meta)
1996 {
1997 anyhow::bail!("{} is not a regular file", src.display());
1998 }
1999 if !util::owned_by_us(&meta) {
2000 anyhow::bail!("{} is owned by another user", src.display());
2001 }
2002 if meta.len() != file.size {
2003 anyhow::bail!(
2004 "{} size drift (recorded {}, found {})",
2005 src.display(),
2006 file.size,
2007 meta.len()
2008 );
2009 }
2010 if file.sha256.is_empty() {
2011 anyhow::bail!("{} has no recorded SHA-256", src.display());
2012 }
2013 let found = util::sha256_file(src)?;
2014 if found != file.sha256 {
2015 anyhow::bail!("{} failed its SHA-256 check", src.display());
2016 }
2017 Ok(())
2018}
2019
2020#[cfg(not(unix))]
2021fn install_private_copy(src: &Path, dst: &Path, stamp: u64, file: &FileEntry) -> Result<()> {
2022 let parent = dst
2023 .parent()
2024 .with_context(|| format!("destination {} has no parent", dst.display()))?;
2025 let nonce = SystemTime::now()
2026 .duration_since(UNIX_EPOCH)
2027 .map(|duration| duration.as_nanos())
2028 .unwrap_or(0);
2029 let tmp = parent.join(format!(".epicenter-copy-{}-{nonce}", std::process::id()));
2030 util::copy_file_verified(src, &tmp, file.size, &file.sha256)?;
2031 make_writable(&tmp)?;
2032 set_stamp(&tmp, stamp)?;
2033 let installed = fs::hard_link(&tmp, dst);
2037 let _ = fs::remove_file(&tmp);
2038 installed.with_context(|| format!("installing {}", dst.display()))?;
2039 Ok(())
2040}
2041
2042fn read_small_regular(path: &Path) -> Option<String> {
2044 util::read_small_text(path, 4 * 1024 * 1024).ok()
2045}
2046
2047#[derive(Debug, Default)]
2051pub struct VerifyReport {
2052 pub parity: String,
2053 pub units: usize,
2054 pub files: usize,
2055 pub bytes: u64,
2056 pub problems: Vec<String>,
2058 pub warnings: Vec<String>,
2060 pub ordering: Vec<String>,
2064}
2065
2066impl VerifyReport {
2067 pub fn is_clean(&self) -> bool {
2068 self.problems.is_empty()
2069 }
2070}
2071
2072pub fn verify(store_root: &Path) -> Result<Vec<VerifyReport>> {
2076 let mut reports = Vec::new();
2077 for parity in generation_names(store_root)? {
2078 let mut report = VerifyReport {
2079 parity: parity.clone(),
2080 ..Default::default()
2081 };
2082 if let Err(err) = check_store_trust(store_root) {
2083 report
2084 .problems
2085 .push(format!("unsafe store: {err:#} — no artifact was read"));
2086 reports.push(report);
2087 continue;
2088 }
2089 let Some(manifest) = load_manifest(store_root, &parity)? else {
2090 report.problems.push(format!(
2091 "generation {parity} has no readable manifest.json — it cannot be audited"
2092 ));
2093 reports.push(report);
2094 continue;
2095 };
2096 let units = units_dir(store_root, &parity);
2097 for entry in &manifest.units {
2098 for file in &entry.files {
2099 let path = units.join(&entry.name).join(&file.path);
2100 let rel = Path::new(&entry.name).join(&file.path);
2101 if let Err(err) = util::check_regular_chain(&units, &rel) {
2102 report.problems.push(format!(
2103 "unsafe path: {} ({err:#})",
2104 display_join(&path, &units)
2105 ));
2106 continue;
2107 }
2108 let meta = match fs::symlink_metadata(&path) {
2109 Ok(meta) => meta,
2110 Err(_) => {
2111 report
2112 .problems
2113 .push(format!("missing: {}", display_join(&path, units.as_path())));
2114 continue;
2115 }
2116 };
2117 if meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
2118 report.problems.push(format!(
2119 "symlink/reparse in store (refused): {}",
2120 display_join(&path, &units)
2121 ));
2122 continue;
2123 }
2124 if !meta.is_file() {
2125 report.problems.push(format!(
2126 "not a regular file: {}",
2127 display_join(&path, &units)
2128 ));
2129 continue;
2130 }
2131 if !util::owned_by_us(&meta) {
2132 report.problems.push(format!(
2133 "foreign-owned file: {}",
2134 display_join(&path, &units)
2135 ));
2136 }
2137 report.files += 1;
2138 report.bytes += meta.len();
2139 if meta.len() != file.size {
2140 report.problems.push(format!(
2141 "size drift: {} (recorded {}, found {})",
2142 display_join(&path, &units),
2143 file.size,
2144 meta.len()
2145 ));
2146 }
2147 let found_stamp = meta.modified().map(ceil_secs).unwrap_or(0);
2148 if found_stamp != entry.stamp {
2149 report.problems.push(format!(
2150 "stamp drift: {} (recorded {}, found {})",
2151 display_join(&path, &units),
2152 entry.stamp,
2153 found_stamp
2154 ));
2155 }
2156 if file.sha256.is_empty() {
2157 report.problems.push(format!(
2158 "no recorded SHA-256: {} (re-harvest this generation)",
2159 display_join(&path, &units)
2160 ));
2161 } else {
2162 match util::sha256_file(&path) {
2163 Ok(found) if found != file.sha256 => report.problems.push(format!(
2164 "content drift: {} (recorded {}, found {})",
2165 display_join(&path, &units),
2166 &file.sha256[..file.sha256.len().min(12)],
2167 &found[..found.len().min(12)]
2168 )),
2169 Ok(_) => {}
2170 Err(err) => report.problems.push(format!(
2171 "unreadable: {}: {err}",
2172 display_join(&path, &units)
2173 )),
2174 }
2175 }
2176 if file.role == "memo" {
2177 match read_small_regular(&path) {
2178 Some(text) if file.path == entry.memo && text.trim() != entry.hash => {
2179 let found = text.trim();
2180 let detail = if crate::fingerprint::looks_like_hash(found) {
2181 format!("found {found}")
2182 } else {
2183 "found non-hash content (not printed)".to_string()
2184 };
2185 report.problems.push(format!(
2186 "hash drift: {} (recorded {}; {detail})",
2187 display_join(&path, &units),
2188 entry.hash
2189 ));
2190 }
2191 Some(text)
2192 if file.path != entry.memo
2193 && !crate::fingerprint::looks_like_hash(text.trim()) =>
2194 {
2195 report.problems.push(format!(
2196 "malformed memo file: {} (content is not a 16-hex hash)",
2197 display_join(&path, &units)
2198 ));
2199 }
2200 Some(_) => {}
2201 None => report.problems.push(format!(
2202 "unreadable/symlinked memo file: {}",
2203 display_join(&path, &units)
2204 )),
2205 }
2206 }
2207 }
2208 }
2209 for entry in &manifest.units {
2212 let unit_dir = units.join(&entry.name);
2213 let expected: std::collections::BTreeSet<String> =
2214 entry.files.iter().map(|file| file.path.clone()).collect();
2215 let walk = match WalkDir::new(&unit_dir)
2216 .min_depth(1)
2217 .into_iter()
2218 .collect::<Result<Vec<_>, _>>()
2219 {
2220 Ok(walk) => walk,
2221 Err(err) => {
2222 report
2223 .problems
2224 .push(format!("unreadable unit dir: units/{} ({err})", entry.name));
2225 continue;
2226 }
2227 };
2228 for file in walk {
2229 if !file.file_type().is_file() {
2230 continue;
2231 }
2232 let rel = file
2233 .path()
2234 .strip_prefix(&unit_dir)
2235 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
2236 .unwrap_or_default();
2237 if !expected.contains(&rel) {
2238 report
2239 .problems
2240 .push(format!("unrecorded file: units/{}/{}", entry.name, rel));
2241 }
2242 }
2243 }
2244
2245 let by_hash: std::collections::HashMap<&str, usize> = manifest
2252 .units
2253 .iter()
2254 .enumerate()
2255 .map(|(index, entry)| (entry.hash.as_str(), index))
2256 .collect();
2257 for entry in &manifest.units {
2258 let json = entry
2259 .files
2260 .iter()
2261 .find(|file| file.path.ends_with(".json"))
2262 .and_then(|file| read_small_regular(&units.join(&entry.name).join(&file.path)))
2263 .and_then(|text| crate::fingerprint::parse_fingerprint(&text));
2264 let Some(json) = json else { continue };
2265 for dep in &json.deps {
2266 let Some(dep_index) = dep
2267 .memo_hash()
2268 .and_then(|hash| by_hash.get(hash.as_str()))
2269 .copied()
2270 else {
2271 continue;
2272 };
2273 let dep_entry = &manifest.units[dep_index];
2274 if dep_entry.stamp > entry.stamp {
2275 report.ordering.push(format!(
2276 "{} is older than its dependency {} ({} older) — cargo recompiles it after a restore",
2277 entry.name,
2278 dep_entry.name,
2279 crate::util::human_duration(Duration::from_secs(dep_entry.stamp - entry.stamp))
2280 ));
2281 }
2282 }
2283 }
2284 report.units = manifest.units.len();
2285
2286 match fs::symlink_metadata(&units) {
2288 Ok(meta)
2289 if meta.is_dir()
2290 && !meta.file_type().is_symlink()
2291 && !util::is_reparse_point(&meta) => {}
2292 Ok(_) => {
2293 report.problems.push(format!(
2294 "units directory {} is a symlink/reparse or not a directory",
2295 units.display()
2296 ));
2297 }
2298 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2299 Err(err) => report.problems.push(format!(
2300 "unreadable units directory {}: {err}",
2301 units.display()
2302 )),
2303 }
2304 if units.is_dir() {
2305 let known: std::collections::BTreeSet<&str> = manifest
2306 .units
2307 .iter()
2308 .map(|unit| unit.name.as_str())
2309 .collect();
2310 for entry in fs::read_dir(&units)? {
2311 let entry = entry?;
2312 if !entry.file_type()?.is_dir() {
2313 continue;
2314 }
2315 let name = entry.file_name().to_string_lossy().into_owned();
2316 if name.starts_with('.') {
2317 continue; }
2319 if !known.contains(name.as_str()) {
2320 report
2325 .problems
2326 .push(format!("unrecorded unit group: units/{name}"));
2327 }
2328 }
2329 }
2330 reports.push(report);
2331 }
2332 Ok(reports)
2333}
2334
2335fn display_join(path: &Path, base: &Path) -> String {
2337 path.strip_prefix(base)
2338 .unwrap_or(path)
2339 .to_string_lossy()
2340 .replace('\\', "/")
2341}
2342
2343#[derive(Debug, Default)]
2347pub struct GenerationInfo {
2348 pub parity: String,
2349 pub active: bool,
2351 pub units: usize,
2352 pub files: usize,
2353 pub bytes: u64,
2355 pub linked: usize,
2357 pub created: Option<u64>,
2358 pub updated: Option<u64>,
2359}
2360
2361pub fn generations(store_root: &Path) -> Result<Vec<GenerationInfo>> {
2363 let active = parity_key().ok();
2364 let mut out = Vec::new();
2365 for parity in generation_names(store_root)? {
2366 let mut info = GenerationInfo {
2367 parity: parity.clone(),
2368 active: active.as_deref() == Some(parity.as_str()),
2369 ..Default::default()
2370 };
2371 if let Some(manifest) = load_manifest(store_root, &parity)? {
2372 info.units = manifest.units.len();
2373 info.created = Some(manifest.created);
2374 info.updated = Some(manifest.updated);
2375 }
2376 let units = units_dir(store_root, &parity);
2377 if units.is_dir() {
2378 for entry in WalkDir::new(&units).min_depth(1) {
2379 let entry = entry?;
2380 if !entry.file_type().is_file() {
2381 continue;
2382 }
2383 let meta = entry.metadata()?;
2384 info.files += 1;
2385 info.bytes += meta.len();
2386 if link_count(&meta) > 1 {
2387 info.linked += 1;
2388 }
2389 }
2390 }
2391 out.push(info);
2392 }
2393 Ok(out)
2394}
2395
2396pub(crate) fn link_count(meta: &fs::Metadata) -> u64 {
2398 #[cfg(unix)]
2399 {
2400 use std::os::unix::fs::MetadataExt;
2401 meta.nlink()
2402 }
2403 #[cfg(not(unix))]
2404 {
2405 let _ = meta;
2406 1
2407 }
2408}
2409
2410fn unit_name_matches(name: &str, query: &str) -> bool {
2412 name.contains(query)
2413}
2414
2415fn unit_package(name: &str) -> Option<&str> {
2418 let (package, hash) = name.rsplit_once('-')?;
2419 (hash.len() == 16 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(package)
2420}
2421
2422#[cfg(test)]
2423mod tests {
2424 use super::*;
2425
2426 fn synthetic_build_dir(root: &Path) -> Result<()> {
2431 let jsons =
2432 r#"{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","deps":[]}"#;
2433
2434 let lib = root.join("debug/.fingerprint/serde-102a33f055a8d229");
2435 fs::create_dir_all(&lib)?;
2436 fs::write(lib.join("lib-serde"), "5d82713a7f2d05e2")?;
2437 fs::write(lib.join("lib-serde.json"), jsons)?;
2438 fs::write(lib.join("dep-lib-serde"), [0u8; 24])?;
2439 fs::write(lib.join("invoked.timestamp"), "")?;
2440 fs::create_dir_all(root.join("debug/deps"))?;
2441 fs::write(
2442 root.join("debug/deps/libserde-102a33f055a8d229.rlib"),
2443 [7u8; 64],
2444 )?;
2445 fs::write(
2446 root.join("debug/deps/libserde-102a33f055a8d229.rmeta"),
2447 [6u8; 48],
2448 )?;
2449 fs::write(
2450 root.join("debug/deps/serde-102a33f055a8d229.d"),
2451 b"side file",
2452 )?;
2453 fs::write(
2454 root.join("debug/deps/libserde-102a33f055a8d229.kache-real"),
2455 b"wrapper",
2456 )?;
2457
2458 let compile = root.join("debug/.fingerprint/x-1122334455667788");
2459 fs::create_dir_all(&compile)?;
2460 fs::write(
2461 compile.join("build-script-build-script-build"),
2462 "aaaabbbbccccdddd",
2463 )?;
2464 fs::write(compile.join("build-script-build-script-build.json"), jsons)?;
2465 fs::write(
2466 compile.join("dep-build-script-build-script-build"),
2467 [0u8; 8],
2468 )?;
2469 let compile_anchor = root.join("debug/build/x-1122334455667788");
2470 fs::create_dir_all(&compile_anchor)?;
2471 fs::write(
2472 compile_anchor.join("build_script_build-1122334455667788"),
2473 [3u8; 32],
2474 )?;
2475 fs::write(
2476 compile_anchor.join("build_script_build-1122334455667788.d"),
2477 b"dep",
2478 )?;
2479 fs::write(
2480 compile_anchor.join("build_script_build-1122334455667788.kache-real"),
2481 b"wrapper",
2482 )?;
2483 fs::write(compile_anchor.join("invoked.timestamp"), "")?;
2484
2485 let run = root.join("debug/.fingerprint/x-aabbccddeeff0011");
2487 fs::create_dir_all(&run)?;
2488 fs::write(
2489 run.join("run-build-script-build-script-build"),
2490 "eeeeffff00001111",
2491 )?;
2492 fs::write(run.join("run-build-script-build-script-build.json"), jsons)?;
2493 let run_anchor = root.join("debug/build/x-aabbccddeeff0011");
2494 fs::create_dir_all(&run_anchor)?;
2495 fs::write(
2496 run_anchor.join("output"),
2497 b"cargo:rerun-if-changed=build.rs",
2498 )?;
2499 fs::write(run_anchor.join("stderr"), b"")?;
2500 fs::write(run_anchor.join("invoked.timestamp"), "")?;
2501 Ok(())
2502 }
2503
2504 fn synthetic_husk(root: &Path) -> Result<()> {
2506 let unit = root.join("debug/.fingerprint/husk-aaaabbbbccccdddd");
2507 fs::create_dir_all(&unit)?;
2508 fs::write(unit.join("lib-husk"), "0000111122223333")?;
2509 Ok(())
2510 }
2511
2512 #[test]
2513 fn parity_key_is_stable_and_shaped() -> Result<()> {
2514 let first = parity_key()?;
2515 let second = parity_key()?;
2516 assert_eq!(first, second);
2517 let tc = toolchain()?;
2518 assert!(first.starts_with(&format!("{}-{}", tc.release, tc.commit)));
2519 assert!(first.ends_with(&flags_digest(&tc.host)));
2520 Ok(())
2521 }
2522
2523 #[test]
2524 fn harvest_stores_groups_with_one_canonical_stamp() -> Result<()> {
2525 let build = tempfile::tempdir()?;
2526 let store = tempfile::tempdir()?;
2527 synthetic_build_dir(build.path())?;
2528
2529 let report = harvest(build.path(), store.path(), None, false)?;
2530 assert_eq!(report.stored, 3, "lib + build-script compile + run");
2531 assert_eq!(report.skipped_incomplete, 0);
2532 assert!(report.files >= 10, "got {}", report.files);
2533 assert!(report.bytes > 0);
2534
2535 let parity = parity_key()?;
2536 let manifest = load_manifest(store.path(), &parity)?.expect("manifest written");
2537 assert_eq!(manifest.units.len(), 3);
2538 let lib_entry = manifest
2539 .units
2540 .iter()
2541 .find(|unit| unit.name == "serde-102a33f055a8d229")
2542 .expect("lib unit recorded");
2543 assert_eq!(lib_entry.hash, "5d82713a7f2d05e2");
2544 assert_eq!(lib_entry.profile, "debug");
2545
2546 let group = units_dir(store.path(), &parity).join(&lib_entry.name);
2548 for file in &lib_entry.files {
2549 let meta = fs::metadata(group.join(&file.path))?;
2550 assert_eq!(
2551 ceil_secs(meta.modified()?),
2552 lib_entry.stamp,
2553 "{}",
2554 file.path
2555 );
2556 assert_eq!(meta.len(), file.size);
2557 }
2558
2559 assert!(!group.join("invoked.timestamp").exists());
2561 for file in &lib_entry.files {
2562 assert!(!file.path.contains("kache"), "{}", file.path);
2563 assert!(!file.path.ends_with(".d"), "{}", file.path);
2564 }
2565
2566 #[cfg(unix)]
2567 {
2568 use std::os::unix::fs::PermissionsExt;
2569 let meta = fs::metadata(group.join(&lib_entry.files[0].path))?;
2570 assert_eq!(
2571 meta.permissions().mode() & 0o777,
2572 0o444,
2573 "store is read-only"
2574 );
2575 }
2576 Ok(())
2577 }
2578
2579 #[test]
2580 fn harvest_is_idempotent_and_skips_incomplete_units() -> Result<()> {
2581 let build = tempfile::tempdir()?;
2582 let store = tempfile::tempdir()?;
2583 synthetic_build_dir(build.path())?;
2584 synthetic_husk(build.path())?;
2585
2586 let first = harvest(build.path(), store.path(), None, false)?;
2587 assert_eq!(first.stored, 3);
2588 assert_eq!(first.skipped_incomplete, 1, "husk without artifacts");
2589
2590 let second = harvest(build.path(), store.path(), None, false)?;
2591 assert_eq!(second.stored, 0);
2592 assert_eq!(second.unchanged, 3);
2593 assert_eq!(second.skipped_incomplete, 1);
2594 Ok(())
2595 }
2596
2597 #[test]
2598 fn harvest_refreshes_when_the_file_set_grows() -> Result<()> {
2599 let build = tempfile::tempdir()?;
2600 let store = tempfile::tempdir()?;
2601 synthetic_build_dir(build.path())?;
2602 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2603
2604 fs::write(
2605 build.path().join("debug/deps/libserde-102a33f055a8d229.so"),
2606 [1u8; 16],
2607 )?;
2608 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2609 assert_eq!(report.refreshed, 1);
2610 assert_eq!(report.stored, 0);
2611
2612 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2613 let paths: Vec<&str> = manifest.units[0]
2614 .files
2615 .iter()
2616 .map(|file| file.path.as_str())
2617 .collect();
2618 assert!(
2619 paths.contains(&"deps/libserde-102a33f055a8d229.so"),
2620 "{paths:?}"
2621 );
2622 Ok(())
2623 }
2624
2625 #[test]
2626 fn harvest_stores_all_memos_of_shared_dirs() -> Result<()> {
2627 let build = tempfile::tempdir()?;
2628 let store = tempfile::tempdir()?;
2629 synthetic_build_dir(build.path())?;
2630 let lib = build
2631 .path()
2632 .join("debug/.fingerprint/serde-102a33f055a8d229");
2633 fs::write(lib.join("bin-serde"), "1111222233334444")?;
2634 fs::write(lib.join("dep-bin-serde"), [0u8; 8])?;
2635
2636 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2637 assert_eq!(report.stored, 1);
2638 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2639 let entry = &manifest.units[0];
2640 let paths: Vec<&str> = entry.files.iter().map(|file| file.path.as_str()).collect();
2641 assert!(paths.contains(&"bin-serde"), "{paths:?}");
2642 assert!(paths.contains(&"dep-bin-serde"), "{paths:?}");
2643
2644 let reports = verify(store.path())?;
2645 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2646 Ok(())
2647 }
2648
2649 #[test]
2650 fn harvest_dry_run_writes_nothing() -> Result<()> {
2651 let build = tempfile::tempdir()?;
2652 let store = tempfile::tempdir()?;
2653 synthetic_build_dir(build.path())?;
2654
2655 let report = harvest(build.path(), store.path(), None, true)?;
2656 assert_eq!(report.stored, 3);
2657 assert_eq!(generation_names(store.path())?.len(), 0, "no generation");
2658 Ok(())
2659 }
2660
2661 #[test]
2662 fn harvest_unit_filter_selects_one_group() -> Result<()> {
2663 let build = tempfile::tempdir()?;
2664 let store = tempfile::tempdir()?;
2665 synthetic_build_dir(build.path())?;
2666
2667 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2668 assert_eq!(report.stored, 1);
2669 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2670 assert_eq!(manifest.units.len(), 1);
2671 assert_eq!(manifest.units[0].name, "serde-102a33f055a8d229");
2672 Ok(())
2673 }
2674
2675 #[test]
2676 fn restore_links_hardlinks_and_never_overwrites() -> Result<()> {
2677 let build = tempfile::tempdir()?;
2678 let store = tempfile::tempdir()?;
2679 let view = tempfile::tempdir()?;
2680 synthetic_build_dir(build.path())?;
2681 harvest(build.path(), store.path(), None, false)?;
2682
2683 let occupied = view
2685 .path()
2686 .join("debug/deps/libserde-102a33f055a8d229.rlib");
2687 fs::create_dir_all(occupied.parent().unwrap())?;
2688 fs::write(&occupied, b"live artifact")?;
2689
2690 let report = restore(view.path(), store.path(), None, None, false)?;
2691 assert_eq!(report.units_linked, 3);
2692 assert_eq!(report.files_existing, 1, "the live artifact was skipped");
2693 assert_eq!(fs::read(&occupied)?, b"live artifact");
2694 assert_eq!(report.files_linked, 0, "no other deps/ artifact");
2698 assert!(report.files_copied >= 12, "got {}", report.files_copied);
2699
2700 let memo = view
2702 .path()
2703 .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
2704 assert_eq!(fs::read_to_string(&memo)?.trim(), "5d82713a7f2d05e2");
2705 assert!(
2706 view.path()
2707 .join("debug/build/x-aabbccddeeff0011/output")
2708 .is_file()
2709 );
2710
2711 #[cfg(unix)]
2715 {
2716 use std::os::unix::fs::MetadataExt;
2717 use std::os::unix::fs::PermissionsExt;
2718 let parity = parity_key()?;
2719 let stored = units_dir(store.path(), &parity).join("serde-102a33f055a8d229/lib-serde");
2720 let view_meta = fs::metadata(&memo)?;
2721 assert_ne!(
2722 view_meta.ino(),
2723 fs::metadata(&stored)?.ino(),
2724 "state not shared"
2725 );
2726 assert_eq!(view_meta.nlink(), 1, "state owns its inode");
2727 assert_ne!(view_meta.permissions().mode() & 0o200, 0, "state writable");
2728 }
2729
2730 let again = restore(view.path(), store.path(), None, None, false)?;
2732 assert_eq!(again.units_already, 3);
2733 assert_eq!(again.files_linked, 0);
2734 assert_eq!(again.files_copied, 0);
2735 Ok(())
2736 }
2737
2738 #[test]
2739 fn restore_into_missing_profile_dirs_creates_them() -> Result<()> {
2740 let build = tempfile::tempdir()?;
2741 let store = tempfile::tempdir()?;
2742 let view = tempfile::tempdir()?;
2743 synthetic_build_dir(build.path())?;
2744 harvest(build.path(), store.path(), None, false)?;
2745
2746 let report = restore(view.path(), store.path(), None, None, false)?;
2747 assert_eq!(report.errors, 0);
2748 assert!(view.path().join("debug/.fingerprint").is_dir());
2749 Ok(())
2750 }
2751
2752 #[test]
2753 fn restore_can_restrict_units_to_packages() -> Result<()> {
2754 let build = tempfile::tempdir()?;
2755 let store = tempfile::tempdir()?;
2756 let view = tempfile::tempdir()?;
2757 let now = now_secs();
2758 synthetic_edge_dir(build.path(), now - 3600, now)?;
2759 harvest(build.path(), store.path(), None, false)?;
2760
2761 let mut packages = std::collections::HashSet::new();
2762 packages.insert("serde_core".to_string());
2763 let report = restore(view.path(), store.path(), None, Some(&packages), false)?;
2764 assert_eq!(report.errors, 0);
2765 assert!(
2766 view.path()
2767 .join("debug/.fingerprint/serde_core-b468be6fa8c1f936")
2768 .is_dir(),
2769 "a unit of a selected package is restored"
2770 );
2771 assert!(
2772 !view
2773 .path()
2774 .join("debug/.fingerprint/serde-746d173581f1a314")
2775 .exists(),
2776 "a unit of an unselected package is not restored"
2777 );
2778 Ok(())
2779 }
2780
2781 #[test]
2782 fn harvest_and_restore_new_layout_units() -> Result<()> {
2783 let build = tempfile::tempdir()?;
2784 let store = tempfile::tempdir()?;
2785 let view = tempfile::tempdir()?;
2786 let unit = synthetic_new_layout_unit(build.path(), "anyhow", "9709d57b46b8d526")?;
2787
2788 let report = harvest_with(
2789 build.path(),
2790 store.path(),
2791 None,
2792 false,
2793 None,
2794 Some(fingerprint::UnitLayout::New),
2795 )?;
2796 assert_eq!(report.stored, 1, "{report:?}");
2797
2798 let parity = parity_key()?;
2799 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2800 let entry = &manifest.units[0];
2801 assert_eq!(entry.layout(), fingerprint::UnitLayout::New);
2802 assert!(
2803 entry
2804 .files
2805 .iter()
2806 .any(|file| file.dest == "build/anyhow/9709d57b46b8d526/fingerprint/lib-anyhow")
2807 );
2808 assert!(
2809 entry.files.iter().any(|file| file.dest
2810 == "build/anyhow/9709d57b46b8d526/out/libanyhow-9709d57b46b8d526.rlib")
2811 );
2812 assert!(
2813 !entry.files.iter().any(|file| file.dest.ends_with(".d")),
2814 "rustc .d side files stay out of the vault"
2815 );
2816
2817 let restored = restore_with(
2818 view.path(),
2819 store.path(),
2820 None,
2821 None,
2822 Some(fingerprint::UnitLayout::New),
2823 None,
2824 false,
2825 )?;
2826 assert_eq!(restored.errors, 0);
2827 let root = view.path().join("debug/build/anyhow/9709d57b46b8d526");
2828 assert!(root.join("fingerprint/lib-anyhow").is_file());
2829 assert!(root.join("out/libanyhow-9709d57b46b8d526.rlib").is_file());
2830 assert!(root.join("out/libanyhow-9709d57b46b8d526.rmeta").is_file());
2831 assert!(
2832 !view.path().join("debug/.fingerprint").exists(),
2833 "no classic paths for a new-layout unit"
2834 );
2835
2836 #[cfg(unix)]
2837 {
2838 use std::os::unix::fs::MetadataExt;
2839 let rlib = root.join("out/libanyhow-9709d57b46b8d526.rlib");
2840 let rmeta = root.join("out/libanyhow-9709d57b46b8d526.rmeta");
2841 assert!(fs::metadata(&rlib)?.nlink() >= 2, "rlib is a hard link");
2842 assert_eq!(fs::metadata(&rmeta)?.nlink(), 1, "rmeta is a copy");
2843 }
2844 assert!(unit.join("out/libanyhow-9709d57b46b8d526.rlib").is_file());
2846 Ok(())
2847 }
2848
2849 #[test]
2850 fn harvest_refreshes_when_only_the_layout_changes() -> Result<()> {
2851 let build = tempfile::tempdir()?;
2852 let store = tempfile::tempdir()?;
2853 synthetic_build_dir(build.path())?;
2854 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2855
2856 let parity = parity_key()?;
2857 let before = load_manifest(store.path(), &parity)?.expect("manifest");
2858 assert_eq!(before.units[0].layout(), fingerprint::UnitLayout::Classic);
2859 let stamp = before.units[0].stamp;
2860
2861 fs::remove_dir_all(build.path().join("debug/.fingerprint"))?;
2864 let unit = synthetic_new_layout_unit(build.path(), "serde", "102a33f055a8d229")?;
2865 fs::write(
2866 unit.join("fingerprint/lib-serde.json"),
2867 r#"{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","deps":[]}"#,
2868 )?;
2869 for file in [
2870 unit.join("fingerprint/lib-serde"),
2871 unit.join("fingerprint/lib-serde.json"),
2872 unit.join("fingerprint/dep-lib-serde"),
2873 unit.join("out/libserde-102a33f055a8d229.rlib"),
2874 unit.join("out/libserde-102a33f055a8d229.rmeta"),
2875 ] {
2876 set_stamp(&file, stamp)?;
2877 }
2878
2879 let report = harvest_with(
2880 build.path(),
2881 store.path(),
2882 Some("102a33f0"),
2883 false,
2884 None,
2885 Some(fingerprint::UnitLayout::New),
2886 )?;
2887 assert_eq!(report.refreshed, 1, "{report:?}");
2888 assert_eq!(report.stored, 0);
2889
2890 let after = load_manifest(store.path(), &parity)?.expect("manifest");
2891 assert_eq!(
2892 after.units[0].layout(),
2893 fingerprint::UnitLayout::New,
2894 "the recorded layout follows the dests"
2895 );
2896 assert!(
2897 after.units[0]
2898 .files
2899 .iter()
2900 .any(|file| file.dest.starts_with("build/serde/102a33f055a8d229/"))
2901 );
2902 Ok(())
2903 }
2904
2905 #[test]
2906 fn restore_can_filter_units_by_layout() -> Result<()> {
2907 let build = tempfile::tempdir()?;
2908 let store = tempfile::tempdir()?;
2909 let view = tempfile::tempdir()?;
2910 synthetic_build_dir(build.path())?;
2911 synthetic_new_layout_unit(build.path(), "anyhow", "9709d57b46b8d526")?;
2912
2913 harvest_with(
2914 build.path(),
2915 store.path(),
2916 None,
2917 false,
2918 None,
2919 Some(fingerprint::UnitLayout::Classic),
2920 )?;
2921 harvest_with(
2922 build.path(),
2923 store.path(),
2924 None,
2925 false,
2926 None,
2927 Some(fingerprint::UnitLayout::New),
2928 )?;
2929
2930 let report = restore_with(
2931 view.path(),
2932 store.path(),
2933 None,
2934 None,
2935 Some(fingerprint::UnitLayout::New),
2936 None,
2937 false,
2938 )?;
2939 assert_eq!(report.errors, 0);
2940 assert!(
2941 view.path()
2942 .join("debug/build/anyhow/9709d57b46b8d526/fingerprint/lib-anyhow")
2943 .is_file()
2944 );
2945 assert!(
2946 !view
2947 .path()
2948 .join("debug/.fingerprint/serde-102a33f055a8d229")
2949 .exists(),
2950 "a classic unit is not restored into a new-layout child"
2951 );
2952 Ok(())
2953 }
2954
2955 #[test]
2956 fn restore_without_a_generation_reports_fail_open() -> Result<()> {
2957 let view = tempfile::tempdir()?;
2958 let store = tempfile::tempdir()?;
2959 let report = restore(view.path(), store.path(), None, None, false)?;
2960 assert!(report.note.is_some());
2961 assert_eq!(report.files_linked, 0);
2962 Ok(())
2963 }
2964
2965 #[test]
2966 fn verify_passes_clean_and_flags_drift() -> Result<()> {
2967 let build = tempfile::tempdir()?;
2968 let store = tempfile::tempdir()?;
2969 synthetic_build_dir(build.path())?;
2970 harvest(build.path(), store.path(), None, false)?;
2971
2972 let reports = verify(store.path())?;
2973 assert_eq!(reports.len(), 1);
2974 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2975 assert_eq!(reports[0].units, 3);
2976
2977 let parity = parity_key()?;
2979 let victim = units_dir(store.path(), &parity).join("serde-102a33f055a8d229/dep-lib-serde");
2980 #[cfg(unix)]
2981 {
2982 use std::os::unix::fs::PermissionsExt;
2983 fs::set_permissions(&victim, fs::Permissions::from_mode(0o644))?;
2984 }
2985 fs::write(&victim, [9u8; 32])?;
2986
2987 let reports = verify(store.path())?;
2988 assert!(!reports[0].is_clean());
2989 assert!(
2990 reports[0]
2991 .problems
2992 .iter()
2993 .any(|line| line.contains("size drift")),
2994 "{:?}",
2995 reports[0].problems
2996 );
2997 Ok(())
2998 }
2999
3000 #[test]
3001 fn verify_reports_unrecorded_unit_groups() -> Result<()> {
3002 let build = tempfile::tempdir()?;
3003 let store = tempfile::tempdir()?;
3004 synthetic_build_dir(build.path())?;
3005 harvest(build.path(), store.path(), None, false)?;
3006
3007 let stray = units_dir(store.path(), &parity_key()?).join("stray-0000000000000000");
3008 fs::create_dir_all(stray.join("deps"))?;
3009 fs::write(stray.join("deps/x.rlib"), [0u8; 4])?;
3010
3011 let reports = verify(store.path())?;
3012 assert!(
3013 reports[0]
3014 .problems
3015 .iter()
3016 .any(|line| line.contains("stray-0000000000000000")),
3017 "{:?}",
3018 reports[0].problems
3019 );
3020 assert!(!reports[0].is_clean());
3021 Ok(())
3022 }
3023
3024 #[test]
3025 fn generations_summary_counts_store_files() -> Result<()> {
3026 let build = tempfile::tempdir()?;
3027 let store = tempfile::tempdir()?;
3028 synthetic_build_dir(build.path())?;
3029 harvest(build.path(), store.path(), None, false)?;
3030
3031 let generations = generations(store.path())?;
3032 assert_eq!(generations.len(), 1);
3033 assert!(generations[0].active, "current toolchain generation");
3034 assert_eq!(generations[0].units, 3);
3035 assert!(generations[0].files >= 10);
3036 assert!(generations[0].linked <= generations[0].files);
3037 Ok(())
3038 }
3039
3040 #[test]
3041 fn cached_diagnostics_are_stored_as_extra_and_verify_clean() -> Result<()> {
3042 let build = tempfile::tempdir()?;
3043 let store = tempfile::tempdir()?;
3044 synthetic_build_dir(build.path())?;
3045 fs::write(
3047 build
3048 .path()
3049 .join("debug/.fingerprint/serde-102a33f055a8d229/output-lib-serde"),
3050 r#"{"$message_type":"diagnostic","message":"warning: unused"}"#,
3051 )?;
3052
3053 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3054 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3055 let output = manifest.units[0]
3056 .files
3057 .iter()
3058 .find(|file| file.path == "output-lib-serde")
3059 .expect("cached diagnostics are stored");
3060 assert_eq!(output.role, "extra");
3061
3062 let reports = verify(store.path())?;
3063 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3064 Ok(())
3065 }
3066
3067 #[test]
3068 fn harvest_refreshes_when_a_role_drifted() -> Result<()> {
3069 let build = tempfile::tempdir()?;
3070 let store = tempfile::tempdir()?;
3071 synthetic_build_dir(build.path())?;
3072 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3073
3074 let parity = parity_key()?;
3077 let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3078 let memo_index = manifest.units[0]
3079 .files
3080 .iter()
3081 .position(|file| file.path == manifest.units[0].memo)
3082 .expect("memo recorded");
3083 manifest.units[0].files[memo_index].role = "extra".to_string();
3084 save_manifest(store.path(), &parity, &manifest)?;
3085
3086 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3087 assert_eq!(report.refreshed, 1, "role drift forces a refresh");
3088 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3089 let role = &manifest.units[0].files[memo_index].role;
3090 assert_eq!(role, "memo", "roles are rewritten");
3091 Ok(())
3092 }
3093
3094 #[test]
3095 fn restore_shares_artifacts_but_copies_state_writable() -> Result<()> {
3096 let build = tempfile::tempdir()?;
3097 let store = tempfile::tempdir()?;
3098 synthetic_build_dir(build.path())?;
3099 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3100
3101 let view = tempfile::tempdir()?;
3102 restore(view.path(), store.path(), Some("102a33f0"), None, false)?;
3103
3104 let state = view
3105 .path()
3106 .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
3107 let artifact = view
3108 .path()
3109 .join("debug/deps/libserde-102a33f055a8d229.rlib");
3110 let metadata = view
3111 .path()
3112 .join("debug/deps/libserde-102a33f055a8d229.rmeta");
3113 assert!(state.is_file() && artifact.is_file() && metadata.is_file());
3114
3115 let parity = parity_key()?;
3116 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3117 let entry = manifest
3118 .units
3119 .iter()
3120 .find(|entry| entry.name == "serde-102a33f055a8d229")
3121 .expect("stored unit");
3122 assert_eq!(ceil_secs(fs::metadata(&state)?.modified()?), entry.stamp);
3123
3124 fs::write(&state, "ffffffffffffffff")?;
3127 let stored = units_dir(store.path(), &parity)
3128 .join("serde-102a33f055a8d229")
3129 .join("lib-serde");
3130 assert_eq!(fs::read_to_string(&stored)?, "5d82713a7f2d05e2");
3131
3132 #[cfg(unix)]
3133 {
3134 use std::os::unix::fs::MetadataExt;
3135 assert!(
3136 fs::metadata(&artifact)?.nlink() >= 2,
3137 "artifact shares the store inode"
3138 );
3139 assert_eq!(fs::metadata(&state)?.nlink(), 1, "state is a private copy");
3140 assert_eq!(
3141 fs::metadata(&metadata)?.nlink(),
3142 1,
3143 ".rmeta is a private copy (rustc opens it for write)"
3144 );
3145 }
3146 Ok(())
3147 }
3148
3149 fn synthetic_new_layout_unit(root: &Path, package: &str, hash: &str) -> Result<PathBuf> {
3154 let unit = root.join("debug/build").join(package).join(hash);
3155 fs::create_dir_all(unit.join("fingerprint"))?;
3156 fs::create_dir_all(unit.join("out"))?;
3157 fs::write(
3158 unit.join("fingerprint").join(format!("lib-{package}")),
3159 "5d82713a7f2d05e2",
3160 )?;
3161 fs::write(
3162 unit.join("fingerprint").join(format!("dep-lib-{package}")),
3163 [0u8; 8],
3164 )?;
3165 fs::write(
3166 unit.join("out").join(format!("lib{package}-{hash}.rlib")),
3167 [7u8; 64],
3168 )?;
3169 fs::write(
3170 unit.join("out").join(format!("lib{package}-{hash}.rmeta")),
3171 [6u8; 48],
3172 )?;
3173 Ok(unit)
3174 }
3175
3176 fn synthetic_edge_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
3177 fs::create_dir_all(root.join("debug/deps"))?;
3178 let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
3179 fs::create_dir_all(&core)?;
3180 fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
3181 fs::write(core.join("dep-lib-serde_core"), [0u8; 8])?;
3182 let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
3183 fs::write(&core_rlib, [1u8; 32])?;
3184
3185 let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
3186 fs::create_dir_all(&unit)?;
3187 fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
3188 fs::write(
3189 unit.join("lib-serde.json"),
3190 r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
3191 )?;
3192 fs::write(unit.join("dep-lib-serde"), [0u8; 8])?;
3193 let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
3194 fs::write(&unit_rlib, [1u8; 32])?;
3195
3196 set_stamp(&core.join("lib-serde_core"), dep_at)?;
3197 set_stamp(&core.join("dep-lib-serde_core"), dep_at)?;
3198 set_stamp(&core_rlib, dep_at)?;
3199 set_stamp(&unit.join("lib-serde"), unit_at)?;
3200 set_stamp(&unit.join("lib-serde.json"), unit_at)?;
3201 set_stamp(&unit.join("dep-lib-serde"), unit_at)?;
3202 set_stamp(&unit_rlib, unit_at)?;
3203 Ok(())
3204 }
3205
3206 #[test]
3207 fn harvest_clamps_a_dependent_to_its_dependency() -> Result<()> {
3208 let build = tempfile::tempdir()?;
3209 let store = tempfile::tempdir()?;
3210 let now = now_secs();
3211 synthetic_edge_dir(build.path(), now, now - 3600)?;
3214 harvest(build.path(), store.path(), None, false)?;
3215
3216 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3217 let dependent = manifest
3218 .units
3219 .iter()
3220 .find(|entry| entry.name == "serde-746d173581f1a314")
3221 .expect("dependent stored");
3222 let dependency = manifest
3223 .units
3224 .iter()
3225 .find(|entry| entry.name == "serde_core-b468be6fa8c1f936")
3226 .expect("dependency stored");
3227 assert!(
3228 dependent.stamp >= dependency.stamp,
3229 "clamped: {} >= {}",
3230 dependent.stamp,
3231 dependency.stamp
3232 );
3233
3234 let reports = verify(store.path())?;
3235 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3236 assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
3237 Ok(())
3238 }
3239
3240 #[test]
3241 fn harvest_stamps_groups_by_the_metadata_output() -> Result<()> {
3242 let build = tempfile::tempdir()?;
3243 let store = tempfile::tempdir()?;
3244 synthetic_build_dir(build.path())?;
3245 let now = now_secs();
3246 set_stamp(
3249 &build
3250 .path()
3251 .join("debug/deps/libserde-102a33f055a8d229.rmeta"),
3252 now - 30,
3253 )?;
3254 set_stamp(
3255 &build
3256 .path()
3257 .join("debug/deps/libserde-102a33f055a8d229.rlib"),
3258 now,
3259 )?;
3260 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3261
3262 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3263 assert_eq!(manifest.units[0].stamp, now - 30);
3264 Ok(())
3265 }
3266
3267 #[test]
3268 fn harvest_stores_read_only_sources() -> Result<()> {
3269 let build = tempfile::tempdir()?;
3270 let store = tempfile::tempdir()?;
3271 synthetic_build_dir(build.path())?;
3272 let source = build
3276 .path()
3277 .join("debug/.fingerprint/serde-102a33f055a8d229/dep-lib-serde");
3278 let mut perms = fs::metadata(&source)?.permissions();
3279 perms.set_readonly(true);
3280 fs::set_permissions(&source, perms)?;
3281
3282 let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3283 assert_eq!(report.stored, 1);
3284
3285 let parity = parity_key()?;
3286 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3287 let stored = units_dir(store.path(), &parity)
3288 .join(&manifest.units[0].name)
3289 .join("dep-lib-serde");
3290 assert!(stored.is_file(), "the state file is stored");
3291 assert_eq!(
3292 ceil_secs(fs::metadata(&stored)?.modified()?),
3293 manifest.units[0].stamp,
3294 "the stored copy carries the canonical stamp"
3295 );
3296 Ok(())
3297 }
3298
3299 #[cfg(windows)]
3303 #[test]
3304 fn set_stamp_sets_times_on_windows() -> Result<()> {
3305 let dir = tempfile::tempdir()?;
3306 let file = dir.path().join("dep-lib-x");
3307 fs::write(&file, b"x")?;
3308
3309 set_stamp(&file, 1_700_000_000)?;
3310 assert_eq!(
3311 ceil_secs(fs::metadata(&file)?.modified()?),
3312 1_700_000_000,
3313 "a writable copy must be stampable"
3314 );
3315
3316 let mut perms = fs::metadata(&file)?.permissions();
3319 perms.set_readonly(true);
3320 fs::set_permissions(&file, perms)?;
3321 make_writable(&file)?;
3322 set_stamp(&file, 1_700_000_100)?;
3323 assert_eq!(
3324 ceil_secs(fs::metadata(&file)?.modified()?),
3325 1_700_000_100,
3326 "a copy of a read-only source must be stampable"
3327 );
3328 Ok(())
3329 }
3330
3331 #[cfg(unix)]
3332 #[test]
3333 fn a_failed_store_drops_its_staging_dir() -> Result<()> {
3334 use std::os::unix::fs::MetadataExt;
3335
3336 let build = tempfile::tempdir()?;
3337 let store = tempfile::tempdir()?;
3338 synthetic_build_dir(build.path())?;
3339 if fs::metadata(build.path())?.uid() == 0 {
3340 return Ok(()); }
3342 let artifact = build
3343 .path()
3344 .join("debug/deps/libserde-102a33f055a8d229.rlib");
3345 {
3346 use std::os::unix::fs::PermissionsExt;
3347 fs::set_permissions(&artifact, fs::Permissions::from_mode(0o000))?;
3348 }
3349
3350 let result = harvest(build.path(), store.path(), Some("102a33f0"), false);
3351 assert!(result.is_err(), "an unreadable artifact aborts the store");
3352
3353 let units = units_dir(store.path(), &parity_key()?);
3354 if units.is_dir() {
3355 for entry in fs::read_dir(&units)? {
3356 let name = entry?.file_name().to_string_lossy().into_owned();
3357 assert!(!name.contains(".tmp-"), "staging leftover: {name}");
3358 }
3359 }
3360 Ok(())
3361 }
3362
3363 #[cfg(unix)]
3364 #[test]
3365 fn harvest_sweeps_stale_staging_dirs() -> Result<()> {
3366 use std::os::unix::fs::PermissionsExt;
3367
3368 let build = tempfile::tempdir()?;
3369 let store = tempfile::tempdir()?;
3370 synthetic_build_dir(build.path())?;
3371 let units = units_dir(store.path(), &parity_key()?);
3372
3373 let stale = units.join(".serde-102a33f055a8d229.tmp-99999");
3375 fs::create_dir_all(&stale)?;
3376 let leftover = stale.join("dep-lib-serde");
3377 fs::write(&leftover, b"x")?;
3378 fs::set_permissions(&leftover, fs::Permissions::from_mode(0o444))?;
3379 set_stamp(&stale, now_secs() - 7200)?;
3380
3381 let fresh = units.join(".serde-102a33f055a8d229.tmp-11111");
3383 fs::create_dir_all(&fresh)?;
3384
3385 harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3386 assert!(!stale.exists(), "stale staging dir swept");
3387 assert!(fresh.exists(), "fresh staging dir left alone");
3388 Ok(())
3389 }
3390
3391 #[test]
3392 fn verify_flags_an_inverted_store_stamp() -> Result<()> {
3393 let build = tempfile::tempdir()?;
3394 let store = tempfile::tempdir()?;
3395 let now = now_secs();
3396 synthetic_edge_dir(build.path(), now - 3600, now)?;
3397 harvest(build.path(), store.path(), None, false)?;
3398
3399 let parity = parity_key()?;
3402 let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3403 let units = units_dir(store.path(), &parity);
3404 let entry = manifest
3405 .units
3406 .iter_mut()
3407 .find(|entry| entry.name == "serde-746d173581f1a314")
3408 .expect("dependent stored");
3409 entry.stamp = now - 7200;
3410 for file in &entry.files {
3411 set_stamp(&units.join(&entry.name).join(&file.path), now - 7200)?;
3412 }
3413 save_manifest(store.path(), &parity, &manifest)?;
3414
3415 let reports = verify(store.path())?;
3416 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3417 assert_eq!(reports[0].ordering.len(), 1, "{:?}", reports[0].ordering);
3418 assert!(reports[0].ordering[0].contains("serde-746d173581f1a314"));
3419 assert!(reports[0].ordering[0].contains("serde_core-b468be6fa8c1f936"));
3420 Ok(())
3421 }
3422
3423 #[test]
3424 fn manifest_validation_rejects_escapes() {
3425 let unit = |name: &str, profile: &str, path: &str, dest: &str| UnitEntry {
3426 name: name.into(),
3427 profile: profile.into(),
3428 memo: "lib-x".into(),
3429 hash: "0123456789abcdef".into(),
3430 stamp: 1,
3431 layout: None,
3432 files: vec![FileEntry {
3433 path: path.into(),
3434 dest: dest.into(),
3435 size: 1,
3436 sha256: String::new(),
3437 role: "artifact".into(),
3438 }],
3439 };
3440 let base = Manifest {
3441 parity_key: "p".into(),
3442 toolchain: "t".into(),
3443 created: 1,
3444 updated: 1,
3445 units: vec![],
3446 mac: String::new(),
3447 };
3448 for unit in [
3449 unit("../escape", "debug", "deps/x", "deps/x"),
3450 unit("ok", "/etc", "deps/x", "deps/x"),
3451 unit("ok", "debug", "/etc/passwd", "deps/x"),
3452 unit("ok", "debug", "deps/x", "/root/.ssh/authorized_keys"),
3453 unit("ok", "debug", "deps/../../x", "deps/x"),
3454 unit(".. ", "debug", "deps/x", "deps/x"),
3455 unit("...", "debug", "deps/x", "deps/x"),
3456 unit("CON.txt", "debug", "deps/x", "deps/x"),
3457 ] {
3458 let manifest = Manifest {
3459 units: vec![unit],
3460 ..base.clone()
3461 };
3462 assert!(manifest.validate().is_err(), "must reject {manifest:?}");
3463 }
3464 }
3465
3466 #[test]
3467 fn harvest_records_sha256_and_verify_flags_content_drift() -> Result<()> {
3468 let build = tempfile::tempdir()?;
3469 let store = tempfile::tempdir()?;
3470 synthetic_build_dir(build.path())?;
3471 harvest(build.path(), store.path(), None, false)?;
3472
3473 let parity = parity_key()?;
3474 let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3475 assert!(
3476 manifest
3477 .units
3478 .iter()
3479 .flat_map(|unit| &unit.files)
3480 .all(|file| !file.sha256.is_empty()),
3481 "every stored file must carry a hash"
3482 );
3483
3484 let entry = manifest
3485 .units
3486 .iter()
3487 .find(|entry| entry.name == "serde-102a33f055a8d229")
3488 .expect("unit");
3489 let file = entry
3490 .files
3491 .iter()
3492 .find(|file| file.path == "deps/libserde-102a33f055a8d229.rlib")
3493 .expect("rlib");
3494 let path = units_dir(store.path(), &parity)
3495 .join(&entry.name)
3496 .join(&file.path);
3497 let mut bytes = fs::read(&path)?;
3498 let last = bytes.len() - 1;
3499 bytes[last] ^= 0x01; #[cfg(unix)]
3501 {
3502 use std::os::unix::fs::PermissionsExt;
3503 fs::set_permissions(&path, fs::Permissions::from_mode(0o644))?;
3504 }
3505 fs::write(&path, &bytes)?;
3506 let stamp = UNIX_EPOCH + Duration::from_secs(entry.stamp);
3507 fs::OpenOptions::new()
3508 .write(true)
3509 .open(&path)?
3510 .set_modified(stamp)?;
3511 #[cfg(unix)]
3512 {
3513 use std::os::unix::fs::PermissionsExt;
3514 fs::set_permissions(&path, fs::Permissions::from_mode(0o444))?;
3515 }
3516
3517 let reports = verify(store.path())?;
3518 assert!(
3519 reports[0]
3520 .problems
3521 .iter()
3522 .any(|problem| problem.contains("content drift")),
3523 "{:?}",
3524 reports[0].problems
3525 );
3526
3527 let view = tempfile::tempdir()?;
3528 let report = restore(view.path(), store.path(), None, None, false)?;
3529 assert!(report.errors > 0, "tampered artifact must not install");
3530 assert!(
3531 !view.path().join(&file.dest).exists(),
3532 "tampered artifact must be absent from the view"
3533 );
3534 Ok(())
3535 }
3536
3537 #[test]
3538 fn concurrent_harvest_keeps_every_unit() -> Result<()> {
3539 let store = tempfile::tempdir()?;
3540 let mut builds = Vec::new();
3541 for index in 0..6u32 {
3542 let build = tempfile::tempdir()?;
3543 let hex = format!("{:016x}", index + 1);
3544 let unit = build
3545 .path()
3546 .join("debug/.fingerprint")
3547 .join(format!("u{index}-{hex}"));
3548 fs::create_dir_all(&unit)?;
3549 fs::write(unit.join(format!("lib-u{index}")), "0123456789abcdef")?;
3550 fs::write(
3551 unit.join(format!("lib-u{index}.json")),
3552 r#"{"rustc":1,"features":"[]","declared_features":"[]","deps":[]}"#,
3553 )?;
3554 fs::create_dir_all(build.path().join("debug/deps"))?;
3555 fs::write(
3556 build
3557 .path()
3558 .join("debug/deps")
3559 .join(format!("libu{index}-{hex}.rlib")),
3560 [index as u8; 8],
3561 )?;
3562 builds.push(build);
3563 }
3564 std::thread::scope(|scope| {
3565 for build in &builds {
3566 scope.spawn(|| harvest(build.path(), store.path(), None, false));
3567 }
3568 });
3569 let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3570 assert_eq!(manifest.units.len(), builds.len(), "{:?}", manifest.units);
3571 Ok(())
3572 }
3573
3574 #[cfg(unix)]
3575 #[test]
3576 fn store_directories_are_private() -> Result<()> {
3577 use std::os::unix::fs::MetadataExt;
3578 let build = tempfile::tempdir()?;
3579 let store = tempfile::tempdir()?;
3580 synthetic_build_dir(build.path())?;
3581 harvest(build.path(), store.path(), None, false)?;
3582 assert_eq!(
3583 fs::metadata(store.path())?.mode() & 0o077,
3584 0,
3585 "store root 0700"
3586 );
3587 let generation = store.path().join(parity_key()?);
3588 assert_eq!(
3589 fs::metadata(&generation)?.mode() & 0o077,
3590 0,
3591 "generation 0700"
3592 );
3593 Ok(())
3594 }
3595
3596 #[test]
3597 fn verify_ordering_is_quiet_when_units_are_current() -> Result<()> {
3598 let build = tempfile::tempdir()?;
3599 let store = tempfile::tempdir()?;
3600 let now = now_secs();
3601 synthetic_edge_dir(build.path(), now - 3600, now)?;
3602 harvest(build.path(), store.path(), None, false)?;
3603
3604 let reports = verify(store.path())?;
3605 assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3606 assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
3607 Ok(())
3608 }
3609}