|
| 1 | +using System; |
1 | 2 | using System.IO; |
| 3 | +using System.Linq; |
2 | 4 |
|
3 | 5 | namespace RomM.Games |
4 | 6 | { |
5 | 7 | // Derives a ROM's install directory and playable path. These MUST come from the actual ROM file |
6 | 8 | // name (what gets downloaded), not the display name: using the display name drops the extension |
7 | 9 | // and can include characters that don't match the installed file, breaking IsInstalled detection |
8 | 10 | // 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"). |
9 | 16 | internal static class RomMInstallPaths |
10 | 17 | { |
| 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 | + |
11 | 51 | // <root>/<file name without extension> |
12 | 52 | 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)); |
14 | 60 |
|
15 | 61 | // <root>/<file name without extension>/<file name> |
16 | 62 | 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)); |
18 | 68 | } |
19 | 69 | } |
0 commit comments