feat(desktop): add safe explicit Flash save-file persistence
This commit is contained in:
parent
8ded9bc90e
commit
86f29018fd
17 changed files with 1032 additions and 133 deletions
275
crates/desktop/src/cartridge/save_file.rs
Normal file
275
crates/desktop/src/cartridge/save_file.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
//! Explicit raw-save storage. Never runs in the core or writes on an emulation error.
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests;
|
||||
use gba_core::{cartridge::SaveDevice, memory::Memory};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
fn sibling(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut name = path.file_name().unwrap().to_os_string();
|
||||
name.push(suffix);
|
||||
path.with_file_name(name)
|
||||
}
|
||||
fn create_new(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
options.open(path)
|
||||
}
|
||||
fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
a.dev() == b.dev() && a.ino() == b.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (a, b);
|
||||
false
|
||||
}
|
||||
}
|
||||
fn read_image(path: &Path, size: usize) -> io::Result<Option<Vec<u8>>> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(io::Error::other(
|
||||
"save path must be a regular file, not a symlink or directory",
|
||||
));
|
||||
}
|
||||
if metadata.len() != size as u64 {
|
||||
return Err(io::Error::other(format!(
|
||||
"save file must contain exactly {size} bytes"
|
||||
)));
|
||||
}
|
||||
if metadata.permissions().readonly() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"save file is read-only",
|
||||
));
|
||||
}
|
||||
let file = File::open(path)?;
|
||||
#[cfg(unix)]
|
||||
if !same_file(&metadata, &file.metadata()?) {
|
||||
return Err(io::Error::other("save file changed while opening"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
file.take(size as u64 + 1).read_to_end(&mut data)?;
|
||||
if data.len() != size {
|
||||
return Err(io::Error::other("save size changed while reading"));
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
struct Lease {
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
}
|
||||
impl Drop for Lease {
|
||||
fn drop(&mut self) {
|
||||
// Never remove a lock that another actor replaced after we created ours.
|
||||
if let (Ok(current), Ok(owned)) = (fs::symlink_metadata(&self.path), self.file.metadata()) {
|
||||
if current.is_file() && same_file(¤t, &owned) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Temporary {
|
||||
path: PathBuf,
|
||||
retain: bool,
|
||||
}
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
if !self.retain {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unique_file(path: &Path, kind: &str) -> io::Result<(Temporary, File)> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
for _ in 0..128 {
|
||||
let name = sibling(
|
||||
path,
|
||||
&format!(
|
||||
".{kind}.{}.{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
),
|
||||
);
|
||||
match create_new(&name) {
|
||||
Ok(file) => {
|
||||
return Ok((
|
||||
Temporary {
|
||||
path: name,
|
||||
retain: false,
|
||||
},
|
||||
file,
|
||||
))
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::other(
|
||||
"could not reserve a unique save sidecar file",
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Persisted {
|
||||
pub(crate) backup: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) struct SaveFile {
|
||||
path: PathBuf,
|
||||
original: Option<Vec<u8>>,
|
||||
size: usize,
|
||||
_lease: Lease,
|
||||
}
|
||||
impl SaveFile {
|
||||
pub(crate) fn open(path: &Path, rom: &Path, device: SaveDevice) -> io::Result<Self> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (path, rom, device);
|
||||
return Err(io::Error::other(
|
||||
"save-file persistence currently requires a Unix host",
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if device == SaveDevice::None {
|
||||
return Err(io::Error::other("--save-file requires --save-type"));
|
||||
}
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::other("save file needs a filename"))?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let path = parent.canonicalize()?.join(filename);
|
||||
let rom = rom.canonicalize()?;
|
||||
let rom_metadata = fs::metadata(&rom)?;
|
||||
if path == rom
|
||||
|| fs::metadata(&path)
|
||||
.ok()
|
||||
.is_some_and(|m| same_file(&m, &rom_metadata))
|
||||
{
|
||||
return Err(io::Error::other("save file must not alias the ROM"));
|
||||
}
|
||||
let lock_path = sibling(&path, ".lock");
|
||||
let file = create_new(&lock_path).map_err(|e| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!("cannot lock save file (another process or stale lock): {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut lease = Lease {
|
||||
path: lock_path,
|
||||
file,
|
||||
};
|
||||
writeln!(lease.file, "gameboy-rust pid={}", std::process::id())?;
|
||||
lease.file.sync_all()?;
|
||||
let original = read_image(&path, device.capacity())?;
|
||||
Ok(Self {
|
||||
path,
|
||||
original,
|
||||
size: device.capacity(),
|
||||
_lease: lease,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn initialize(&self, memory: &mut Memory) -> io::Result<()> {
|
||||
if let Some(image) = &self.original {
|
||||
memory.load_save_image(image).map_err(io::Error::other)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn loaded(&self) -> bool {
|
||||
self.original.is_some()
|
||||
}
|
||||
pub(crate) fn persist(&mut self, memory: &Memory) -> io::Result<Option<Persisted>> {
|
||||
if memory.save_write_pending() {
|
||||
return Err(io::Error::other(
|
||||
"save not written: Flash command/operation is incomplete",
|
||||
));
|
||||
}
|
||||
if !memory.save_modified() {
|
||||
return Ok(None);
|
||||
}
|
||||
let image = memory
|
||||
.save_image()
|
||||
.ok_or_else(|| io::Error::other("no save image"))?;
|
||||
if image.len() != self.size {
|
||||
return Err(io::Error::other("save device changed during execution"));
|
||||
}
|
||||
if self.original.as_deref() == Some(image) {
|
||||
return Ok(None);
|
||||
}
|
||||
if read_image(&self.path, self.size)? != self.original {
|
||||
return Err(io::Error::other(
|
||||
"save file changed externally; refusing overwrite",
|
||||
));
|
||||
}
|
||||
let (temp, mut file) = unique_file(&self.path, "tmp")?;
|
||||
file.write_all(image)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
let backup = if let Some(original) = &self.original {
|
||||
let (mut backup, mut file) = unique_file(&self.path, "bak")?;
|
||||
file.write_all(original)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
backup.retain = true;
|
||||
Some(backup.path.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Recheck immediately before installation. The lock coordinates this frontend,
|
||||
// not unrelated programs; this is not an atomic compare-and-swap filesystem API.
|
||||
if read_image(&self.path, self.size)? != self.original {
|
||||
return Err(io::Error::other(
|
||||
"save file changed before replacement; refusing overwrite",
|
||||
));
|
||||
}
|
||||
if self.original.is_some() {
|
||||
fs::rename(&temp.path, &self.path)?;
|
||||
} else {
|
||||
fs::hard_link(&temp.path, &self.path)?;
|
||||
} // No-clobber first creation, even if a new file raced us.
|
||||
self.original = Some(image.to_vec());
|
||||
fs::remove_file(&temp.path)
|
||||
.or_else(|e| {
|
||||
if e.kind() == io::ErrorKind::NotFound {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
.map_err(|e| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!("save installed, but temporary cleanup failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
File::open(self.path.parent().unwrap())
|
||||
.and_then(|directory| directory.sync_all())
|
||||
.map_err(|e| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!("save installed, but directory sync failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(Persisted { backup }))
|
||||
}
|
||||
}
|
||||
201
crates/desktop/src/cartridge/save_file/tests.rs
Normal file
201
crates/desktop/src/cartridge/save_file/tests.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! File tests use generated bytes in private temporary directories, never user saves.
|
||||
use super::*;
|
||||
use gba_core::cartridge::{FLASH_PROGRAM_CYCLES, SAVE_START};
|
||||
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
rom: PathBuf,
|
||||
save: PathBuf,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"gba-save-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let rom = dir.join("original.gba");
|
||||
fs::write(&rom, [0xfe, 0xff, 0xff, 0xea]).unwrap();
|
||||
let save = dir.join("original.sav");
|
||||
Self { dir, rom, save }
|
||||
}
|
||||
fn open(&self) -> io::Result<SaveFile> {
|
||||
SaveFile::open(&self.save, &self.rom, SaveDevice::Flash128)
|
||||
}
|
||||
fn image(&self, value: u8) -> Vec<u8> {
|
||||
let bytes = vec![value; SaveDevice::Flash128.capacity()];
|
||||
fs::write(&self.save, &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
fn memory() -> Memory {
|
||||
let mut m = Memory::new(vec![]).unwrap();
|
||||
m.set_save_device(SaveDevice::Flash128);
|
||||
m
|
||||
}
|
||||
fn start_program(m: &mut Memory, value: u8) {
|
||||
for (offset, byte) in [(0x5555, 0xaa), (0x2aaa, 0x55), (0x5555, 0xa0), (0, value)] {
|
||||
m.write8(SAVE_START + offset, byte).unwrap();
|
||||
}
|
||||
}
|
||||
fn program(m: &mut Memory, value: u8) {
|
||||
start_program(m, value);
|
||||
m.advance_cycles(FLASH_PROGRAM_CYCLES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_image_is_volatile_until_modified_then_reloads_with_unique_backups() {
|
||||
let f = Fixture::new();
|
||||
let mut m = memory();
|
||||
let mut save = f.open().unwrap();
|
||||
assert!(!save.loaded());
|
||||
save.initialize(&mut m).unwrap();
|
||||
assert!(save.persist(&m).unwrap().is_none());
|
||||
assert!(!f.save.exists());
|
||||
program(&mut m, 0xa5);
|
||||
assert!(save.persist(&m).unwrap().unwrap().backup.is_none());
|
||||
assert_eq!(fs::read(&f.save).unwrap(), m.save_image().unwrap());
|
||||
assert!(save.persist(&m).unwrap().is_none());
|
||||
drop(save);
|
||||
assert!(!sibling(&f.save, ".lock").exists());
|
||||
let mut save = f.open().unwrap();
|
||||
assert!(save.loaded());
|
||||
let mut m = memory();
|
||||
save.initialize(&mut m).unwrap();
|
||||
assert!(!m.save_modified());
|
||||
assert_eq!(m.read8(SAVE_START).unwrap(), 0xa5);
|
||||
program(&mut m, 0xa0);
|
||||
let first = save.persist(&m).unwrap().unwrap().backup.unwrap();
|
||||
let original = fs::read(&first).unwrap();
|
||||
assert_eq!(original[0], 0xa5);
|
||||
program(&mut m, 0x80);
|
||||
let second = save.persist(&m).unwrap().unwrap().backup.unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(fs::read(&first).unwrap(), original);
|
||||
assert_eq!(fs::read(second).unwrap()[0], 0xa0);
|
||||
assert_eq!(fs::read(&f.save).unwrap()[0], 0x80);
|
||||
assert!(!fs::read_dir(&f.dir).unwrap().any(|p| p
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.contains(".tmp.")));
|
||||
assert_eq!(fs::read(&f.rom).unwrap(), [0xfe, 0xff, 0xff, 0xea]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_commands_and_busy_operations_preserve_the_existing_image() {
|
||||
let f = Fixture::new();
|
||||
let original = f.image(255);
|
||||
let mut save = f.open().unwrap();
|
||||
let mut m = memory();
|
||||
save.initialize(&mut m).unwrap();
|
||||
m.write8(SAVE_START + 0x5555, 0xaa).unwrap();
|
||||
assert!(save.persist(&m).is_err());
|
||||
assert_eq!(fs::read(&f.save).unwrap(), original);
|
||||
m.load_save_image(&original).unwrap();
|
||||
start_program(&mut m, 0x12);
|
||||
assert!(save.persist(&m).is_err());
|
||||
assert_eq!(fs::read(&f.save).unwrap(), original);
|
||||
m.advance_cycles(FLASH_PROGRAM_CYCLES);
|
||||
assert!(save.persist(&m).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_excludes_other_sessions_and_does_not_delete_replaced_locks() {
|
||||
let f = Fixture::new();
|
||||
let first = f.open().unwrap();
|
||||
assert!(f.open().is_err());
|
||||
let lock = sibling(&f.save, ".lock");
|
||||
fs::rename(&lock, f.dir.join("owned-lock")).unwrap();
|
||||
fs::write(&lock, b"another owner").unwrap();
|
||||
drop(first);
|
||||
assert_eq!(fs::read(&lock).unwrap(), b"another owner");
|
||||
assert!(f.open().is_err());
|
||||
fs::remove_file(&lock).unwrap();
|
||||
drop(f.open().unwrap());
|
||||
assert!(!lock.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_and_device_errors_leave_no_lock_and_no_changed_bytes() {
|
||||
let f = Fixture::new();
|
||||
for length in [0, 1, 65536, 131071, 131073] {
|
||||
let bytes = vec![0x5a; length];
|
||||
fs::write(&f.save, &bytes).unwrap();
|
||||
assert!(f.open().is_err());
|
||||
assert_eq!(fs::read(&f.save).unwrap(), bytes);
|
||||
assert!(!sibling(&f.save, ".lock").exists());
|
||||
}
|
||||
assert!(SaveFile::open(&f.save, &f.rom, SaveDevice::None).is_err());
|
||||
let bytes = vec![255; 65536];
|
||||
fs::write(&f.save, &bytes).unwrap();
|
||||
let save = SaveFile::open(&f.save, &f.rom, SaveDevice::Flash64).unwrap();
|
||||
let mut m = Memory::new(vec![]).unwrap();
|
||||
m.set_save_device(SaveDevice::Flash64);
|
||||
save.initialize(&mut m).unwrap();
|
||||
assert_eq!(m.save_image().unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes_and_new_files_are_never_overwritten() {
|
||||
for existing in [false, true] {
|
||||
let f = Fixture::new();
|
||||
if existing {
|
||||
f.image(255);
|
||||
}
|
||||
let mut save = f.open().unwrap();
|
||||
let mut m = memory();
|
||||
save.initialize(&mut m).unwrap();
|
||||
program(&mut m, 0x12);
|
||||
let external = f.image(0x5a);
|
||||
assert!(save
|
||||
.persist(&m)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("changed externally"));
|
||||
assert_eq!(fs::read(&f.save).unwrap(), external);
|
||||
assert_eq!(fs::read_dir(&f.dir).unwrap().count(), 3); // ROM, image, owned lock only.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reject_rom_aliases_symlinks_directories_and_readonly_images() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let f = Fixture::new();
|
||||
assert!(SaveFile::open(&f.rom, &f.rom, SaveDevice::Flash128).is_err());
|
||||
fs::hard_link(&f.rom, &f.save).unwrap();
|
||||
assert!(f.open().is_err());
|
||||
fs::remove_file(&f.save).unwrap();
|
||||
let target = f.dir.join("target");
|
||||
fs::write(&target, vec![255; 131072]).unwrap();
|
||||
symlink(&target, &f.save).unwrap();
|
||||
assert!(f.open().is_err());
|
||||
fs::remove_file(&f.save).unwrap();
|
||||
symlink(f.dir.join("absent"), &f.save).unwrap();
|
||||
assert!(f.open().is_err());
|
||||
fs::remove_file(&f.save).unwrap();
|
||||
fs::create_dir(&f.save).unwrap();
|
||||
assert!(f.open().is_err());
|
||||
fs::remove_dir(&f.save).unwrap();
|
||||
let image = f.image(255);
|
||||
fs::set_permissions(&f.save, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
assert!(f.open().is_err());
|
||||
assert_eq!(fs::read(&f.save).unwrap(), image);
|
||||
fs::set_permissions(&f.save, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let mut save = f.open().unwrap();
|
||||
let mut m = memory();
|
||||
save.initialize(&mut m).unwrap();
|
||||
program(&mut m, 0x12);
|
||||
fs::set_permissions(&f.save, fs::Permissions::from_mode(0o400)).unwrap();
|
||||
assert!(save.persist(&m).is_err());
|
||||
assert_eq!(fs::read(&f.save).unwrap(), image);
|
||||
fs::set_permissions(&f.save, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ fn options_require_both_flags_in_either_order() {
|
|||
mode: Mode::Terminal { steps: count },
|
||||
hardware: CartridgeHardware::None,
|
||||
save_device: SaveDevice::None,
|
||||
save_file: None,
|
||||
audio: false,
|
||||
}
|
||||
);
|
||||
|
|
@ -36,6 +37,39 @@ fn options_require_both_flags_in_either_order() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_file_requires_explicit_device_and_one_nonempty_path() {
|
||||
let base = args(&["--rom", "test.gba", "--steps", "100"]);
|
||||
for tail in [
|
||||
vec!["--save-file", "test.sav"],
|
||||
vec!["--save-type", "flash128", "--save-file", ""],
|
||||
vec!["--save-type", "flash128", "--save-file"],
|
||||
vec![
|
||||
"--save-type",
|
||||
"flash128",
|
||||
"--save-file",
|
||||
"a",
|
||||
"--save-file",
|
||||
"b",
|
||||
],
|
||||
] {
|
||||
let mut arguments = base.clone();
|
||||
arguments.extend(args(&tail));
|
||||
assert!(parse_args(&arguments).is_err());
|
||||
}
|
||||
let mut arguments = base;
|
||||
arguments.extend(args(&[
|
||||
"--save-file",
|
||||
"test.sav",
|
||||
"--save-type",
|
||||
"flash128",
|
||||
]));
|
||||
assert_eq!(
|
||||
parse_args(&arguments).unwrap().save_file,
|
||||
Some("test.sav".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
||||
assert!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue