monochromatic-git-policy-cli 0.5.33

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  The process that owns one item of a copy transaction (a journal, a stage claim, a temporary file), written into the
//!  item's own name or text, and the proof of whether that process still runs.
//! Why:
//!  Concurrent adds in one repository must never wait for one another, so no lock stands between them. An item has exactly
//!  one owner, and any other command may touch an item only when its owner is proven dead: the live copy of an add is never
//!  recovered or reaped, a dead one is. The owner is part of the item's name, so taking over a dead owner's journal is one
//!  atomic rename (a single winner, no lock file, no stale lock to retire).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const owner = currentCopyOwner(); // { pid, birthIdentity, pidNamespace }
//! const name = `${id}.${owner.token()}.json`;
//! if (!CopyOwner.fromToken(token).isAlive(path)) { /* the item may be taken over */ }
//! ```

use crate::pid_namespace::{current_pid_namespace, foreign_owner_standing};
use crate::process_identity::{current_birth_identity, process_birth_identity};
use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
use std::path::Path;

/// What:
///  The process identity that owns an item: the process id, the identity of its birth, and its process-id namespace.
/// Why:
///   A process id alone is reused; the birth identity tells a reused id from the owner, and the namespace tells an owner
///  this process cannot interpret from one that is gone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CopyOwner = { pid: number; birthIdentity: string; pidNamespace?: string };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyOwner {
    /// The owner's process id.
    pub pid: i64,
    /// The owner's birth identity as the platform reports it.
    pub birth_identity: String,
    /// The owner's process-id namespace, when the platform has one.
    pub pid_namespace: Option<String>,
}

/// The separator between the fields of an owner in a token; it is never a character a field is written with unescaped.
const FIELD_SEPARATOR: char = '~';

/// The escape that introduces two hexadecimal digits for a byte a name cannot carry as it is.
const ESCAPE: char = '%';

/// Whether a byte stands in a token as it is: letters, digits and the few marks the identities use.
/// A dot is never one of them, because a dot separates the parts of a journal's name.
fn is_plain(byte: u8) -> bool {
    return byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-' | b'[' | b']');
}

/// A field of an owner as text a file name carries: plain bytes as they are, every other byte as `%` and two hexadecimal digits.
fn escape_field(field: &str) -> String {
    let mut written: String = String::with_capacity(field.len());
    for byte in field.bytes() {
        if is_plain(byte) {
            written.push(char::from(byte));
        } else {
            written.push_str(format!("{ESCAPE}{byte:02x}").as_str());
        }
    }
    return written;
}

/// The field an escaped text names, or nothing when an escape is cut short, is not hexadecimal, or the bytes are not UTF-8.
fn unescape_field(text: &str) -> Option<String> {
    let mut bytes: Vec<u8> = Vec::with_capacity(text.len());
    let mut remaining = text.bytes();
    while let Some(byte) = remaining.next() {
        if char::from(byte) == ESCAPE {
            let high: u8 = remaining.next()?;
            let low: u8 = remaining.next()?;
            let pair: [u8; 2] = [high, low];
            bytes.push(u8::from_str_radix(std::str::from_utf8(&pair).ok()?, 16).ok()?);
        } else {
            bytes.push(byte);
        }
    }
    return String::from_utf8(bytes).ok();
}

/// What:
///  The owner of the running process.
/// Why:
///   Every item this process creates names it, so a later command can tell a live copy from an abandoned one.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function currentCopyOwner(): CopyOwner;
/// ```
///
/// # Errors
///  When the platform cannot report the running process's birth identity.
pub fn current_copy_owner() -> Result<CopyOwner, WorktreeCopyError> {
    let birth: String = current_birth_identity().map_err(|error| {
        return failure_with_cause("cli-git: the identity of this process is unavailable, so a copy cannot name its owner.", Some(&error));
    })?;
    return Ok(CopyOwner {
        pid: i64::from(std::process::id()),
        birth_identity: birth,
        pid_namespace: current_pid_namespace(),
    });
}

impl CopyOwner {
    /// The owner as text that holds no dot, slash, space or other character a file name's parts cannot carry, in about forty
    /// characters for a Linux process.
    pub fn token(&self) -> String {
        let namespace: String = escape_field(self.pid_namespace.as_deref().unwrap_or(""));
        return format!("{}{FIELD_SEPARATOR}{}{FIELD_SEPARATOR}{namespace}", self.pid, escape_field(self.birth_identity.as_str()));
    }

    /// The owner a token names, or nothing when the text is no token (a name an older release wrote, or a stranger's).
    pub fn from_token(token: &str) -> Option<CopyOwner> {
        let mut fields = token.split(FIELD_SEPARATOR);
        let pid: i64 = fields.next()?.parse::<i64>().ok().filter(|found| { return *found >= 1; })?;
        let birth: String = unescape_field(fields.next()?).filter(|found| { return !found.is_empty(); })?;
        let namespace: String = unescape_field(fields.next()?)?;
        if fields.next().is_some() {
            return None;
        }
        return Some(CopyOwner {
            pid,
            birth_identity: birth,
            pid_namespace: if namespace.is_empty() { None } else { Some(namespace) },
        });
    }

    /// What:
    ///  Whether the owner still runs.
    /// Why:
    ///   An owner in a PID namespace this process cannot see is judged by how recently its item changed (`item`'s age),
    ///  because its process id means nothing here; any other owner is alive when a process with its id and its birth exists.
    ///
    /// In TS you'd write (pseudocode):
    /// ```ts
    /// owner.isAlive(itemPath);
    /// ```
    ///
    /// # Errors
    ///  When the platform refuses to report the process.
    pub fn is_alive(&self, item: &Path) -> Result<bool, WorktreeCopyError> {
        if let Some(standing) = foreign_owner_standing(self.pid_namespace.as_deref(), item) {
            return Ok(standing);
        }
        // `match` on the platform's answer: a live process with the recorded birth is the owner; no process is a dead owner.
        match process_birth_identity(self.pid).map_err(|error| return raw_failure(&error))? {
            Some(current) => return Ok(current == self.birth_identity),
            None => return Ok(false),
        }
    }
}

/// What:
///  Whether the owner a token names is proven dead; an absent or unreadable token names nobody, so nobody can be alive there.
/// Why:
///   Items written before owners existed, and strangers' files, must not stay in the journal root forever; and an owner the
///  platform will not report on is not proven dead, so its item is kept rather than taken.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function ownerIsDead(token: string | undefined, itemPath: string): boolean;
/// ```
pub fn token_owner_is_dead(token: Option<&str>, item: &Path) -> bool {
    let Some(owner) = token.and_then(CopyOwner::from_token) else {
        return true;
    };
    // `match` on the proof: only a clean answer of "not running" is a dead owner.
    match owner.is_alive(item) {
        Ok(alive) => return !alive,
        Err(_unproven) => return false,
    }
}

#[cfg(test)]
#[path = "worktree_copy_owner_tests.rs"]
mod tests;