329 lines
11 KiB
Rust
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();
|
|
}
|
|
}
|