diff --git a/.github/release-notes/v2.3.1.md b/.github/release-notes/v2.3.1.md new file mode 100644 index 0000000..6e53bfa --- /dev/null +++ b/.github/release-notes/v2.3.1.md @@ -0,0 +1,22 @@ +# BadBuilderLinux v2.3.1 + +Fix a preflight failure with ABadAvatar 1.3 Beta + XeUnshackle Max that reported: + +`The expected installation overwrite did not occur: BadUpdatePayload/default.xex` + +## Fixes + +- Treat `BadUpdatePayload/default.xex` as an **optional overlay** for XeUnshackle and XeUnshackle Max. It can be added when absent or overwritten when present. +- Maintain strict preflight protection against all undeclared file collisions. +- Require the optional overlay source actually to be present, catching broken archive layouts. +- Retain strict required-overwrite behavior for other operations. +- Add regression tests covering both conditions, unexpected collisions and missing optional sources. +- Preserve the SHA-256-pinned XeUnshackle Max package and existing Linux/Windows safety features. + +## Downloads + +- `BadBuilder-linux-x64` — self-contained Linux x64 +- `BadBuilder-win-x64.exe` — self-contained Windows x64 +- `SHA256SUMS` — SHA-256 checksums + +Previously downloaded and verified archives can be reused. The failed preflight did not format the selected USB drive. BadBuilder still erases the target disk after explicit destructive-operation confirmation. diff --git a/BadBuilder.Tests/GitHubAndDownloadTests.cs b/BadBuilder.Tests/GitHubAndDownloadTests.cs index 3885443..807935d 100644 --- a/BadBuilder.Tests/GitHubAndDownloadTests.cs +++ b/BadBuilder.Tests/GitHubAndDownloadTests.cs @@ -64,7 +64,8 @@ public void Catalog_XeUnshackleMax_PinsReleaseAndOmitsRemoteAccessPlugins() Assert.Equal(2, artifact.Operations?.Count); Assert.Equal("BadUpdatePayload", artifact.Operations![0].DestinationPath); Assert.Equal("/BadUpdatePayload", artifact.Operations[0].SourcePath); - Assert.Contains("BadUpdatePayload/default.xex", artifact.Operations[0].AllowedOverwritePaths ?? []); + Assert.Contains("BadUpdatePayload/default.xex", artifact.Operations[0].OptionalOverwritePaths ?? []); + Assert.Empty(artifact.Operations[0].AllowedOverwritePaths ?? []); Assert.Equal("launch.ini", artifact.Operations[1].DestinationPath); Assert.Equal("/launch.ini", artifact.Operations[1].SourcePath); } diff --git a/BadBuilder.Tests/InstallAndFormattingTests.cs b/BadBuilder.Tests/InstallAndFormattingTests.cs index b941262..3fc056d 100644 --- a/BadBuilder.Tests/InstallAndFormattingTests.cs +++ b/BadBuilder.Tests/InstallAndFormattingTests.cs @@ -150,8 +150,10 @@ [new InstallOperation( [(firstArtifact, first), (missingExpected, second)], [], 128L * 1024 * 1024)); } - [Fact] - public async Task XeUnshackleMax_OverlaysOnlyPayloadAndLaunchConfig() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task XeUnshackleMax_OverlaysOnlyPayloadAndLaunchConfig(bool exploitHasDefaultXex) { using TemporaryDirectory temporary = new(); string exploitRoot = Path.Combine(temporary.Path, "exploit"); @@ -162,7 +164,10 @@ public async Task XeUnshackleMax_OverlaysOnlyPayloadAndLaunchConfig() Directory.CreateDirectory(Path.Combine(maxFolder, "BadUpdatePayload")); Directory.CreateDirectory(target); - await File.WriteAllTextAsync(Path.Combine(exploitRoot, "BadUpdatePayload", "default.xex"), "exploit"); + if (exploitHasDefaultXex) + await File.WriteAllTextAsync(Path.Combine(exploitRoot, "BadUpdatePayload", "default.xex"), "exploit"); + else + await File.WriteAllTextAsync(Path.Combine(exploitRoot, "BadUpdatePayload", "stage2.bin"), "exploit"); await File.WriteAllTextAsync(Path.Combine(maxFolder, "BadUpdatePayload", "default.xex"), "max"); await File.WriteAllTextAsync(Path.Combine(maxFolder, "BadUpdatePayload", "BadStorage.xex.dll"), "storage"); await File.WriteAllTextAsync(Path.Combine(maxFolder, "launch.ini"), "[Paths]"); @@ -184,6 +189,61 @@ public async Task XeUnshackleMax_OverlaysOnlyPayloadAndLaunchConfig() Assert.False(File.Exists(Path.Combine(target, "Xbdm.xex"))); } + [Fact] + public void OptionalOverwrite_RejectsUndeclaredCollision() + { + using TemporaryDirectory temp = new(); + string first = Path.Combine(temp.Path, "first"); + string second = Path.Combine(temp.Path, "second"); + Directory.CreateDirectory(first); + Directory.CreateDirectory(second); + File.WriteAllText(Path.Combine(first, "default.xex"), "one"); + File.WriteAllText(Path.Combine(first, "other.bin"), "one"); + File.WriteAllText(Path.Combine(second, "default.xex"), "two"); + File.WriteAllText(Path.Combine(second, "other.bin"), "two"); + ArtifactDefinition exploit = new("exploit", "Exploit", "Test", "", null, + [new InstallOperation(InstallOperationKind.CopyDirectory, ".", ".")]); + ArtifactDefinition bootstrap = new("bootstrap", "Bootstrap", "Test", "", null, + [new InstallOperation(InstallOperationKind.CopyDirectory, ".", ".", + OptionalOverwritePaths: ["default.xex"])]); + Assert.Throws(() => InstallService.BuildPlan( + [(exploit, first), (bootstrap, second)], [], 128L * 1024 * 1024)); + } + + [Fact] + public void OptionalOverwrite_RequiresTheOverlayFileToBeProduced() + { + using TemporaryDirectory temp = new(); + File.WriteAllText(Path.Combine(temp.Path, "other.bin"), "contents"); + ArtifactDefinition artifact = new("bootstrap", "Bootstrap", "Test", "", null, + [new InstallOperation(InstallOperationKind.CopyDirectory, ".", ".", + OptionalOverwritePaths: ["default.xex"])]); + Assert.Throws(() => InstallService.BuildPlan( + [(artifact, temp.Path)], [], 128L * 1024 * 1024)); + } + + [Fact] + public async Task OriginalXeUnshackle_InstallsEvenWhenExploitHasNoDefaultXex() + { + using TemporaryDirectory temp = new(); + string first = Path.Combine(temp.Path, "exploit"); + string second = Path.Combine(temp.Path, "bootstrap"); + string wrapped = Path.Combine(second, "XeUnshackle-BETA"); + string target = Path.Combine(temp.Path, "target"); + Directory.CreateDirectory(Path.Combine(first, "BadUpdatePayload")); + Directory.CreateDirectory(Path.Combine(wrapped, "BadUpdatePayload")); + Directory.CreateDirectory(target); + await File.WriteAllTextAsync(Path.Combine(first, "BadUpdatePayload", "stage2.bin"), "exploit"); + await File.WriteAllTextAsync(Path.Combine(wrapped, "BadUpdatePayload", "default.xex"), "bootstrap"); + ArtifactDefinition exploit = new("exploit", "Exploit", "Test", "", null, + [new InstallOperation(InstallOperationKind.CopyDirectory, ".", ".")]); + InstallPlan plan = InstallService.BuildPlan( + [(exploit, first), (ArtifactCatalog.Bootstraps[BootstrapOption.XeUnshackle], second)], + [], 128L * 1024 * 1024); + await InstallService.ExecuteAsync(plan, target, CancellationToken.None); + Assert.Equal("bootstrap", await File.ReadAllTextAsync(Path.Combine(target, "BadUpdatePayload", "default.xex"))); + } + [Fact] public void FormatterSizePolicy_StaysWithinGuaranteedFat32Range() { diff --git a/BadBuilder/BadBuilder.csproj b/BadBuilder/BadBuilder.csproj index f2f30d6..b86d6d0 100644 --- a/BadBuilder/BadBuilder.csproj +++ b/BadBuilder/BadBuilder.csproj @@ -12,7 +12,7 @@ all low latest-recommended - 2.3.0 + 2.3.1 win-x64;linux-x64 true true diff --git a/BadBuilder/Configuration/ArtifactCatalog.cs b/BadBuilder/Configuration/ArtifactCatalog.cs index 2ba3c8a..3ae7309 100644 --- a/BadBuilder/Configuration/ArtifactCatalog.cs +++ b/BadBuilder/Configuration/ArtifactCatalog.cs @@ -63,7 +63,7 @@ internal static class ArtifactCatalog InstallOperationKind.CopyDirectory, ".", "/.", - AllowedOverwritePaths: ["BadUpdatePayload/default.xex"]) + OptionalOverwritePaths: ["BadUpdatePayload/default.xex"]) ], ArtifactPriority.Bootstrap, Layout: new ArchiveLayout(RequireSingleTopLevelDirectory: true) @@ -87,7 +87,7 @@ internal static class ArtifactCatalog InstallOperationKind.CopyDirectory, "BadUpdatePayload", "/BadUpdatePayload", - AllowedOverwritePaths: ["BadUpdatePayload/default.xex"]), + OptionalOverwritePaths: ["BadUpdatePayload/default.xex"]), new InstallOperation( InstallOperationKind.CopyFile, "launch.ini", diff --git a/BadBuilder/Configuration/Models.cs b/BadBuilder/Configuration/Models.cs index fac7954..2338851 100644 --- a/BadBuilder/Configuration/Models.cs +++ b/BadBuilder/Configuration/Models.cs @@ -75,7 +75,8 @@ internal sealed record InstallOperation( string DestinationPath, string? SourcePath = null, string? Contents = null, - IReadOnlyList? AllowedOverwritePaths = null); + IReadOnlyList? AllowedOverwritePaths = null, + IReadOnlyList? OptionalOverwritePaths = null); internal enum InstallOperationKind { diff --git a/BadBuilder/Services/InstallService.cs b/BadBuilder/Services/InstallService.cs index c4d5b46..e4bd79b 100644 --- a/BadBuilder/Services/InstallService.cs +++ b/BadBuilder/Services/InstallService.cs @@ -126,18 +126,28 @@ private static void PlanOperation( HashSet allowedOverwritePaths = new( (operation.AllowedOverwritePaths ?? []).Select(NormalizeTargetPath), StringComparer.OrdinalIgnoreCase); + HashSet optionalOverwritePaths = new( + (operation.OptionalOverwritePaths ?? []).Select(NormalizeTargetPath), + StringComparer.OrdinalIgnoreCase); + if (optionalOverwritePaths.Overlaps(allowedOverwritePaths)) + throw new InvalidDataException("A path cannot be both a required and optional overwrite."); + HashSet matchedOverwritePaths = new(StringComparer.OrdinalIgnoreCase); + HashSet producedOptionalPaths = new(StringComparer.OrdinalIgnoreCase); bool AddPlannedDestination(string path, long size) { - bool explicitlyAllowed = allowedOverwritePaths.Contains(path); + bool required = allowedOverwritePaths.Contains(path); + bool optional = optionalOverwritePaths.Contains(path); bool collided = AddDestination( destinationFiles, path, size, - allowOverwrite || explicitlyAllowed); - if (collided && explicitlyAllowed) + allowOverwrite || required || optional); + if (collided && required) matchedOverwritePaths.Add(path); + if (optional) + producedOptionalPaths.Add(path); return collided; } @@ -209,6 +219,13 @@ bool AddPlannedDestination(string path, long size) throw new InvalidDataException( $"The expected installation overwrite did not occur: {string.Join(", ", unmatched)}"); } + + string[] absentOptional = [..optionalOverwritePaths.Except(producedOptionalPaths, StringComparer.OrdinalIgnoreCase)]; + if (absentOptional.Length > 0) + { + throw new InvalidDataException( + $"The optional installation overlay source was not present: {string.Join(", ", absentOptional)}"); + } } private static string ResolveStagedSource(string stagingPath, InstallOperation operation) diff --git a/README.md b/README.md index 274e23e..57c6a24 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ Expected disk, network, cache, and archive errors are shown as actionable messag ## XeUnshackle Max +**Fixed in v2.3.1:** A missing exploit-side `BadUpdatePayload/default.xex` no longer makes the XeUnshackle or XeUnshackle Max install plan fail. That file may be created or overwritten as needed; all other undeclared collisions are still rejected. + XeUnshackle Max is an additional post-exploit bootstrap choice alongside the original XeUnshackle and FreeMyXe. Its pinned source is [klofi/XeUnshackle-Max v1.0.0](https://github.com/klofi/XeUnshackle-Max/releases/tag/v1.0.0), an independent **beta** fork of Byrom90's XeUnshackle. The fork adds optional automatic startup, CPU/DVD-key masking, boot-video skipping and volume control, and custom boot-video playback. On the Xbox 360, pressing Start can create a XeUnshackleConfig.txt next to BadUpdatePayload/default.xex. See the [upstream configuration documentation](https://github.com/klofi/XeUnshackle-Max#features). Both XeUnshackle variants support default homebrew launching through launch.ini.