feat(launcher): complete Campaign LA11 pre-gate support

This commit is contained in:
Erik 2026-08-15 00:02:04 +02:00
parent f881e5b467
commit 134edabed2
19 changed files with 433 additions and 55 deletions

View file

@ -343,6 +343,14 @@ src/
-> references Platform only; no Avalonia or game-host dependency -> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell 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, ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard and including the first-run DAT/bake wizard and
nonfatal startup/manual update state, actions, nonfatal startup/manual update state, actions,

View file

@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
| LA8 | — | | | | | LA8 | — | | | |
| LA9 | — | | | | | LA9 | — | | | |
| LA10 | — | | | | | 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. |

View file

@ -72,15 +72,18 @@ The expected matrix is:
| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 515 min | | Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 515 min |
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 2060 min | | Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 2060 min |
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 14 min | | Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 14 min |
| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 1025 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 | 1025 min |
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 310 min | | Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 310 min |
| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 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 | 3590 min | | Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 3590 min |
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands, `report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight 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 plans 30 rows. It never launches App or Headless in connected mode and
never reads a credential. 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 ### 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 `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 fails if the test skipped or did anything other than pass. The action-map
and portal-asset probes are additional coverage, never substitutes. Expected 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 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 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 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 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: Start the Windows loopback server without a shell or visible helper window:

View file

@ -333,7 +333,10 @@ the process supervisor executes.
(load, capability flag, teardown). (load, capability flag, teardown).
- **Connected gates (user-driven):** execute the exact serial matrix in - **Connected gates (user-driven):** execute the exact serial matrix in
`docs/research/2026-08-14-campaign-la-test-script.md` only after its `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 → (gui / guiSelect / headless), the character probe (fresh account →
refresh → roster appears, and repeated probes leaving no stale ACE refresh → roster appears, and repeated probes leaving no stale ACE
session), clean-profile first-run wizard end-to-end, staged-manifest session), clean-profile first-run wizard end-to-end, staged-manifest

View file

@ -11,8 +11,9 @@ public interface IReleaseManifestClient
/// <summary> /// <summary>
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production /// Strict, bounded reader for the pinned GitHub Releases manifest. Production
/// construction is HTTPS-only. The loopback HTTP allowance is available only /// construction is pinned and HTTPS-only. The explicitly named process-local
/// through an internal fixture factory and is never inferred from a URI. /// 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 /// Redirects are followed manually so every hop is checked before any bytes
/// cross that hop. /// cross that hop.
/// </summary> /// </summary>
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
CreateRedirectDisabledHandler(), CreateRedirectDisabledHandler(),
timeout); timeout);
/// <summary>
/// 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.
/// </summary>
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( internal static ReleaseManifestClient CreateForTransportTest(
Uri manifestUri, Uri manifestUri,
bool allowLoopbackHttp, bool allowLoopbackHttp,

View file

@ -28,10 +28,6 @@
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" /> <ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<!-- Distribution composition only: do not add a Launcher -> Bake project <!-- Distribution composition only: do not add a Launcher -> Bake project
reference. A per-RID launcher publish explicitly publishes the GL-free reference. A per-RID launcher publish explicitly publishes the GL-free
CLI as its own self-contained single file into the same directory. --> CLI as its own self-contained single file into the same directory. -->

View file

@ -14,17 +14,33 @@ namespace AcDream.Launcher;
public sealed partial class App : Application public sealed partial class App : Application
{ {
private readonly LauncherStartupOptions? _startupOptions;
private LauncherOrchestrator? _orchestrator; private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel; private LauncherWindowViewModel? _viewModel;
private LauncherUpdateComposition? _updateComposition; 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 Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
ApplicationPathSet paths = ApplicationPathSet.Resolve(); LauncherStartupOptions startupOptions = StartupOptions;
ApplicationPathSet paths = startupOptions.Paths;
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
string rid = LauncherRuntimeIdentity.DetectRid(); string rid = LauncherRuntimeIdentity.DetectRid();
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
@ -57,7 +73,8 @@ public sealed partial class App : Application
GetLauncherVersion(), GetLauncherVersion(),
AppContext.BaseDirectory, AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
== true); == true,
updateManifestUri: startupOptions.UpdateManifestUri);
_updateComposition = updates; _updateComposition = updates;
_orchestrator = new LauncherOrchestrator( _orchestrator = new LauncherOrchestrator(

View file

@ -9,6 +9,7 @@ internal enum LauncherStartupMode
VerifyPublish, VerifyPublish,
SelfUpdateHelper, SelfUpdateHelper,
SelfUpdateConfirmation, SelfUpdateConfirmation,
SelfUpdateDeferred,
} }
/// <summary> /// <summary>
@ -18,6 +19,12 @@ internal enum LauncherStartupMode
/// </summary> /// </summary>
internal sealed class LauncherStartupOptions 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<string> _publicArguments; private readonly IReadOnlyList<string> _publicArguments;
private LauncherStartupOptions( private LauncherStartupOptions(
@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions
"The update manifest URI cannot contain user information."); "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; updateManifestUri = parsed;
break; break;
default: default:
@ -199,6 +213,14 @@ internal sealed class LauncherStartupOptions
arguments.Count >= 2 ? 2 : arguments.Count); arguments.Count >= 2 ? 2 : arguments.Count);
} }
if (string.Equals(
arguments[0],
DeferredSelfUpdateArgument,
StringComparison.Ordinal))
{
return (LauncherStartupMode.SelfUpdateDeferred, 1);
}
return (LauncherStartupMode.Desktop, 0); return (LauncherStartupMode.Desktop, 0);
} }

View file

@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable
ClientVersionStore versions, ClientVersionStore versions,
LauncherExecutableSet executables, LauncherExecutableSet executables,
ILauncherUpdater updater, ILauncherUpdater updater,
Uri updateManifestUri,
HttpClient? artifactClient, HttpClient? artifactClient,
ReleaseManifestClient? manifestClient) ReleaseManifestClient? manifestClient)
{ {
Versions = versions; Versions = versions;
Executables = executables; Executables = executables;
Updater = updater; Updater = updater;
UpdateManifestUri = updateManifestUri;
_artifactClient = artifactClient; _artifactClient = artifactClient;
_manifestClient = manifestClient; _manifestClient = manifestClient;
} }
@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable
public ILauncherUpdater Updater { get; } public ILauncherUpdater Updater { get; }
internal Uri UpdateManifestUri { get; }
public static LauncherUpdateComposition Create( public static LauncherUpdateComposition Create(
ApplicationPathSet paths, ApplicationPathSet paths,
string rid, string rid,
LauncherVersion launcherVersion, LauncherVersion launcherVersion,
string launcherTargetDirectory, string launcherTargetDirectory,
Func<bool> hasRunningSessions, Func<bool> hasRunningSessions,
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null) Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null,
Uri? updateManifestUri = null)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(launcherVersion); ArgumentNullException.ThrowIfNull(launcherVersion);
ArgumentNullException.ThrowIfNull(hasRunningSessions); ArgumentNullException.ThrowIfNull(hasRunningSessions);
Uri manifestUri = updateManifestUri
?? ReleaseManifestClient.ProductionManifestUri;
var versions = new ClientVersionStore(paths); var versions = new ClientVersionStore(paths);
HttpClient? artifactClient = null; HttpClient? artifactClient = null;
ReleaseManifestClient? manifestClient = null; ReleaseManifestClient? manifestClient = null;
@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
Timeout = TimeSpan.FromSeconds(15), Timeout = TimeSpan.FromSeconds(15),
}; };
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15)); manifestClient = CreateManifestClient(manifestUri);
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
var updater = new LauncherUpdater( var updater = new LauncherUpdater(
manifestClient, manifestClient,
@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
versions, versions,
LauncherExecutableSet.FromCurrentVersionStore(versions), LauncherExecutableSet.FromCurrentVersionStore(versions),
updater, updater,
manifestUri,
artifactClient, artifactClient,
manifestClient); manifestClient);
} }
@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
versions, versions,
LauncherExecutableSet.Unavailable(status), LauncherExecutableSet.Unavailable(status),
new UnavailableLauncherUpdater(status, resolution), new UnavailableLauncherUpdater(status, resolution),
manifestUri,
artifactClient: null, artifactClient: null,
manifestClient: null); manifestClient: null);
} }
@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable
_artifactClient?.Dispose(); _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 private static bool IsStorageFailure(Exception exception) => exception is
IOException IOException
or UnauthorizedAccessException or UnauthorizedAccessException

View file

@ -1,5 +1,4 @@
using AcDream.Launcher.Core.Updates; using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
using Avalonia; using Avalonia;
namespace AcDream.Launcher; namespace AcDream.Launcher;
@ -9,19 +8,18 @@ internal static class Program
[STAThread] [STAThread]
public static int Main(string[] args) 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 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(); using var httpClient = new HttpClient();
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient); var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
string executable = Environment.ProcessPath string executable = Environment.ProcessPath
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The launcher executable path is unavailable."); "The launcher executable path is unavailable.");
@ -37,8 +35,9 @@ internal static class Program
return startup.ExitCode; return startup.ExitCode;
} }
return BuildAvaloniaApp().StartWithClassicDesktopLifetime( RequireUnchangedPublicArguments(options, startup);
startup.RemainingArguments);
return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -47,7 +46,25 @@ internal static class Program
} }
} }
public static AppBuilder BuildAvaloniaApp() => internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
AppBuilder.Configure<App>() {
ArgumentNullException.ThrowIfNull(options);
return AppBuilder.Configure(() => new App(options))
.UsePlatformDetect(); .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.");
}
}
} }

View file

@ -160,18 +160,23 @@ static async Task<int> BootstrapProbeAsync(string[] arguments)
static int CanonicalProbe(string[] arguments) static int CanonicalProbe(string[] arguments)
{ {
if (arguments.Length != 1) if (arguments.Length < 1)
{ {
return 2; return 2;
} }
string suffix = arguments.Length == 1
? string.Empty
: Environment.NewLine
+ string.Join(Environment.NewLine, arguments[1..]);
File.WriteAllText( File.WriteAllText(
Path.GetFullPath(arguments[0]), Path.GetFullPath(arguments[0]),
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ "|" + "|"
+ Path.GetFullPath( + Path.GetFullPath(
Environment.ProcessPath Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable."))); ?? throw new InvalidOperationException("Process path is unavailable."))
+ suffix);
return 0; return 0;
} }

View file

@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing() public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
{ {
using var harness = new Harness(_root); 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( SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
["--literal", "argument with spaces & metacharacters"], publicArguments,
harness.Manager, harness.Manager,
harness.Target, harness.Target,
harness.LauncherPath); harness.LauncherPath);
Assert.False(ordinary.ShouldExit); Assert.False(ordinary.ShouldExit);
Assert.Equal(["--literal", "argument with spaces & metacharacters"], Assert.Equal(publicArguments, ordinary.RemainingArguments);
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(); _ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId], [
LauncherSelfUpdateBootstrap.ConfirmArgument,
applied.TransactionId,
.. publicArguments,
],
harness.Manager, harness.Manager,
harness.Target, harness.Target,
harness.LauncherPath); harness.LauncherPath);
Assert.False(confirmation.ShouldExit); Assert.False(confirmation.ShouldExit);
Assert.Empty(confirmation.RemainingArguments); Assert.Equal(publicArguments, confirmation.RemainingArguments);
Assert.False(File.Exists(harness.Manager.PendingPlanPath)); Assert.False(File.Exists(harness.Manager.PendingPlanPath));
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
} }

View file

@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
string ready = Path.Combine(_root, "crash.ready"); string ready = Path.Combine(_root, "crash.ready");
string launched = Path.Combine(_root, "replacement.ready"); string launched = Path.Combine(_root, "replacement.ready");
string helperPidPath = Path.Combine(_root, "helper.pid"); 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); Directory.CreateDirectory(_root);
string rid = LauncherRuntimeIdentity.DetectRid(); string rid = LauncherRuntimeIdentity.DetectRid();
PreparedLauncher prepared = PrepareLauncherClosure(target, rid); PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
}; };
using Process canonical = StartProcess( using Process canonical = StartProcess(
prepared.CanonicalPath, prepared.CanonicalPath,
["canonical-probe", launched], ["canonical-probe", launched, .. processLocalSuffix],
environment); environment);
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode); Assert.Equal(0, canonical.ExitCode);
@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
SearchOption.TopDirectoryOnly)); SearchOption.TopDirectoryOnly));
Assert.Null(await manager.LoadPendingAsync()); 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( int helperPid = int.Parse(
await File.ReadAllTextAsync(helperPidPath), await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture); System.Globalization.CultureInfo.InvariantCulture);

View file

@ -44,6 +44,26 @@ public sealed class LauncherVersionTests
public sealed class ReleaseManifestClientTests 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<LauncherUpdateException>(() =>
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
[Fact] [Fact]
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed() public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
{ {

View file

@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests
Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); 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<InvalidOperationException>(() =>
Program.RequireUnchangedPublicArguments(
options,
new SelfUpdateStartupResult(
false,
0,
["--update-manifest-uri", "https://other.example.test/manifest.json"])));
}
public static TheoryData<string[]> InvalidArguments() public static TheoryData<string[]> InvalidArguments()
{ {
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); 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", "file:///tmp/manifest.json"]);
data.Add( data.Add(
["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); ["--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(["--update-manifest-uri", "not-a-uri"]);
data.Add( data.Add(
[ [

View file

@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
() => composition.Updater.CheckAsync()); () => composition.Updater.CheckAsync());
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); 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));
}
} }

View file

@ -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.App.exe'
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.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-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.App'
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake'
} }
if (Test-Path -LiteralPath $OutputDirectory) { if (Test-Path -LiteralPath $OutputDirectory) {
@ -155,6 +157,7 @@ function New-DeterministicZip(
$executable = $relative -ceq 'AcDream.App' -or $executable = $relative -ceq 'AcDream.App' -or
$relative -ceq 'acdream-headless' -or $relative -ceq 'acdream-headless' -or
$relative -ceq 'acdream-launcher' -or $relative -ceq 'acdream-launcher' -or
$relative -ceq 'acdream-bake' -or
$relative.EndsWith('.sh', [StringComparison]::Ordinal) $relative.EndsWith('.sh', [StringComparison]::Ordinal)
$mode = if ($executable) { 0x81ED } else { 0x81A4 } $mode = if ($executable) { 0x81ED } else { 0x81A4 }
$entry.ExternalAttributes = $mode -shl 16 $entry.ExternalAttributes = $mode -shl 16
@ -239,16 +242,26 @@ $server = @'
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory = $true)][string]$Root, [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 Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($PSScriptRoot))
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) $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/" $prefix = "http://127.0.0.1:$Port/"
$listener = [Net.HttpListener]::new() $listener = [Net.HttpListener]::new()
$listener.Prefixes.Add($prefix) $listener.Prefixes.Add($prefix)
$listener.Start() $listener.Start()
Write-Host "Campaign LA fixture listening on $prefix" Write-Host "Campaign LA fixture listening on $prefix"
$servedRequests = 0
try { try {
while ($listener.IsListening) { while ($listener.IsListening) {
$context = $listener.GetContext() $context = $listener.GetContext()
@ -287,7 +300,13 @@ try {
$context.Response.StatusCode = 500 $context.Response.StatusCode = 500
Write-Error $_ Write-Error $_
} }
finally { $context.Response.Close() } finally {
$context.Response.Close()
$servedRequests++
}
if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
break
}
} }
} }
finally { $listener.Close() } finally { $listener.Close() }
@ -301,8 +320,24 @@ param(
[string]$Root = $PSScriptRoot [string]$Root = $PSScriptRoot
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' $expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline [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" Write-Host "Campaign LA fixture active release: $Release"
'@ '@
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM $selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM

View file

@ -68,16 +68,22 @@ $startedUtc = [DateTime]::UtcNow
function Protect-Text([string]$Text) { function Protect-Text([string]$Text) {
if ($null -eq $Text) { return '' } if ($null -eq $Text) { return '' }
$protected = $Text $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, '<redacted>', [StringComparison]::Ordinal)
}
}
$protected = [Text.RegularExpressions.Regex]::Replace( $protected = [Text.RegularExpressions.Regex]::Replace(
$protected, $protected,
'(?i)(--password|-password)(\s+|=)([^\s"'']+)', '(?i)(--password|-password)(\s+|=)([^\s"'']+)',
'$1$2<redacted>') '$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)',
'$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(https?://)[^/\s:@]+:[^@\s/]+@',
'$1<redacted>@')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+',
'$1<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace( $protected = [Text.RegularExpressions.Regex]::Replace(
$protected, $protected,
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
@ -142,6 +148,11 @@ function Invoke-GateCommand(
$startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true $startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } 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()) { foreach ($entry in $Environment.GetEnumerator()) {
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
} }
@ -258,6 +269,34 @@ try {
'--', 'RunConfiguration.MaxCpuCount=1') '--', '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')) { foreach ($rid in @('win-x64', 'linux-x64')) {
$destination = Join-Path $publishDirectory $rid $destination = Join-Path $publishDirectory $rid
Invoke-DotNet "publish-launcher-$rid" @( Invoke-DotNet "publish-launcher-$rid" @(
@ -382,7 +421,8 @@ finally {
failures = @($failures) failures = @($failures)
redaction = [ordered]@{ redaction = [ordered]@{
applied = $true applied = $true
environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') inheritedAcdreamEnvironmentCleared = $true
inheritedEnvironmentValuesRead = $false
credentialArgumentsAllowed = $false credentialArgumentsAllowed = $false
} }
artifacts = $artifacts artifacts = $artifacts

View file

@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
} }
$eventName = Assert-String $root 'e' $eventName = Assert-String $root 'e'
if (-not $exactFields.ContainsKey($eventName)) { 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] $expected = $exactFields[$eventName]
if ($names.Count -ne $expected.Count -or 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" } if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
if ($reason -cne $expectedReason) { if ($reason -cne $expectedReason) {
throw "terminal reason is '$reason', expected '$expectedReason'" throw "terminal reason does not match mode '$Mode'"
} }
$terminalSeen = $true $terminalSeen = $true
} }