use core::sync::atomic::{AtomicBool, Ordering};
use std::fs;
use std::sync::Mutex;
use camino::{Utf8Path, Utf8PathBuf};
use ignore::{WalkBuilder, WalkState};
use serde::{Deserialize, Serialize};
use super::record::digest;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct SnapshotFile {
pub(super) path: Utf8PathBuf,
pub(super) digest: String,
pub(super) size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) modified: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ExternalInput {
root: Utf8PathBuf,
files: Vec<SnapshotFile>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct WorkspaceSnapshot {
#[serde(default)]
complete: bool,
#[serde(default)]
excluded: Vec<Utf8PathBuf>,
#[serde(default)]
pub(super) files: Vec<SnapshotFile>,
#[serde(default)]
external: Vec<ExternalInput>,
}
impl WorkspaceSnapshot {
#[cfg(test)]
pub(super) fn capture(root: &Utf8Path, excluded: &[Utf8PathBuf]) -> Self {
Self::capture_with_external(root, excluded, &[], false)
}
pub(super) fn capture_with_external(
root: &Utf8Path,
excluded: &[Utf8PathBuf],
external_roots: &[Utf8PathBuf],
untracked_build_script_inputs: bool,
) -> Self {
let mut snapshot = Self {
complete: !untracked_build_script_inputs,
excluded: exclusions(root, excluded),
files: Vec::new(),
external: Vec::new(),
};
snapshot.files = capture_tree(root, &snapshot.excluded, &mut snapshot.complete);
let workspace = match crate::paths::physical(root) {
Ok(path) => path,
Err(_unresolved) => {
snapshot.complete = false;
return snapshot;
}
};
let mut roots = Vec::new();
for root in external_roots {
match crate::paths::physical(root) {
Ok(root) if !root.starts_with(&workspace) => roots.push(root),
Ok(_inside_workspace) => {}
Err(_unresolved) => snapshot.complete = false,
}
}
roots.sort();
roots.dedup();
for root in roots {
let mut excluded = exclusions(&root, &[]);
if let Ok(relative) = workspace.strip_prefix(&root)
&& !relative.as_str().is_empty()
{
excluded.push(relative.to_path_buf());
excluded.sort();
excluded.dedup();
}
let files = capture_tree(&root, &excluded, &mut snapshot.complete);
snapshot.external.push(ExternalInput { root, files });
}
snapshot
}
pub(super) fn matches_current(&self, root: &Utf8Path) -> bool {
self.complete && *self == self.recapture(root)
}
pub(super) fn recapture(&self, root: &Utf8Path) -> Self {
let external_roots: Vec<Utf8PathBuf> = self.external.iter().map(|input| input.root.clone()).collect();
Self::capture_with_external(root, &self.excluded, &external_roots, false)
}
pub(super) const fn is_complete(&self) -> bool {
self.complete
}
pub(super) fn matches_compilation_inputs(&self, current: &Self, roots: &[Utf8PathBuf]) -> bool {
self.complete
&& current.complete
&& self.external == current.external
&& self
.files
.iter()
.filter(|file| Self::is_compilation_input(&file.path, roots))
.eq(current.files.iter().filter(|file| Self::is_compilation_input(&file.path, roots)))
}
pub(super) fn file(&self, path: &Utf8Path) -> Option<&SnapshotFile> {
let index = self.files.binary_search_by(|file| file.path.as_path().cmp(path)).ok()?;
self.files.get(index)
}
fn is_compilation_input(path: &Utf8Path, roots: &[Utf8PathBuf]) -> bool {
matches!(
path.as_str(),
"Cargo.toml" | "Cargo.lock" | "rust-toolchain" | "rust-toolchain.toml"
) || path.starts_with(".cargo")
|| roots.iter().any(|root| root.as_str().is_empty() || path.starts_with(root))
}
#[cfg(test)]
pub(super) fn rust_files(&self, root: &Utf8Path) -> Vec<Utf8PathBuf> {
self.files
.iter()
.filter(|file| file.path.extension() == Some("rs"))
.map(|file| root.join(&file.path))
.collect()
}
}
fn capture_tree(root: &Utf8Path, excluded: &[Utf8PathBuf], complete: &mut bool) -> Vec<SnapshotFile> {
let boundary = fs::canonicalize(root.as_std_path())
.ok()
.and_then(|path| Utf8PathBuf::from_path_buf(path).ok());
if boundary.is_none() {
*complete = false;
}
let complete_flag = AtomicBool::new(*complete);
let files: Mutex<Vec<SnapshotFile>> = Mutex::new(Vec::new());
let excluded = excluded.to_vec();
let root_owned = root.to_owned();
let mut builder = WalkBuilder::new(root.as_std_path());
let _builder = builder
.hidden(false)
.parents(false)
.require_git(false)
.git_ignore(false)
.git_exclude(false)
.git_global(false)
.ignore(false)
.follow_links(false);
builder.build_parallel().run(|| {
let root = root_owned.clone();
let excluded = excluded.clone();
let boundary = boundary.clone();
let complete_flag = &complete_flag;
let files = &files;
Box::new(move |entry| {
let Ok(entry) = entry else {
complete_flag.store(false, Ordering::Relaxed);
return WalkState::Continue;
};
let Ok(path) = Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) else {
complete_flag.store(false, Ordering::Relaxed);
return WalkState::Continue;
};
let relative = path.strip_prefix(&root).unwrap_or(&path);
let is_dir = entry.file_type().is_some_and(|file_type| file_type.is_dir());
if excluded.iter().any(|excluded| relative.starts_with(excluded)) {
return if is_dir { WalkState::Skip } else { WalkState::Continue };
}
if is_dir {
return WalkState::Continue;
}
if entry.path_is_symlink() {
let Ok(target) = fs::read_link(path.as_std_path()) else {
complete_flag.store(false, Ordering::Relaxed);
return WalkState::Continue;
};
let bytes = target.as_os_str().as_encoded_bytes();
if let Some(boundary) = &boundary {
match fs::canonicalize(path.as_std_path()) {
Ok(referent) => match Utf8PathBuf::from_path_buf(referent) {
Ok(referent) if referent.starts_with(boundary) => {
let relative = referent.strip_prefix(boundary).expect("the prefix was checked");
if excluded.iter().any(|excluded| relative.starts_with(excluded)) {
complete_flag.store(false, Ordering::Relaxed);
}
}
Ok(_) | Err(_) => complete_flag.store(false, Ordering::Relaxed),
},
Err(cause) if cause.kind() == std::io::ErrorKind::NotFound => {}
Err(_unresolved) => complete_flag.store(false, Ordering::Relaxed),
}
}
let file = SnapshotFile {
path: relative.to_path_buf(),
digest: digest(bytes),
size: bytes.len() as u64,
modified: symlink_modified_at(&path),
};
files.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(file);
return WalkState::Continue;
}
match fs::metadata(path.as_std_path()) {
Ok(metadata) if metadata.is_file() => {}
_other => {
complete_flag.store(false, Ordering::Relaxed);
return WalkState::Continue;
}
}
let Ok(bytes) = fs::read(path.as_std_path()) else {
complete_flag.store(false, Ordering::Relaxed);
return WalkState::Continue;
};
let file = SnapshotFile {
path: relative.to_path_buf(),
digest: digest(&bytes),
size: bytes.len() as u64,
modified: modified_at(&path),
};
files.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(file);
WalkState::Continue
})
});
*complete = complete_flag.load(Ordering::Relaxed);
let mut files = files.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
files.sort_by(|left, right| left.path.cmp(&right.path));
files
}
fn modified_at(path: &Utf8Path) -> Option<u64> {
modified(&fs::metadata(path.as_std_path()).ok()?)
}
fn symlink_modified_at(path: &Utf8Path) -> Option<u64> {
modified(&fs::symlink_metadata(path.as_std_path()).ok()?)
}
fn modified(metadata: &fs::Metadata) -> Option<u64> {
let since = metadata.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
u64::try_from(since.as_nanos()).ok()
}
fn exclusions(root: &Utf8Path, excluded: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
let mut paths = vec![Utf8PathBuf::from("target"), Utf8PathBuf::from(".git")];
for path in excluded {
if path.is_relative() {
paths.push(path.clone());
} else if let Ok(relative) = path.strip_prefix(root)
&& !relative.as_str().is_empty()
{
paths.push(relative.to_path_buf());
}
}
paths.sort();
paths.dedup();
paths
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use std::fs;
use camino::Utf8Path;
use super::WorkspaceSnapshot;
#[cfg(unix)]
#[test]
fn directory_and_broken_symlinks_form_a_repeatable_snapshot() {
let directory = crate::testing::workdir("snapshot-symlinks-");
let root = Utf8Path::from_path(directory.path()).expect("the temporary path is UTF-8");
fs::create_dir(root.join("real")).expect("directory");
std::os::unix::fs::symlink("real", root.join("directory-link")).expect("directory symlink");
std::os::unix::fs::symlink("missing", root.join("broken-link")).expect("broken symlink");
let snapshot = WorkspaceSnapshot::capture(root, &[]);
assert!(snapshot.matches_current(root));
assert_eq!(
snapshot.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
["broken-link", "directory-link"]
);
}
#[cfg(unix)]
#[test]
fn a_symlink_to_an_external_referent_makes_reuse_ineligible() {
let directory = crate::testing::workdir("snapshot-external-link-");
let container = Utf8Path::from_path(directory.path()).expect("the temporary path is UTF-8");
let root = container.join("workspace");
let external = container.join("dependency.rs");
fs::create_dir(&root).expect("workspace");
fs::write(&external, "pub fn dependency() {}\n").expect("external input");
std::os::unix::fs::symlink(&external, root.join("linked.rs")).expect("external link");
let snapshot = WorkspaceSnapshot::capture(&root, &[]);
assert!(
!snapshot.is_complete(),
"a link whose referent is outside the captured roots cannot certify a cache entry"
);
}
#[test]
fn an_ancestor_path_dependency_does_not_recapture_the_workspace() {
let directory = crate::testing::workdir("snapshot-ancestor-dependency-");
let container = Utf8Path::from_path(directory.path()).expect("the temporary path is UTF-8");
let workspace = container.join("workspace");
let dependency = container.join("dependency.rs");
fs::create_dir(&workspace).expect("workspace");
fs::write(workspace.join("source.rs"), "pub fn source() {}\n").expect("workspace input");
fs::write(&dependency, "pub fn dependency() {}\n").expect("dependency input");
let snapshot = WorkspaceSnapshot::capture_with_external(&workspace, &[], &[container.to_path_buf()], false);
assert!(snapshot.is_complete());
assert!(
snapshot.external[0].files.iter().all(|file| !file.path.starts_with("workspace")),
"the external half must not double-capture the workspace or its scratch outputs"
);
assert!(snapshot.matches_current(&workspace));
fs::write(&dependency, "pub fn dependency() { panic!() }\n").expect("changed dependency");
assert!(
!snapshot.matches_current(&workspace),
"a change outside the workspace must invalidate reuse"
);
}
}