monochromatic-git-policy-cli 0.5.20

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  The journal of one conversion of a worktree into a subvolume, and the lock that lets one command at a time own it.
//! Why:
//!  The journal is the only proof that the converting subvolume and the index lock are this command's, and it names the
//!  identity of the old root and of the subvolume it created, which is what every later phase checks before it reuses,
//!  exchanges, destroys or deletes anything. The journal is written through exclusively created files and never replaces
//!  a file it did not make, and a kernel advisory lock held from before the journal is read until the command ends keeps a
//!  second command from reading, writing or resuming it at the same time (the kernel drops the lock with the process, so a
//!  kill leaves nothing to clean up).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const lock = await ConversionLock.acquire(admin); // undefined when another command holds it
//! const journal = await Journal.load(admin);
//! await journal.save(admin); // exclusive temporary, then rename
//! ```

use super::fs_beneath::ObjectId;
use super::subvolume_error::MigrateError;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};

/// The name of the journal inside the worktree's administrative directory.
pub const JOURNAL_NAME: &str = "cli-git-convert";

/// The name of the lock file that serializes conversions of one worktree; it stays after a command ends, because removing a
///  lock file another command may have open would let two commands hold "the" lock.
pub const LOCK_NAME: &str = "cli-git-convert.lock";

/// The most bytes of a journal or a small registration file that are read.
const SMALL_FILE_LIMIT: u64 = 1 << 20;

/// The first line of every journal.
const JOURNAL_HEADER: &str = "cli-git-convert v1";

/// What:
///  The phase a conversion has reached, in order.
/// Why:
///   A rerun reads it to skip the finished phases and to decide whether an abandon is still possible.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Phase = 'started' | 'creating' | 'synced' | 'exchanged' | 'refreshed' | 'removed';
/// ```
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Phase {
    /// The journal and the index lock exist; nothing else.
    Started,
    /// The converting subvolume is being created, or exists.
    Creating,
    /// The copy was made and proved equal to the original; provisional until the swap, because a writer may act after it.
    Synced,
    /// The path names the subvolume; the old tree sits at the converting name.
    Exchanged,
    /// The index stat fields describe the files of the subvolume.
    Refreshed,
    /// The old tree is gone; only the journal and the lock remain.
    Removed,
}

impl Phase {
    /// The name written to the journal.
    fn name(self) -> &'static str {
        return match self {
            Phase::Started => "started",
            Phase::Creating => "creating",
            Phase::Synced => "synced",
            Phase::Exchanged => "exchanged",
            Phase::Refreshed => "refreshed",
            Phase::Removed => "removed",
        };
    }

    /// The phase a journal line names.
    fn parse(text: &str) -> Option<Phase> {
        return [Phase::Started, Phase::Creating, Phase::Synced, Phase::Exchanged, Phase::Refreshed, Phase::Removed]
            .into_iter()
            .find(|phase: &Phase| { return phase.name() == text; });
    }
}

/// What:
///  The journal of one conversion, kept in the worktree's administrative directory.
/// Why:
///   See the module description.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ConvertJournal = { phase: Phase; token: string; path: string; oldIdentity: ObjectId; gitFile: Uint8Array; newIdentity?: ObjectId };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Journal {
    /// The phase reached.
    pub phase: Phase,
    /// The token the index lock holds.
    pub token: String,
    /// The worktree path as raw bytes.
    pub path: Vec<u8>,
    /// The identity of the old root: device, inode and, where the filesystem gives one, the birth time, which tells the recorded
    ///  directory from another that later got the same inode number.
    pub old_identity: ObjectId,
    /// The exact bytes of the `.git` file.
    pub git_file: Vec<u8>,
    /// The identity of the subvolume this command created, recorded right after the creation; absent until then, so a
    ///  directory at the converting name that the journal has no identity for is foreign.
    pub new_identity: Option<ObjectId>,
}

/// The lowercase hexadecimal form of some bytes.
fn hex(bytes: &[u8]) -> String {
    return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
}

/// The bytes a hexadecimal string names.
fn unhex(text: &str) -> Option<Vec<u8>> {
    if text.len() % 2 != 0 {
        return None;
    }
    return (0..text.len()).step_by(2).map(|at: usize| { return u8::from_str_radix(text.get(at..at + 2)?, 16).ok(); }).collect();
}

/// A name for a temporary file that no other file has: this process and the clock make it, and the creation is exclusive.
fn temporary_name() -> String {
    let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
    return format!("{JOURNAL_NAME}.tmp.{}.{nanos}", std::process::id());
}

impl Journal {
    /// The journal text.
    fn encode(&self) -> String {
        return format!(
            "{JOURNAL_HEADER}\nphase={}\ntoken={}\npath={}\nold_identity={}\ngit_file={}\nnew_identity={}\n",
            self.phase.name(),
            self.token,
            hex(self.path.as_slice()),
            self.old_identity.encode(),
            hex(self.git_file.as_slice()),
            self.new_identity.map_or_else(|| { return String::from("-"); }, |identity: ObjectId| { return identity.encode(); })
        );
    }

    /// The journal a text describes.
    fn decode(text: &str) -> Result<Journal, MigrateError> {
        let bad = || { return MigrateError::Journal(String::from("the conversion journal has a shape this command does not know; nothing was changed")); };
        let mut lines = text.lines();
        if lines.next() != Some(JOURNAL_HEADER) {
            return Err(bad());
        }
        let field = |name: &str| -> Option<String> { return text.lines().find_map(|line: &str| { return line.strip_prefix(name).and_then(|rest: &str| { return rest.strip_prefix('='); }).map(String::from); }); };
        let new_identity: Option<ObjectId> = match field("new_identity").as_deref() {
            None | Some("-") => None,
            Some(encoded) => Some(ObjectId::decode(encoded).ok_or_else(bad)?),
        };
        // A journal written before the birth time was recorded names the old root by device and inode alone.
        let old_identity: ObjectId = match field("old_identity") {
            Some(encoded) => ObjectId::decode(encoded.as_str()).ok_or_else(bad)?,
            None => ObjectId {
                device: field("old_device").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
                inode: field("old_inode").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
                birth: None,
            },
        };
        return Ok(Journal {
            phase: Phase::parse(field("phase").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
            token: field("token").ok_or_else(bad)?,
            path: unhex(field("path").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
            old_identity,
            git_file: unhex(field("git_file").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
            new_identity,
        });
    }

    /// Write the journal into a file made for it alone: created exclusively, so nothing that is there is truncated or followed.
    fn write_temporary(&self, admin: &Path) -> std::io::Result<PathBuf> {
        let temporary: PathBuf = admin.join(temporary_name());
        let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&temporary)?;
        let written: std::io::Result<()> = file.write_all(self.encode().as_bytes()).and_then(|()| { return file.sync_all(); });
        if let Err(error) = written {
            let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
            return Err(error);
        }
        return Ok(temporary);
    }

    /// Replace the journal with this one: written beside it, synchronized, renamed into place.
    pub fn save(&self, admin: &Path) -> Result<(), MigrateError> {
        let target: PathBuf = admin.join(JOURNAL_NAME);
        let written: std::io::Result<()> = (|| {
            let temporary: PathBuf = self.write_temporary(admin)?;
            if let Err(error) = std::fs::rename(&temporary, &target) {
                let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
                return Err(error);
            }
            return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
        })();
        return written.map_err(|error: std::io::Error| { return MigrateError::io("write the conversion journal", target.as_path(), &error); });
    }

    /// Make this the journal of a conversion that has none: it fails when any journal exists, so a journal another command
    ///  made is never replaced.
    pub fn create_new(&self, admin: &Path) -> Result<(), MigrateError> {
        let target: PathBuf = admin.join(JOURNAL_NAME);
        let written: std::io::Result<()> = (|| {
            let temporary: PathBuf = self.write_temporary(admin)?;
            let linked: std::io::Result<()> = std::fs::hard_link(&temporary, &target);
            let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
            linked?;
            return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
        })();
        return written.map_err(|error: std::io::Error| { return MigrateError::io("create the conversion journal", target.as_path(), &error); });
    }

    /// The journal of an administrative directory, if one exists.
    pub fn load(admin: &Path) -> Result<Option<Journal>, MigrateError> {
        let path: PathBuf = admin.join(JOURNAL_NAME);
        match read_plain_file(path.as_path(), SMALL_FILE_LIMIT) {
            Ok(bytes) => return Journal::decode(String::from_utf8_lossy(bytes.as_slice()).as_ref()).map(Some),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(MigrateError::io("read the conversion journal", path.as_path(), &error)),
        }
    }
}

/// What:
///  The bytes of the plain file at `path`, opened without blocking and without following a link, refused when it is anything
///  else or larger than `limit`.
/// Why:
///   Opening a named pipe for reading waits for a writer, and a command that waits while it holds the conversion lock blocks every
///  other conversion; the type is read from the open handle, and the read is bounded.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function readPlainFile(path: string, limit: number): Buffer; // throws
/// ```
pub fn read_plain_file(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
    let file: std::fs::File = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
    if !file.metadata()?.file_type().is_file() {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "it is not a plain file (a named pipe, a device or a directory)"));
    }
    let mut bytes: Vec<u8> = Vec::new();
    std::io::Read::read_to_end(&mut std::io::Read::take(&file, limit + 1), &mut bytes)?;
    if bytes.len() as u64 > limit {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "it is larger than this command reads"));
    }
    return Ok(bytes);
}

/// The text of the plain file at `path`, read like `read_plain_file`.
pub fn read_plain_text(path: &Path) -> std::io::Result<String> {
    return String::from_utf8(read_plain_file(path, SMALL_FILE_LIMIT)?).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidData, "it is not text"); });
}

/// Whether `found` is the object the journal recorded as `recorded`: the same device and inode and, when a birth time was
///  recorded, the same birth time.
pub fn is_recorded_object(recorded: &ObjectId, found: &ObjectId) -> bool {
    return recorded.device == found.device && recorded.inode == found.inode && (recorded.birth.is_none() || recorded.birth == found.birth);
}

/// What:
///  The exclusive right to convert one worktree, held for as long as the value lives.
/// Why:
///   Two commands that both pass the preflight would otherwise both write the journal, share a token on resume and exchange
///  the directories back; the kernel drops the lock when the holder ends, however it ends.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// using lock = await ConversionLock.acquire(admin); // undefined when held elsewhere
/// ```
#[derive(Debug)]
pub struct ConversionLock {
    /// The open lock file; closing it releases the lock.
    _file: std::fs::File,
}

impl ConversionLock {
    /// Take the lock of `admin` without waiting; `Ok(None)` when another command holds it.
    pub fn acquire(admin: &Path) -> Result<Option<ConversionLock>, MigrateError> {
        let path: PathBuf = admin.join(LOCK_NAME);
        let file: std::fs::File = std::fs::OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .mode(0o600)
            .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC)
            .open(&path)
            .map_err(|error: std::io::Error| { return MigrateError::io("open the conversion lock", path.as_path(), &error); })?;
        let plain: bool = file.metadata().map_err(|error: std::io::Error| { return MigrateError::io("examine the conversion lock", path.as_path(), &error); })?.file_type().is_file();
        if !plain {
            return Err(MigrateError::Journal(format!("{} is not a plain file, so it cannot serve as the conversion lock; nothing was changed", path.display())));
        }
        // SAFETY: `flock` takes an open descriptor, which `file` keeps open for the call.
        let result: libc::c_int = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
        if result == 0 {
            return Ok(Some(ConversionLock { _file: file }));
        }
        let error: std::io::Error = std::io::Error::last_os_error();
        if error.kind() == std::io::ErrorKind::WouldBlock {
            return Ok(None);
        }
        return Err(MigrateError::io("take the conversion lock", path.as_path(), &error));
    }
}