use std::ffi::OsString;
use std::fs::FileType;
use std::io;
use std::path::{Path, PathBuf};
pub const LAST_CHECKOUT_MARKER: &str = ".trusty-slot-last-checkout";
const CARGO_LOCK: &str = ".cargo-lock";
const FINGERPRINT_DIR: &str = ".fingerprint";
trait SlotFs {
fn child_dirs(&self, dir: &Path) -> io::Result<Vec<OsString>>;
fn file_type(&self, path: &Path) -> io::Result<FileType>;
fn try_lock(&self, lock: &Path) -> io::Result<bool>;
}
struct RealFs;
impl SlotFs for RealFs {
fn child_dirs(&self, dir: &Path) -> io::Result<Vec<OsString>> {
let read = std::fs::read_dir(dir)?;
dirs_in(read.map(|e| e.map(|e| (e.file_name(), e.file_type()))))
}
fn file_type(&self, path: &Path) -> io::Result<FileType> {
std::fs::metadata(path).map(|m| m.file_type())
}
fn try_lock(&self, lock: &Path) -> io::Result<bool> {
use std::os::fd::AsRawFd;
let file = std::fs::File::open(lock)?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
return Ok(false);
}
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
Ok(true)
}
}
fn dirs_in(
entries: impl Iterator<Item = io::Result<(OsString, io::Result<FileType>)>>,
) -> io::Result<Vec<OsString>> {
let entry_error = |err: io::Error| io::Error::other(format!("slot entry unreadable: {err}"));
let mut out = Vec::new();
for entry in entries {
let (name, kind) = entry.map_err(entry_error)?;
match kind {
Ok(kind) if kind.is_dir() => out.push(name),
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(entry_error(err)),
}
}
Ok(out)
}
fn is_absent(err: &io::Error) -> bool {
matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Invalidation {
SameCheckout,
Cleared {
previous: Option<String>,
removed: usize,
all: bool,
},
}
#[must_use]
pub fn checkout_root(cwd: &Path) -> PathBuf {
cwd.ancestors()
.find(|dir| dir.join(".git").exists())
.unwrap_or(cwd)
.to_path_buf()
}
pub fn invalidate_if_checkout_changed(
slot_dir: &Path,
checkout: &Path,
packages: impl FnOnce() -> Result<Vec<String>, String>,
) -> Result<Invalidation, String> {
let marker = slot_dir.join(LAST_CHECKOUT_MARKER);
let current = checkout.display().to_string();
let previous = std::fs::read_to_string(&marker)
.ok()
.map(|s| s.trim().to_string());
if previous.as_deref() == Some(current.as_str()) {
return Ok(Invalidation::SameCheckout);
}
let names = packages();
let all = names.is_err();
let names = names.unwrap_or_default();
let mut removed = 0;
let dirs = fingerprint_dirs(&RealFs, slot_dir).map_err(|err| {
format!(
"could not list fingerprints in {}: {err}; the slot is not used",
slot_dir.display()
)
})?;
for dir in dirs {
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) if is_absent(&err) => continue,
Err(err) => {
return Err(format!(
"could not list {}: {err}; the slot is not used",
dir.display()
));
}
};
for entry in entries {
let entry = entry.map_err(|err| {
format!(
"could not list {}: {err}; the slot is not used",
dir.display()
)
})?;
let name = entry.file_name().to_string_lossy().into_owned();
if !(all || names.iter().any(|pkg| is_unit_of(&name, pkg))) {
continue;
}
std::fs::remove_dir_all(entry.path()).map_err(|err| {
format!(
"could not clear stale fingerprint {}: {err}; the slot is not used",
entry.path().display()
)
})?;
removed += 1;
}
}
std::fs::write(&marker, ¤t)
.map_err(|err| format!("could not write {}: {err}", marker.display()))?;
Ok(Invalidation::Cleared {
previous,
removed,
all,
})
}
#[must_use]
pub fn cargo_lock_held(slot_dir: &Path) -> bool {
cargo_lock_held_in(&RealFs, slot_dir)
}
fn cargo_lock_held_in(fs: &impl SlotFs, slot_dir: &Path) -> bool {
let children = match fs.child_dirs(slot_dir) {
Ok(children) => children,
Err(err) if is_absent(&err) => return false,
Err(_) => return true,
};
let mut profiles = Vec::new();
for child in &children {
match probe_lock(fs, &slot_dir.join(child).join(CARGO_LOCK)) {
LockProbe::Absent => {}
LockProbe::Free => profiles.push(child),
LockProbe::Busy => return true,
}
}
children.iter().any(|child| {
profiles.iter().any(|profile| {
let lock = slot_dir.join(child).join(profile).join(CARGO_LOCK);
probe_lock(fs, &lock) == LockProbe::Busy
})
})
}
#[derive(Debug, PartialEq, Eq)]
enum LockProbe {
Absent,
Free,
Busy,
}
fn probe_lock(fs: &impl SlotFs, lock: &Path) -> LockProbe {
match fs.file_type(lock) {
Err(err) if is_absent(&err) => LockProbe::Absent,
Err(_) => LockProbe::Busy,
Ok(kind) if !kind.is_file() => LockProbe::Absent,
Ok(_) => match fs.try_lock(lock) {
Ok(true) => LockProbe::Free,
Ok(false) | Err(_) => LockProbe::Busy,
},
}
}
fn is_unit_of(entry: &str, pkg: &str) -> bool {
entry
.strip_prefix(pkg)
.and_then(|rest| rest.strip_prefix('-'))
.is_some_and(|hash| hash.len() == 16 && hash.chars().all(|c| c.is_ascii_hexdigit()))
}
fn fingerprint_dirs(fs: &impl SlotFs, root: &Path) -> io::Result<Vec<PathBuf>> {
let children = fs.child_dirs(root)?;
let fingerprints = |dir: PathBuf| -> io::Result<Option<PathBuf>> {
let fp = dir.join(FINGERPRINT_DIR);
match fs.file_type(&fp) {
Ok(kind) => Ok(kind.is_dir().then_some(fp)),
Err(err) if is_absent(&err) => Ok(None),
Err(err) => Err(err),
}
};
let mut profiles: Vec<&OsString> = Vec::new();
let mut out: Vec<PathBuf> = Vec::new();
for child in &children {
if let Some(fp) = fingerprints(root.join(child))? {
profiles.push(child);
out.push(fp);
}
}
for child in &children {
for profile in &profiles {
out.extend(fingerprints(root.join(child).join(profile))?);
}
}
Ok(out)
}
pub fn workspace_packages(checkout: &Path) -> Result<Vec<String>, String> {
let out = std::process::Command::new("cargo")
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--offline",
])
.current_dir(checkout)
.output()
.map_err(|err| format!("could not run cargo metadata: {err}"))?;
if !out.status.success() {
return Err(format!(
"cargo metadata exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
let meta: serde_json::Value = serde_json::from_slice(&out.stdout)
.map_err(|err| format!("cargo metadata output did not parse: {err}"))?;
Ok(meta["packages"]
.as_array()
.map(|pkgs| {
pkgs.iter()
.filter_map(|p| p["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
#[cfg(test)]
#[path = "stale_guard_tests.rs"]
mod tests;