diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj index f31af6e8..85dc49f0 100644 --- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -12,6 +12,9 @@ context. --> true + + + diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index f9b10694..0f7fcac1 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -1,3 +1,5 @@ +using System.Runtime.ExceptionServices; + namespace AcDream.Launcher.Core.Launching; /// @@ -10,19 +12,41 @@ public sealed class LauncherProcessSupervisor : IDisposable { private readonly ILauncherChildProcessFactory _factory; private readonly object _gate = new(); + private readonly Queue _pendingStateChanges = []; private ILauncherChildProcess? _process; + private LauncherSessionState _state = LauncherSessionState.Starting; + private int? _exitCode; + private bool _publishingStateChanges; public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) { _factory = factory ?? new SystemChildProcessFactory(); } - public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting; + public LauncherSessionState State + { + get + { + lock (_gate) + { + return _state; + } + } + } /// Set once reaches /// ; null before then. /// - public int? ExitCode { get; private set; } + public int? ExitCode + { + get + { + lock (_gate) + { + return _exitCode; + } + } + } /// Fires on every /// transition, in order. @@ -148,14 +172,15 @@ public sealed class LauncherProcessSupervisor : IDisposable private void OnProcessExited(object? sender, EventArgs e) { - ILauncherChildProcess? process; + int? exitCode; lock (_gate) { - process = _process; + exitCode = _process is { HasExited: true } process + ? process.ExitCode + : null; } - ExitCode = process is { HasExited: true } ? process.ExitCode : null; - SetState(LauncherSessionState.Exited); + SetState(LauncherSessionState.Exited, exitCode); } /// @@ -171,19 +196,79 @@ public sealed class LauncherProcessSupervisor : IDisposable /// — without this guard, "Running" would silently resurrect a /// process that has already reported its exit. /// - private void SetState(LauncherSessionState state) + private void SetState(LauncherSessionState state, int? exitCode = null) { + bool publish; lock (_gate) { - if (State == LauncherSessionState.Exited) + if (_state == LauncherSessionState.Exited) { 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(); + } + } + + /// + /// Drains state notifications through one publisher. Transition storage + /// stays under , 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. + /// + 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() diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs index 26f3957e..25d30787 100644 --- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -19,6 +19,8 @@ namespace AcDream.Launcher.Core.Profiles; public sealed class LauncherProfileStore { internal const int CurrentVersion = 1; + internal const UnixFileMode OwnerOnlyFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -115,14 +117,12 @@ public sealed class LauncherProfileStore /// /// Persists to via 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 - /// owner read/write (0600) immediately after creation — BEFORE any - /// plaintext credential is serialized into it — so there is no window - /// where the temp file carries the process umask's (potentially - /// world/group-readable) default permissions while holding a - /// password; the final path gets the same restriction after the - /// rename (Campaign LA's plaintext-credential decision, spec §5, - /// decisions log; the temp-file window itself is review finding F4). + /// truncated credentials file. On Linux, the temp file is created + /// atomically with owner read/write (0600) as its requested creation + /// mode — before its path is observable and before any plaintext + /// credential is serialized into it. The final path retains that mode + /// through the rename (Campaign LA's plaintext-credential decision, + /// spec §5, decisions log). /// A failure between temp-file creation and the rename deletes the /// stale temp file rather than leaving it behind. /// @@ -135,15 +135,18 @@ public sealed class LauncherProfileStore } string tempPath = FilePath + ".tmp"; + DeleteStaleTempFile(tempPath); try { - using (FileStream stream = File.Create(tempPath)) + using (FileStream stream = CreateCredentialTempFile(tempPath)) { if (OperatingSystem.IsLinux()) { - File.SetUnixFileMode( - tempPath, - UnixFileMode.UserRead | UnixFileMode.UserWrite); + // UnixCreateMode is subject to the process umask. It + // guarantees the file is never created with group/other + // 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); @@ -159,12 +162,39 @@ public sealed class LauncherProfileStore if (OperatingSystem.IsLinux()) { - File.SetUnixFileMode( - FilePath, - UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.SetUnixFileMode(FilePath, OwnerOnlyFileMode); } } + /// + /// Builds the exact options used for the plaintext-credential temp + /// file. makes creation atomic and + /// refuses to follow an existing stale or raced path. On Linux, + /// 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. + /// + 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) { try diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index b4471e36..c090b6e3 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -69,9 +69,9 @@ public sealed record ExitedStatusEvent : StatusEvent } /// -/// A well-formed status line whose e value (or overall envelope -/// shape) this reader does not recognize. The tailer never throws on an -/// unrecognized event — an older launcher reading a newer host's stream +/// A JSON-object status line whose non-empty string e value this +/// reader does not recognize. The tailer never throws on an unrecognized +/// event — an older launcher reading a newer host's stream /// degrades to seeing rows instead of /// crashing. /// @@ -81,10 +81,10 @@ public sealed record UnknownStatusEvent : StatusEvent } /// -/// A status line whose e value IS one of the recognized event -/// names, but whose payload does not match that event's expected shape -/// (a missing required field, or a field present with the wrong JSON -/// kind). Distinguished from (Campaign +/// A complete JSON value that is not an object, an object without a +/// usable event name, or a known event whose pinned v1 envelope/payload +/// does not match its expected shape. Distinguished from +/// (Campaign /// 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 /// sent me garbage for an event I do know" — the two cases call for diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index 112ddfe5..f6811a60 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -11,13 +11,13 @@ namespace AcDream.Launcher.Core.Status; /// "accountName":"...","slotCount":6,"characters":[...]}. /// /// -/// Never throws: a null/blank/malformed-JSON line, an unrecognized -/// e value, or a recognized e whose payload doesn't match -/// that event's expected shape, all degrade to a typed event -/// ( or -/// — see each type's docs) rather than an exception — a launcher 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. +/// Never throws: a null/blank/malformed-JSON line, a complete JSON value +/// with a non-object root, an unrecognized e value, or a recognized +/// e whose envelope/payload does not match the pinned v1 shape all +/// degrade to a typed event ( or +/// ) rather than an exception. A launcher +/// must keep tailing a session's status stream even against a host running a +/// newer/older wire version or a host that writes a bad line. /// /// public static class StatusEventParser @@ -26,10 +26,6 @@ public static class StatusEventParser { 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); } @@ -46,14 +42,51 @@ public static class StatusEventParser using (document) { 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"); - string e = GetStringOrDefault(root, "e"); - DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t"); - string sessionId = GetStringOrDefault(root, "sessionId"); + if (!TryGetEventName(root, out string e, out string eventNameError)) + { + return MalformedEvent( + 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 { + // 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 { "started" => @@ -72,40 +105,62 @@ public static class StatusEventParser ParseDisconnected(root, v, e, t, sessionId), "exited" => ParseExited(root, v, e, t, sessionId), - _ => - new UnknownStatusEvent - { - V = v, - E = e, - T = t, - SessionId = sessionId, - RawJson = line, - }, + _ => throw new InvalidOperationException("known event dispatch is incomplete."), }; } catch (Exception ex) when (ex is FormatException or InvalidOperationException) { - // FormatException: a Require* helper found a missing - // field or a field of the wrong JSON kind (e.g. - // "secondsGreyedOut": true"). InvalidOperationException: - // a JsonElement API call (EnumerateArray, TryGetProperty) - // against an element of the wrong ValueKind (e.g. - // "characters" present but not an array). Both mean `e` - // 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, - }; + return MalformedEvent( + GetInt32OrDefault(root, "v"), + e, + GetDateTimeOffsetOrDefault(root, "t"), + GetStringOrDefault(root, "sessionId"), + 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) => new() { @@ -116,6 +171,21 @@ public static class StatusEventParser 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( JsonElement root, int v, @@ -244,11 +314,8 @@ public static class StatusEventParser string name) => root.TryGetProperty(name, out JsonElement element) && element.ValueKind == JsonValueKind.String - && DateTimeOffset.TryParse( - element.GetString(), - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, - out DateTimeOffset value) + && element.TryGetDateTimeOffset(out DateTimeOffset value) + && value.Offset == TimeSpan.Zero ? value : default; @@ -273,11 +340,52 @@ public static class StatusEventParser : 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) { JsonElement element = RequireProperty(root, name); return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint 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."); } } diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index bab8b6c2..4ec1431e 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Threading; using AcDream.Launcher.Core.Launching; @@ -235,6 +236,95 @@ public sealed class LauncherProcessSupervisorTests 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(); + + 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(); + + 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 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] public void LauncherProcessSpecCarriesNoCredentialLikeMember() { @@ -358,12 +448,22 @@ public sealed class LauncherProcessSupervisorTests { // Simulates a child that dies synchronously from inside // Process.Start() itself (review finding F9's race). - HasExited = true; - ExitCode = 0; - Exited?.Invoke(this, EventArgs.Empty); + ExitForTest(0); } } + public void ExitForTest(int exitCode) + { + if (HasExited) + { + return; + } + + HasExited = true; + ExitCode = exitCode; + Exited?.Invoke(this, EventArgs.Empty); + } + public bool TryRequestGracefulStop() { TryRequestGracefulStopCallCount++; @@ -382,9 +482,7 @@ public sealed class LauncherProcessSupervisorTests { KillCallCount++; CallOrder.Add("kill"); - HasExited = true; - ExitCode = -1; - Exited?.Invoke(this, EventArgs.Empty); + ExitForTest(-1); } public bool WaitForExit(TimeSpan timeout) @@ -392,9 +490,7 @@ public sealed class LauncherProcessSupervisorTests if (!exitsWithinStopTimeout) return false; - HasExited = true; - ExitCode = 0; - Exited?.Invoke(this, EventArgs.Empty); + ExitForTest(0); return true; } diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs index 957fa2cb..8d83c88d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs @@ -1,4 +1,3 @@ -using System.Threading; using AcDream.Launcher.Core.Profiles; namespace AcDream.Launcher.Core.Tests.Profiles; @@ -287,68 +286,42 @@ public sealed class LauncherProfileStoreTests : IDisposable } [Fact] - public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite() + public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation() { - // Review finding F4: the temp file used to be created with the - // process's default umask and only chmod'd AFTER the atomic - // rename, leaving a window where the plaintext-credential temp - // file could be world/group-readable. The fix chmods the temp - // file immediately after creation, BEFORE any content (including - // the password) is serialized into it. A large document makes - // the write take long enough for a concurrent poller to have a - // real chance at observing a regression. + FileStreamOptions options = + LauncherProfileStore.CreateCredentialTempFileOptions(); + Assert.Equal(FileMode.CreateNew, options.Mode); + Assert.Equal(FileAccess.Write, options.Access); + Assert.Equal(FileShare.None, options.Share); + + if (OperatingSystem.IsLinux()) + { + 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()) 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"; - bool observedLooseMode = false; - 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(); + using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath); - store.Save(); - - Volatile.Write(ref stop, true); - poller.Join(); - - Assert.False(observedLooseMode); + Assert.Equal( + LauncherProfileStore.OwnerOnlyFileMode, + File.GetUnixFileMode(tempPath)); } [Fact] diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index d28b642b..47f5e118 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -112,6 +112,19 @@ public sealed class StatusEventParserTests Assert.IsType(e); } + [Theory] + [InlineData("[]")] + [InlineData("null")] + [InlineData("42")] + [InlineData("\"text\"")] + public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line) + { + var e = StatusEventParser.Parse(line); + + var malformed = Assert.IsType(e); + Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase); + } + [Theory] [InlineData("")] [InlineData(" ")] @@ -136,6 +149,33 @@ public sealed class StatusEventParserTests Assert.IsType(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(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(StatusEventParser.Parse(line)); + } + [Fact] public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs index b619fe25..d2ad011c 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable Assert.IsType(events[1]); } + [Fact] + public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine() + { + AppendShared( + "[]\nnull\n42\n\"text\"\n" + + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(5, events.Count); + Assert.All(events.Take(4), e => Assert.IsType(e)); + Assert.IsType(events[4]); + } + [Fact] public void TolerateAPartialLastLineAndCompletesItOnALaterPoll() {