Skip to content

Commit d37ef7d

Browse files
Sync master with main after remaining PR 63 fixes
2 parents 2f660b9 + 29ae34f commit d37ef7d

13 files changed

Lines changed: 368 additions & 28 deletions

Core/OivPipeline/InstallPlanner.cs

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public static class InstallPlanner
77
public static IReadOnlyList<InstallOperation> Plan(
88
ClassificationResult classification,
99
IReadOnlyList<BundleFile> files,
10-
BundleManifest manifest)
10+
BundleManifest? manifest)
1111
{
1212
if (!classification.IsClassified)
1313
throw new InvalidOperationException("Cannot plan installation for an unclassified bundle.");
@@ -37,11 +37,11 @@ public static IReadOnlyList<InstallOperation> Plan(
3737
.ToList();
3838
}
3939

40-
private static List<InstallOperation> PlanVehicleAddon(IReadOnlyList<BundleFile> files, BundleManifest manifest)
40+
private static List<InstallOperation> PlanVehicleAddon(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
4141
{
42-
var dlcPackName = manifest.DlcPackName!;
42+
var dlcPackName = ResolveDlcPackName(files, manifest);
4343
var dlcBase = $"mods/update/x64/dlcpacks/{dlcPackName}";
44-
var rootPrefix = DetermineRootPrefix(files, manifest.SourceFolder, dlcPackName);
44+
var rootPrefix = DetermineRootPrefix(files, manifest?.SourceFolder, dlcPackName);
4545

4646
var ops = new List<InstallOperation>();
4747

@@ -65,11 +65,17 @@ private static List<InstallOperation> PlanVehicleAddon(IReadOnlyList<BundleFile>
6565
return ops;
6666
}
6767

68-
private static List<InstallOperation> PlanVehicleReplace(IReadOnlyList<BundleFile> files, BundleManifest manifest)
68+
private static List<InstallOperation> PlanVehicleReplace(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
6969
{
70+
if (manifest is null)
71+
throw new InvalidOperationException("vehicle_replace planning requires manifest target metadata.");
72+
7073
var targetArchivePath = manifest.TargetArchivePath
7174
?? BundleValidator.KnownSlotMapLookup(manifest.ReplaceSlot!);
7275

76+
if (targetArchivePath is null)
77+
throw new InvalidOperationException("vehicle_replace planning requires a target archive path.");
78+
7379
var ops = new List<InstallOperation>();
7480

7581
foreach (var file in files)
@@ -90,7 +96,7 @@ private static List<InstallOperation> PlanVehicleReplace(IReadOnlyList<BundleFil
9096
return ops;
9197
}
9298

93-
private static List<InstallOperation> PlanEls(IReadOnlyList<BundleFile> files, BundleManifest manifest)
99+
private static List<InstallOperation> PlanEls(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
94100
{
95101
var ops = new List<InstallOperation>();
96102

@@ -111,7 +117,7 @@ private static List<InstallOperation> PlanEls(IReadOnlyList<BundleFile> files, B
111117
return ops;
112118
}
113119

114-
private static List<InstallOperation> PlanRphPlugin(IReadOnlyList<BundleFile> files, BundleManifest manifest)
120+
private static List<InstallOperation> PlanRphPlugin(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
115121
{
116122
var ops = new List<InstallOperation>();
117123

@@ -132,7 +138,7 @@ private static List<InstallOperation> PlanRphPlugin(IReadOnlyList<BundleFile> fi
132138
return ops;
133139
}
134140

135-
private static List<InstallOperation> PlanShvdnScript(IReadOnlyList<BundleFile> files, BundleManifest manifest)
141+
private static List<InstallOperation> PlanShvdnScript(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
136142
{
137143
var ops = new List<InstallOperation>();
138144

@@ -154,7 +160,7 @@ private static List<InstallOperation> PlanShvdnScript(IReadOnlyList<BundleFile>
154160
return ops;
155161
}
156162

157-
private static List<InstallOperation> PlanSirenPack(IReadOnlyList<BundleFile> files, BundleManifest manifest)
163+
private static List<InstallOperation> PlanSirenPack(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
158164
{
159165
var ops = new List<InstallOperation>();
160166

@@ -169,11 +175,11 @@ private static List<InstallOperation> PlanSirenPack(IReadOnlyList<BundleFile> fi
169175
return ops;
170176
}
171177

172-
private static List<InstallOperation> PlanWeaponAddon(IReadOnlyList<BundleFile> files, BundleManifest manifest)
178+
private static List<InstallOperation> PlanWeaponAddon(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
173179
{
174-
var dlcPackName = manifest.DlcPackName!;
180+
var dlcPackName = ResolveDlcPackName(files, manifest);
175181
var dlcBase = $"mods/update/x64/dlcpacks/{dlcPackName}";
176-
var rootPrefix = DetermineRootPrefix(files, manifest.SourceFolder, dlcPackName);
182+
var rootPrefix = DetermineRootPrefix(files, manifest?.SourceFolder, dlcPackName);
177183

178184
var ops = new List<InstallOperation>();
179185

@@ -221,6 +227,15 @@ private static List<InstallOperation> PlanWeaponAddon(IReadOnlyList<BundleFile>
221227
return null;
222228
}
223229

230+
private static string ResolveDlcPackName(IReadOnlyList<BundleFile> files, BundleManifest? manifest)
231+
{
232+
var dlcPackName = manifest?.DlcPackName ?? BundleValidator.DetectDlcPackName(files);
233+
if (string.IsNullOrWhiteSpace(dlcPackName))
234+
throw new InvalidOperationException("DLC pack name cannot be determined.");
235+
236+
return dlcPackName;
237+
}
238+
224239
private static List<InstallOperation> DeduplicatePatches(List<InstallOperation> ops)
225240
{
226241
var result = new List<InstallOperation>();

Core/OivPipeline/ManifestReader.cs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
using System.Text.Json;
2+
using System.Text.RegularExpressions;
23
using LSPDFRManager.OivPipeline.Models;
34

45
namespace LSPDFRManager.OivPipeline;
56

67
public static class ManifestReader
78
{
9+
private static readonly Regex DlcPackNamePattern = new("^[A-Za-z0-9_-]+$", RegexOptions.CultureInvariant);
10+
811
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
912
{
1013
"vehicle_addon", "vehicle_replace", "els", "rph_plugin",
@@ -67,6 +70,8 @@ public static ManifestReadResult Read(IReadOnlyList<BundleFile> files, string bu
6770
var dlc = GetString(root, "dlcPackName");
6871
if (string.IsNullOrEmpty(dlc))
6972
errors.Add($"manifest.json: 'dlcPackName' is required for type '{typeValue}'.");
73+
else if (!DlcPackNamePattern.IsMatch(dlc))
74+
errors.Add("manifest.json: 'dlcPackName' may only contain letters, numbers, underscores, and hyphens.");
7075
}
7176

7277
if (typeValue.Equals("vehicle_replace", StringComparison.OrdinalIgnoreCase))
@@ -77,6 +82,19 @@ public static ManifestReadResult Read(IReadOnlyList<BundleFile> files, string bu
7782
errors.Add("manifest.json: 'replaceSlot' or 'targetArchivePath' is required for type 'vehicle_replace'.");
7883
}
7984

85+
var sourceFolder = GetString(root, "sourceFolder");
86+
if (!string.IsNullOrWhiteSpace(sourceFolder))
87+
{
88+
if (!IsSafeRelativeManifestPath(sourceFolder))
89+
errors.Add("manifest.json: 'sourceFolder' must be a relative path and cannot contain '..' segments.");
90+
else if (!BundleContainsFolder(files, sourceFolder))
91+
errors.Add($"manifest.json: 'sourceFolder' '{sourceFolder}' was not found in the bundle.");
92+
}
93+
94+
var targetArchivePath = GetString(root, "targetArchivePath");
95+
if (!string.IsNullOrWhiteSpace(targetArchivePath) && !IsSafeRelativeManifestPath(targetArchivePath))
96+
errors.Add("manifest.json: 'targetArchivePath' must be a relative path and cannot contain '..' segments.");
97+
8098
if (errors.Count > 0)
8199
return new ManifestReadResult { ValidationErrors = errors };
82100

@@ -86,16 +104,35 @@ public static ManifestReadResult Read(IReadOnlyList<BundleFile> files, string bu
86104
PackageName = GetString(root, "packageName"),
87105
DlcPackName = GetString(root, "dlcPackName"),
88106
ReplaceSlot = GetString(root, "replaceSlot"),
89-
SourceFolder = GetString(root, "sourceFolder"),
107+
SourceFolder = sourceFolder,
90108
ConfigOnly = GetBool(root, "configOnly"),
91109
Dependencies = GetStringArray(root, "dependencies"),
92-
TargetArchivePath = GetString(root, "targetArchivePath")
110+
TargetArchivePath = targetArchivePath
93111
};
94112

95113
return new ManifestReadResult { Manifest = manifest };
96114
}
97115
}
98116

117+
private static bool IsSafeRelativeManifestPath(string path)
118+
{
119+
var normalized = path.Replace('\\', '/').Trim();
120+
if (string.IsNullOrWhiteSpace(normalized))
121+
return false;
122+
123+
if (Path.IsPathRooted(path) || normalized.StartsWith('/') || normalized.StartsWith('\\'))
124+
return false;
125+
126+
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
127+
return segments.Length > 0 && !segments.Any(s => s.Equals("..", StringComparison.Ordinal));
128+
}
129+
130+
private static bool BundleContainsFolder(IReadOnlyList<BundleFile> files, string sourceFolder)
131+
{
132+
var prefix = sourceFolder.Replace('\\', '/').Trim().TrimEnd('/') + "/";
133+
return files.Any(f => f.RelativePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
134+
}
135+
99136
private static string? GetString(JsonElement element, string property)
100137
{
101138
if (element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String)

Core/OivPipeline/OivBuildPipeline.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public async Task<PipelineResult> RunAsync(
8989
IReadOnlyList<InstallOperation> operations;
9090
try
9191
{
92-
operations = InstallPlanner.Plan(classification, files, manifestResult.Manifest!);
92+
operations = InstallPlanner.Plan(classification, files, manifestResult.Manifest);
9393
}
9494
catch (Exception ex)
9595
{

LSPDFRManager.Shared/Services/BackupService.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ public async Task<string> CreateBackupAsync(IProgress<string>? progress = null)
1010
var config = AppConfig.Instance;
1111
Directory.CreateDirectory(config.BackupPath);
1212

13-
var backupPath = Path.Combine(
14-
config.BackupPath,
15-
$"lspmanager_backup_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.zip");
13+
var backupPath = GetUniqueBackupPath(config.BackupPath);
1614

1715
progress?.Report("Creating backup...");
1816

@@ -82,6 +80,13 @@ public IEnumerable<string> ListBackups()
8280
.OrderByDescending(file => file);
8381
}
8482

83+
private static string GetUniqueBackupPath(string backupDirectory)
84+
{
85+
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss-fff");
86+
var suffix = Guid.NewGuid().ToString("N")[..8];
87+
return Path.Combine(backupDirectory, $"lspmanager_backup_{timestamp}_{suffix}.zip");
88+
}
89+
8590
private static IEnumerable<string> GetFilesToBackup()
8691
{
8792
yield return AppDataPaths.LibraryFile;

LSPDFRManager.Shared/Services/InstalledModFileService.cs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ public void SetEnabled(InstalledMod mod, bool enabled)
1010
ArgumentNullException.ThrowIfNull(mod);
1111

1212
var failed = new List<string>();
13+
var completedRenames = new List<(string from, string to)>();
1314
foreach (var file in mod.InstalledFiles.Distinct(StringComparer.OrdinalIgnoreCase))
1415
{
1516
try
1617
{
17-
ToggleFile(file, enabled);
18+
var rename = ToggleFile(file, enabled);
19+
if (rename is not null)
20+
completedRenames.Add(rename.Value);
1821
}
1922
catch (Exception ex)
2023
{
@@ -26,7 +29,10 @@ public void SetEnabled(InstalledMod mod, bool enabled)
2629
if (failed.Count == 0)
2730
mod.IsEnabled = enabled;
2831
else
32+
{
33+
RollbackRenames(completedRenames);
2934
AppLogger.Warning($"SetEnabled({enabled}) incomplete for '{mod.Name}': {failed.Count} file(s) could not be toggled — library state not updated.");
35+
}
3036
}
3137

3238
public ModUninstallResult Uninstall(InstalledMod mod) => Uninstall(mod, []);
@@ -157,19 +163,43 @@ private static bool IsDlcPackUsedByOtherMod(InstalledMod mod, IEnumerable<Instal
157163
other.DlcPackName.Equals(mod.DlcPackName, StringComparison.OrdinalIgnoreCase));
158164
}
159165

160-
private static void ToggleFile(string file, bool enabled)
166+
private static (string from, string to)? ToggleFile(string file, bool enabled)
161167
{
162168
var disabledPath = GetDisabledPath(file);
163169

164170
if (enabled)
165171
{
166172
if (File.Exists(disabledPath) && !File.Exists(file))
173+
{
167174
File.Move(disabledPath, file);
168-
return;
175+
return (disabledPath, file);
176+
}
177+
return null;
169178
}
170179

171180
if (File.Exists(file))
181+
{
172182
File.Move(file, disabledPath);
183+
return (file, disabledPath);
184+
}
185+
186+
return null;
187+
}
188+
189+
private static void RollbackRenames(List<(string from, string to)> completedRenames)
190+
{
191+
foreach (var (from, to) in completedRenames.AsEnumerable().Reverse())
192+
{
193+
try
194+
{
195+
if (File.Exists(to) && !File.Exists(from))
196+
File.Move(to, from);
197+
}
198+
catch (Exception ex)
199+
{
200+
AppLogger.Warning($"Rollback toggle '{to}' -> '{from}' failed: {ex.Message}");
201+
}
202+
}
173203
}
174204

175205
private static string GetDisabledPath(string file) => file + ".disabled";

LSPDFRManager.Shared/Services/LspdfrInstallLocator.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,8 @@ public static class LspdfrInstallLocator
4646
public static bool IsGtaInstalled(string gtaPath) => FindGtaExe(gtaPath) is not null;
4747

4848
public static bool IsLspdfrInstalled(string gtaPath) =>
49-
FindLspdfrCore(gtaPath) is not null ||
50-
FindLspdfrFolder(gtaPath) is not null ||
51-
FindLspdfrTool(gtaPath) is not null;
49+
FindRagePluginHook(gtaPath) is not null &&
50+
File.Exists(Path.Combine(gtaPath, Normalize(@"plugins\LSPD First Response.dll")));
5251

5352
public static bool IsRagePluginHookInstalled(string gtaPath) =>
5453
FindRagePluginHook(gtaPath) is not null &&

LSPDFRManager.Shared/Services/OivService.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,9 +372,10 @@ public static async Task<InstallResult> InstallPackage(OivPackage pkg, string ta
372372
return new InstallResult { Success = false, Error = err };
373373
}
374374

375-
// Pre-flight: verify all declared content entries exist in the archive before writing anything.
376-
using (var preflight = ZipFile.OpenRead(pkg.SourcePath))
375+
try
377376
{
377+
// Pre-flight: verify all declared content entries exist in the archive before writing anything.
378+
using var preflight = ZipFile.OpenRead(pkg.SourcePath);
378379
var missingEntries = pkg.Files
379380
.Select(f => f.SourcePath.Replace('\\', '/').TrimStart('/'))
380381
.Where(key => preflight.GetEntry(key) is null)
@@ -387,6 +388,12 @@ public static async Task<InstallResult> InstallPackage(OivPackage pkg, string ta
387388
return new InstallResult { Success = false, Error = err };
388389
}
389390
}
391+
catch (Exception ex)
392+
{
393+
var err = $"Unable to read OIV archive: {ex.Message}";
394+
AppLogger.Error($"[OIV_ERROR] {err}", ex);
395+
return new InstallResult { Success = false, Error = err };
396+
}
390397

391398
var writtenFiles = new List<string>();
392399
var backupRoot = Path.Combine(Path.GetTempPath(), $".oiv_rollback_{Guid.NewGuid():N}");

LSPDFRManager.Tests/BackupServiceTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ public async Task CreateBackup_ZipContainsLibraryJson()
6666
Assert.True(hasLibrary, "Backup zip should contain library.json");
6767
}
6868

69+
[Fact]
70+
public async Task CreateBackup_TwiceInSameSecond_UsesDistinctFileNames()
71+
{
72+
var svc = new BackupService();
73+
74+
var first = await svc.CreateBackupAsync();
75+
var second = await svc.CreateBackupAsync();
76+
77+
Assert.NotEqual(first, second);
78+
Assert.True(File.Exists(first));
79+
Assert.True(File.Exists(second));
80+
}
81+
6982
[Fact]
7083
public async Task ListBackups_AfterCreate_ReturnsCreatedBackup()
7184
{

LSPDFRManager.Tests/LspdfrInstallLocatorTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,38 @@ public void Locator_FindsLspdfrToolInRootSupportFolder()
3636
File.WriteAllText(tool, "fake");
3737

3838
Assert.Equal(tool, LspdfrInstallLocator.FindLspdfrTool(GtaDir));
39+
Assert.False(LspdfrInstallLocator.IsLspdfrInstalled(GtaDir));
40+
}
41+
42+
[Fact]
43+
public void IsLspdfrInstalled_ReturnsTrue_WhenRphExeAndOfficialPluginDllExist()
44+
{
45+
File.WriteAllText(Path.Combine(GtaDir, "RAGEPluginHook.exe"), "fake");
46+
Directory.CreateDirectory(Path.Combine(GtaDir, "plugins"));
47+
File.WriteAllText(Path.Combine(GtaDir, "plugins", "LSPD First Response.dll"), "fake");
48+
3949
Assert.True(LspdfrInstallLocator.IsLspdfrInstalled(GtaDir));
4050
}
4151

52+
[Fact]
53+
public void IsLspdfrInstalled_ReturnsFalse_WhenOfficialPluginDllMissing()
54+
{
55+
File.WriteAllText(Path.Combine(GtaDir, "RAGEPluginHook.exe"), "fake");
56+
Directory.CreateDirectory(Path.Combine(GtaDir, "plugins"));
57+
File.WriteAllText(Path.Combine(GtaDir, "plugins", "LSPDFR.dll"), "fake");
58+
59+
Assert.False(LspdfrInstallLocator.IsLspdfrInstalled(GtaDir));
60+
}
61+
62+
[Fact]
63+
public void IsLspdfrInstalled_ReturnsFalse_WhenRagePluginHookExeMissing()
64+
{
65+
Directory.CreateDirectory(Path.Combine(GtaDir, "plugins"));
66+
File.WriteAllText(Path.Combine(GtaDir, "plugins", "LSPD First Response.dll"), "fake");
67+
68+
Assert.False(LspdfrInstallLocator.IsLspdfrInstalled(GtaDir));
69+
}
70+
4271
[Fact]
4372
public void FindGtaExe_ReturnsGTA5BE_WhenOnlyThatExists()
4473
{

0 commit comments

Comments
 (0)