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
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,

View file

@ -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. |

View file

@ -72,15 +72,18 @@ The expected matrix is:
| 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 | 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 | 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 |
`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:

View file

@ -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

View file

@ -11,8 +11,9 @@ public interface IReleaseManifestClient
/// <summary>
/// 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.
/// </summary>
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
CreateRedirectDisabledHandler(),
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(
Uri manifestUri,
bool allowLoopbackHttp,

View file

@ -28,10 +28,6 @@
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<!-- Distribution composition only: do not add a Launcher -> Bake project
reference. A per-RID launcher publish explicitly publishes the GL-free
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
{
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(

View file

@ -9,6 +9,7 @@ internal enum LauncherStartupMode
VerifyPublish,
SelfUpdateHelper,
SelfUpdateConfirmation,
SelfUpdateDeferred,
}
/// <summary>
@ -18,6 +19,12 @@ internal enum LauncherStartupMode
/// </summary>
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 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);
}

View file

@ -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<bool> hasRunningSessions,
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
Func<ClientVersionStore, string, ClientVersionResolution>? 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

View file

@ -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<App>()
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.");
}
}
}

View file

@ -160,18 +160,23 @@ static async Task<int> 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;
}

View file

@ -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));
}

View file

@ -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);

View file

@ -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<LauncherUpdateException>(() =>
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
[Fact]
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
{

View file

@ -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<InvalidOperationException>(() =>
Program.RequireUnchangedPublicArguments(
options,
new SelfUpdateStartupResult(
false,
0,
["--update-manifest-uri", "https://other.example.test/manifest.json"])));
}
public static TheoryData<string[]> 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(
[

View file

@ -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));
}
}

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-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

View file

@ -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, '<redacted>', [StringComparison]::Ordinal)
}
}
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
'$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,
'(?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

View file

@ -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
}