use super::pid_namespace::{current_pid_namespace, foreign_owner_standing};
use super::process_identity::{current_birth_identity, process_birth_identity};
use super::subvolume_error::MigrateError;
use std::ffi::{OsStr, OsString};
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const CLAIM_HEADER: &str = "cli-git owner-claim v1";
const RELEASED_LINE: &str = "released";
const MOST_ROUNDS: u32 = 64;
static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct OwnerClaim {
path: PathBuf,
directory: PathBuf,
released: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BusyOwner {
pub pid: i64,
pub path: PathBuf,
}
#[derive(Debug)]
pub enum ClaimOutcome {
Held(OwnerClaim),
Busy(BusyOwner),
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Record {
Released,
Owned { pid: i64, birth: String, namespace: Option<String> },
}
fn encode(record: &Record) -> String {
return match record {
Record::Released => format!("{CLAIM_HEADER}\n{RELEASED_LINE}\n"),
Record::Owned { pid, birth, namespace } => format!("{CLAIM_HEADER}\npid={pid}\nbirth={birth}\nnamespace={}\n", namespace.as_deref().unwrap_or("-")),
};
}
fn decode(text: &str) -> Option<Record> {
let mut lines = text.lines();
if lines.next() != Some(CLAIM_HEADER) {
return None;
}
let second: &str = lines.next()?;
if second == RELEASED_LINE {
return Some(Record::Released);
}
let pid: i64 = second.strip_prefix("pid=")?.parse().ok().filter(|pid: &i64| { return *pid > 0; })?;
let birth: String = lines.next()?.strip_prefix("birth=")?.to_string();
let namespace: String = lines.next()?.strip_prefix("namespace=")?.to_string();
if birth.is_empty() {
return None;
}
return Some(Record::Owned { pid, birth, namespace: if namespace == "-" { None } else { Some(namespace) } });
}
fn generation_of(name: &OsStr, prefix: &OsStr) -> Option<u64> {
let digits: &[u8] = name.as_bytes().strip_prefix(prefix.as_bytes())?.strip_prefix(b".")?;
if digits.is_empty() || !digits.iter().all(|byte: &u8| { return byte.is_ascii_digit(); }) {
return None;
}
return std::str::from_utf8(digits).ok()?.parse().ok();
}
fn generations(directory: &Path, prefix: &OsStr) -> Result<Vec<u64>, MigrateError> {
let entries: std::fs::ReadDir = std::fs::read_dir(directory).map_err(|error: std::io::Error| { return MigrateError::io("list the claims in", directory, &error); })?;
let mut found: Vec<u64> = entries.flatten().filter_map(|entry: std::fs::DirEntry| { return generation_of(entry.file_name().as_os_str(), prefix); }).collect();
found.sort_unstable();
return Ok(found);
}
fn path_of(directory: &Path, prefix: &OsStr, generation: u64) -> PathBuf {
let mut name: OsString = prefix.to_os_string();
name.push(format!(".{generation}"));
return directory.join(name);
}
fn write_private(directory: &Path, record: &Record) -> std::io::Result<PathBuf> {
let unique: u64 = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
let path: PathBuf = directory.join(format!(".cli-git-claim.tmp.{}.{nanos}.{unique}", std::process::id()));
let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&path)?;
let written: std::io::Result<()> = file.write_all(encode(record).as_bytes()).and_then(|()| { return file.sync_all(); });
if let Err(error) = written {
let _removed: std::io::Result<()> = std::fs::remove_file(&path);
return Err(error);
}
return Ok(path);
}
fn sync_directory(directory: &Path) -> std::io::Result<()> {
return std::fs::File::open(directory).and_then(|handle: std::fs::File| { return handle.sync_all(); });
}
fn publish_new(directory: &Path, target: &Path, record: &Record) -> std::io::Result<()> {
let private: PathBuf = write_private(directory, record)?;
let linked: std::io::Result<()> = std::fs::hard_link(&private, target);
let _removed: std::io::Result<()> = std::fs::remove_file(&private);
linked?;
return sync_directory(directory);
}
fn read_record(path: &Path) -> Result<Option<Record>, MigrateError> {
let bytes: Vec<u8> = match super::worktree_convert_journal::read_plain_file(path, SMALL_RECORD_LIMIT) {
Ok(found) => found,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(MigrateError::io("read the claim", path, &error)),
};
return match decode(String::from_utf8_lossy(bytes.as_slice()).as_ref()) {
Some(record) => Ok(Some(record)),
None => Err(MigrateError::Journal(format!(
"{} is not a claim file this command wrote, so it cannot tell whether another command is working here; nothing was changed. Look at the file: if no cli-git command is running on this item, remove it and run the command again",
path.display()
))),
};
}
const SMALL_RECORD_LIMIT: u64 = 4096;
fn live_owner(record: &Record, path: &Path) -> Result<Option<i64>, MigrateError> {
let Record::Owned { pid, birth, namespace } = record else { return Ok(None) };
if let Some(standing) = foreign_owner_standing(namespace.as_deref(), path) {
return Ok(standing.then_some(*pid));
}
return match process_birth_identity(*pid) {
Ok(Some(current)) => Ok((¤t == birth).then_some(*pid)),
Ok(None) => Ok(None),
Err(error) => Err(MigrateError::Journal(format!(
"{} names process {pid}, and whether it still runs cannot be decided ({error}); nothing was changed. If it does not, remove the file and run the command again",
path.display()
))),
};
}
fn own_record() -> Result<Record, MigrateError> {
let birth: String = current_birth_identity().map_err(|error| { return MigrateError::Journal(format!("this process cannot be identified for a claim ({error}); nothing was changed")); })?;
return Ok(Record::Owned { pid: i64::from(std::process::id()), birth, namespace: current_pid_namespace() });
}
pub fn take_claim(directory: &Path, prefix: &OsStr) -> Result<ClaimOutcome, MigrateError> {
return take_claim_seamed(directory, prefix, &mut || {});
}
pub fn take_claim_seamed(directory: &Path, prefix: &OsStr, after_listing: &mut dyn FnMut()) -> Result<ClaimOutcome, MigrateError> {
let record: Record = own_record()?;
for _round in 0..MOST_ROUNDS {
let existing: Vec<u64> = generations(directory, prefix)?;
after_listing();
let target: u64 = match existing.last() {
None => 1,
Some(newest) => {
let newest_path: PathBuf = path_of(directory, prefix, *newest);
let Some(found) = read_record(newest_path.as_path())? else { continue };
if let Some(pid) = live_owner(&found, newest_path.as_path())? {
return Ok(ClaimOutcome::Busy(BusyOwner { pid, path: newest_path }));
}
newest + 1
}
};
let path: PathBuf = path_of(directory, prefix, target);
match publish_new(directory, path.as_path(), &record) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(MigrateError::io("create the claim", path.as_path(), &error)),
}
if generations(directory, prefix)?.last().is_some_and(|newest: &u64| { return *newest > target; }) {
let _withdrawn: std::io::Result<()> = std::fs::remove_file(&path);
continue;
}
for older in existing.iter().filter(|older: &&u64| { return **older < target; }) {
let _removed: std::io::Result<()> = std::fs::remove_file(path_of(directory, prefix, *older));
}
return Ok(ClaimOutcome::Held(OwnerClaim { path, directory: directory.to_path_buf(), released: false }));
}
return Err(MigrateError::Journal(format!("other commands kept taking the claim in {} while this one looked; nothing was changed, run the command again", directory.display())));
}
pub fn live_owner_of(directory: &Path, prefix: &OsStr) -> Result<Option<BusyOwner>, MigrateError> {
if !directory.is_dir() {
return Ok(None);
}
let existing: Vec<u64> = generations(directory, prefix)?;
let Some(newest) = existing.last() else { return Ok(None) };
let path: PathBuf = path_of(directory, prefix, *newest);
let Some(found) = read_record(path.as_path())? else { return Ok(None) };
return Ok(live_owner(&found, path.as_path())?.map(|pid: i64| { return BusyOwner { pid, path }; }));
}
impl OwnerClaim {
pub fn release(&mut self) -> Result<(), MigrateError> {
if self.released {
return Ok(());
}
let written: std::io::Result<()> = (|| {
let private: PathBuf = write_private(self.directory.as_path(), &Record::Released)?;
if let Err(error) = std::fs::rename(&private, &self.path) {
let _removed: std::io::Result<()> = std::fs::remove_file(&private);
return Err(error);
}
return sync_directory(self.directory.as_path());
})();
self.released = true;
return written.map_err(|error: std::io::Error| { return MigrateError::io("release the claim", self.path.as_path(), &error); });
}
}
impl Drop for OwnerClaim {
fn drop(&mut self) {
let _released: Result<(), MigrateError> = self.release();
}
}
#[cfg(test)]
pub(crate) fn plant_claim_for_tests(directory: &Path, prefix: &OsStr, generation: u64, pid: i64, birth: &str) {
let record: Record = Record::Owned { pid, birth: birth.to_string(), namespace: current_pid_namespace() };
std::fs::write(path_of(directory, prefix, generation), encode(&record)).expect("planted claim");
}
#[cfg(test)]
pub(crate) fn unused_pid_for_tests() -> i64 {
let text: String = std::fs::read_to_string("/proc/sys/kernel/pid_max").expect("pid_max");
return text.trim().parse().expect("a number");
}
#[cfg(test)]
#[path = "single_owner_claim_tests.rs"]
mod tests;