use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::domain::{ProductId, ReleaseId};
use crate::error::{Error, RecoveryError, RecoveryResult, Result};
static NEXT_LOCK_NONCE: AtomicU64 = AtomicU64::new(0);
const MAX_LOCK_BYTES: u64 = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LockStatus {
Available,
Held {
lock: PathBuf,
contents: Option<String>,
},
Malformed {
lock: PathBuf,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockObservation {
path: PathBuf,
record: String,
pid: Option<u32>,
nonce: Option<u64>,
product: Option<String>,
release: Option<String>,
}
impl LockObservation {
pub fn path(&self) -> &Path {
&self.path
}
pub fn record(&self) -> &str {
&self.record
}
pub fn pid(&self) -> Option<u32> {
self.pid
}
pub fn nonce(&self) -> Option<u64> {
self.nonce
}
pub fn product(&self) -> Option<&str> {
self.product.as_deref()
}
pub fn release(&self) -> Option<&str> {
self.release.as_deref()
}
pub fn format_known(&self) -> bool {
self.pid.is_some() && self.nonce.is_some()
}
fn parse(path: PathBuf, record: String) -> Self {
let mut parsed = Self {
path,
record,
pid: None,
nonce: None,
product: None,
release: None,
};
parsed.parse_fields();
parsed
}
fn parse_fields(&mut self) {
let Some(body) = self.record.strip_suffix('\n') else {
return;
};
let mut pid = None;
let mut nonce = None;
let mut product = None;
let mut release = None;
for field in body.split(' ') {
let Some((key, value)) = field.split_once('=') else {
return;
};
if value.is_empty() {
return;
}
match key {
"pid" => match value.parse::<u32>() {
Ok(value) => pid = Some(value),
Err(_) => return,
},
"nonce" => match value.parse::<u64>() {
Ok(value) => nonce = Some(value),
Err(_) => return,
},
"product" => product = Some(value.to_string()),
"release" => release = Some(value.to_string()),
_ => return,
}
}
self.pid = pid;
self.nonce = nonce;
self.product = product;
self.release = release;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StaleLockDecision {
Active,
ProvenStale,
Unknown,
}
pub trait StaleLockVerifier {
fn classify(&self, observed: &LockObservation) -> StaleLockDecision;
}
#[derive(Debug)]
pub struct MutationLock {
path: PathBuf,
token: String,
cleanup: bool,
_file: File,
}
impl MutationLock {
pub fn acquire(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Self> {
match Self::create_new(root, product, release)? {
Some(lock) => Ok(lock),
None => Err(Error::UpdateInProgress {
lock: lock_path(root),
}),
}
}
pub fn acquire_with_recovery(
root: &Path,
product: &ProductId,
release: &ReleaseId,
verifier: &dyn StaleLockVerifier,
) -> RecoveryResult<Self> {
if let Some(lock) = Self::create_new(root, product, release)? {
return Ok(lock);
}
let observed = match Self::observe(root)? {
Some(observation) => observation,
None => {
return match Self::create_new(root, product, release)? {
Some(lock) => Ok(lock),
None => Err(Error::UpdateInProgress {
lock: lock_path(root),
}
.into()),
};
}
};
if verifier.classify(&observed) != StaleLockDecision::ProvenStale {
return Err(Error::UpdateInProgress {
lock: observed.path().to_path_buf(),
}
.into());
}
let claim = Self::claim(root, &observed)?;
fire_post_claim_hook();
match Self::create_new(root, product, release) {
Ok(Some(lock)) => {
remove_claim(&claim)?;
Ok(lock)
}
Ok(None) => {
remove_claim(&claim)?;
Err(Error::UpdateInProgress {
lock: lock_path(root),
}
.into())
}
Err(error) => {
match remove_claim(&claim) {
Ok(()) => Err(error.into()),
Err(retained) => Err(retained),
}
}
}
}
pub fn observe(root: &Path) -> Result<Option<LockObservation>> {
match Self::inspect(root)? {
LockStatus::Available => Ok(None),
LockStatus::Held {
lock,
contents: Some(contents),
} => Ok(Some(LockObservation::parse(lock, contents))),
LockStatus::Held { lock, .. } | LockStatus::Malformed { lock } => {
Err(Error::UpdateInProgress { lock })
}
}
}
pub fn inspect(root: &Path) -> Result<LockStatus> {
let path = lock_path(root);
let meta = match fs::symlink_metadata(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LockStatus::Available);
}
Err(source) => return Err(Error::io("reading mutation lock", source)),
Ok(m) => m,
};
if !meta.is_file() {
return Ok(LockStatus::Malformed { lock: path });
}
#[cfg(unix)]
{
if meta.file_type().is_symlink() {
return Ok(LockStatus::Malformed { lock: path });
}
}
if meta.len() > MAX_LOCK_BYTES {
return Ok(LockStatus::Malformed { lock: path });
}
match fs::read_to_string(&path) {
Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(LockStatus::Held {
lock: path,
contents: Some(contents),
}),
_ => Ok(LockStatus::Malformed { lock: path }),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub(crate) fn preserve(&mut self) {
self.cleanup = false;
}
fn create_new(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Option<Self>> {
let path = lock_path(root);
let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
let token = format!(
"pid={} nonce={} product={} release={}\n",
std::process::id(),
nonce,
product,
release
);
if token.len() as u64 > MAX_LOCK_BYTES {
return Err(Error::invalid("lock record exceeds bound"));
}
let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => file,
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
Err(source) => return Err(Error::io("creating mutation lock", source)),
};
if let Err(source) = file.write_all(token.as_bytes()) {
let _ = fs::remove_file(&path);
return Err(Error::io("writing mutation lock", source));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(Some(Self {
path,
token,
cleanup: true,
_file: file,
}))
}
fn claim(root: &Path, observed: &LockObservation) -> RecoveryResult<PathBuf> {
let path = observed.path().to_path_buf();
let claim = claim_path(root);
match read_record(&path) {
Ok(current) if current == observed.record() => {}
Ok(_) => return Err(Error::UpdateInProgress { lock: path }.into()),
Err(error) => return Err(error.into()),
}
fs::rename(&path, &claim)
.map_err(|source| Error::io("claiming the authorized stale lock record", source))?;
match read_record(&claim) {
Ok(claimed) if claimed == observed.record() => Ok(claim),
Ok(claimed) => Err(restore_claim(&claim, &path, &claimed)),
Err(error) => Err(RecoveryError::RecoveryRequired {
evidence: claim,
detail: format!("the claimed stale lock record could not be verified: {error}"),
}),
}
}
}
impl Drop for MutationLock {
fn drop(&mut self) {
if !self.cleanup {
return;
}
let Ok(metadata) = fs::symlink_metadata(&self.path) else {
return;
};
if !metadata.is_file() {
return;
}
let Ok(contents) = fs::read_to_string(&self.path) else {
return;
};
if contents == self.token {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
pub(crate) static POST_CLAIM_HOOK: std::sync::Mutex<Option<Box<dyn Fn() + Send>>> =
std::sync::Mutex::new(None);
#[cfg(test)]
fn fire_post_claim_hook() {
let hook = POST_CLAIM_HOOK.lock().ok().and_then(|mut slot| slot.take());
if let Some(hook) = hook {
hook();
}
}
#[cfg(not(test))]
fn fire_post_claim_hook() {}
fn lock_path(root: &Path) -> PathBuf {
root.join(".eggup-mutation.lock")
}
fn claim_path(root: &Path) -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
root.join(format!(
".eggup-stale-claim-{}-{nonce}-{nanos}",
std::process::id()
))
}
fn read_record(path: &Path) -> Result<String> {
let metadata = match fs::symlink_metadata(path) {
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::UpdateInProgress {
lock: path.to_path_buf(),
})
}
Err(source) => return Err(Error::io("reading lock record", source)),
Ok(metadata) => metadata,
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::UpdateInProgress {
lock: path.to_path_buf(),
});
}
if metadata.len() > MAX_LOCK_BYTES {
return Err(Error::UpdateInProgress {
lock: path.to_path_buf(),
});
}
match fs::read_to_string(path) {
Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(contents),
_ => Err(Error::UpdateInProgress {
lock: path.to_path_buf(),
}),
}
}
pub(crate) fn restore_claim(claim: &Path, lock: &Path, record: &str) -> RecoveryError {
match OpenOptions::new().write(true).create_new(true).open(lock) {
Ok(mut file) => {
if let Err(source) = file.write_all(record.as_bytes()) {
return RecoveryError::RecoveryRequired {
evidence: claim.to_path_buf(),
detail: format!(
"a displaced lock record was re-created at {} but could not be written: {source}",
lock.display()
),
};
}
let _ = fs::remove_file(claim);
RecoveryError::Core(Error::UpdateInProgress {
lock: lock.to_path_buf(),
})
}
Err(_) => RecoveryError::RecoveryRequired {
evidence: claim.to_path_buf(),
detail: "the displaced lock record was not the authorized one and the lock path was re-taken; retained evidence requires inspection"
.to_string(),
},
}
}
fn remove_claim(claim: &Path) -> RecoveryResult<()> {
match fs::remove_file(claim) {
Ok(()) => Ok(()),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(RecoveryError::RecoveryRequired {
evidence: claim.to_path_buf(),
detail: format!("a claimed stale lock record could not be removed: {source}"),
}),
}
}