Skip to content

Commit 97cc401

Browse files
committed
fix: skip a synced memory card left in an older save archive instead of failing the restore
1 parent c700f96 commit 97cc401

2 files changed

Lines changed: 46 additions & 14 deletions

File tree

webstation_broker/api.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,22 @@ def _resolve_callback(body_cb: Optional[CallbackIn], request: Request) -> dict:
105105
}
106106

107107

108-
def _archive_subtrees(emulator, memory_card_synced: bool) -> tuple[str, ...]:
109-
"""The save subtrees this session ships in its archive.
108+
def _archive_subtrees(
109+
emulator, memory_card_synced: bool
110+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
111+
"""The save subtrees this session ships, and the ones it leaves alone.
110112
111113
With the whole card synced, leaving it in the archive too would have the
112114
restore and the card hydrate writing over each other, and a stale card
113115
inside an older archive would land on top of the one RomM just laid down.
116+
The excluded set is returned rather than simply dropped because archives
117+
RomM took before the card was synced still carry it, and a restore has to
118+
pass those members over instead of refusing the whole archive.
114119
"""
115120
if not memory_card_synced or emulator.memory_card_subtree is None:
116-
return emulator.save_subtrees
117-
return tuple(s for s in emulator.save_subtrees if s != emulator.memory_card_subtree)
121+
return emulator.save_subtrees, ()
122+
card = emulator.memory_card_subtree
123+
return tuple(s for s in emulator.save_subtrees if s != card), (card,)
118124

119125

120126
@router.get("/api/health")
@@ -174,7 +180,9 @@ async def activate(
174180
await anyio.to_thread.run_sync(emulator.clear_working_slot)
175181
restore_report = None
176182
save = body.save
177-
subtrees = _archive_subtrees(emulator, bool(save and save.memory_card_synced))
183+
subtrees, excluded = _archive_subtrees(
184+
emulator, bool(save and save.memory_card_synced)
185+
)
178186
if save and save.archive and subtrees:
179187
archive_path = Path(save.archive)
180188
if not archive_path.is_file():
@@ -184,7 +192,11 @@ async def activate(
184192
content = await anyio.to_thread.run_sync(archive_path.read_bytes)
185193
await anyio.to_thread.run_sync(emulator.prepare_restore)
186194
restore_report = await anyio.to_thread.run_sync(
187-
saves.extract_save_archive, content, emulator.save_root, subtrees
195+
saves.extract_save_archive,
196+
content,
197+
emulator.save_root,
198+
subtrees,
199+
excluded,
188200
)
189201
if restore_report["error"]:
190202
raise HTTPException(
@@ -252,12 +264,13 @@ async def _do_exit(save_slot: int) -> dict:
252264
emulator = sess["emulator_obj"]
253265
exit_report = await anyio.to_thread.run_sync(emulator.save_and_exit, save_slot)
254266

267+
dump_subtrees, _ = _archive_subtrees(
268+
emulator, bool((sess.get("save") or {}).get("memory_card_synced"))
269+
)
255270
dump = await anyio.to_thread.run_sync(
256271
saves.build_save_archive,
257272
emulator.save_root,
258-
_archive_subtrees(
259-
emulator, bool((sess.get("save") or {}).get("memory_card_synced"))
260-
),
273+
dump_subtrees,
261274
sess["save_baseline"],
262275
)
263276
cb = sess.get("callback")

webstation_broker/saves.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,30 @@ def build_save_archive(
120120
return report
121121

122122

123+
def _under(member: PurePosixPath, subtrees: tuple[str, ...]) -> bool:
124+
return any(member.as_posix().startswith(sub + "/") for sub in subtrees)
125+
126+
123127
def extract_save_archive(
124-
content: bytes, root: Path, subtrees: tuple[str, ...]
128+
content: bytes,
129+
root: Path,
130+
subtrees: tuple[str, ...],
131+
excluded: tuple[str, ...] = (),
125132
) -> dict:
126133
"""Restore an archive into the emulator's data dir.
127134
135+
`excluded` names subtrees the emulator owns but this session syncs some
136+
other way. Those members are dropped rather than refused: archives taken
137+
before that sync was turned on still carry them, and restoring one would
138+
undo what the other route just wrote. A member under neither is still a
139+
hard error, since that is the guard against an archive writing outside the
140+
save area.
141+
128142
Existing files newer than their archive member are skipped so a restore
129143
can never roll back saves made since the archive was taken. Returns
130-
{"written", "skipped", "failed", "error"}.
144+
{"written", "skipped", "excluded", "failed", "error"}.
131145
"""
132-
result = {"written": 0, "skipped": 0, "failed": 0, "error": None}
146+
result = {"written": 0, "skipped": 0, "excluded": 0, "failed": 0, "error": None}
133147
try:
134148
zf = zipfile.ZipFile(io.BytesIO(content))
135149
except zipfile.BadZipFile:
@@ -140,16 +154,21 @@ def extract_save_archive(
140154
if sum(i.file_size for i in infos) > SAVE_FILE_MAX_BYTES:
141155
result["error"] = "archive exceeds size limit when extracted"
142156
return result
157+
wanted = []
143158
for info in infos:
144159
member = PurePosixPath(info.filename)
145160
if member.is_absolute() or ".." in member.parts:
146161
result["error"] = f"archive member escapes save dir: {info.filename}"
147162
return result
148-
if not any(member.as_posix().startswith(sub + "/") for sub in subtrees):
163+
if _under(member, excluded):
164+
result["excluded"] += 1
165+
continue
166+
if not _under(member, subtrees):
149167
result["error"] = f"archive member outside save subtrees: {info.filename}"
150168
return result
169+
wanted.append(info)
151170

152-
for info in infos:
171+
for info in wanted:
153172
target = root / PurePosixPath(info.filename)
154173
mtime = calendar.timegm(info.date_time)
155174
try:

0 commit comments

Comments
 (0)