Preserve damaged backup evidence across later saves

This commit is contained in:
NINI
2026-10-05 11:44:28 +09:00
parent 7c2f6d9fd1
commit 7641365e37
4 changed files with 24 additions and 1 deletions
@@ -75,6 +75,18 @@ namespace MagnetismHeroes.Tests
Assert.That(Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText), Does.Contain("broken-primary")); Assert.That(Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText), Does.Contain("broken-primary"));
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("broken-backup")); Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("broken-backup"));
} }
[Test] public void BothDamagedOriginalsSurviveTwoSubsequentSuccessfulWrites()
{
File.WriteAllText(_path, "distinct-broken-primary"); File.WriteAllText(_path + ".bak", "distinct-broken-backup");
var store = Store(); Assert.That(store.TryLoad(out _, out _), Is.False);
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
var forensic = Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText).ToArray();
Assert.That(forensic, Does.Contain("distinct-broken-primary"));
Assert.That(forensic, Does.Contain("distinct-broken-backup"));
Assert.That(store.TryLoad(out var restored, out error), Is.True, error);
Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1));
}
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave() [Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
{ {
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True); var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
@@ -14,6 +14,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
private readonly string _path; private readonly string _path;
private readonly IReadOnlyList<StageDefinition> _definitions; private readonly IReadOnlyList<StageDefinition> _definitions;
private bool _preserveDamagedOriginal; private bool _preserveDamagedOriginal;
private bool _preserveDamagedBackup;
private sealed class SaveFieldsResolver : DefaultContractResolver private sealed class SaveFieldsResolver : DefaultContractResolver
{ {
protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization serialization) protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization serialization)
@@ -32,6 +33,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
if (Read(_path + ".bak", out save, out var backupError)) if (Read(_path + ".bak", out save, out var backupError))
{ error = "Recovered valid backup: " + originalError; _preserveDamagedOriginal = true; return true; } { error = "Recovered valid backup: " + originalError; _preserveDamagedOriginal = true; return true; }
_preserveDamagedOriginal = true; _preserveDamagedOriginal = true;
_preserveDamagedBackup = File.Exists(_path + ".bak");
error = "Invalid primary and backup: " + originalError + "; " + backupError; error = "Invalid primary and backup: " + originalError + "; " + backupError;
return false; return false;
} }
@@ -105,6 +107,11 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
File.Copy(_path, _path + ".damaged-" + Guid.NewGuid().ToString("N")); File.Copy(_path, _path + ".damaged-" + Guid.NewGuid().ToString("N"));
_preserveDamagedOriginal = false; _preserveDamagedOriginal = false;
} }
if (_preserveDamagedBackup && File.Exists(_path + ".bak"))
{
File.Copy(_path + ".bak", _path + ".bak.damaged-" + Guid.NewGuid().ToString("N"));
_preserveDamagedBackup = false;
}
using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
using (var writer = new StreamWriter(stream)) using (var writer = new StreamWriter(stream))
{ createdTemporary = true; writer.Write(JsonConvert.SerializeObject(save, Formatting.Indented, SerializerSettings)); writer.Flush(); stream.Flush(true); } { createdTemporary = true; writer.Write(JsonConvert.SerializeObject(save, Formatting.Indented, SerializerSettings)); writer.Flush(); stream.Flush(true); }
+1 -1
View File
@@ -8,7 +8,7 @@ JSON v1은 앱 전용 `progress.json`에 확정 상태만 기록한다. 각 단
확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다. 확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다.
임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상 primary는 유효 backup 전체로 복구한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 원본 증거를 보존한다. 임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상 primary는 유효 backup 전체로 복구한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 primary·backup의 서로 다른 원본을 각각 보관해 이후 정상 저장에서도 증거를 보존한다.
검증 명령: 검증 명령:
+4
View File
@@ -60,6 +60,8 @@ try {
Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
$build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json $build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
Check 'build-provenance' ($build.status -eq 'passed' -and $build.exitCode -eq 0 -and !$build.sourceDirty -and !$build.sourceDirtyEnd -and $build.sourceCommit -eq $result.sourceCommit -and $build.sourceCommitEnd -eq $result.sourceCommit -and $build.apkSha256 -eq $result.apkSha256) $result.apkSha256 Check 'build-provenance' ($build.status -eq 'passed' -and $build.exitCode -eq 0 -and !$build.sourceDirty -and !$build.sourceDirtyEnd -and $build.sourceCommit -eq $result.sourceCommit -and $build.sourceCommitEnd -eq $result.sourceCommit -and $build.apkSha256 -eq $result.apkSha256) $result.apkSha256
Adb @('wait-for-device') | Out-Null
Check 'device-boot-completed' ((Adb @('shell','getprop','sys.boot_completed')) -eq '1') 'Actual emulator boot completed before install'
Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null; Adb @('shell','pm','clear',$package) | Out-Null Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null; Adb @('shell','pm','clear',$package) | Out-Null
$result.device=@{model=(Adb @('shell','getprop','ro.product.model'));api=(Adb @('shell','getprop','ro.build.version.sdk'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'))} $result.device=@{model=(Adb @('shell','getprop','ro.product.model'));api=(Adb @('shell','getprop','ro.build.version.sdk'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'))}
Step 'initial-home' @('shell','am','start','-W','-n',$activity) 6500 Step 'initial-home' @('shell','am','start','-W','-n',$activity) 6500
@@ -171,6 +173,8 @@ try {
Step 'both-damaged-new-selection' @('shell','input','tap','1200','616'); $initial=Save 'both-damaged-new-initial' Step 'both-damaged-new-selection' @('shell','input','tap','1200','616'); $initial=Save 'both-damaged-new-initial'
Check 'both-damage-normal-initial' ($initial.completedStageIds.Count -eq 0 -and $initial.puzzleStates.Count -eq 0 -and $null -eq $initial.latestFreePlay) 'No valid save starts clean initial state' Check 'both-damage-normal-initial' ($initial.completedStageIds.Count -eq 0 -and $initial.puzzleStates.Count -eq 0 -and $null -eq $initial.latestFreePlay) 'No valid save starts clean initial state'
Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained' Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained'
Step 'both-damage-second-normal-save' @('shell','input','tap','1200','562'); $secondInitial=Save 'both-damaged-second-initial'
Check 'both-damage-two-normal-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-both-backup' -and $secondInitial.puzzleStates.Count -eq 1) 'Both distinct damaged byte strings survive two successful normal writes'
$result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain) $result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain)
Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd
$result.status='passed' $result.status='passed'