From 134edabed29fcf63d53ae68a62973d7aae74009f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 00:02:04 +0200 Subject: [PATCH] feat(launcher): complete Campaign LA11 pre-gate support --- docs/architecture/acdream-architecture.md | 8 ++ docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../2026-08-14-campaign-la-test-script.md | 18 +++-- .../2026-08-14-launcher-campaign-design.md | 5 +- .../Updates/ReleaseManifestClient.cs | 48 +++++++++++- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 - src/AcDream.Launcher/App.axaml.cs | 21 ++++- .../LauncherStartupOptions.cs | 22 ++++++ .../LauncherUpdateComposition.cs | 23 +++++- src/AcDream.Launcher/Program.cs | 47 ++++++++---- .../Program.cs | 9 ++- .../Updates/LauncherSelfUpdateManagerTests.cs | 28 +++++-- .../Updates/LauncherSelfUpdateProcessTests.cs | 16 +++- .../Updates/ReleaseTransportTests.cs | 20 +++++ .../LauncherStartupOptionsTests.cs | 76 +++++++++++++++++++ .../LauncherUpdateCompositionTests.cs | 40 ++++++++++ tools/new-campaign-la-update-fixture.ps1 | 43 ++++++++++- tools/run-campaign-la-preflight.ps1 | 54 +++++++++++-- tools/test-campaign-la-session-status.ps1 | 4 +- 19 files changed, 433 insertions(+), 55 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c909820d..b4b000b9 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -343,6 +343,14 @@ src/ -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell + Startup/Program -> one immutable process-local option graph before + owner construction; config/data/cache require + three absolute normalized roots and one exact + `ApplicationPathSet` reaches profiles, installer, + versions/updater, sessions, cache, orchestration + -> manifest override reaches only update composition, + is never persisted, and permits HTTP only for a + loopback fixture; production remains pinned HTTPS ViewModels/ -> thin MVVM projection over Launcher.Core, including the first-run DAT/bake wizard and nonfatal startup/manual update state, actions, diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 76fd65bc..a07b767a 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | -| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. | +| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing and one exact launcher path graph are composed on the reviewed LA10 updater base; Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. No connected row has run and the campaign is not shipped. | diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md index 664a569e..25109d73 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -72,15 +72,18 @@ The expected matrix is: | Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | | Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | | Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | -| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min | +| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min | | Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | | Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | | Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | `report.json` records the tested HEAD/dirty state, OS/RID, exact commands, durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight -plans 26 commands. It never launches App or Headless in connected mode and -never reads a credential. +plans 30 rows. It never launches App or Headless in connected mode and +never reads a credential. Every child starts with all inherited `ACDREAM_*` +variables removed, so a developer shell cannot accidentally enable live, +installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional +row below adds the two named DAT variables back for its three exact tests. ### Optional installed-DAT read-only row @@ -100,7 +103,7 @@ The mandatory installed-DAT result is `ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX and fails if the test skipped or did anything other than pass. The action-map and portal-asset probes are additional coverage, never substitutes. Expected -matrix size: 30 rows. +matrix size: 34 rows. On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository path, and a Linux output path. Do not treat a Windows-hosted run over @@ -167,9 +170,12 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1 ``` The helper rejects nonempty output, invalid or non-monotonic versions, missing -root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs, +root executables (including the co-deployed Bake CLI), and nonabsolute inputs. +It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only -server, and an A/B selector. It does not download or mutate payload sources. +server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B +selector. Both generated helpers reject a `-Root` other than their own fixture +directory. The generator does not download or mutate payload sources. Start the Windows loopback server without a shell or visible helper window: diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index dbd662b3..0224a7f7 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -333,7 +333,10 @@ the process supervisor executes. (load, capability flag, teardown). - **Connected gates (user-driven):** execute the exact serial matrix in `docs/research/2026-08-14-campaign-la-test-script.md` only after its - connection-free automated preflight passes. Cover every launch mode against local ACE + connection-free automated preflight passes. The launcher uses one immutable + process-local config/data/cache path set for the whole matrix; the local feed + URI reaches only updater composition and is never persisted. Cover every + launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index a31625c1..7736fee5 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -11,8 +11,9 @@ public interface IReleaseManifestClient /// /// Strict, bounded reader for the pinned GitHub Releases manifest. Production -/// construction is HTTPS-only. The loopback HTTP allowance is available only -/// through an internal fixture factory and is never inferred from a URI. +/// construction is pinned and HTTPS-only. The explicitly named process-local +/// feed factory independently revalidates its URI and can admit HTTP only for +/// the loopback operator fixture; it cannot change the production constructor. /// Redirects are followed manually so every hop is checked before any bytes /// cross that hop. /// @@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable CreateRedirectDisabledHandler(), timeout); + /// + /// Creates the explicit process-local feed seam used by the Campaign LA + /// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted + /// only for a loopback manifest, and never by the pinned production + /// constructor. Credential-bearing or mutable URI suffixes are rejected. + /// + public static ReleaseManifestClient CreateLocalUpdateFeedOverride( + Uri manifestUri, + TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(manifestUri); + if (!string.IsNullOrEmpty(manifestUri.UserInfo) + || !string.IsNullOrEmpty(manifestUri.Query) + || !string.IsNullOrEmpty(manifestUri.Fragment)) + { + throw new LauncherUpdateException( + "A process-local manifest URI cannot contain user information, " + + "a query, or a fragment."); + } + + bool allowLoopbackHttp = string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttp, + StringComparison.Ordinal) + && manifestUri.IsLoopback; + if (!string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttps, + StringComparison.Ordinal) + && !allowLoopbackHttp) + { + throw new LauncherUpdateException( + "A process-local manifest URI must use HTTPS " + + "(loopback HTTP is fixture-only)."); + } + + return new ReleaseManifestClient( + manifestUri, + allowLoopbackHttp, + CreateRedirectDisabledHandler(), + timeout); + } + internal static ReleaseManifestClient CreateForTransportTest( Uri manifestUri, bool allowLoopbackHttp, diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 1b02f993..350260b2 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -28,10 +28,6 @@ - - - - diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index c42f0de0..b348cf07 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -14,17 +14,33 @@ namespace AcDream.Launcher; public sealed partial class App : Application { + private readonly LauncherStartupOptions? _startupOptions; private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; private LauncherUpdateComposition? _updateComposition; + public App() + { + } + + internal App(LauncherStartupOptions startupOptions) + { + _startupOptions = startupOptions + ?? throw new ArgumentNullException(nameof(startupOptions)); + } + + internal LauncherStartupOptions StartupOptions => _startupOptions + ?? throw new InvalidOperationException( + "Launcher startup options were not supplied by the composition root."); + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherStartupOptions startupOptions = StartupOptions; + ApplicationPathSet paths = startupOptions.Paths; LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); string rid = LauncherRuntimeIdentity.DetectRid(); string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; @@ -57,7 +73,8 @@ public sealed partial class App : Application GetLauncherVersion(), AppContext.BaseDirectory, () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) - == true); + == true, + updateManifestUri: startupOptions.UpdateManifestUri); _updateComposition = updates; _orchestrator = new LauncherOrchestrator( diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs index 1f1c9164..43b3429e 100644 --- a/src/AcDream.Launcher/LauncherStartupOptions.cs +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -9,6 +9,7 @@ internal enum LauncherStartupMode VerifyPublish, SelfUpdateHelper, SelfUpdateConfirmation, + SelfUpdateDeferred, } /// @@ -18,6 +19,12 @@ internal enum LauncherStartupMode /// internal sealed class LauncherStartupOptions { + // This prefix is consumed only after LauncherSelfUpdateBootstrap has + // already decided to continue after a recovered rollback. Keep it local + // so the process-level bootstrap can remain internal to Launcher.Core. + private const string DeferredSelfUpdateArgument = + "--acdream-self-update-deferred-v1"; + private readonly IReadOnlyList _publicArguments; private LauncherStartupOptions( @@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions "The update manifest URI cannot contain user information."); } + if (!string.IsNullOrEmpty(parsed.Query) + || !string.IsNullOrEmpty(parsed.Fragment)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain a query or fragment."); + } + updateManifestUri = parsed; break; default: @@ -199,6 +213,14 @@ internal sealed class LauncherStartupOptions arguments.Count >= 2 ? 2 : arguments.Count); } + if (string.Equals( + arguments[0], + DeferredSelfUpdateArgument, + StringComparison.Ordinal)) + { + return (LauncherStartupMode.SelfUpdateDeferred, 1); + } + return (LauncherStartupMode.Desktop, 0); } diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index 3b395042..22af717f 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable ClientVersionStore versions, LauncherExecutableSet executables, ILauncherUpdater updater, + Uri updateManifestUri, HttpClient? artifactClient, ReleaseManifestClient? manifestClient) { Versions = versions; Executables = executables; Updater = updater; + UpdateManifestUri = updateManifestUri; _artifactClient = artifactClient; _manifestClient = manifestClient; } @@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable public ILauncherUpdater Updater { get; } + internal Uri UpdateManifestUri { get; } + public static LauncherUpdateComposition Create( ApplicationPathSet paths, string rid, LauncherVersion launcherVersion, string launcherTargetDirectory, Func hasRunningSessions, - Func? initialize = null) + Func? initialize = null, + Uri? updateManifestUri = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(launcherVersion); ArgumentNullException.ThrowIfNull(hasRunningSessions); + Uri manifestUri = updateManifestUri + ?? ReleaseManifestClient.ProductionManifestUri; var versions = new ClientVersionStore(paths); HttpClient? artifactClient = null; ReleaseManifestClient? manifestClient = null; @@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable Timeout = TimeSpan.FromSeconds(15), }; artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); - manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15)); + manifestClient = CreateManifestClient(manifestUri); var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); var updater = new LauncherUpdater( manifestClient, @@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable versions, LauncherExecutableSet.FromCurrentVersionStore(versions), updater, + manifestUri, artifactClient, manifestClient); } @@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable versions, LauncherExecutableSet.Unavailable(status), new UnavailableLauncherUpdater(status, resolution), + manifestUri, artifactClient: null, manifestClient: null); } @@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable _artifactClient?.Dispose(); } + private static ReleaseManifestClient CreateManifestClient(Uri manifestUri) + { + ArgumentNullException.ThrowIfNull(manifestUri); + return manifestUri == ReleaseManifestClient.ProductionManifestUri + ? new ReleaseManifestClient(TimeSpan.FromSeconds(15)) + : ReleaseManifestClient.CreateLocalUpdateFeedOverride( + manifestUri, + TimeSpan.FromSeconds(15)); + } + private static bool IsStorageFailure(Exception exception) => exception is IOException or UnauthorizedAccessException diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs index 4bce034a..bb6b7c1f 100644 --- a/src/AcDream.Launcher/Program.cs +++ b/src/AcDream.Launcher/Program.cs @@ -1,5 +1,4 @@ using AcDream.Launcher.Core.Updates; -using AcDream.Platform; using Avalonia; namespace AcDream.Launcher; @@ -9,19 +8,18 @@ internal static class Program [STAThread] public static int Main(string[] args) { - if (args is ["--verify-publish"]) - { - // A display-free execution probe for the packaged artifact. CI - // runs this with DOTNET_ROOT pointing at a missing directory; a - // framework-dependent publish cannot reach this return statement. - return 0; - } - try { - ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherStartupOptions options = LauncherStartupOptions.Parse(args); + if (options.Mode == LauncherStartupMode.VerifyPublish) + { + // A display-free execution probe for the packaged artifact. + // Parsing above deliberately never resolves user paths. + return 0; + } + using var httpClient = new HttpClient(); - var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient); + var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient); string executable = Environment.ProcessPath ?? throw new InvalidOperationException( "The launcher executable path is unavailable."); @@ -37,8 +35,9 @@ internal static class Program return startup.ExitCode; } - return BuildAvaloniaApp().StartWithClassicDesktopLifetime( - startup.RemainingArguments); + RequireUnchangedPublicArguments(options, startup); + + return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]); } catch (Exception ex) { @@ -47,7 +46,25 @@ internal static class Program } } - public static AppBuilder BuildAvaloniaApp() => - AppBuilder.Configure() + internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return AppBuilder.Configure(() => new App(options)) .UsePlatformDetect(); + } + + internal static void RequireUnchangedPublicArguments( + LauncherStartupOptions options, + SelfUpdateStartupResult startup) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(startup); + if (!startup.RemainingArguments.SequenceEqual( + options.PublicArguments, + StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "The self-update bootstrap changed validated launcher arguments."); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 8e3f45ac..18a98d1e 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -160,18 +160,23 @@ static async Task BootstrapProbeAsync(string[] arguments) static int CanonicalProbe(string[] arguments) { - if (arguments.Length != 1) + if (arguments.Length < 1) { return 2; } + string suffix = arguments.Length == 1 + ? string.Empty + : Environment.NewLine + + string.Join(Environment.NewLine, arguments[1..]); File.WriteAllText( Path.GetFullPath(arguments[0]), Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "|" + Path.GetFullPath( Environment.ProcessPath - ?? throw new InvalidOperationException("Process path is unavailable."))); + ?? throw new InvalidOperationException("Process path is unavailable.")) + + suffix); return 0; } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index e2c9b4b3..40fe89f3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing() { using var harness = new Harness(_root); + string[] publicArguments = + [ + "--config-dir", Path.Combine(_root, "config with spaces"), + "--data-dir", Path.Combine(_root, "data & literal"), + "--cache-dir", Path.Combine(_root, "cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync( - ["--literal", "argument with spaces & metacharacters"], + publicArguments, harness.Manager, harness.Target, harness.LauncherPath); Assert.False(ordinary.ShouldExit); - Assert.Equal(["--literal", "argument with spaces & metacharacters"], - ordinary.RemainingArguments); + Assert.Equal(publicArguments, ordinary.RemainingArguments); + + SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(deferred.ShouldExit); + Assert.Equal(publicArguments, deferred.RemainingArguments); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( - [LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId], + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + applied.TransactionId, + .. publicArguments, + ], harness.Manager, harness.Target, harness.LauncherPath); Assert.False(confirmation.ShouldExit); - Assert.Empty(confirmation.RemainingArguments); + Assert.Equal(publicArguments, confirmation.RemainingArguments); Assert.False(File.Exists(harness.Manager.PendingPlanPath)); Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 1c67efc3..5185fc7d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string ready = Path.Combine(_root, "crash.ready"); string launched = Path.Combine(_root, "replacement.ready"); string helperPidPath = Path.Combine(_root, "helper.pid"); + string[] processLocalSuffix = + [ + "--config-dir", Path.Combine(_root, "isolated config"), + "--data-dir", Path.Combine(_root, "isolated data"), + "--cache-dir", Path.Combine(_root, "isolated cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; Directory.CreateDirectory(_root); string rid = LauncherRuntimeIdentity.DetectRid(); PreparedLauncher prepared = PrepareLauncherClosure(target, rid); @@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable }; using Process canonical = StartProcess( prepared.CanonicalPath, - ["canonical-probe", launched], + ["canonical-probe", launched, .. processLocalSuffix], environment); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); Assert.Equal(0, canonical.ExitCode); @@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable SearchOption.TopDirectoryOnly)); Assert.Null(await manager.LoadPendingAsync()); - int replacementPid = ParsePid(await File.ReadAllTextAsync(launched)); + string launchMarker = await File.ReadAllTextAsync(launched); + Assert.EndsWith( + Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix), + launchMarker, + StringComparison.Ordinal); + int replacementPid = ParsePid(launchMarker); int helperPid = int.Parse( await File.ReadAllTextAsync(helperPidPath), System.Globalization.CultureInfo.InvariantCulture); diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index b3d80e11..0208d60d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -44,6 +44,26 @@ public sealed class LauncherVersionTests public sealed class ReleaseManifestClientTests { + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://127.0.0.1:43119/manifest.json")] + [InlineData("http://localhost:43119/manifest.json")] + public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value) + { + using ReleaseManifestClient source = + ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)); + } + + [Theory] + [InlineData("http://updates.example.test/manifest.json")] + [InlineData("file:///tmp/manifest.json")] + [InlineData("https://user:secret@updates.example.test/manifest.json")] + [InlineData("https://updates.example.test/manifest.json?token=secret")] + [InlineData("https://updates.example.test/manifest.json#fragment")] + public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) => + Assert.Throws(() => + ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value))); + [Fact] public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed() { diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs index ff3bb24c..d7f223ce 100644 --- a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); } + [Fact] + public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-la11-deferred")); + string[] suffix = + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--acdream-self-update-deferred-v1", .. suffix], + () => throw new InvalidOperationException( + "canonical path resolver was touched")); + + Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode); + Assert.Equal(suffix, options.PublicArguments); + Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory); + Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory); + Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory); + } + + [Fact] + public void AvaloniaCompositionRetainsTheExactParsedOptionsAndPathSet() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-la11-app-composition")); + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "https://updates.example.test/manifest.json", + ], + () => throw new InvalidOperationException( + "canonical path resolver was touched")); + + var app = new App(options); + + Assert.Same(options, app.StartupOptions); + Assert.Same(options.Paths, app.StartupOptions.Paths); + Assert.Equal( + new Uri("https://updates.example.test/manifest.json"), + app.StartupOptions.UpdateManifestUri); + } + + [Fact] + public void CompositionRejectsAnyBootstrapArgumentDrift() + { + var paths = new ApplicationPathSet("config", "data", "cache", null); + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--update-manifest-uri", "https://updates.example.test/manifest.json"], + () => paths); + + Program.RequireUnchangedPublicArguments( + options, + new SelfUpdateStartupResult( + false, + 0, + options.PublicArguments.ToArray())); + Assert.Throws(() => + Program.RequireUnchangedPublicArguments( + options, + new SelfUpdateStartupResult( + false, + 0, + ["--update-manifest-uri", "https://other.example.test/manifest.json"]))); + } + public static TheoryData InvalidArguments() { string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); @@ -171,6 +243,10 @@ public sealed class LauncherStartupOptionsTests data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]); data.Add( ["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); + data.Add( + ["--update-manifest-uri", "https://example.test/manifest.json?token=secret"]); + data.Add( + ["--update-manifest-uri", "https://example.test/manifest.json#fragment"]); data.Add(["--update-manifest-uri", "not-a-uri"]); data.Add( [ diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs index 37365efe..d2f2ed98 100644 --- a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable () => composition.Updater.CheckAsync()); Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); } + + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://127.0.0.1:43119/manifest.json")] + public void ProcessLocalManifestOverrideReachesOnlyUpdateComposition(string value) + { + Directory.CreateDirectory(_root); + var paths = new ApplicationPathSet( + Path.Combine(_root, "config"), + Path.Combine(_root, "data"), + Path.Combine(_root, "cache"), + null); + var manifestUri = new Uri(value); + + using LauncherUpdateComposition composition = LauncherUpdateComposition.Create( + paths, + LauncherRuntimeIdentity.DetectRid(), + LauncherVersion.Parse("1.0.0"), + _root, + () => false, + initialize: (_, _) => new ClientVersionResolution( + ClientVersionState.Missing, + "No client version is installed.", + null, + null, + null, + null), + updateManifestUri: manifestUri); + + Assert.Same(manifestUri, composition.UpdateManifestUri); + Assert.Equal( + Path.Combine(paths.DataDirectory, "app"), + composition.Versions.AppDirectory); + Assert.False(File.Exists( + Path.Combine(paths.ConfigDirectory, "launcher-profiles.json"))); + Assert.Empty(Directory.EnumerateFiles( + _root, + "*", + SearchOption.AllDirectories)); + } } diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 98d129eb..52108e71 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -85,9 +85,11 @@ foreach ($release in @('A', 'B')) { Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe' Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake' } if (Test-Path -LiteralPath $OutputDirectory) { @@ -155,6 +157,7 @@ function New-DeterministicZip( $executable = $relative -ceq 'AcDream.App' -or $relative -ceq 'acdream-headless' -or $relative -ceq 'acdream-launcher' -or + $relative -ceq 'acdream-bake' -or $relative.EndsWith('.sh', [StringComparison]::Ordinal) $mode = if ($executable) { 0x81ED } else { 0x81A4 } $entry.ExternalAttributes = $mode -shl 16 @@ -239,16 +242,26 @@ $server = @' [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Root, - [Parameter(Mandatory = $true)][int]$Port + [Parameter(Mandatory = $true)][int]$Port, + [ValidateRange(0, 1000000)][int]$MaximumRequests = 0 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) $Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing serve-fixture.ps1.' +} $prefix = "http://127.0.0.1:$Port/" $listener = [Net.HttpListener]::new() $listener.Prefixes.Add($prefix) $listener.Start() Write-Host "Campaign LA fixture listening on $prefix" +$servedRequests = 0 try { while ($listener.IsListening) { $context = $listener.GetContext() @@ -287,7 +300,13 @@ try { $context.Response.StatusCode = 500 Write-Error $_ } - finally { $context.Response.Close() } + finally { + $context.Response.Close() + $servedRequests++ + } + if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) { + break + } } } finally { $listener.Close() } @@ -301,8 +320,24 @@ param( [string]$Root = $PSScriptRoot ) Set-StrictMode -Version Latest -$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' -Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing set-active-release.ps1.' +} +$path = Join-Path $Root 'active-release.txt' +$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp" +try { + [IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII) + [IO.File]::Move($temporary, $path, $true) +} +finally { + if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } +} Write-Host "Campaign LA fixture active release: $Release" '@ $selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 index 0a676df1..13b376e1 100644 --- a/tools/run-campaign-la-preflight.ps1 +++ b/tools/run-campaign-la-preflight.ps1 @@ -68,16 +68,22 @@ $startedUtc = [DateTime]::UtcNow function Protect-Text([string]$Text) { if ($null -eq $Text) { return '' } $protected = $Text - foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { - $value = [Environment]::GetEnvironmentVariable($name) - if (-not [string]::IsNullOrEmpty($value)) { - $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) - } - } $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)(--password|-password)(\s+|=)([^\s"'']+)', '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(https?://)[^/\s:@]+:[^@\s/]+@', + '$1@') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+', + '$1') $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', @@ -142,6 +148,11 @@ function Invoke-GateCommand( $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($key in @($startInfo.Environment.Keys)) { + if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) { + $startInfo.Environment.Remove($key) + } + } foreach ($entry in $Environment.GetEnumerator()) { $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value } @@ -258,6 +269,34 @@ try { '--', 'RunConfiguration.MaxCpuCount=1') } + $headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json' + Add-InternalCheck 'portable-headless-write-empty-config' { + [IO.File]::WriteAllText( + $headlessValidationConfig, + '{"version":1,"sessions":[]}', + [Text.UTF8Encoding]::new($false)) + } + Invoke-DotNet 'portable-headless-help-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', '--help') + Invoke-DotNet 'portable-headless-validate-empty-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', + 'validate', '--config', $headlessValidationConfig) + Add-InternalCheck 'portable-headless-native-permission' { + if (-not $IsWindows) { + $headlessExecutable = Join-Path ` + $Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless' + if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) { + throw 'The native Headless build output is missing.' + } + $mode = [IO.File]::GetUnixFileMode($headlessExecutable) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'The native Headless build output is not executable.' + } + } + } + foreach ($rid in @('win-x64', 'linux-x64')) { $destination = Join-Path $publishDirectory $rid Invoke-DotNet "publish-launcher-$rid" @( @@ -382,7 +421,8 @@ finally { failures = @($failures) redaction = [ordered]@{ applied = $true - environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') + inheritedAcdreamEnvironmentCleared = $true + inheritedEnvironmentValuesRead = $false credentialArgumentsAllowed = $false } artifacts = $artifacts diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 834ff031..8f3b2c61 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { } $eventName = Assert-String $root 'e' if (-not $exactFields.ContainsKey($eventName)) { - throw "event '$eventName' is not in the v1 vocabulary" + throw 'event name is not in the v1 vocabulary' } $expected = $exactFields[$eventName] if ($names.Count -ne $expected.Count -or @@ -221,7 +221,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } if ($reason -cne $expectedReason) { - throw "terminal reason is '$reason', expected '$expectedReason'" + throw "terminal reason does not match mode '$Mode'" } $terminalSeen = $true }