Blacksite/crates/editor/src/scene/recovery.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

329 lines
11 KiB
Rust

//! Transactional scene persistence and user-local recovery snapshot storage.
use std::fs::{self, File, OpenOptions};
use std::hash::Hasher;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const STATE_DIR: &str = "blacksite-editor";
const RECOVERY_DIR: &str = "recovery";
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
atomic_write_with_pre_rename(path, bytes, || Ok(()))
}
fn atomic_write_with_pre_rename(
path: &Path,
bytes: &[u8],
before_rename: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("scene");
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id()));
let result = (|| {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temporary)
.map_err(|error| format!("could not create {}: {error}", temporary.display()))?;
file.write_all(bytes)
.map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
file.sync_all()
.map_err(|error| format!("could not sync {}: {error}", temporary.display()))?;
before_rename()?;
fs::rename(&temporary, path).map_err(|error| {
format!(
"could not replace {} with {}: {error}",
path.display(),
temporary.display()
)
})?;
sync_parent_directory(parent);
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
#[cfg(unix)]
fn sync_parent_directory(parent: &Path) {
if let Ok(directory) = File::open(parent) {
let _ = directory.sync_all();
}
}
#[cfg(not(unix))]
fn sync_parent_directory(_parent: &Path) {}
pub(crate) fn default_state_root() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("XDG_STATE_HOME") {
return Some(PathBuf::from(path).join(STATE_DIR));
}
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".local/state").join(STATE_DIR))
}
pub(crate) fn write_recovery_snapshot(
state_root: &Path,
project_root: &Path,
scene_path: &Path,
bytes: &[u8],
max_generations: usize,
) -> Result<PathBuf, String> {
let directory = recovery_directory(state_root, project_root, scene_path);
fs::create_dir_all(&directory)
.map_err(|error| format!("could not create {}: {error}", directory.display()))?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let snapshot = directory.join(format!(
"recovery-{timestamp:020}-{}.scn.ron",
std::process::id()
));
atomic_write(&snapshot, bytes)?;
prune_recovery_generations(&directory, max_generations.max(1))?;
Ok(snapshot)
}
pub(crate) fn latest_recovery_snapshot(
state_root: &Path,
project_root: &Path,
scene_path: &Path,
) -> Option<PathBuf> {
let directory = recovery_directory(state_root, project_root, scene_path);
let source_modified = fs::metadata(scene_path)
.and_then(|metadata| metadata.modified())
.unwrap_or(UNIX_EPOCH);
recovery_files(&directory)
.into_iter()
.filter(|path| {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| modified > source_modified)
})
.max_by_key(|path| path.file_name().map(|name| name.to_os_string()))
}
pub(crate) fn discard_recovery_snapshots(
state_root: &Path,
project_root: &Path,
scene_path: &Path,
) -> Result<(), String> {
let directory = recovery_directory(state_root, project_root, scene_path);
if !directory.exists() {
return Ok(());
}
fs::remove_dir_all(&directory)
.map_err(|error| format!("could not remove {}: {error}", directory.display()))
}
fn recovery_directory(state_root: &Path, project_root: &Path, scene_path: &Path) -> PathBuf {
state_root
.join(RECOVERY_DIR)
.join(stable_path_hash(project_root))
.join(stable_path_hash(scene_path))
}
fn stable_path_hash(path: &Path) -> String {
let normalized = path.to_string_lossy().replace('\\', "/");
let mut hasher = Fnv1a64::default();
hasher.write(normalized.as_bytes());
format!("{:016x}", hasher.finish())
}
fn prune_recovery_generations(directory: &Path, max_generations: usize) -> Result<(), String> {
let mut files = recovery_files(directory);
files.sort_by_key(|path| path.file_name().map(|name| name.to_os_string()));
let remove_count = files.len().saturating_sub(max_generations);
for path in files.into_iter().take(remove_count) {
fs::remove_file(&path)
.map_err(|error| format!("could not prune {}: {error}", path.display()))?;
}
Ok(())
}
fn recovery_files(directory: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(directory) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("recovery-") && name.ends_with(".scn.ron"))
})
.collect()
}
#[derive(Default)]
struct Fnv1a64(u64);
impl Hasher for Fnv1a64 {
fn finish(&self) -> u64 {
if self.0 == 0 {
0xcbf29ce484222325
} else {
self.0
}
}
fn write(&mut self, bytes: &[u8]) {
let mut hash = self.finish();
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
self.0 = hash;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_directory(label: &str) -> PathBuf {
std::env::temp_dir().join(format!("blacksite-{label}-{}", uuid::Uuid::new_v4()))
}
#[test]
fn atomic_write_replaces_content_without_leaving_temporary_files() {
let root = temp_directory("atomic-write");
let path = root.join("scene.scn.ron");
fs::create_dir_all(&root).unwrap();
fs::write(&path, b"old").unwrap();
atomic_write(&path, b"new scene").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new scene");
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn interrupted_atomic_write_preserves_the_last_authored_scene() {
let root = temp_directory("atomic-interruption");
let path = root.join("scene.scn.ron");
fs::create_dir_all(&root).unwrap();
fs::write(&path, b"last known good scene").unwrap();
let result = atomic_write_with_pre_rename(&path, b"partial replacement", || {
Err("simulated interruption before rename".to_string())
});
assert_eq!(result.unwrap_err(), "simulated interruption before rename");
assert_eq!(fs::read(&path).unwrap(), b"last known good scene");
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn recovery_paths_are_stable_and_scene_specific() {
let state = Path::new("/state");
let project = Path::new("/project");
let first = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron"));
let repeated = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron"));
let second = recovery_directory(state, project, Path::new("assets/levels/b.scn.ron"));
assert_eq!(first, repeated);
assert_ne!(first, second);
assert!(first.starts_with("/state/recovery"));
}
#[test]
fn newer_recovery_generation_is_discovered_for_authored_scene() {
let root = temp_directory("recovery-discovery");
let project = Path::new("/project");
let scene = root.join("assets/levels/test.scn.ron");
fs::create_dir_all(scene.parent().unwrap()).unwrap();
fs::write(&scene, b"authored").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let snapshot = write_recovery_snapshot(&root, project, &scene, b"recovered", 5).unwrap();
assert_eq!(
latest_recovery_snapshot(&root, project, &scene),
Some(snapshot)
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn recovery_generations_are_bounded() {
let root = temp_directory("recovery-prune");
let project = Path::new("/project");
let scene = Path::new("assets/levels/test.scn.ron");
let directory = recovery_directory(&root, project, scene);
fs::create_dir_all(&directory).unwrap();
for index in 0..5 {
fs::write(
directory.join(format!("recovery-{index:020}-1.scn.ron")),
index.to_string(),
)
.unwrap();
}
prune_recovery_generations(&directory, 3).unwrap();
let mut names: Vec<String> = recovery_files(&directory)
.into_iter()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(
names,
vec![
"recovery-00000000000000000002-1.scn.ron",
"recovery-00000000000000000003-1.scn.ron",
"recovery-00000000000000000004-1.scn.ron",
]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn discard_removes_all_scene_generations() {
let root = temp_directory("recovery-discard");
let project = Path::new("/project");
let scene = Path::new("assets/levels/test.scn.ron");
let directory = recovery_directory(&root, project, scene);
fs::create_dir_all(&directory).unwrap();
fs::write(
directory.join("recovery-00000000000000000001-1.scn.ron"),
b"one",
)
.unwrap();
fs::write(
directory.join("recovery-00000000000000000002-1.scn.ron"),
b"two",
)
.unwrap();
discard_recovery_snapshots(&root, project, scene).unwrap();
assert!(!directory.exists());
fs::remove_dir_all(root).unwrap();
}
}