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