use anyhow::{Context, Result, anyhow, bail};
use std::{
borrow::Cow,
fs,
io::Write,
path::{Path, PathBuf},
};
use tempfile::{NamedTempFile, TempPath};
pub struct WritePlan<'a> {
pub path: PathBuf,
pub original: Cow<'a, [u8]>,
pub replacement: Cow<'a, [u8]>,
}
struct Prepared<'a> {
plan: WritePlan<'a>,
temporary: Option<TempPath>,
backup: PathBuf,
}
pub fn apply_transaction(plans: Vec<WritePlan<'_>>) -> Result<()> {
if plans.is_empty() {
return Ok(());
}
let mut prepared = Vec::with_capacity(plans.len());
for (sequence, plan) in plans.into_iter().enumerate() {
reject_symlink(&plan.path, "transaction preparation")?;
let current = fs::read(&plan.path)
.with_context(|| format!("cannot re-read {}", plan.path.display()))?;
if current != plan.original.as_ref() {
bail!(
"{} changed while it was being checked; no files were modified",
plan.path.display()
);
}
let parent = parent_directory(&plan.path);
let mut temporary = NamedTempFile::new_in(parent).with_context(|| {
format!(
"cannot create a temporary file beside {}",
plan.path.display()
)
})?;
temporary.write_all(plan.replacement.as_ref())?;
let permissions = fs::metadata(&plan.path)?.permissions();
temporary.as_file().set_permissions(permissions)?;
temporary.as_file_mut().sync_all()?;
let temporary = temporary.into_temp_path();
let name = plan
.path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source");
let backup = parent.join(format!(
".{name}.ocomment-rollback-{}-{sequence}",
std::process::id()
));
if backup.symlink_metadata().is_ok() {
bail!(
"rollback path {} already exists; a previous ocomment run may have been \
interrupted — inspect and delete it before retrying",
backup.display()
);
}
prepared.push(Prepared {
plan,
temporary: Some(temporary),
backup,
});
}
for index in 0..prepared.len() {
if let Err(error) = commit_one(&mut prepared[index]) {
let failed_path = prepared[index].plan.path.clone();
let rollback_error = rollback(&prepared[..=index]);
return Err(match rollback_error {
Ok(()) => anyhow!(
"transaction failed at {} and was rolled back: {error}",
failed_path.display()
),
Err(rollback) => anyhow!(
"transaction failed at {}; rollback also failed: {error}; {rollback}",
failed_path.display()
),
});
}
}
for item in &prepared {
fs::remove_file(&item.backup)
.with_context(|| format!("cannot remove rollback journal {}", item.backup.display()))?;
sync_parent(&item.plan.path)?;
}
Ok(())
}
fn commit_one(item: &mut Prepared<'_>) -> Result<()> {
reject_symlink(&item.plan.path, "transaction commit")?;
let current = fs::read(&item.plan.path)
.with_context(|| format!("cannot recheck {} before commit", item.plan.path.display()))?;
if current != item.plan.original.as_ref() {
bail!(
"{} changed after transaction preparation",
item.plan.path.display()
);
}
fs::rename(&item.plan.path, &item.backup).with_context(|| {
format!(
"cannot create rollback backup for {}",
item.plan.path.display()
)
})?;
let temporary = item.temporary.take().expect("prepared temporary file");
if let Err(error) = temporary.persist(&item.plan.path) {
let _ = fs::rename(&item.backup, &item.plan.path);
return Err(error.error).context("cannot atomically install transformed file");
}
if let Err(error) = sync_parent(&item.plan.path) {
let _ = fs::remove_file(&item.plan.path);
let _ = fs::rename(&item.backup, &item.plan.path);
return Err(error).context("cannot sync transformed file directory");
}
Ok(())
}
fn rollback(items: &[Prepared<'_>]) -> Result<()> {
for item in items.iter().rev() {
if item.backup.symlink_metadata().is_ok() {
if item.plan.path.symlink_metadata().is_ok() {
fs::remove_file(&item.plan.path)?;
}
fs::rename(&item.backup, &item.plan.path)?;
sync_parent(&item.plan.path)?;
}
}
Ok(())
}
fn reject_symlink(path: &Path, phase: &str) -> Result<()> {
let metadata = path
.symlink_metadata()
.with_context(|| format!("cannot inspect {} during {phase}", path.display()))?;
if metadata.file_type().is_symlink() {
bail!(
"refusing to rewrite symbolic link {} during {phase}; no files were modified",
path.display()
);
}
Ok(())
}
fn sync_parent(path: &Path) -> Result<()> {
#[cfg(unix)]
{
let directory = fs::File::open(parent_directory(path))?;
directory.sync_all()?;
}
Ok(())
}
fn parent_directory(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_and_preserves_permissions() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("x.rs");
fs::write(&path, b"old").unwrap();
let permissions = fs::metadata(&path).unwrap().permissions();
apply_transaction(vec![WritePlan {
path: path.clone(),
original: Cow::Borrowed(b"old"),
replacement: Cow::Borrowed(b"new"),
}])
.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
assert_eq!(
fs::metadata(&path).unwrap().permissions().readonly(),
permissions.readonly()
);
}
#[test]
fn an_existing_rollback_journal_says_what_to_do_about_it() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("x.rs");
fs::write(&path, b"old").unwrap();
let journal = directory
.path()
.join(format!(".x.rs.ocomment-rollback-{}-0", std::process::id()));
fs::write(&journal, b"interrupted").unwrap();
let error = apply_transaction(vec![WritePlan {
path: path.clone(),
original: Cow::Borrowed(b"old"),
replacement: Cow::Borrowed(b"new"),
}])
.unwrap_err();
assert_eq!(
error.to_string(),
format!(
"rollback path {} already exists; a previous ocomment run may have been \
interrupted — inspect and delete it before retrying",
journal.display()
)
);
assert_eq!(fs::read(&path).unwrap(), b"old", "the file was rewritten");
}
#[cfg(unix)]
#[test]
fn a_symlink_aborts_the_whole_transaction_without_replacing_link_or_target() {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir().unwrap();
let ordinary = directory.path().join("ordinary.rs");
let target = directory.path().join("target.rs");
let link = directory.path().join("link.rs");
fs::write(&ordinary, b"ordinary old").unwrap();
fs::write(&target, b"target old").unwrap();
symlink("target.rs", &link).unwrap();
let error = apply_transaction(vec![
WritePlan {
path: ordinary.clone(),
original: Cow::Borrowed(b"ordinary old"),
replacement: Cow::Borrowed(b"ordinary new"),
},
WritePlan {
path: link.clone(),
original: Cow::Borrowed(b"target old"),
replacement: Cow::Borrowed(b"target new"),
},
])
.unwrap_err();
assert!(
error
.to_string()
.contains("refusing to rewrite symbolic link")
);
assert_eq!(fs::read(&ordinary).unwrap(), b"ordinary old");
assert_eq!(fs::read(&target).unwrap(), b"target old");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
}
}