Skip to content

Commit bf0ea24

Browse files
LxKnsyclaude
andcommitted
Hash archives by their entries, not their bytes
A plain .srm hashes as MD5 over its raw bytes, which is what SaveFileHash did. Archives do not: the server's _compute_zip_hash MD5s each entry's content, pairs that with the entry name, sorts the pairs by name, joins them as "name:hash" with newlines, and MD5s that string. Raw-byte MD5 over a zip can never agree with it, because zip bytes vary with entry order, compression and timestamps while the content does not. Nothing uploads an archive yet, but every folder-based platform will (PS2 memory cards, Switch, PSP, GameCube), and getting this wrong makes negotiate report a conflict on every sync for saves that are identical on both ends - a quiet failure that is far cheaper to prevent than to diagnose later. FolderAsZipHex computes the same digest straight from a folder without writing a temp archive, so reporting local state during negotiate does not cost a file. Entry names are normalised to '/' because a name derived from a Windows path would otherwise diverge from what every other client computes for the same save. Cross-checked against argosy-launcher's SaveArchiver.calculateZipHash (GPL-3.0, same license) so both clients agree on what "unchanged" means; its published vector is pinned as a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 991fad7 commit bf0ea24

3 files changed

Lines changed: 213 additions & 3 deletions

File tree

RomM.Tests/RomM.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<ItemGroup>
1414
<PackageReference Include="Microsoft.Net.Sdk.Compilers.Toolset" Version="9.0.300" PrivateAssets="all" />
1515
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
16+
<PackageReference Include="SharpCompress" Version="0.36.0" />
1617
<PackageReference Include="xunit" Version="2.6.6" />
1718
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
1819
<PrivateAssets>all</PrivateAssets>

RomM.Tests/SaveFileHashTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
using System.IO;
22
using System.Text;
33
using RomM.Saves;
4+
using SharpCompress.Archives.Zip;
5+
using SharpCompress.Common;
6+
using SharpCompress.Writers;
47
using Xunit;
58

69
namespace RomM.Tests
@@ -41,5 +44,103 @@ public void Md5HexFile_reads_and_hashes_file()
4144
File.Delete(path);
4245
}
4346
}
47+
48+
// Archives are hashed by their entries, not their bytes. This vector is the one
49+
// argosy-launcher pins in SaveArchiverHashParityTest against the server's
50+
// _compute_zip_hash; drift here means Playnite and Argosy would disagree about whether a
51+
// save changed, and negotiate would report conflicts for identical data.
52+
[Fact]
53+
public void ZipHexFile_matches_server_compute_zip_hash_vector()
54+
{
55+
var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".zip");
56+
try
57+
{
58+
WriteZip(path,
59+
new ZipEntrySpec("a.sav", new byte[] { 0x00, 0x01, 0x02 }),
60+
new ZipEntrySpec("b.sav", new byte[] { 0xFF, 0xFE }));
61+
62+
Assert.Equal("fe72f8d850245659647bd6b5f3577a7a", SaveFileHash.ZipHexFile(path));
63+
}
64+
finally
65+
{
66+
File.Delete(path);
67+
}
68+
}
69+
70+
// The digest keys on entry names, so the order they were written in must not matter --
71+
// otherwise two clients zipping the same save in a different order would disagree.
72+
[Fact]
73+
public void ZipHexFile_is_independent_of_entry_order()
74+
{
75+
var forward = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".zip");
76+
var reverse = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".zip");
77+
try
78+
{
79+
WriteZip(forward,
80+
new ZipEntrySpec("a.sav", new byte[] { 0x00, 0x01, 0x02 }),
81+
new ZipEntrySpec("b.sav", new byte[] { 0xFF, 0xFE }));
82+
WriteZip(reverse,
83+
new ZipEntrySpec("b.sav", new byte[] { 0xFF, 0xFE }),
84+
new ZipEntrySpec("a.sav", new byte[] { 0x00, 0x01, 0x02 }));
85+
86+
Assert.Equal(SaveFileHash.ZipHexFile(forward), SaveFileHash.ZipHexFile(reverse));
87+
}
88+
finally
89+
{
90+
File.Delete(forward);
91+
File.Delete(reverse);
92+
}
93+
}
94+
95+
// Reporting local state during negotiate hashes the folder directly rather than building a
96+
// throwaway archive, so the two paths have to agree -- including the '/' separator, which
97+
// a Windows path would otherwise contribute as '\'.
98+
[Fact]
99+
public void FolderAsZipHex_matches_the_archive_it_would_produce()
100+
{
101+
var work = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
102+
var folder = Path.Combine(work, "BESCES-53326nico");
103+
var zipPath = Path.Combine(work, "bundle.zip");
104+
try
105+
{
106+
Directory.CreateDirectory(Path.Combine(folder, "nested"));
107+
File.WriteAllBytes(Path.Combine(folder, "icon.sys"), new byte[] { 0x01, 0x02 });
108+
File.WriteAllBytes(Path.Combine(folder, "nested", "data.bin"), new byte[] { 0x03 });
109+
110+
WriteZip(zipPath,
111+
new ZipEntrySpec("BESCES-53326nico/icon.sys", new byte[] { 0x01, 0x02 }),
112+
new ZipEntrySpec("BESCES-53326nico/nested/data.bin", new byte[] { 0x03 }));
113+
114+
Assert.Equal(SaveFileHash.ZipHexFile(zipPath), SaveFileHash.FolderAsZipHex(folder));
115+
}
116+
finally
117+
{
118+
Directory.Delete(work, true);
119+
}
120+
}
121+
122+
private class ZipEntrySpec
123+
{
124+
public ZipEntrySpec(string name, byte[] content)
125+
{
126+
Name = name;
127+
Content = content;
128+
}
129+
130+
public string Name { get; }
131+
public byte[] Content { get; }
132+
}
133+
134+
private static void WriteZip(string path, params ZipEntrySpec[] entries)
135+
{
136+
using (var archive = ZipArchive.Create())
137+
{
138+
foreach (var entry in entries)
139+
archive.AddEntry(entry.Name, new MemoryStream(entry.Content), closeStream: true);
140+
141+
using (var stream = File.OpenWrite(path))
142+
archive.SaveTo(stream, new WriterOptions(CompressionType.Deflate));
143+
}
144+
}
44145
}
45146
}

Saves/SaveFileHash.cs

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
1+
using System.Collections.Generic;
12
using System.IO;
23
using System.Security.Cryptography;
34
using System.Text;
5+
using SharpCompress.Archives;
6+
using SharpCompress.Archives.Zip;
47

58
namespace RomM.Saves
69
{
710
/// <summary>
8-
/// Computes the content hash RomM uses to compare saves. The server hashes save files with
9-
/// MD5 (hex digest of the raw bytes), so the client must match that exactly or negotiate will
10-
/// never report "no_op" / will misclassify identical files. See romm assets_handler.
11+
/// Content hashes RomM uses to compare saves during negotiate. There are two schemes, and
12+
/// picking the wrong one makes the server misclassify a save:
13+
///
14+
/// * A plain save file (RetroArch's .srm) hashes as MD5 over its raw bytes.
15+
/// See <see cref="Md5HexFile"/>.
16+
/// * An archive hashes over its *entries*, not its bytes. The server's _compute_zip_hash
17+
/// MD5s each entry's content, pairs that digest with the entry name, sorts the pairs by
18+
/// name, joins them as "name:hash" separated by '\n', and MD5s that string.
19+
/// See <see cref="ZipHexFile"/>.
20+
///
21+
/// Raw-byte MD5 over an archive can never agree with the server, because zip bytes vary with
22+
/// entry order, compression settings and timestamps while the content does not. Folder-based
23+
/// platforms (PS2 memory cards, Switch, PSP, GameCube) upload archives, so they need the
24+
/// second scheme; using the first would report a conflict on every sync for saves that are in
25+
/// fact identical.
26+
///
27+
/// Cross-checked against argosy-launcher's SaveArchiver.calculateZipHash so both clients agree
28+
/// on what "unchanged" means. SaveFileHashTests pins its published vector.
1129
/// </summary>
1230
public static class SaveFileHash
1331
{
@@ -28,5 +46,95 @@ public static string Md5HexFile(string path)
2846
using (var fs = File.OpenRead(path))
2947
return Md5Hex(fs);
3048
}
49+
50+
/// <summary>
51+
/// Hashes an archive the way the server does: per-entry content digests keyed by entry
52+
/// name, independent of how the archive happens to be laid out. Directory entries carry no
53+
/// content and are skipped.
54+
/// </summary>
55+
public static string ZipHexFile(string path)
56+
{
57+
var entries = new List<KeyValuePair<string, string>>();
58+
59+
using (var archive = ZipArchive.Open(path))
60+
{
61+
foreach (var entry in archive.Entries)
62+
{
63+
if (entry.IsDirectory)
64+
continue;
65+
66+
using (var stream = entry.OpenEntryStream())
67+
entries.Add(new KeyValuePair<string, string>(NormalizeEntryName(entry.Key), Md5Hex(stream)));
68+
}
69+
}
70+
71+
return Combine(entries);
72+
}
73+
74+
/// <summary>
75+
/// The digest <see cref="ZipHexFile"/> would produce for this folder, without building the
76+
/// archive first. Entry names are rooted at the folder's own name so they match what an
77+
/// upload writes, which lets negotiate report local state without a temp file.
78+
/// </summary>
79+
public static string FolderAsZipHex(string folder)
80+
{
81+
return FoldersAsZipHex(new[] { folder });
82+
}
83+
84+
/// <summary>
85+
/// Same as <see cref="FolderAsZipHex"/> for a save whose unit spans several sibling folders
86+
/// (a PS2 game owning multiple card entries, a PSP game's profile and system data).
87+
/// </summary>
88+
public static string FoldersAsZipHex(IEnumerable<string> folders)
89+
{
90+
var entries = new List<KeyValuePair<string, string>>();
91+
92+
foreach (var folder in folders)
93+
{
94+
var root = new DirectoryInfo(folder);
95+
if (!root.Exists)
96+
continue;
97+
98+
foreach (var file in root.GetFiles("*", SearchOption.AllDirectories))
99+
{
100+
var relative = file.FullName.Substring(root.FullName.Length)
101+
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
102+
103+
entries.Add(new KeyValuePair<string, string>(
104+
NormalizeEntryName(root.Name + "/" + relative),
105+
Md5HexFile(file.FullName)));
106+
}
107+
}
108+
109+
return Combine(entries);
110+
}
111+
112+
/// <summary>
113+
/// Zip entry names are '/'-separated by specification. Windows paths are not, so a name
114+
/// derived from the filesystem has to be normalised here or the digest silently diverges
115+
/// from what every other client computes for the very same save.
116+
/// </summary>
117+
private static string NormalizeEntryName(string name)
118+
{
119+
return string.IsNullOrEmpty(name) ? name : name.Replace('\\', '/');
120+
}
121+
122+
private static string Combine(List<KeyValuePair<string, string>> entries)
123+
{
124+
// Ordinal, not culture-aware: the server and the other clients order by raw code unit,
125+
// and a culture-sensitive comparison would reorder names under some locales.
126+
entries.Sort((a, b) => string.CompareOrdinal(a.Key, b.Key));
127+
128+
var sb = new StringBuilder();
129+
for (int i = 0; i < entries.Count; i++)
130+
{
131+
if (i > 0)
132+
sb.Append('\n');
133+
sb.Append(entries[i].Key).Append(':').Append(entries[i].Value);
134+
}
135+
136+
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())))
137+
return Md5Hex(ms);
138+
}
31139
}
32140
}

0 commit comments

Comments
 (0)