Skip to content

Commit cdf05ad

Browse files
committed
Merge remote-tracking branch 'origin/main' into saves/zip-aware-hashing
2 parents f722390 + 940a10c commit cdf05ad

19 files changed

Lines changed: 455 additions & 60 deletions

Downloads/DownloadQueueController.cs

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Playnite.SDK;
22
using Playnite.SDK.Plugins;
3+
using RomM.Games;
34
using SharpCompress.Archives;
45
using SharpCompress.Common;
56
using System;
@@ -176,7 +177,8 @@ private async Task DownloadAndInstall(DownloadQueueItem item, DownloadRequest re
176177
}
177178

178179
// Extract if needed (we treat extract as 0..100 in its own bar)
179-
if (req.HasMultipleFiles || (req.AutoExtract && IsFileCompressed(req.GamePath)))
180+
// Don't extract archives when install flat is enabled
181+
if (req.HasMultipleFiles || (req.AutoExtract && IsFileCompressed(req.GamePath) && !req.InstallFlat))
180182
{
181183
item.SetStatus(DownloadStatus.Extracting, "Extracting...");
182184
Logger.Info($"Extracting {req.GamePath}...");
@@ -213,6 +215,32 @@ private static bool IsFileCompressed(string filePath)
213215
}
214216

215217

218+
// 7-Zip extracts into the output dir and drops absolute paths and ".." components unless -spf
219+
// is passed (we never pass it), but the entry names are checked up front anyway so both
220+
// extraction paths refuse traversal the same way. Formats SharpCompress cannot open fall back
221+
// to 7-Zip's own handling rather than failing a download that used to work.
222+
private void EnsureEntriesContained(string archivePath, string installDir)
223+
{
224+
try
225+
{
226+
using (var archive = ArchiveFactory.Open(archivePath))
227+
{
228+
foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
229+
{
230+
RomMInstallPaths.ResolveWithin(installDir, entry.Key);
231+
}
232+
}
233+
}
234+
catch (ArgumentException)
235+
{
236+
throw;
237+
}
238+
catch (Exception ex)
239+
{
240+
Logger.Warn($"Could not inspect {archivePath} before extraction: {ex.Message}");
241+
}
242+
}
243+
216244
private void ExtractArchiveWith7z(string pathTo7z, string archivePath, string installDir, DownloadQueueItem item, CancellationToken ct)
217245
{
218246
if (archivePath == null || archivePath.Contains("../") || archivePath.Contains(@"..\"))
@@ -224,6 +252,8 @@ private void ExtractArchiveWith7z(string pathTo7z, string archivePath, string in
224252
throw new ArgumentException("Invalid install directory path");
225253
}
226254

255+
EnsureEntriesContained(archivePath, installDir);
256+
227257
ProcessStartInfo startInfo = new ProcessStartInfo
228258
{
229259
FileName = pathTo7z,
@@ -256,11 +286,17 @@ private void ExtractArchiveWithEntryProgress(string archivePath, string installD
256286
{
257287
ct.ThrowIfCancellationRequested();
258288

259-
entry.WriteToDirectory(installDir, new ExtractionOptions
289+
// Entry names come from the downloaded archive, so they are untrusted: handing a
290+
// "../" or rooted key to ExtractFullPath would write outside installDir. Resolve
291+
// and verify each destination, then copy the entry there ourselves.
292+
var destination = RomMInstallPaths.ResolveWithin(installDir, entry.Key);
293+
Directory.CreateDirectory(Path.GetDirectoryName(destination));
294+
295+
using (var entryStream = entry.OpenEntryStream())
296+
using (var file = File.Create(destination))
260297
{
261-
ExtractFullPath = true,
262-
Overwrite = true
263-
});
298+
entryStream.CopyTo(file);
299+
}
264300

265301
done++;
266302
item.SetProgress(done, total, false);
@@ -288,7 +324,8 @@ private void TryCleanupPartialInstall(DownloadRequest req)
288324
SafeDeleteFileWithRetry(req.GamePath);
289325

290326
// delete folder (recursively) if it exists
291-
SafeDeleteDirectoryWithRetry(req.InstallDir);
327+
if(!req.InstallFlat)
328+
SafeDeleteDirectoryWithRetry(req.InstallDir);
292329
}
293330
catch (Exception ex)
294331
{

Downloads/DownloadRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class DownloadRequest
1616
public bool AutoExtract { get; set; } = true;
1717
public bool Use7z { get; set; } = false;
1818
public string PathTo7Z { get; set; } = "";
19-
19+
public bool InstallFlat { get; set; } = false;
2020

2121
/// Optional function used after extraction to build rom list for Playnite
2222
public Func<List<GameRom>> BuildRoms { get; set; }

Games/RomMGameInfo.Plugin.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public EmulatorMapping Mapping
2626

2727
public InstallController GetInstallController(Game game, RomM romm, GameInstallInfo GameData) => new RomMInstallController(game, romm, GameData);
2828

29-
public UninstallController GetUninstallController(Game game, RomM romm) => new RomMUninstallController(game, romm);
29+
public UninstallController GetUninstallController(Game game, RomM romm, EmulatorMapping mapping) => new RomMUninstallController(game, romm, mapping);
3030

3131
protected IEnumerable<string> GetDescriptionLines()
3232
{

Games/RomMImport.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,29 @@ private Game ImportGame(RomMRom ROM, Guid StatusID)
189189
// Paths must be derived from the actual ROM file (what RomMInstallController downloads),
190190
// not the display Name. Using Name drops the extension and can include characters that
191191
// don't match the installed file, breaking IsInstalled detection and the play path.
192+
//
193+
// For folder-based ROMs we point at a real file inside the ROM's folder (fs_name):
194+
// - nested single file: the one file in the folder,
195+
// - multiple files: the primary file (the download descriptor's FileName is the folder
196+
// name / archive base, which is not itself a real file, so use the primary file here).
192197
var baseRevision = BuildRevision(ROM);
193-
var fileName = !string.IsNullOrEmpty(baseRevision?.FileName) ? baseRevision.FileName : ROM.Name;
194-
var gameInstallDir = RomMInstallPaths.InstallDir(rootInstallDir, fileName);
195-
var pathToGame = RomMInstallPaths.GamePath(rootInstallDir, fileName);
198+
var folderName = baseRevision?.FolderName;
199+
var playableFile = ROM.HasMultipleFiles
200+
? RomMRevisionFactory.RelativeFilePath(RomMRevisionFactory.SelectPrimaryFile(ROM.Files), folderName)
201+
: baseRevision?.FileName;
202+
// With no file list, fs_name still beats the extensionless display Name.
203+
var fileName = !string.IsNullOrEmpty(playableFile) ? playableFile
204+
: !string.IsNullOrEmpty(ROM.FileName) ? ROM.FileName
205+
: ROM.Name;
206+
// Skip the ROM rather than letting the throw from RomMInstallPaths abort the whole platform.
207+
if (!RomMInstallPaths.IsContained(folderName) || !RomMInstallPaths.IsContained(fileName))
208+
{
209+
_plugin.Logger.Error($"[Importer] RomM ID {ROM.Id} has a path outside the install root: {folderName} / {fileName}");
210+
return null;
211+
}
212+
213+
var gameInstallDir = _mapping.InstallFlat ? rootInstallDir : RomMInstallPaths.InstallDir(rootInstallDir, folderName, fileName);
214+
var pathToGame = _mapping.InstallFlat ? $"{rootInstallDir}\\{fileName}" : RomMInstallPaths.GamePath(rootInstallDir, folderName, fileName);
196215

197216
var status = _plugin.Playnite.Database.CompletionStatuses.Get(StatusID);
198217
var completionStatusProperty = status != null ? new MetadataNameProperty(status.Name) : null;
@@ -227,6 +246,7 @@ private Game ImportGame(RomMRom ROM, Guid StatusID)
227246
}
228247
};
229248

249+
230250
// Import new game
231251
Game game = _plugin.Playnite.Database.ImportGame(metadata, _plugin);
232252

Games/RomMInstallController.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
using Playnite.SDK;
1+
using Newtonsoft.Json;
2+
using Playnite.SDK;
23
using Playnite.SDK.Models;
34
using Playnite.SDK.Plugins;
45
using RomM.Downloads;
56
using RomM.Models.RomM.Rom;
7+
using RomM.Settings;
68
using SharpCompress.Archives;
79
using System;
810
using System.Collections.Generic;
911
using System.IO;
1012
using System.Linq;
11-
using Newtonsoft.Json;
1213

1314
namespace RomM.Games
1415
{
@@ -41,8 +42,14 @@ public override void Install(InstallActionArgs args)
4142
var dstPath = _gameData.Mapping?.DestinationPathResolved
4243
?? throw new Exception("Mapped emulator data cannot be found, try removing and re-adding.");
4344

44-
// Paths (same as before)
45-
var installDir = Path.Combine(dstPath, Path.GetFileNameWithoutExtension(_gameData.FileName));
45+
// Install dir mirrors RomM's on-disk layout: folder-based ROMs (nested single / multiple
46+
// files) install into the ROM's folder (fs_name); simple single files fall back to a
47+
// folder derived from the file name. Must match the path computed at import time so
48+
// IsInstalled detection lines up.
49+
var installDir = RomMInstallPaths.InstallDir(dstPath, _gameData.FolderName, _gameData.FileName);
50+
51+
if (_gameData.Mapping.InstallFlat)
52+
installDir = dstPath;
4653

4754
// If RomM indicates multiple files, we download as an archive name (zip) into the install folder.
4855
// Otherwise we download the single ROM file.
@@ -63,6 +70,7 @@ public override void Install(InstallActionArgs args)
6370

6471
HasMultipleFiles = _gameData.HasMultipleFiles,
6572
AutoExtract = _gameData.Mapping != null && _gameData.Mapping.AutoExtract,
73+
InstallFlat = _gameData.Mapping.InstallFlat,
6674

6775
// Called by queue AFTER download/extract is done
6876
BuildRoms = () =>

Games/RomMInstallPaths.cs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,69 @@
1+
using System;
12
using System.IO;
3+
using System.Linq;
24

35
namespace RomM.Games
46
{
57
// Derives a ROM's install directory and playable path. These MUST come from the actual ROM file
68
// name (what gets downloaded), not the display name: using the display name drops the extension
79
// and can include characters that don't match the installed file, breaking IsInstalled detection
810
// and the play path.
11+
//
12+
// For folder-based ROMs (nested single file / multiple files) a non-null folderName (fs_name)
13+
// pins the directory to the ROM's actual folder on the RomM filesystem, instead of deriving it
14+
// from the download file name — the file name can carry region tags and an extension that the
15+
// containing folder does not (e.g. file "Game (Europe).zip" inside folder "Game").
916
internal static class RomMInstallPaths
1017
{
18+
// fs_name and file names come straight from the server, so they are untrusted. A rooted value
19+
// ("/tmp", @"C:\x", @"\x") makes Path.Combine discard rootInstallDir and ".." walks back out
20+
// of it — either would let the download and archive extraction write outside the configured
21+
// mapping. Nested relative paths (a primary file inside a subfolder) stay allowed.
22+
// Rooting is checked by hand rather than via Path.IsPathRooted so a Windows-rooted value is
23+
// still rejected when this runs on another platform (e.g. the test host).
24+
public static bool IsContained(string path)
25+
=> string.IsNullOrEmpty(path)
26+
|| (path[0] != '/'
27+
&& path[0] != '\\'
28+
&& path.IndexOf(':') < 0
29+
&& !path.Split('/', '\\').Any(segment => segment == ".."));
30+
31+
private static string Contained(string path)
32+
=> IsContained(path) ? path : throw new ArgumentException($"Path from RomM escapes the install root: {path}");
33+
34+
// Resolves an untrusted relative path against a trusted root, throwing unless the result stays
35+
// inside it. Archive entry names are attacker-controlled too, so extraction resolves every
36+
// destination through here instead of handing raw keys to SharpCompress' ExtractFullPath.
37+
public static string ResolveWithin(string root, string relativePath)
38+
{
39+
if (string.IsNullOrEmpty(relativePath))
40+
throw new ArgumentException("Archive entry has no name, refusing to extract it.");
41+
42+
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
43+
var destination = Path.GetFullPath(Path.Combine(fullRoot, Contained(relativePath)));
44+
45+
if (!destination.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
46+
throw new ArgumentException($"Path escapes the install directory: {relativePath}");
47+
48+
return destination;
49+
}
50+
1151
// <root>/<file name without extension>
1252
public static string InstallDir(string rootInstallDir, string fileName)
13-
=> Path.Combine(rootInstallDir, Path.GetFileNameWithoutExtension(fileName));
53+
=> Path.Combine(rootInstallDir, Path.GetFileNameWithoutExtension(Contained(fileName)));
54+
55+
// <root>/<folder name> when folderName is set, otherwise <root>/<file name without extension>.
56+
public static string InstallDir(string rootInstallDir, string folderName, string fileName)
57+
=> string.IsNullOrEmpty(folderName)
58+
? InstallDir(rootInstallDir, fileName)
59+
: Path.Combine(rootInstallDir, Contained(folderName));
1460

1561
// <root>/<file name without extension>/<file name>
1662
public static string GamePath(string rootInstallDir, string fileName)
17-
=> Path.Combine(InstallDir(rootInstallDir, fileName), fileName);
63+
=> Path.Combine(InstallDir(rootInstallDir, fileName), Contained(fileName));
64+
65+
// <install dir>/<file name>, using the folder-aware install dir.
66+
public static string GamePath(string rootInstallDir, string folderName, string fileName)
67+
=> Path.Combine(InstallDir(rootInstallDir, folderName, fileName), Contained(fileName));
1868
}
1969
}

Games/RomMRevisionFactory.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System;
12
using System.Collections.Generic;
3+
using System.IO;
24
using System.Linq;
35
using RomM.Models.RomM.Rom;
46

@@ -20,6 +22,24 @@ public static RomMFile SelectPrimaryFile(IList<RomMFile> files)
2022
return files.FirstOrDefault();
2123
}
2224

25+
// A file's path relative to the ROM folder (fs_name). Extraction preserves subdirectories, so
26+
// a file below another directory needs "sub/file.bin", not just "file.bin". Falls back to the
27+
// leaf name when the folder is not part of the full path.
28+
public static string RelativeFilePath(RomMFile file, string folderName)
29+
{
30+
if (file == null)
31+
return null;
32+
33+
var segments = (file.FullPath ?? string.Empty).Split('/');
34+
var folderIndex = string.IsNullOrEmpty(folderName)
35+
? -1
36+
: Array.FindLastIndex(segments, s => s.Equals(folderName, StringComparison.OrdinalIgnoreCase));
37+
38+
return folderIndex >= 0 && folderIndex < segments.Length - 1
39+
? string.Join(Path.DirectorySeparatorChar.ToString(), segments.Skip(folderIndex + 1))
40+
: file.FileName;
41+
}
42+
2343
// Returns null when a single-file ROM has no resolvable file. Single files use the 4.9
2444
// /files/content endpoint when a file id is present, falling back to the rom-level endpoint
2545
// (so we never emit "api/roms//files/content/..."); multi-file ROMs download the whole archive.
@@ -39,13 +59,18 @@ public static RomMRevision Build(RomMRom rom, string romMHost)
3959
return null;
4060

4161
revision.FileName = romfile.FileName;
62+
// A nested single file lives inside a folder named after the ROM (fs_name); a simple
63+
// single file sits directly in the platform folder and has no wrapping folder.
64+
revision.FolderName = rom.HasNestedSingleFile ? rom.FileName : null;
4265
revision.DownloadURL = romfile.Id.HasValue
4366
? RomMUrl.Combine(romMHost, $"api/roms/{romfile.Id}/files/content/{romfile.FileName}")
4467
: RomMUrl.Combine(romMHost, $"api/roms/{rom.Id}/content/{romfile.FileName}");
4568
}
4669
else
4770
{
4871
revision.FileName = rom.FileName;
72+
// Multi-file ROMs are always stored in a folder named after the ROM (fs_name).
73+
revision.FolderName = rom.FileName;
4974
revision.DownloadURL = RomMUrl.Combine(romMHost, $"api/roms/{rom.Id}/content/{rom.FileName}");
5075
}
5176

Games/RomMUninstallController.cs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Playnite.SDK.Models;
22
using Playnite.SDK.Plugins;
3+
using RomM.Settings;
34
using System.IO;
45
using System.Windows;
56

@@ -8,24 +9,38 @@ namespace RomM.Games
89
internal class RomMUninstallController : UninstallController
910
{
1011
private readonly IRomM _romM;
12+
private EmulatorMapping _mapping;
1113

12-
internal RomMUninstallController(Game game, IRomM romM) : base(game)
14+
internal RomMUninstallController(Game game, IRomM romM, EmulatorMapping mapping) : base(game)
1315
{
1416
Name = "Uninstall";
1517
_romM = romM;
18+
_mapping = mapping;
1619
}
1720

1821
public override void Uninstall(UninstallActionArgs args)
1922
{
20-
if (new DirectoryInfo(Game.InstallDirectory).Exists)
23+
if(_mapping.InstallFlat)
2124
{
22-
Directory.Delete(Game.InstallDirectory, true);
25+
foreach (var RomFile in Game.Roms)
26+
{
27+
if(File.Exists(RomFile.Path))
28+
File.Delete(RomFile.Path);
29+
}
2330
}
2431
else
2532
{
26-
_romM.Playnite.Dialogs.ShowMessage($"\"{Game.Name}\" folder could not be found. Marking as uninstalled.", "Game not found", MessageBoxButton.OK);
33+
if (new DirectoryInfo(Game.InstallDirectory).Exists)
34+
{
35+
Directory.Delete(Game.InstallDirectory, true);
36+
}
37+
else
38+
{
39+
_romM.Playnite.Dialogs.ShowMessage($"\"{Game.Name}\" folder could not be found. Marking as uninstalled.", "Game not found", MessageBoxButton.OK);
40+
}
2741
}
28-
Game.Roms.Clear();
42+
43+
Game.Roms.Clear();
2944
InvokeOnUninstalled(new GameUninstalledEventArgs());
3045
}
3146
}

Models/RomM/Rom/GameInstallInfo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public struct GameInstallInfo
88
{
99
public int Id { get; set; }
1010
public string FileName { get; set; }
11+
public string FolderName { get; set; }
1112
public bool HasMultipleFiles { get; set; }
1213
public string DownloadURL { get; set; }
1314
public EmulatorMapping Mapping { get; set; }

Models/RomM/Rom/RomMRomLocal.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ public class RomMRevision
1414
{
1515
public int Id { get; set; }
1616
public string FileName { get; set; }
17+
18+
// The ROM's folder on the RomM filesystem (fs_name) for folder-based ROMs (nested single file
19+
// or multiple files). Null/empty for a "simple" single file that lives directly in the platform
20+
// folder. Install paths use this so they mirror RomM's on-disk layout instead of being derived
21+
// from the download file name (which can carry region tags / an extension the folder doesn't).
22+
public string FolderName { get; set; }
23+
1724
public bool HasMultipleFiles { get; set; }
1825
public string DownloadURL { get; set; }
1926
public bool IsSelected { get; set; }

0 commit comments

Comments
 (0)