fix(launcher): Campaign LA LA3 narrow review fixes

This commit is contained in:
Erik 2026-08-14 17:06:47 +02:00
parent 26feba8186
commit 347a1a5d16
9 changed files with 495 additions and 145 deletions

View file

@ -12,6 +12,9 @@
context. --> context. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" /> <ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -1,3 +1,5 @@
using System.Runtime.ExceptionServices;
namespace AcDream.Launcher.Core.Launching; namespace AcDream.Launcher.Core.Launching;
/// <summary> /// <summary>
@ -10,19 +12,41 @@ public sealed class LauncherProcessSupervisor : IDisposable
{ {
private readonly ILauncherChildProcessFactory _factory; private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
private ILauncherChildProcess? _process; private ILauncherChildProcess? _process;
private LauncherSessionState _state = LauncherSessionState.Starting;
private int? _exitCode;
private bool _publishingStateChanges;
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
{ {
_factory = factory ?? new SystemChildProcessFactory(); _factory = factory ?? new SystemChildProcessFactory();
} }
public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting; public LauncherSessionState State
{
get
{
lock (_gate)
{
return _state;
}
}
}
/// <summary>Set once <see cref="State"/> reaches /// <summary>Set once <see cref="State"/> reaches
/// <see cref="LauncherSessionState.Exited"/>; null before then. /// <see cref="LauncherSessionState.Exited"/>; null before then.
/// </summary> /// </summary>
public int? ExitCode { get; private set; } public int? ExitCode
{
get
{
lock (_gate)
{
return _exitCode;
}
}
}
/// <summary>Fires on every <see cref="LauncherSessionState"/> /// <summary>Fires on every <see cref="LauncherSessionState"/>
/// transition, in order.</summary> /// transition, in order.</summary>
@ -148,14 +172,15 @@ public sealed class LauncherProcessSupervisor : IDisposable
private void OnProcessExited(object? sender, EventArgs e) private void OnProcessExited(object? sender, EventArgs e)
{ {
ILauncherChildProcess? process; int? exitCode;
lock (_gate) lock (_gate)
{ {
process = _process; exitCode = _process is { HasExited: true } process
? process.ExitCode
: null;
} }
ExitCode = process is { HasExited: true } ? process.ExitCode : null; SetState(LauncherSessionState.Exited, exitCode);
SetState(LauncherSessionState.Exited);
} }
/// <summary> /// <summary>
@ -171,19 +196,79 @@ public sealed class LauncherProcessSupervisor : IDisposable
/// — without this guard, "Running" would silently resurrect a /// — without this guard, "Running" would silently resurrect a
/// process that has already reported its exit. /// process that has already reported its exit.
/// </summary> /// </summary>
private void SetState(LauncherSessionState state) private void SetState(LauncherSessionState state, int? exitCode = null)
{ {
bool publish;
lock (_gate) lock (_gate)
{ {
if (State == LauncherSessionState.Exited) if (_state == LauncherSessionState.Exited)
{ {
return; return;
} }
State = state; _state = state;
if (state == LauncherSessionState.Exited)
{
_exitCode = exitCode;
}
_pendingStateChanges.Enqueue(state);
publish = !_publishingStateChanges;
if (publish)
{
_publishingStateChanges = true;
}
} }
StateChanged?.Invoke(this, state); if (publish)
{
PublishPendingStateChanges();
}
}
/// <summary>
/// Drains state notifications through one publisher. Transition storage
/// stays under <see cref="_gate"/>, but user callbacks run outside it so
/// they may re-enter the supervisor or wait for another thread reading
/// state without deadlocking. A concurrent/re-entrant transition queues
/// behind the notification already in flight, preserving storage order in
/// the externally observed event stream.
/// </summary>
private void PublishPendingStateChanges()
{
Exception? firstException = null;
while (true)
{
LauncherSessionState state;
lock (_gate)
{
if (_pendingStateChanges.Count == 0)
{
_publishingStateChanges = false;
break;
}
state = _pendingStateChanges.Dequeue();
}
try
{
StateChanged?.Invoke(this, state);
}
catch (Exception ex)
{
// Preserve the previous propagation behavior, but finish
// publishing any transition already committed concurrently
// (especially terminal Exited) before rethrowing the first
// observer failure to the initiating caller.
firstException ??= ex;
}
}
if (firstException is not null)
{
ExceptionDispatchInfo.Capture(firstException).Throw();
}
} }
public void Dispose() public void Dispose()

View file

@ -19,6 +19,8 @@ namespace AcDream.Launcher.Core.Profiles;
public sealed class LauncherProfileStore public sealed class LauncherProfileStore
{ {
internal const int CurrentVersion = 1; internal const int CurrentVersion = 1;
internal const UnixFileMode OwnerOnlyFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite;
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@ -115,14 +117,12 @@ public sealed class LauncherProfileStore
/// <summary> /// <summary>
/// Persists <see cref="Document"/> to <see cref="FilePath"/> via a /// Persists <see cref="Document"/> to <see cref="FilePath"/> via a
/// write-then-atomic-rename so a crash mid-write never leaves a /// write-then-atomic-rename so a crash mid-write never leaves a
/// truncated credentials file. On Linux, the temp file is chmod'd to /// truncated credentials file. On Linux, the temp file is created
/// owner read/write (0600) immediately after creation — BEFORE any /// atomically with owner read/write (0600) as its requested creation
/// plaintext credential is serialized into it — so there is no window /// mode — before its path is observable and before any plaintext
/// where the temp file carries the process umask's (potentially /// credential is serialized into it. The final path retains that mode
/// world/group-readable) default permissions while holding a /// through the rename (Campaign LA's plaintext-credential decision,
/// password; the final path gets the same restriction after the /// spec §5, decisions log).
/// rename (Campaign LA's plaintext-credential decision, spec §5,
/// decisions log; the temp-file window itself is review finding F4).
/// A failure between temp-file creation and the rename deletes the /// A failure between temp-file creation and the rename deletes the
/// stale temp file rather than leaving it behind. /// stale temp file rather than leaving it behind.
/// </summary> /// </summary>
@ -135,15 +135,18 @@ public sealed class LauncherProfileStore
} }
string tempPath = FilePath + ".tmp"; string tempPath = FilePath + ".tmp";
DeleteStaleTempFile(tempPath);
try try
{ {
using (FileStream stream = File.Create(tempPath)) using (FileStream stream = CreateCredentialTempFile(tempPath))
{ {
if (OperatingSystem.IsLinux()) if (OperatingSystem.IsLinux())
{ {
File.SetUnixFileMode( // UnixCreateMode is subject to the process umask. It
tempPath, // guarantees the file is never created with group/other
UnixFileMode.UserRead | UnixFileMode.UserWrite); // access; normalize the owner bits while the still-empty
// file is open so the persisted contract is exactly 0600.
File.SetUnixFileMode(tempPath, OwnerOnlyFileMode);
} }
JsonSerializer.Serialize(stream, Document, SerializerOptions); JsonSerializer.Serialize(stream, Document, SerializerOptions);
@ -159,12 +162,39 @@ public sealed class LauncherProfileStore
if (OperatingSystem.IsLinux()) if (OperatingSystem.IsLinux())
{ {
File.SetUnixFileMode( File.SetUnixFileMode(FilePath, OwnerOnlyFileMode);
FilePath,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
} }
} }
/// <summary>
/// Builds the exact options used for the plaintext-credential temp
/// file. <see cref="FileMode.CreateNew"/> makes creation atomic and
/// refuses to follow an existing stale or raced path. On Linux,
/// <see cref="FileStreamOptions.UnixCreateMode"/> supplies 0600 to
/// the OS create operation itself, eliminating the observable
/// create-then-chmod window. Windows leaves UnixCreateMode unset and
/// therefore retains its normal user-profile ACL behavior.
/// </summary>
internal static FileStreamOptions CreateCredentialTempFileOptions()
{
var options = new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
};
if (OperatingSystem.IsLinux())
{
options.UnixCreateMode = OwnerOnlyFileMode;
}
return options;
}
internal static FileStream CreateCredentialTempFile(string tempPath) =>
new(tempPath, CreateCredentialTempFileOptions());
private static void DeleteStaleTempFile(string tempPath) private static void DeleteStaleTempFile(string tempPath)
{ {
try try

View file

@ -69,9 +69,9 @@ public sealed record ExitedStatusEvent : StatusEvent
} }
/// <summary> /// <summary>
/// A well-formed status line whose <c>e</c> value (or overall envelope /// A JSON-object status line whose non-empty string <c>e</c> value this
/// shape) this reader does not recognize. The tailer never throws on an /// reader does not recognize. The tailer never throws on an unrecognized
/// unrecognized event — an older launcher reading a newer host's stream /// event — an older launcher reading a newer host's stream
/// degrades to seeing <see cref="UnknownStatusEvent"/> rows instead of /// degrades to seeing <see cref="UnknownStatusEvent"/> rows instead of
/// crashing. /// crashing.
/// </summary> /// </summary>
@ -81,10 +81,10 @@ public sealed record UnknownStatusEvent : StatusEvent
} }
/// <summary> /// <summary>
/// A status line whose <c>e</c> value IS one of the recognized event /// A complete JSON value that is not an object, an object without a
/// names, but whose payload does not match that event's expected shape /// usable event name, or a known event whose pinned v1 envelope/payload
/// (a missing required field, or a field present with the wrong JSON /// does not match its expected shape. Distinguished from
/// kind). Distinguished from <see cref="UnknownStatusEvent"/> (Campaign /// <see cref="UnknownStatusEvent"/> (Campaign
/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older /// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older
/// host sent an event I've never heard of" apart from "a host I recognize /// host sent an event I've never heard of" apart from "a host I recognize
/// sent me garbage for an event I do know" — the two cases call for /// sent me garbage for an event I do know" — the two cases call for

View file

@ -11,13 +11,13 @@ namespace AcDream.Launcher.Core.Status;
/// "accountName":"...","slotCount":6,"characters":[...]}</c>. /// "accountName":"...","slotCount":6,"characters":[...]}</c>.
/// ///
/// <para> /// <para>
/// Never throws: a null/blank/malformed-JSON line, an unrecognized /// Never throws: a null/blank/malformed-JSON line, a complete JSON value
/// <c>e</c> value, or a recognized <c>e</c> whose payload doesn't match /// with a non-object root, an unrecognized <c>e</c> value, or a recognized
/// that event's expected shape, all degrade to a typed event /// <c>e</c> whose envelope/payload does not match the pinned v1 shape all
/// (<see cref="UnknownStatusEvent"/> or <see cref="MalformedStatusEvent"/> /// degrade to a typed event (<see cref="UnknownStatusEvent"/> or
/// — see each type's docs) rather than an exception — a launcher must /// <see cref="MalformedStatusEvent"/>) rather than an exception. A launcher
/// keep tailing a session's status stream even against a host running a /// must keep tailing a session's status stream even against a host running a
/// newer/older wire version, or a host that briefly writes a torn line. /// newer/older wire version or a host that writes a bad line.
/// </para> /// </para>
/// </summary> /// </summary>
public static class StatusEventParser public static class StatusEventParser
@ -26,10 +26,6 @@ public static class StatusEventParser
{ {
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
{ {
// Campaign LA plan §LA3 review finding F7: a blank/whitespace
// line is a normal "nothing complete here yet" degrade, not a
// caller error — the old ArgumentException.ThrowIfNullOrWhiteSpace
// guard ran BEFORE the try/catch below and escaped uncaught.
return UnknownEvent(line ?? string.Empty); return UnknownEvent(line ?? string.Empty);
} }
@ -46,14 +42,51 @@ public static class StatusEventParser
using (document) using (document)
{ {
JsonElement root = document.RootElement; JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return MalformedEvent(
v: 0,
e: string.Empty,
t: default,
sessionId: string.Empty,
"status event root is not a JSON object.");
}
int v = GetInt32OrDefault(root, "v"); if (!TryGetEventName(root, out string e, out string eventNameError))
string e = GetStringOrDefault(root, "e"); {
DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t"); return MalformedEvent(
string sessionId = GetStringOrDefault(root, "sessionId"); GetInt32OrDefault(root, "v"),
e,
GetDateTimeOffsetOrDefault(root, "t"),
GetStringOrDefault(root, "sessionId"),
eventNameError);
}
// A genuinely unknown event name is the forward-compatibility
// case. Retain its best-effort envelope and raw JSON without
// imposing this launcher's known-event envelope/payload schema.
if (!IsKnownEventName(e))
{
return new UnknownStatusEvent
{
V = GetInt32OrDefault(root, "v"),
E = e,
T = GetDateTimeOffsetOrDefault(root, "t"),
SessionId = GetStringOrDefault(root, "sessionId"),
RawJson = line,
};
}
try try
{ {
// The pinned v1 envelope applies to every known event,
// including payload-free started/connected rows. Defaulting
// malformed fields would turn corrupt or cross-version input
// into an apparently valid typed event.
int v = RequireVersionOne(root);
DateTimeOffset t = RequireUtcTimestamp(root, "t");
string sessionId = RequireNonEmptyString(root, "sessionId");
return e switch return e switch
{ {
"started" => "started" =>
@ -72,40 +105,62 @@ public static class StatusEventParser
ParseDisconnected(root, v, e, t, sessionId), ParseDisconnected(root, v, e, t, sessionId),
"exited" => "exited" =>
ParseExited(root, v, e, t, sessionId), ParseExited(root, v, e, t, sessionId),
_ => _ => throw new InvalidOperationException("known event dispatch is incomplete."),
new UnknownStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
RawJson = line,
},
}; };
} }
catch (Exception ex) when (ex is FormatException or InvalidOperationException) catch (Exception ex) when (ex is FormatException or InvalidOperationException)
{ {
// FormatException: a Require* helper found a missing return MalformedEvent(
// field or a field of the wrong JSON kind (e.g. GetInt32OrDefault(root, "v"),
// "secondsGreyedOut": true"). InvalidOperationException: e,
// a JsonElement API call (EnumerateArray, TryGetProperty) GetDateTimeOffsetOrDefault(root, "t"),
// against an element of the wrong ValueKind (e.g. GetStringOrDefault(root, "sessionId"),
// "characters" present but not an array). Both mean `e` ex.Message);
// WAS recognized but its payload wasn't — distinguished
// from UnknownStatusEvent (Campaign LA plan §LA3 review
// finding F12).
return new MalformedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Error = ex.Message,
};
} }
} }
} }
private static bool IsKnownEventName(string eventName) =>
eventName is
"started" or
"connected" or
"characterList" or
"enteredWorld" or
"pluginLoaded" or
"pluginFailed" or
"disconnected" or
"exited";
private static bool TryGetEventName(
JsonElement root,
out string eventName,
out string error)
{
if (!root.TryGetProperty("e", out JsonElement element))
{
eventName = string.Empty;
error = "status event is missing 'e'.";
return false;
}
if (element.ValueKind != JsonValueKind.String)
{
eventName = string.Empty;
error = "status event field 'e' is not a string.";
return false;
}
eventName = element.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(eventName))
{
error = "status event field 'e' is empty.";
return false;
}
error = string.Empty;
return true;
}
private static UnknownStatusEvent UnknownEvent(string rawLine) => private static UnknownStatusEvent UnknownEvent(string rawLine) =>
new() new()
{ {
@ -116,6 +171,21 @@ public static class StatusEventParser
RawJson = rawLine, RawJson = rawLine,
}; };
private static MalformedStatusEvent MalformedEvent(
int v,
string e,
DateTimeOffset t,
string sessionId,
string error) =>
new()
{
V = v,
E = e,
T = t,
SessionId = sessionId,
Error = error,
};
private static StatusEvent ParseCharacterList( private static StatusEvent ParseCharacterList(
JsonElement root, JsonElement root,
int v, int v,
@ -244,11 +314,8 @@ public static class StatusEventParser
string name) => string name) =>
root.TryGetProperty(name, out JsonElement element) root.TryGetProperty(name, out JsonElement element)
&& element.ValueKind == JsonValueKind.String && element.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse( && element.TryGetDateTimeOffset(out DateTimeOffset value)
element.GetString(), && value.Offset == TimeSpan.Zero
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out DateTimeOffset value)
? value ? value
: default; : default;
@ -273,11 +340,52 @@ public static class StatusEventParser
: throw new FormatException($"status event field '{name}' is not an integer."); : throw new FormatException($"status event field '{name}' is not an integer.");
} }
private static int RequireVersionOne(JsonElement root)
{
int version = RequireInt32(root, "v");
return version == 1
? version
: throw new FormatException(
$"status event version is {version}; expected 1.");
}
private static string RequireNonEmptyString(JsonElement root, string name)
{
string value = RequireString(root, name);
return !string.IsNullOrWhiteSpace(value)
? value
: throw new FormatException($"status event field '{name}' is empty.");
}
private static DateTimeOffset RequireUtcTimestamp(JsonElement root, string name)
{
JsonElement element = RequireProperty(root, name);
if (element.ValueKind != JsonValueKind.String)
{
throw new FormatException(
$"status event field '{name}' is not an ISO-8601 UTC string.");
}
string text = element.GetString() ?? string.Empty;
bool hasExplicitUtcOffset = text.EndsWith('Z')
|| text.EndsWith("+00:00", StringComparison.Ordinal);
if (!hasExplicitUtcOffset
|| !element.TryGetDateTimeOffset(out DateTimeOffset value)
|| value.Offset != TimeSpan.Zero)
{
throw new FormatException(
$"status event field '{name}' is not an ISO-8601 UTC timestamp.");
}
return value;
}
private static uint RequireUInt32(JsonElement root, string name) private static uint RequireUInt32(JsonElement root, string name)
{ {
JsonElement element = RequireProperty(root, name); JsonElement element = RequireProperty(root, name);
return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value) return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value)
? value ? value
: throw new FormatException($"status event field '{name}' is not an unsigned integer."); : throw new FormatException(
$"status event field '{name}' is not an unsigned integer.");
} }
} }

View file

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Threading; using System.Threading;
using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Launching;
@ -235,6 +236,95 @@ public sealed class LauncherProcessSupervisorTests
states); states);
} }
[Fact]
public async Task ConcurrentRunningAndExitedPublicationsRemainMonotonicAndInOrder()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
using var runningPublicationEntered = new ManualResetEventSlim(false);
using var releaseRunningPublication = new ManualResetEventSlim(false);
var states = new ConcurrentQueue<LauncherSessionState>();
supervisor.StateChanged += (_, state) =>
{
if (state == LauncherSessionState.Running)
{
runningPublicationEntered.Set();
Assert.True(
releaseRunningPublication.Wait(TimeSpan.FromSeconds(5)),
"test did not release the Running publication barrier");
}
states.Enqueue(state);
};
Task startTask = Task.Run(() => supervisor.Start(Spec(), "pw"));
try
{
Assert.True(
runningPublicationEntered.Wait(TimeSpan.FromSeconds(5)),
"Running publication did not reach the test barrier");
// Commit Exited while Running's observer is deliberately
// paused. Storage reaches the terminal state immediately, but
// publication must queue behind the earlier Running event.
factory.LastCreated!.ExitForTest(17);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
}
finally
{
releaseRunningPublication.Set();
}
await startTask.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(
[
LauncherSessionState.Starting,
LauncherSessionState.Running,
LauncherSessionState.Exited,
],
states);
Assert.Equal(17, supervisor.ExitCode);
}
[Fact]
public void StateChangedPublicationAllowsCrossThreadReadsAndReentrantExit()
{
var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true);
using var supervisor = new LauncherProcessSupervisor(factory);
var states = new List<LauncherSessionState>();
supervisor.StateChanged += (_, state) =>
{
states.Add(state);
if (state != LauncherSessionState.Running)
{
return;
}
// A publisher that invokes callbacks while holding the state
// gate deadlocks this cross-thread read. The callback also
// raises Exited re-entrantly; it must queue after Running rather
// than recurse out of order or deadlock.
Task<LauncherSessionState> readTask = Task.Run(() => supervisor.State);
Assert.True(readTask.Wait(TimeSpan.FromSeconds(5)));
Assert.Equal(LauncherSessionState.Running, readTask.Result);
factory.LastCreated!.ExitForTest(23);
};
supervisor.Start(Spec(), "pw");
Assert.Equal(
[
LauncherSessionState.Starting,
LauncherSessionState.Running,
LauncherSessionState.Exited,
],
states);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.Equal(23, supervisor.ExitCode);
}
[Fact] [Fact]
public void LauncherProcessSpecCarriesNoCredentialLikeMember() public void LauncherProcessSpecCarriesNoCredentialLikeMember()
{ {
@ -358,12 +448,22 @@ public sealed class LauncherProcessSupervisorTests
{ {
// Simulates a child that dies synchronously from inside // Simulates a child that dies synchronously from inside
// Process.Start() itself (review finding F9's race). // Process.Start() itself (review finding F9's race).
HasExited = true; ExitForTest(0);
ExitCode = 0;
Exited?.Invoke(this, EventArgs.Empty);
} }
} }
public void ExitForTest(int exitCode)
{
if (HasExited)
{
return;
}
HasExited = true;
ExitCode = exitCode;
Exited?.Invoke(this, EventArgs.Empty);
}
public bool TryRequestGracefulStop() public bool TryRequestGracefulStop()
{ {
TryRequestGracefulStopCallCount++; TryRequestGracefulStopCallCount++;
@ -382,9 +482,7 @@ public sealed class LauncherProcessSupervisorTests
{ {
KillCallCount++; KillCallCount++;
CallOrder.Add("kill"); CallOrder.Add("kill");
HasExited = true; ExitForTest(-1);
ExitCode = -1;
Exited?.Invoke(this, EventArgs.Empty);
} }
public bool WaitForExit(TimeSpan timeout) public bool WaitForExit(TimeSpan timeout)
@ -392,9 +490,7 @@ public sealed class LauncherProcessSupervisorTests
if (!exitsWithinStopTimeout) if (!exitsWithinStopTimeout)
return false; return false;
HasExited = true; ExitForTest(0);
ExitCode = 0;
Exited?.Invoke(this, EventArgs.Empty);
return true; return true;
} }

View file

@ -1,4 +1,3 @@
using System.Threading;
using AcDream.Launcher.Core.Profiles; using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles; namespace AcDream.Launcher.Core.Tests.Profiles;
@ -287,68 +286,42 @@ public sealed class LauncherProfileStoreTests : IDisposable
} }
[Fact] [Fact]
public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite() public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation()
{ {
// Review finding F4: the temp file used to be created with the FileStreamOptions options =
// process's default umask and only chmod'd AFTER the atomic LauncherProfileStore.CreateCredentialTempFileOptions();
// rename, leaving a window where the plaintext-credential temp Assert.Equal(FileMode.CreateNew, options.Mode);
// file could be world/group-readable. The fix chmods the temp Assert.Equal(FileAccess.Write, options.Access);
// file immediately after creation, BEFORE any content (including Assert.Equal(FileShare.None, options.Share);
// the password) is serialized into it. A large document makes
// the write take long enough for a concurrent poller to have a if (OperatingSystem.IsLinux())
// real chance at observing a regression. {
Assert.Equal(
LauncherProfileStore.OwnerOnlyFileMode,
options.UnixCreateMode);
}
else
{
Assert.Null(options.UnixCreateMode);
}
}
[Fact]
public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState()
{
// Deterministic proof of the exact production create path: inspect
// the file while the CreateNew handle is still open, before any
// serialization or post-create chmod can occur. This replaces the
// old timing-only poller, which could miss the vulnerable window.
if (!OperatingSystem.IsLinux()) if (!OperatingSystem.IsLinux())
return; return;
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
for (int i = 0; i < 300; i++)
{
store.AddAccount("Local ACE", $"account{i}", new string('x', 4096));
}
string tempPath = _filePath + ".tmp"; string tempPath = _filePath + ".tmp";
bool observedLooseMode = false; using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath);
bool stop = false;
var poller = new Thread(() =>
{
while (!Volatile.Read(ref stop))
{
if (File.Exists(tempPath))
{
try
{
// The platform-compat analyzer can't see the
// enclosing test method's `OperatingSystem.IsLinux()`
// guard across this lambda boundary; suppressed
// rather than restructured, since the guard is
// real and this whole method is a no-op off Linux.
#pragma warning disable CA1416
UnixFileMode mode = File.GetUnixFileMode(tempPath);
#pragma warning restore CA1416
if ((mode & ~(UnixFileMode.UserRead | UnixFileMode.UserWrite)) != 0)
{
observedLooseMode = true;
}
}
catch (IOException)
{
// Renamed/deleted between the Exists check and
// GetUnixFileMode — not a finding, just keep
// polling.
}
}
}
});
poller.Start();
store.Save(); Assert.Equal(
LauncherProfileStore.OwnerOnlyFileMode,
Volatile.Write(ref stop, true); File.GetUnixFileMode(tempPath));
poller.Join();
Assert.False(observedLooseMode);
} }
[Fact] [Fact]

View file

@ -112,6 +112,19 @@ public sealed class StatusEventParserTests
Assert.IsType<UnknownStatusEvent>(e); Assert.IsType<UnknownStatusEvent>(e);
} }
[Theory]
[InlineData("[]")]
[InlineData("null")]
[InlineData("42")]
[InlineData("\"text\"")]
public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line)
{
var e = StatusEventParser.Parse(line);
var malformed = Assert.IsType<MalformedStatusEvent>(e);
Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase);
}
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
@ -136,6 +149,33 @@ public sealed class StatusEventParserTests
Assert.IsType<UnknownStatusEvent>(e); Assert.IsType<UnknownStatusEvent>(e);
} }
[Theory]
[InlineData("{\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":\"1\",\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":2,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":42,\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"not-a-time\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00+02:00\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\"}")]
[InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":42}")]
[InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"\"}")]
public void PayloadFreeKnownEventsRequireTheFullPinnedV1Envelope(string line)
{
var e = StatusEventParser.Parse(line);
var malformed = Assert.IsType<MalformedStatusEvent>(e);
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
}
[Theory]
[InlineData("{\"v\":1,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
[InlineData("{\"v\":1,\"e\":42,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")]
public void MissingOrWrongKindEventNameSurfacesAsMalformedEvent(string line)
{
Assert.IsType<MalformedStatusEvent>(StatusEventParser.Parse(line));
}
[Fact] [Fact]
public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing() public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing()
{ {

View file

@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable
Assert.IsType<ConnectedStatusEvent>(events[1]); Assert.IsType<ConnectedStatusEvent>(events[1]);
} }
[Fact]
public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine()
{
AppendShared(
"[]\nnull\n42\n\"text\"\n"
+ Line("connected", "s1"));
var tailer = new StatusFileTailer(_path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
Assert.Equal(5, events.Count);
Assert.All(events.Take(4), e => Assert.IsType<MalformedStatusEvent>(e));
Assert.IsType<ConnectedStatusEvent>(events[4]);
}
[Fact] [Fact]
public void TolerateAPartialLastLineAndCompletesItOnALaterPoll() public void TolerateAPartialLastLineAndCompletesItOnALaterPoll()
{ {