use std::collections::{HashMap, HashSet};
use std::fs::{self, Permissions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use anyhow::{Context, Result as AnyResult, bail};
use atomic_write_file::AtomicWriteFile;
use serde::Serialize;
use crate::{ApplyDiffError, ApplyDiffMode, apply_diff_to_text, apply_diff_to_text_with_mode};
static PATH_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileChangeKind {
Added,
Updated,
Deleted,
Moved,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AppliedFile {
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_path: Option<PathBuf>,
pub kind: FileChangeKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FailedFile {
pub path: PathBuf,
pub related_paths: Vec<PathBuf>,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct ApplyDiffReport {
pub applied: Vec<AppliedFile>,
pub failed: Vec<FailedFile>,
}
impl ApplyDiffReport {
pub fn is_success(&self) -> bool {
self.failed.is_empty()
}
}
pub fn apply_diff(root: impl AsRef<Path>, patch: &str) -> Result<ApplyDiffReport, ApplyDiffError> {
let root = root.as_ref();
let metadata = fs::metadata(root).map_err(|error| {
ApplyDiffError::new(format!(
"Cannot access patch root {}: {error}",
root.display()
))
})?;
if !metadata.is_dir() {
return Err(ApplyDiffError::new(format!(
"Patch root is not a directory: {}",
root.display()
)));
}
let root = fs::canonicalize(root).map_err(|error| {
ApplyDiffError::new(format!(
"Cannot resolve patch root {}: {error}",
root.display()
))
})?;
let operations = parse_patch(patch)?;
let groups = group_operations(operations);
let mut report = ApplyDiffReport::default();
for group in groups {
let locks = path_locks(&root, &group.paths);
let _guards: Vec<_> = locks
.iter()
.map(|lock| lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))
.collect();
match apply_group(&root, &group) {
Ok(changes) => report.applied.extend(changes),
Err(error) => report.failed.push(FailedFile {
path: group.operations[0].source.clone(),
related_paths: sorted_paths(&group.paths),
message: format!("{error:#}"),
}),
}
}
Ok(report)
}
#[derive(Debug, Clone)]
enum OperationKind {
Add,
Update { destination: Option<PathBuf> },
Delete,
}
#[derive(Debug, Clone)]
struct Operation {
index: usize,
source: PathBuf,
body: String,
kind: OperationKind,
}
impl Operation {
fn paths(&self) -> Vec<PathBuf> {
let mut paths = vec![self.source.clone()];
if let OperationKind::Update {
destination: Some(destination),
} = &self.kind
{
paths.push(destination.clone());
}
paths
}
}
#[derive(Debug)]
struct OperationGroup {
operations: Vec<Operation>,
paths: HashSet<PathBuf>,
}
fn parse_patch(patch: &str) -> Result<Vec<Operation>, ApplyDiffError> {
let normalized = patch.replace("\r\n", "\n").replace('\r', "\n");
let mut lines: Vec<&str> = normalized.lines().collect();
while lines.first().is_some_and(|line| line.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
if lines.first().copied() != Some("*** Begin Patch") {
return Err(ApplyDiffError::new(
"Patch must start with `*** Begin Patch`",
));
}
if lines.last().copied() != Some("*** End Patch") {
return Err(ApplyDiffError::new("Patch must end with `*** End Patch`"));
}
let mut operations = Vec::new();
let mut cursor = 1;
while cursor + 1 < lines.len() {
if lines[cursor].trim().is_empty() {
cursor += 1;
continue;
}
let header = lines[cursor];
let (kind, path_text) = if let Some(path) = header.strip_prefix("*** Add File: ") {
("add", path)
} else if let Some(path) = header.strip_prefix("*** Update File: ") {
("update", path)
} else if let Some(path) = header.strip_prefix("*** Delete File: ") {
("delete", path)
} else {
return Err(ApplyDiffError::new(format!(
"Invalid patch header: {header}"
)));
};
let source = validate_relative_path(path_text)?;
cursor += 1;
let destination = if kind == "update" {
lines
.get(cursor)
.and_then(|line| {
line.strip_prefix("*** Move to: ")
.map(validate_relative_path)
})
.transpose()?
} else {
None
};
if destination.is_some() {
cursor += 1;
}
let body_start = cursor;
while cursor + 1 < lines.len() && !is_file_header(lines[cursor]) {
cursor += 1;
}
let body = lines[body_start..cursor].join("\n");
let kind = match kind {
"add" => OperationKind::Add,
"update" => OperationKind::Update { destination },
"delete" => {
if !body.trim().is_empty() {
return Err(ApplyDiffError::new(format!(
"Delete block for {} cannot contain a patch body",
source.display()
)));
}
OperationKind::Delete
}
_ => unreachable!(),
};
operations.push(Operation {
index: operations.len(),
source,
body,
kind,
});
}
if operations.is_empty() {
return Err(ApplyDiffError::new("Patch contains no file operations"));
}
Ok(operations)
}
fn is_file_header(line: &str) -> bool {
line.starts_with("*** Add File: ")
|| line.starts_with("*** Update File: ")
|| line.starts_with("*** Delete File: ")
|| line == "*** End Patch"
}
fn validate_relative_path(path: &str) -> Result<PathBuf, ApplyDiffError> {
let path = Path::new(path.trim());
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(ApplyDiffError::new(format!(
"Patch path must be a non-empty relative path: {}",
path.display()
)));
}
if path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(ApplyDiffError::new(format!(
"Patch path contains an unsafe component: {}",
path.display()
)));
}
Ok(path.to_path_buf())
}
fn group_operations(operations: Vec<Operation>) -> Vec<OperationGroup> {
let mut groups: Vec<OperationGroup> = Vec::new();
for operation in operations {
let operation_paths: HashSet<PathBuf> = operation.paths().into_iter().collect();
let matching: Vec<usize> = groups
.iter()
.enumerate()
.filter_map(|(index, group)| {
(!group.paths.is_disjoint(&operation_paths)).then_some(index)
})
.collect();
if matching.is_empty() {
groups.push(OperationGroup {
operations: vec![operation],
paths: operation_paths,
});
continue;
}
let target = matching[0];
groups[target].operations.push(operation);
groups[target].paths.extend(operation_paths);
for index in matching.into_iter().skip(1).rev() {
let merged = groups.remove(index);
groups[target].operations.extend(merged.operations);
groups[target].paths.extend(merged.paths);
}
groups[target]
.operations
.sort_by_key(|operation| operation.index);
}
groups.sort_by_key(|group| group.operations[0].index);
groups
}
#[derive(Clone)]
struct FileState {
text: String,
permissions: Option<Permissions>,
}
fn apply_group(root: &Path, group: &OperationGroup) -> AnyResult<Vec<AppliedFile>> {
let mut initial = HashMap::new();
for path in &group.paths {
initial.insert(path.clone(), read_file(root, path)?);
}
let mut current = initial.clone();
for operation in &group.operations {
match &operation.kind {
OperationKind::Add => {
if current.get(&operation.source).is_some_and(Option::is_some) {
bail!("File already exists: {}", operation.source.display());
}
let text = apply_diff_to_text_with_mode("", &operation.body, ApplyDiffMode::Create)
.map_err(anyhow::Error::new)?;
current.insert(
operation.source.clone(),
Some(FileState {
text,
permissions: None,
}),
);
}
OperationKind::Update { destination } => {
let Some(mut state) = current.get(&operation.source).cloned().flatten() else {
bail!("File does not exist: {}", operation.source.display());
};
state.text =
apply_diff_to_text(&state.text, &operation.body).map_err(anyhow::Error::new)?;
if let Some(destination) = destination {
if destination != &operation.source
&& current.get(destination).is_some_and(Option::is_some)
{
bail!("Move destination already exists: {}", destination.display());
}
current.insert(operation.source.clone(), None);
current.insert(destination.clone(), Some(state));
} else {
current.insert(operation.source.clone(), Some(state));
}
}
OperationKind::Delete => {
if !current.get(&operation.source).is_some_and(Option::is_some) {
bail!("File does not exist: {}", operation.source.display());
}
current.insert(operation.source.clone(), None);
}
}
}
let changes = describe_changes(group, &initial, ¤t);
commit_group(root, &initial, ¤t)?;
Ok(changes)
}
fn read_file(root: &Path, relative: &Path) -> AnyResult<Option<FileState>> {
ensure_safe_components(root, relative)?;
let path = root.join(relative);
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("Cannot inspect {}", relative.display()));
}
};
if metadata.file_type().is_symlink() {
bail!("Symbolic links are not patchable: {}", relative.display());
}
if !metadata.is_file() {
bail!("Path is not a regular file: {}", relative.display());
}
let bytes = fs::read(&path).with_context(|| format!("Cannot read {}", relative.display()))?;
let text = String::from_utf8(bytes)
.with_context(|| format!("File is not valid UTF-8: {}", relative.display()))?;
Ok(Some(FileState {
text,
permissions: Some(metadata.permissions()),
}))
}
fn ensure_safe_components(root: &Path, relative: &Path) -> AnyResult<()> {
let mut current = root.to_path_buf();
let component_count = relative.components().count();
for (index, component) in relative.components().enumerate() {
current.push(component.as_os_str());
let metadata = match fs::symlink_metadata(¤t) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error)
.with_context(|| format!("Cannot inspect {}", relative.display()));
}
};
if metadata.file_type().is_symlink() {
bail!("Symbolic links are not patchable: {}", relative.display());
}
if index + 1 < component_count && !metadata.is_dir() {
bail!("Path component is not a directory: {}", relative.display());
}
}
Ok(())
}
fn describe_changes(
group: &OperationGroup,
initial: &HashMap<PathBuf, Option<FileState>>,
current: &HashMap<PathBuf, Option<FileState>>,
) -> Vec<AppliedFile> {
let initial_present: Vec<_> = initial
.iter()
.filter_map(|(path, state)| state.is_some().then_some(path.clone()))
.collect();
let current_present: Vec<_> = current
.iter()
.filter_map(|(path, state)| state.is_some().then_some(path.clone()))
.collect();
let has_move = group.operations.iter().any(|operation| {
matches!(
operation.kind,
OperationKind::Update {
destination: Some(_)
}
)
});
if has_move && initial_present.len() == 1 && current_present.len() == 1 {
let previous = &initial_present[0];
let path = ¤t_present[0];
if previous != path {
return vec![AppliedFile {
path: path.clone(),
previous_path: Some(previous.clone()),
kind: FileChangeKind::Moved,
}];
}
}
let mut paths = sorted_paths(&group.paths);
let mut changes = Vec::new();
for path in paths.drain(..) {
match (
initial.get(&path).and_then(Option::as_ref),
current.get(&path).and_then(Option::as_ref),
) {
(None, Some(_)) => changes.push(AppliedFile {
path,
previous_path: None,
kind: FileChangeKind::Added,
}),
(Some(_), None) => changes.push(AppliedFile {
path,
previous_path: None,
kind: FileChangeKind::Deleted,
}),
(Some(before), Some(after)) if !states_equal(before, after) => {
changes.push(AppliedFile {
path,
previous_path: None,
kind: FileChangeKind::Updated,
})
}
_ => {}
}
}
changes
}
fn states_equal(left: &FileState, right: &FileState) -> bool {
left.text == right.text && permissions_equal(&left.permissions, &right.permissions)
}
fn optional_states_equal(left: Option<&FileState>, right: Option<&FileState>) -> bool {
match (left, right) {
(None, None) => true,
(Some(expected), Some(actual)) => {
expected.text == actual.text
&& (expected.permissions.is_none()
|| permissions_equal(&expected.permissions, &actual.permissions))
}
_ => false,
}
}
#[cfg(unix)]
fn permissions_equal(left: &Option<Permissions>, right: &Option<Permissions>) -> bool {
use std::os::unix::fs::PermissionsExt;
left.as_ref().map(PermissionsExt::mode) == right.as_ref().map(PermissionsExt::mode)
}
#[cfg(not(unix))]
fn permissions_equal(left: &Option<Permissions>, right: &Option<Permissions>) -> bool {
left.as_ref().map(Permissions::readonly) == right.as_ref().map(Permissions::readonly)
}
struct PreparedChange {
relative: PathBuf,
full: PathBuf,
initial: Option<FileState>,
final_state: Option<FileState>,
writer: Option<AtomicWriteFile>,
committed: bool,
}
fn commit_group(
root: &Path,
initial: &HashMap<PathBuf, Option<FileState>>,
current: &HashMap<PathBuf, Option<FileState>>,
) -> AnyResult<()> {
let mut changed_paths: Vec<PathBuf> = initial
.keys()
.filter(
|path| match (initial[*path].as_ref(), current[*path].as_ref()) {
(None, None) => false,
(Some(before), Some(after)) => !states_equal(before, after),
_ => true,
},
)
.cloned()
.collect();
changed_paths.sort_by(|left, right| {
current[left]
.is_none()
.cmp(¤t[right].is_none())
.then_with(|| left.cmp(right))
});
if changed_paths.is_empty() {
return Ok(());
}
let mut created_directories = Vec::new();
for relative in &changed_paths {
if current[relative].is_some()
&& let Err(error) = create_parent_directories(root, relative, &mut created_directories)
{
remove_created_directories(&created_directories);
return Err(error);
}
}
let mut prepared = Vec::new();
for relative in changed_paths {
let full = root.join(&relative);
let writer = match current[&relative].as_ref() {
Some(state) => match prepare_atomic_write(&full, state) {
Ok(writer) => Some(writer),
Err(error) => {
drop(prepared);
remove_created_directories(&created_directories);
return Err(error)
.with_context(|| format!("Cannot prepare {}", relative.display()));
}
},
None => None,
};
prepared.push(PreparedChange {
relative: relative.clone(),
full,
initial: initial[&relative].clone(),
final_state: current[&relative].clone(),
writer,
committed: false,
});
}
for index in 0..prepared.len() {
if let Err(error) = verify_unchanged(
root,
&prepared[index].relative,
prepared[index].initial.as_ref(),
)
.and_then(|()| commit_one(&mut prepared[index]))
{
let rollback_errors = rollback_prepared(root, &mut prepared, &created_directories);
let mut error = error.context(format!(
"Cannot commit {}",
prepared[index].relative.display()
));
if !rollback_errors.is_empty() {
error = error.context(format!(
"Rollback conflicts: {}",
rollback_errors.join("; ")
));
}
return Err(error);
}
}
Ok(())
}
fn create_parent_directories(
root: &Path,
relative: &Path,
created: &mut Vec<PathBuf>,
) -> AnyResult<()> {
let Some(parent) = relative.parent() else {
return Ok(());
};
let mut current = root.to_path_buf();
for component in parent.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
Ok(_) => bail!("Unsafe parent directory: {}", relative.display()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(¤t)
.with_context(|| format!("Cannot create {}", current.display()))?;
created.push(current.clone());
}
Err(error) => {
return Err(error).with_context(|| format!("Cannot inspect {}", current.display()));
}
}
}
Ok(())
}
fn prepare_atomic_write(target: &Path, state: &FileState) -> AnyResult<AtomicWriteFile> {
let mut file = AtomicWriteFile::open(target)?;
file.write_all(state.text.as_bytes())?;
if let Some(permissions) = &state.permissions {
file.set_permissions(permissions.clone())?;
}
file.sync_all()?;
Ok(file)
}
fn verify_unchanged(root: &Path, relative: &Path, expected: Option<&FileState>) -> AnyResult<()> {
ensure_safe_components(root, relative)?;
let actual = read_file(root, relative)?;
let unchanged = optional_states_equal(expected, actual.as_ref());
if !unchanged {
bail!(
"File changed concurrently while the patch was being prepared: {}",
relative.display()
);
}
Ok(())
}
fn commit_one(change: &mut PreparedChange) -> AnyResult<()> {
change.committed = true;
match change.writer.take() {
Some(writer) => writer.commit()?,
None => remove_file_durably(&change.full)?,
}
Ok(())
}
fn remove_file_durably(path: &Path) -> std::io::Result<()> {
fs::remove_file(path)?;
sync_parent_directory(path)
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
use std::fs::File;
File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn rollback_prepared(
root: &Path,
prepared: &mut [PreparedChange],
created_directories: &[PathBuf],
) -> Vec<String> {
let mut errors = Vec::new();
for change in prepared.iter_mut().filter(|change| !change.committed) {
drop(change.writer.take());
}
for change in prepared.iter_mut().rev().filter(|change| change.committed) {
let actual = match read_file(root, &change.relative) {
Ok(actual) => actual,
Err(error) => {
errors.push(error.to_string());
continue;
}
};
if optional_states_equal(change.initial.as_ref(), actual.as_ref()) {
continue;
}
if !optional_states_equal(change.final_state.as_ref(), actual.as_ref()) {
errors.push(format!(
"{} changed concurrently during rollback",
change.relative.display()
));
continue;
}
let result = match &change.initial {
Some(initial) => prepare_atomic_write(&change.full, initial)
.and_then(|writer| writer.commit().map_err(anyhow::Error::new)),
None => remove_file_durably(&change.full).map_err(anyhow::Error::new),
};
if let Err(error) = result {
errors.push(format!("{}: {error:#}", change.relative.display()));
}
}
remove_created_directories(created_directories);
errors
}
fn remove_created_directories(paths: &[PathBuf]) {
for path in paths.iter().rev() {
let _ = fs::remove_dir(path);
}
}
fn path_locks(root: &Path, paths: &HashSet<PathBuf>) -> Vec<Arc<Mutex<()>>> {
let registry = PATH_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut registry = registry
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
registry.retain(|_, lock| lock.strong_count() > 0);
let mut keys: Vec<PathBuf> = paths.iter().map(|path| root.join(path)).collect();
keys.sort();
keys.into_iter()
.map(|key| {
if let Some(lock) = registry.get(&key).and_then(Weak::upgrade) {
return lock;
}
let lock = Arc::new(Mutex::new(()));
registry.insert(key, Arc::downgrade(&lock));
lock
})
.collect()
}
fn sorted_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
let mut paths: Vec<_> = paths.iter().cloned().collect();
paths.sort();
paths
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connected_operations_are_grouped_transitively() {
let operations = vec![
Operation {
index: 0,
source: PathBuf::from("a"),
body: String::new(),
kind: OperationKind::Update {
destination: Some(PathBuf::from("b")),
},
},
Operation {
index: 1,
source: PathBuf::from("c"),
body: String::new(),
kind: OperationKind::Update {
destination: Some(PathBuf::from("d")),
},
},
Operation {
index: 2,
source: PathBuf::from("b"),
body: String::new(),
kind: OperationKind::Update {
destination: Some(PathBuf::from("c")),
},
},
];
let groups = group_operations(operations);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].operations.len(), 3);
}
#[test]
fn unspecified_creation_permissions_match_the_committed_file() {
let expected = FileState {
text: "content".to_owned(),
permissions: None,
};
let actual = FileState {
text: "content".to_owned(),
permissions: Some(fs::metadata(".").unwrap().permissions()),
};
assert!(optional_states_equal(Some(&expected), Some(&actual)));
}
#[test]
fn rollback_removes_a_committed_add_with_os_assigned_permissions() {
let root = tempfile::tempdir().unwrap();
let relative = PathBuf::from("added.txt");
let full = root.path().join(&relative);
fs::write(&full, "content").unwrap();
let mut prepared = vec![PreparedChange {
relative,
full: full.clone(),
initial: None,
final_state: Some(FileState {
text: "content".to_owned(),
permissions: None,
}),
writer: None,
committed: true,
}];
let errors = rollback_prepared(root.path(), &mut prepared, &[]);
assert!(errors.is_empty(), "{errors:?}");
assert!(!full.exists());
}
}