fix(launcher): Campaign LA gate-round-1 review findings F1-F6 + hardening

F1: the crash reporter comment claimed the launcher never holds a password
in any field - false (ProfileEditorDialogViewModel, AccountProfile.Password,
StartRequest.Password). Reworded to the true, narrower invariant (no throw
site interpolates a credential VALUE into an exception message) and pinned
it with CrashReportNeverContainsAStoredPassword: a real STJ failure over a
profiles document containing a known password, corrupted after the
credential, must yield a crash file with the stack and without the value.

F2: the co-deploy Inputs covered only Bake own sources; a Content edit
never refreshed the 83 MB exe. Now the full reference closure. Fixing it
surfaced two more incrementality traps, both fixed and comment-documented:
SkipUnchangedFiles left the output older than the triggering input (target
re-ran forever - added an explicit Touch), and %(Item.Metadata) in a plain
Include does not batch (the literal percent-text became a permanently
out-of-date phantom input - globs are now spelled per project). Verified:
Core edit retriggers, then two consecutive clean incremental builds.

F3: RID publishes ran BOTH co-deploy paths (two self-contained bake
publishes). Build-time target now guarded on _IsPublishing; verified a
real win-x64 publish runs zero build-target co-deploys and still ships
both exes.

F4: comment misattributed PublishBakeTool=false to CI lanes; it is
target-local recursion guarding. F5: the x:Name reflection sweep now walks
the markup as XML and tolerates template-scoped names (no generated field
exists for those). F6: dead using removed. Hardening: the crash reporter
positional --data-dir fallback requires a fully-qualified path so a
relative or flag-shaped value cannot create ./crash-reports at an
arbitrary CWD.

Launcher 67/67, Launcher.Core 317/317.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 08:19:23 +02:00
parent 1f87acf1af
commit 981e168fb9
6 changed files with 178 additions and 32 deletions

View file

@ -1,5 +1,5 @@
using System.Reflection;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
@ -74,10 +74,23 @@ public sealed class MainWindowViewTests
"src",
"AcDream.Launcher",
"MainWindow.axaml");
string markup = File.ReadAllText(markupPath);
List<string> names = Regex
.Matches(markup, "x:Name=\"([^\"]+)\"")
.Select(match => match.Groups[1].Value)
// Walk the markup as XML rather than regexing the raw text:
// template-scoped names (inside a DataTemplate/ControlTemplate/
// ItemTemplate) get NO generated backing field, so demanding one
// would false-fail the first time a template gains an x:Name
// (gate-round-1 review F5 — latent today, MainWindow has two
// templates with none inside).
XDocument document = XDocument.Load(markupPath);
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
List<string> names = document
.Descendants()
.Where(element => element.Attribute(x + "Name") is not null)
.Where(element => !element
.Ancestors()
.Any(ancestor => ancestor.Name.LocalName.EndsWith(
"Template",
StringComparison.Ordinal)))
.Select(element => element.Attribute(x + "Name")!.Value)
.Distinct(StringComparer.Ordinal)
.ToList();
@ -260,6 +273,74 @@ public sealed class MainWindowViewTests
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
/// <summary>
/// Gate-round-1 review F1: the crash reporter's safety rests on the
/// invariant that no code path interpolates a credential VALUE into an
/// exception message — the launcher genuinely holds passwords
/// (ProfileEditorDialogViewModel, AccountProfile.Password,
/// StartRequest.Password), so "no password in any field" was never the
/// guarantee. This test pins the real one against the most
/// credential-adjacent realistic failure: a profiles-shaped document
/// that CONTAINS the password and is corrupted AFTER it, so the JSON
/// parser has consumed the credential value before throwing.
/// System.Text.Json quotes paths and positions, never values — if that
/// (or any future throw site) ever changes, this fails and the sink
/// needs the status-stream's credential scanning.
/// </summary>
[Fact]
public void CrashReportNeverContainsAStoredPassword()
{
string root = Path.Combine(
Path.GetTempPath(),
"acdream-tests",
Path.GetRandomFileName());
string dataDirectory = Path.Combine(root, "data");
const string password = "hunter2-gate-round-1-secret";
string corruptProfiles =
"{ \"version\": 1, \"servers\": [ { \"name\": \"s\", \"host\": \"h\", "
+ "\"port\": 9000, \"accounts\": [ { \"account\": \"a\", \"password\": \""
+ password
+ "\", \"characters\": [ } ] } ] }";
Exception failure;
try
{
_ = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(
corruptProfiles);
throw new InvalidOperationException(
"The corrupt fixture unexpectedly parsed; the test premise is broken.");
}
catch (System.Text.Json.JsonException jsonFailure)
{
failure = new InvalidOperationException(
"Profile load failed during startup.",
jsonFailure);
}
try
{
string? report = Program.TryWriteCrashReport(
["--data-dir", dataDirectory],
failure);
Assert.NotNull(report);
// Isolation re-pinned: the report must land under the caller's
// --data-dir, never the machine's real data root.
Assert.StartsWith(dataDirectory, report, StringComparison.OrdinalIgnoreCase);
string content = File.ReadAllText(report);
Assert.Contains("JsonException", content);
Assert.Contains(" at ", content);
Assert.DoesNotContain(password, content, StringComparison.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
/// <summary>
/// Minimal no-op orchestrator. These tests exercise MainWindow's own
/// dispatcher/focus wiring, not orchestrator behavior (already covered