use crate::json_record::quote;
use crate::parallel_budget::{HostResources, sliced_workers};
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};
use crate::worktree_copy_install_entry::InstalledPath;
use crate::worktree_copy_owner::{CopyOwner, current_copy_owner, token_owner_is_dead};
use crate::worktree_copy_selection::{STAGE_PREFIX, assert_safe_repository_path, js_order};
use crate::worktree_copy_stage::{PARALLEL_COPY_MAX_WORKERS, StagedSnapshot};
use std::path::{Path, PathBuf};
pub const JOURNAL_PARENT_NAME: &str = "cli-git-worktree-copy";
pub const JOURNAL_VERSION_NAME: &str = "v1";
const JOURNAL_SUFFIX: &str = ".json";
pub const TEMPORARY_SUFFIX: &str = ".tmp";
const NON_PRIVATE_MODE_BITS: u32 = 0o077;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Phase {
Staged,
Installing,
Complete,
}
impl Phase {
fn as_str(self) -> &'static str {
match self {
Phase::Staged => return "staged",
Phase::Installing => return "installing",
Phase::Complete => return "complete",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Journal {
pub created_entries: Vec<InstalledPath>,
pub intended_entries: Vec<String>,
pub destination_root: String,
pub phase: Phase,
pub selected_roots: Vec<String>,
pub source_root: String,
pub stage_container: String,
pub stage_root: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PendingJournal {
pub path: PathBuf,
pub journal: Journal,
}
impl PendingJournal {
pub fn owner_token(&self) -> Option<String> {
return owner_token_of(self.path.as_path());
}
}
pub fn owner_token_of(path: &Path) -> Option<String> {
let name: String = path.file_name()?.to_string_lossy().into_owned();
let stem: &str = name.strip_suffix(JOURNAL_SUFFIX)?;
let (_identifier, token) = stem.rsplit_once('.')?;
return Some(token.to_string());
}
pub fn encode_journal(journal: &Journal) -> String {
let created: Vec<String> = journal
.created_entries
.iter()
.map(encode_installed_entry)
.collect();
return format!(
"{{\"createdEntries\":[{}],\"intendedEntries\":{},\"destinationRoot\":{},\"phase\":{},\"selectedRoots\":{},\"sourceRoot\":{},\"stageContainer\":{},\"stageRoot\":{},\"version\":1}}\n",
created.join(","),
string_array(journal.intended_entries.as_slice()),
quote(journal.destination_root.as_str()),
quote(journal.phase.as_str()),
string_array(journal.selected_roots.as_slice()),
quote(journal.source_root.as_str()),
quote(journal.stage_container.as_str()),
quote(journal.stage_root.as_str()),
);
}
pub fn encode_installed_entry(entry: &InstalledPath) -> String {
return format!(
"{{\"device\":\"{}\",\"inode\":\"{}\",\"relativePath\":{},\"selected\":{}}}",
entry.device,
entry.inode,
quote(entry.relative_path.as_str()),
entry.selected
);
}
pub fn string_array(items: &[String]) -> String {
let quoted: Vec<String> = items
.iter()
.map(|item| return quote(item.as_str()))
.collect();
return format!("[{}]", quoted.join(","));
}
fn is_canonical_absolute(value: &str) -> bool {
if !value.starts_with('/') {
return false;
}
if value == "/" {
return true;
}
return value[1..]
.split('/')
.all(|component| return !component.is_empty() && component != "." && component != "..");
}
fn decimal_identity(value: &serde_json::Value) -> Option<u64> {
let text: &str = value.as_str()?;
if text.is_empty() || (text.len() > 1 && text.starts_with('0')) {
return None;
}
if !text.bytes().all(|byte| return byte.is_ascii_digit()) {
return None;
}
return text.parse::<u64>().ok();
}
pub fn installed_entry(value: &serde_json::Value) -> Option<InstalledPath> {
let object = value.as_object()?;
return Some(InstalledPath {
device: decimal_identity(object.get("device")?)?,
inode: decimal_identity(object.get("inode")?)?,
relative_path: object.get("relativePath")?.as_str()?.to_string(),
selected: object.get("selected")?.as_bool()?,
});
}
fn string_list(value: Option<&serde_json::Value>) -> Option<Vec<String>> {
let array = value?.as_array()?;
return array
.iter()
.map(|item| return item.as_str().map(str::to_string))
.collect();
}
fn read_shape(value: &serde_json::Value) -> Option<Journal> {
let object = value.as_object()?;
if object.get("version")?.as_f64()? != 1.0 {
return None;
}
let phase: Phase = match object.get("phase")?.as_str()? {
"staged" => Phase::Staged,
"installing" => Phase::Installing,
"complete" => Phase::Complete,
_ => return None,
};
let created_entries: Vec<InstalledPath> = object
.get("createdEntries")?
.as_array()?
.iter()
.map(installed_entry)
.collect::<Option<Vec<InstalledPath>>>()?;
return Some(Journal {
created_entries,
intended_entries: string_list(object.get("intendedEntries"))?,
destination_root: object.get("destinationRoot")?.as_str()?.to_string(),
phase,
selected_roots: string_list(object.get("selectedRoots"))?,
source_root: object.get("sourceRoot")?.as_str()?.to_string(),
stage_container: object.get("stageContainer")?.as_str()?.to_string(),
stage_root: object.get("stageRoot")?.as_str()?.to_string(),
});
}
pub fn parse_journal(
text: &str,
path: &Path,
journal_root: &Path,
) -> Result<Journal, WorktreeCopyError> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|error| {
let message: String = format!(
"cli-git: could not read worktree-copy journal {}.",
quote(path.to_string_lossy().as_ref())
);
return failure_with_cause(message.as_str(), Some(&error));
})?;
let Some(journal) = read_shape(&value) else {
return Err(corrupt(path));
};
if path.parent() != Some(journal_root) {
return Err(failure_with_cause(
format!(
"cli-git: worktree-copy journal escaped private root: {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
None,
));
}
for (field, field_value) in [
("destinationRoot", journal.destination_root.as_str()),
("sourceRoot", journal.source_root.as_str()),
("stageContainer", journal.stage_container.as_str()),
("stageRoot", journal.stage_root.as_str()),
] {
if !is_canonical_absolute(field_value) {
return Err(failure_with_cause(
format!(
"cli-git: worktree-copy journal has unsafe {field}: {}.",
quote(field_value)
)
.as_str(),
None,
));
}
}
let stage_relation: bool = Path::new(journal.stage_container.as_str()).parent()
== Path::new(journal.destination_root.as_str()).parent()
&& Path::new(journal.stage_container.as_str())
.file_name()
.is_some_and(|name| return name.to_string_lossy().starts_with(STAGE_PREFIX))
&& journal.stage_root == format!("{}/payload", journal.stage_container);
if !stage_relation {
return Err(failure_with_cause(
format!(
"cli-git: worktree-copy journal has unsafe private stage relation: {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
None,
));
}
for selected in &journal.selected_roots {
assert_safe_repository_path(selected)?;
}
for intended in &journal.intended_entries {
assert_safe_repository_path(intended)?;
}
for created in &journal.created_entries {
assert_safe_repository_path(created.relative_path.as_str())?;
}
let created_paths: Vec<&str> = journal
.created_entries
.iter()
.map(|entry| return entry.relative_path.as_str())
.collect();
let duplicated: bool = has_duplicates(journal.selected_roots.as_slice())
|| has_duplicates(journal.intended_entries.as_slice())
|| has_duplicates(created_paths.as_slice());
if duplicated {
return Err(failure_with_cause(
format!(
"cli-git: worktree-copy journal contains duplicate repository paths: {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
None,
));
}
return Ok(journal);
}
fn has_duplicates<T: AsRef<str>>(items: &[T]) -> bool {
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
return items.iter().any(|item| return !seen.insert(item.as_ref()));
}
fn corrupt(path: &Path) -> WorktreeCopyError {
return failure_with_cause(
format!(
"cli-git: worktree-copy journal is corrupt: {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
None,
);
}
#[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(|error| return raw_failure(&error))?;
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(|error| return raw_failure(&error))?
.as_path()
== path;
if !shape
|| (metadata.mode() & NON_PRIVATE_MODE_BITS) != 0
|| metadata.uid() != effective_user
|| !canonical
{
return Err(failure_with_cause(
format!(
"cli-git: 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 = random_uuid().map_err(|error| {
return failure_with_cause(
format!(
"cli-git: 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<()> =
written
.and_then(|()| return std::fs::rename(temporary.as_path(), path))
.and_then(|()| match (flush, path.parent()) {
(Flush::ToDisk, Some(parent)) => return sync_directory(parent),
_ => return Ok(()),
});
if let Err(error) = persisted {
let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
return Err(failure_with_cause(
format!(
"cli-git: 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 = random_uuid().map_err(|error| return 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(|error| return raw_failure(&error))?;
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 read_listed_journal(root: &Path, name: &str) -> Result<Option<PendingJournal>, WorktreeCopyError> {
let path: PathBuf = root.join(name);
let loaded: Result<Journal, WorktreeCopyError> = (|| {
assert_private_path(path.as_path(), Role::JournalFile)?;
let text: String = read_journal_text(path.as_path())?;
return parse_journal(text.as_str(), 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 = std::fs::read_dir(root.as_path()).map_err(|error| {
let message: String = format!(
"cli-git: could not inspect worktree-copy journals under {}.",
quote(root.to_string_lossy().as_ref())
);
return failure_with_cause(message.as_str(), Some(&error));
})?;
for listed in listing {
let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
let name: String = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(JOURNAL_SUFFIX) {
names.push(name);
}
}
names.sort_by(|left, right| return js_order(left, right));
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> = std::fs::read(path).map_err(|error| {
let message: String = format!(
"cli-git: could not read worktree-copy journal {}.",
quote(path.to_string_lossy().as_ref())
);
return failure_with_cause(message.as_str(), Some(&error));
})?;
return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
}
pub fn prepare_stage_removal(stage_container: &Path) -> Result<(), WorktreeCopyError> {
use std::os::unix::fs::PermissionsExt;
let mut pending: Vec<PathBuf> = vec![stage_container.to_path_buf()];
while let Some(directory) = pending.pop() {
let metadata: std::fs::Metadata = match std::fs::symlink_metadata(directory.as_path()) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(raw_failure(&error)),
};
if !metadata.is_dir() {
continue;
}
std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o700))
.map_err(|error| return raw_failure(&error))?;
for listed in
std::fs::read_dir(directory.as_path()).map_err(|error| return raw_failure(&error))?
{
let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
let child_type: std::fs::FileType = child
.file_type()
.map_err(|error| return raw_failure(&error))?;
if child_type.is_dir() {
pending.push(child.path());
}
}
}
return Ok(());
}
pub fn remove_stage(stage_container: &Path) -> Result<(), WorktreeCopyError> {
#[cfg(target_os = "linux")]
if crate::fs_kind::is_subvolume_root(stage_container).unwrap_or(false) && crate::fs_btrfs_ioctl::subvolume_destroy_at(stage_container).is_ok() {
return Ok(());
}
prepare_stage_removal(stage_container)?;
return remove_tree_if_present(stage_container);
}
pub fn remove_pending_journal(pending: &PendingJournal) -> Result<(), WorktreeCopyError> {
if pending.journal.phase != Phase::Complete {
let completed: Journal = Journal {
phase: Phase::Complete,
..pending.journal.clone()
};
write_journal(pending.path.as_path(), &completed)?;
}
remove_stage(Path::new(pending.journal.stage_container.as_str()))?;
remove_file_if_present(pending.path.as_path())?;
remove_unfinished_writes(pending.path.as_path())?;
if let Some(parent) = pending.path.parent() {
sync_directory(parent).map_err(|error| return raw_failure(&error))?;
}
return Ok(());
}
const REMOVAL_SPLIT_DEPTH: usize = 3;
fn remove_tree_split(path: &Path) -> std::io::Result<()> {
let mut shallow: Vec<PathBuf> = vec![path.to_path_buf()];
let mut level: Vec<PathBuf> = vec![path.to_path_buf()];
let mut subtrees: Vec<PathBuf> = Vec::new();
for depth in 0..REMOVAL_SPLIT_DEPTH {
let mut next: Vec<PathBuf> = Vec::new();
for directory in &level {
for listed in std::fs::read_dir(directory)? {
let entry: std::fs::DirEntry = listed?;
let child: PathBuf = entry.path();
if !entry.file_type()?.is_dir() {
std::fs::remove_file(child.as_path())?;
} else if depth + 1 == REMOVAL_SPLIT_DEPTH {
subtrees.push(child);
} else {
shallow.push(child.clone());
next.push(child);
}
}
}
level = next;
}
let workers: usize = sliced_workers(usize::MAX, PARALLEL_COPY_MAX_WORKERS, &HostResources::read());
let cursor: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let outcomes: Vec<std::io::Result<()>> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_worker| {
return scope.spawn(|| -> std::io::Result<()> {
loop {
let index: usize = cursor.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(subtree) = subtrees.get(index) else {
return Ok(());
};
std::fs::remove_dir_all(subtree.as_path())?;
}
});
})
.collect();
return handles
.into_iter()
.map(|handle| return handle.join().unwrap_or_else(|_panic| return Err(std::io::Error::other("a removal worker panicked"))))
.collect();
});
for outcome in outcomes {
outcome?;
}
for directory in shallow.iter().rev() {
std::fs::remove_dir(directory.as_path())?;
}
return Ok(());
}
fn remove_tree_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
match remove_tree_split(path).or_else(|_split_failure| return std::fs::remove_dir_all(path)) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(raw_failure(&error)),
}
}
pub fn remove_file_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
match std::fs::remove_file(path) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(raw_failure(&error)),
}
}
fn remove_unfinished_writes(path: &Path) -> Result<(), WorktreeCopyError> {
let Some(parent) = path.parent() else {
return Ok(());
};
let prefix: String = format!(
"{}.",
path.file_name().unwrap_or_default().to_string_lossy()
);
for listed in std::fs::read_dir(parent).map_err(|error| return raw_failure(&error))? {
let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
let name: String = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(prefix.as_str()) && name.ends_with(TEMPORARY_SUFFIX) {
remove_file_if_present(entry.path().as_path())?;
}
}
return Ok(());
}
#[cfg(test)]
#[path = "worktree_copy_journal_tests.rs"]
mod tests;