use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use wsb::peer::compatibility::{Epoch, RepositoryFacts};
use wsb::peer::converge::{Entry, EntryKind};
use wsb::peer::model::RepositoryId;
use wsb::peer::protocol::Write;
use wsb::peer::scan;
pub struct Staging {
dir: PathBuf,
}
impl Staging {
pub fn create(root: &Path, token: &str) -> Result<Self, String> {
let dir = root.join(token);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("cannot create the staging directory {}: {e}", dir.display()))?;
Ok(Self { dir })
}
pub fn stage(&self, index: usize, write: &Write) -> Result<PathBuf, String> {
let bytes = write.decoded()?;
let staged = self.dir.join(format!("{index}.part"));
std::fs::write(&staged, &bytes)
.map_err(|e| format!("cannot stage {}: {e}", write.path))?;
Ok(staged)
}
}
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[allow(clippy::too_many_arguments)]
pub fn apply(
workdir: &Path,
repository: &RepositoryId,
git_dir: &Path,
epoch: &Epoch,
writes: &[Write],
deletions: &[String],
found: &BTreeMap<String, Entry>,
staging_root: &Path,
) -> Result<Vec<String>, ApplyRefusal> {
let mut targets: Vec<(usize, &Write, PathBuf)> = Vec::new();
for (index, write) in writes.iter().enumerate() {
let target = scan::local_path(workdir, &write.path)
.map_err(|e| ApplyRefusal::Forbidden(e))?;
targets.push((index, write, target));
}
let mut removals: Vec<(String, PathBuf)> = Vec::new();
for path in deletions {
let target = scan::local_path(workdir, path).map_err(ApplyRefusal::Forbidden)?;
removals.push((path.clone(), target));
}
let token = format!(
"{}-{}",
repository.as_str().replace('/', "_"),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
);
let staging = Staging::create(staging_root, &token).map_err(ApplyRefusal::Internal)?;
let mut staged = Vec::new();
for (index, write, target) in &targets {
let path = staging.stage(*index, write).map_err(ApplyRefusal::Malformed)?;
staged.push((write, target, path));
}
let now = RepositoryFacts::read(repository.clone(), workdir, git_dir)
.map_err(ApplyRefusal::Internal)?;
if !epoch.still_current(&now) {
return Err(ApplyRefusal::EpochMoved);
}
let mut applied = Vec::new();
for (write, target, source) in staged {
if !as_planned(repository, &write.path, target, found) {
continue;
}
if let Some(parent) = target.parent() {
if std::fs::create_dir_all(parent).is_err() {
continue;
}
}
let landed = match write.entry.kind {
EntryKind::File => place_file(&source, target, write.entry.executable),
EntryKind::Symlink => place_symlink(&source, target),
};
if landed.is_ok() {
applied.push(write.path.clone());
}
}
for (wire, target) in removals {
match std::fs::symlink_metadata(&target) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => applied.push(wire),
Err(_) => {}
Ok(meta) => {
let removed = if meta.is_dir() {
Err(())
} else if !as_planned(repository, &wire, &target, found) {
Err(())
} else {
std::fs::remove_file(&target).map_err(|_| ())
};
if removed.is_ok() {
prune_empty_parents(workdir, &target);
tracing::info!(
repository = %repository,
path = %wire,
"removed a file because a paired machine deleted it"
);
applied.push(wire);
}
}
}
}
Ok(applied)
}
fn as_planned(
repository: &RepositoryId,
path: &str,
target: &Path,
found: &BTreeMap<String, Entry>,
) -> bool {
let now = match Entry::read(target) {
Ok(now) => now,
Err(error) => {
tracing::debug!(%repository, %path, %error, "left alone: it cannot be read");
return false;
}
};
let planned = match (now.as_ref(), found.get(path)) {
(None, None) => true,
(Some(now), Some(found)) => now.same_as(found),
_ => false,
};
if !planned {
tracing::debug!(%repository, %path, "left alone: it changed after the plan read it");
}
planned
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplyRefusal {
EpochMoved,
Forbidden(String),
Malformed(String),
Internal(String),
}
fn place_file(source: &Path, target: &Path, executable: bool) -> Result<(), std::io::Error> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if executable { 0o755 } else { 0o644 };
std::fs::set_permissions(source, std::fs::Permissions::from_mode(mode))?;
}
#[cfg(not(unix))]
let _ = executable;
if let Ok(meta) = std::fs::symlink_metadata(target) {
if meta.is_symlink() {
std::fs::remove_file(target)?;
}
}
std::fs::rename(source, target)
}
fn place_symlink(source: &Path, target: &Path) -> Result<(), std::io::Error> {
let text = std::fs::read_to_string(source)?;
match std::fs::symlink_metadata(target) {
Ok(meta) if meta.is_dir() => {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"a directory is at the path a symlink was sent for",
));
}
Ok(_) => std::fs::remove_file(target)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(text.trim_end_matches('\n'), target)
}
#[cfg(windows)]
{
let _ = text;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlinks cannot be created on this machine",
))
}
}
fn prune_empty_parents(workdir: &Path, from: &Path) {
let mut current = from.parent().map(Path::to_path_buf);
while let Some(dir) = current {
if dir == workdir || !dir.starts_with(workdir) {
return;
}
let empty = match std::fs::read_dir(&dir) {
Ok(mut entries) => entries.next().is_none(),
Err(_) => return,
};
if !empty || std::fs::remove_dir(&dir).is_err() {
return;
}
current = dir.parent().map(Path::to_path_buf);
}
}