use crate::json_record::quote;
use crate::private_storage::{sync_directory, write_exclusive_scratch, write_exclusive_synced};
use crate::random_id::random_uuid;
use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure, raw_failure_of};
use crate::worktree_copy_journal_encoding_and_validation::{JOURNAL_PARENT_NAME, JOURNAL_SUFFIX, JOURNAL_VERSION_NAME, Journal, PendingJournal, Phase, TEMPORARY_SUFFIX, encode_journal, parse_journal};
use crate::worktree_copy_owner::{CopyOwner, current_copy_owner, token_owner_is_dead};
use crate::worktree_copy_selection::by_js_order;
use crate::worktree_copy_stage::StagedSnapshot;
use std::path::{Path, PathBuf};
const NON_PRIVATE_MODE_BITS: u32 = 0o077;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Role {
JournalDirectory,
JournalFile,
PrivateStage,
}
impl Role {
fn name(self) -> &'static str {
match self {
Role::JournalDirectory => return "journal directory",
Role::JournalFile => return "journal file",
Role::PrivateStage => return "private stage",
}
}
}
pub fn assert_private_path(path: &Path, role: Role) -> Result<(), WorktreeCopyError> {
use std::os::unix::fs::MetadataExt;
let metadata: std::fs::Metadata =
std::fs::symlink_metadata(path).map_err(raw_failure_of)?;
let shape: bool = match role {
Role::JournalFile => metadata.is_file() && metadata.nlink() == 1,
Role::JournalDirectory | Role::PrivateStage => metadata.is_dir(),
};
let effective_user: u32 = unsafe { libc::geteuid() };
let canonical: bool = std::fs::canonicalize(path)
.map_err(raw_failure_of)?
.as_path()
== path;
if !shape
|| (metadata.mode() & NON_PRIVATE_MODE_BITS) != 0
|| metadata.uid() != effective_user
|| !canonical
{
return Err(failure_with_cause(
format!(
"monochromatic-git-policy-cli: worktree-copy {} is unsafe: {}.",
role.name(),
quote(path.to_string_lossy().as_ref())
)
.as_str(),
None,
));
}
return Ok(());
}
pub fn journal_root(common_dir: &Path) -> PathBuf {
return common_dir
.join(JOURNAL_PARENT_NAME)
.join(JOURNAL_VERSION_NAME);
}
fn ensure_private_directory(path: &Path) -> Result<(), WorktreeCopyError> {
use std::os::unix::fs::DirBuilderExt;
match std::fs::DirBuilder::new().mode(0o700).create(path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(raw_failure(&error)),
}
return assert_private_path(path, Role::JournalDirectory);
}
pub fn ensure_journal_root(common_dir: &Path) -> Result<PathBuf, WorktreeCopyError> {
ensure_private_directory(common_dir.join(JOURNAL_PARENT_NAME).as_path())?;
let root: PathBuf = journal_root(common_dir);
ensure_private_directory(root.as_path())?;
return Ok(root);
}
pub fn write_journal(path: &Path, journal: &Journal) -> Result<(), WorktreeCopyError> {
return write_atomic(path, encode_journal(journal).as_bytes(), "journal");
}
pub fn temporary_path(path: &Path, owner: &CopyOwner, unique: &str) -> PathBuf {
return PathBuf::from(format!("{}.{}.{unique}{TEMPORARY_SUFFIX}", path.to_string_lossy(), owner.token()));
}
pub fn temporary_writer_token(file_name: &str) -> Option<&str> {
let stem: &str = file_name.strip_suffix(TEMPORARY_SUFFIX)?;
let (rest, _unique) = stem.rsplit_once('.')?;
let (_item, token) = rest.rsplit_once('.')?;
return Some(token);
}
pub fn write_atomic(path: &Path, bytes: &[u8], subject: &str) -> Result<(), WorktreeCopyError> {
return write_atomic_flushing(path, bytes, subject, Flush::ToDisk);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Flush {
ToDisk,
Never,
}
pub fn write_atomic_unsynced(path: &Path, bytes: &[u8], subject: &str) -> Result<(), WorktreeCopyError> {
return write_atomic_flushing(path, bytes, subject, Flush::Never);
}
fn write_atomic_flushing(path: &Path, bytes: &[u8], subject: &str, flush: Flush) -> Result<(), WorktreeCopyError> {
let unique: String = match random_uuid() {
Ok(made) => made,
Err(error) => {
return Err(failure_with_cause(
format!(
"monochromatic-git-policy-cli: could not persist worktree-copy {subject} {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
Some(&error.0),
));
}
};
let temporary: PathBuf = temporary_path(path, ¤t_copy_owner()?, unique.as_str());
let written: std::io::Result<()> = match flush {
Flush::ToDisk => write_exclusive_synced(temporary.as_path(), bytes),
Flush::Never => write_exclusive_scratch(temporary.as_path(), bytes),
};
let persisted: std::io::Result<()> = match written {
Ok(()) => publish_written(temporary.as_path(), path, flush),
Err(error) => Err(error),
};
if let Err(error) = persisted {
let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
return Err(failure_with_cause(
format!(
"monochromatic-git-policy-cli: could not persist worktree-copy {subject} {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
Some(&error),
));
}
return Ok(());
}
pub fn create_journal(
common_dir: &Path,
destination_root: &Path,
snapshot: &StagedSnapshot,
) -> Result<PendingJournal, WorktreeCopyError> {
return create_journal_for(common_dir, destination_root, snapshot, ¤t_copy_owner()?);
}
pub fn create_journal_for(
common_dir: &Path,
destination_root: &Path,
snapshot: &StagedSnapshot,
owner: &CopyOwner,
) -> Result<PendingJournal, WorktreeCopyError> {
let root: PathBuf = ensure_journal_root(common_dir)?;
let unique: String = match random_uuid() {
Ok(made) => made,
Err(error) => return Err(raw_failure(&error.0)),
};
let path: PathBuf = root.join(format!("{unique}.{}{JOURNAL_SUFFIX}", owner.token()));
let journal: Journal = Journal {
created_entries: Vec::new(),
intended_entries: Vec::new(),
destination_root: destination_root.to_string_lossy().into_owned(),
phase: Phase::Staged,
selected_roots: snapshot.selected_roots.clone(),
source_root: snapshot.source_root.to_string_lossy().into_owned(),
stage_container: snapshot.stage_container.to_string_lossy().into_owned(),
stage_root: snapshot.stage_root.to_string_lossy().into_owned(),
};
write_journal(path.as_path(), &journal)?;
return Ok(PendingJournal { path, journal });
}
pub fn adopt_journal(pending: &PendingJournal, owner: &CopyOwner) -> Result<Option<PendingJournal>, WorktreeCopyError> {
let Some(parent) = pending.path.parent() else {
return Ok(None);
};
let name: String = pending.path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let identifier: &str = name.split('.').next().unwrap_or_default();
let adopted: PathBuf = parent.join(format!("{identifier}.{}{JOURNAL_SUFFIX}", owner.token()));
match std::fs::rename(pending.path.as_path(), adopted.as_path()) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(raw_failure(&error)),
}
sync_directory(parent).map_err(raw_failure_of)?;
return Ok(Some(PendingJournal { path: adopted, journal: pending.journal.clone() }));
}
pub fn journal_owner_is_dead(pending: &PendingJournal) -> Result<bool, WorktreeCopyError> {
return token_owner_is_dead(pending.owner_token().as_deref(), pending.path.as_path());
}
fn load_listed_journal(path: &Path, root: &Path) -> Result<Journal, WorktreeCopyError> {
assert_private_path(path, Role::JournalFile)?;
let text: String = read_journal_text(path)?;
return parse_journal(text.as_str(), path, root);
}
fn publish_written(temporary: &Path, path: &Path, flush: Flush) -> std::io::Result<()> {
std::fs::rename(temporary, path)?;
return match (flush, path.parent()) {
(Flush::ToDisk, Some(parent)) => sync_directory(parent),
_ => Ok(()),
};
}
fn read_listed_journal(root: &Path, name: &str) -> Result<Option<PendingJournal>, WorktreeCopyError> {
let path: PathBuf = root.join(name);
let loaded: Result<Journal, WorktreeCopyError> = load_listed_journal(path.as_path(), root);
match loaded {
Ok(journal) => return Ok(Some(PendingJournal { path, journal })),
Err(error) => {
let vanished: bool = matches!(std::fs::symlink_metadata(path.as_path()), Err(ref lookup) if lookup.kind() == std::io::ErrorKind::NotFound);
if vanished {
return Ok(None);
}
return Err(error);
}
}
}
pub fn read_pending_journals(common_dir: &Path) -> Result<Vec<PendingJournal>, WorktreeCopyError> {
let root: PathBuf = journal_root(common_dir);
match std::fs::symlink_metadata(root.as_path()) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(raw_failure(&error)),
}
assert_private_path(
root.parent().unwrap_or(root.as_path()),
Role::JournalDirectory,
)?;
assert_private_path(root.as_path(), Role::JournalDirectory)?;
let mut names: Vec<String> = Vec::new();
let listing = match std::fs::read_dir(root.as_path()) {
Ok(found) => found,
Err(error) => {
let message: String = format!(
"monochromatic-git-policy-cli: could not inspect worktree-copy journals under {}.",
quote(root.to_string_lossy().as_ref())
);
return Err(failure_with_cause(message.as_str(), Some(&error)));
}
};
for listed in listing {
let entry: std::fs::DirEntry = listed.map_err(raw_failure_of)?;
let name: String = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(JOURNAL_SUFFIX) {
names.push(name);
}
}
names.sort_by(by_js_order);
let mut pending: Vec<PendingJournal> = Vec::new();
for name in names {
if let Some(found) = read_listed_journal(root.as_path(), name.as_str())? {
pending.push(found);
}
}
return Ok(pending);
}
fn read_journal_text(path: &Path) -> Result<String, WorktreeCopyError> {
let bytes: Vec<u8> = match std::fs::read(path) {
Ok(read) => read,
Err(error) => {
let message: String = format!(
"monochromatic-git-policy-cli: could not read worktree-copy journal {}.",
quote(path.to_string_lossy().as_ref())
);
return Err(failure_with_cause(message.as_str(), Some(&error)));
}
};
return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
}
#[cfg(test)]
#[path = "worktree_copy_journal_tests.rs"]
mod tests;