feat(desktop): add safe explicit Flash save-file persistence

This commit is contained in:
0xrsydn 2026-10-07 08:25:12 +07:00
commit 86f29018fd
17 changed files with 1032 additions and 133 deletions

View 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(&current, &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 }))
}
}

View 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();
}

View file

@ -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!(