diff --git a/README.md b/README.md index 9a331c8..57e1476 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,10 @@ Airlock provides an additional layer of security by routing all network traffic - Local: `.copilot_here/network.json` - Default Rules: `~/.config/copilot_here/default-airlock-rules.json` (updated with script updates) +A local `network.json` replaces the global one outright, so the two are never merged. That's why the first time `--enable-airlock` or `--disable-airlock` creates a local file in a project, it copies your global config across rather than starting empty. The command tells you when it does this. + +Enabling and disabling only ever changes the `enabled` value. The commands leave your comments, key order, indentation, and any keys `copilot_here` doesn't know about exactly as you wrote them. + **Example Configuration:** ```json { @@ -276,7 +280,7 @@ Airlock provides an additional layer of security by routing all network traffic - **enforce** (`e`): Blocks requests not matching the allowlist - **monitor** (`m`): Allows all requests but logs them for review -When enabling Airlock for the first time, you'll be prompted to choose between enforce and monitor mode. +New configs start in enforce mode. Switch to monitor by setting `"mode": "monitor"` in `network.json`. **Logging:** When `enable_logging` is true (or in monitor mode), request logs are saved to `.copilot_here/logs/` (excluded from git by default). diff --git a/app/Commands/Airlock/DisableAirlock.cs b/app/Commands/Airlock/DisableAirlock.cs index 578fb03..c6e4a61 100644 --- a/app/Commands/Airlock/DisableAirlock.cs +++ b/app/Commands/Airlock/DisableAirlock.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CopilotHere.Infrastructure; namespace CopilotHere.Commands.Airlock; @@ -8,13 +7,9 @@ public sealed partial class AirlockCommands private static Command SetDisableAirlockCommand() { var command = new Command("--disable-airlock", "Disable Airlock for local config"); - command.SetAction(_ => - { - var paths = AppPaths.Resolve(); - AirlockConfig.DisableLocal(paths); - Console.WriteLine("✅ Airlock disabled (local)"); - return 0; - }); + command.SetAction(_ => RunToggle( + "✅ Airlock disabled (local)", + paths => (AirlockConfig.DisableLocal(paths), AirlockConfig.GetLocalRulesPath(paths)))); return command; } } diff --git a/app/Commands/Airlock/DisableGlobalAirlock.cs b/app/Commands/Airlock/DisableGlobalAirlock.cs index 6e36159..2cf2ab9 100644 --- a/app/Commands/Airlock/DisableGlobalAirlock.cs +++ b/app/Commands/Airlock/DisableGlobalAirlock.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CopilotHere.Infrastructure; namespace CopilotHere.Commands.Airlock; @@ -8,13 +7,9 @@ public sealed partial class AirlockCommands private static Command SetDisableGlobalAirlockCommand() { var command = new Command("--disable-global-airlock", "Disable Airlock for global config"); - command.SetAction(_ => - { - var paths = AppPaths.Resolve(); - AirlockConfig.DisableGlobal(paths); - Console.WriteLine("✅ Airlock disabled (global)"); - return 0; - }); + command.SetAction(_ => RunToggle( + "✅ Airlock disabled (global)", + paths => (AirlockConfig.DisableGlobal(paths), AirlockConfig.GetGlobalRulesPath(paths)))); return command; } } diff --git a/app/Commands/Airlock/EnableAirlock.cs b/app/Commands/Airlock/EnableAirlock.cs index 5ec537f..e56b51b 100644 --- a/app/Commands/Airlock/EnableAirlock.cs +++ b/app/Commands/Airlock/EnableAirlock.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CopilotHere.Infrastructure; namespace CopilotHere.Commands.Airlock; @@ -8,14 +7,9 @@ public sealed partial class AirlockCommands private static Command SetEnableAirlockCommand() { var command = new Command("--enable-airlock", "Enable Airlock with local rules (.copilot_here/network.json)"); - command.SetAction(_ => - { - var paths = AppPaths.Resolve(); - AirlockConfig.EnableLocal(paths); - Console.WriteLine("✅ Airlock enabled (local)"); - Console.WriteLine($" 📁 Rules: {AirlockConfig.GetLocalRulesPath(paths)}"); - return 0; - }); + command.SetAction(_ => RunToggle( + "✅ Airlock enabled (local)", + paths => (AirlockConfig.EnableLocal(paths), AirlockConfig.GetLocalRulesPath(paths)))); return command; } } diff --git a/app/Commands/Airlock/EnableGlobalAirlock.cs b/app/Commands/Airlock/EnableGlobalAirlock.cs index 451b197..ab91db5 100644 --- a/app/Commands/Airlock/EnableGlobalAirlock.cs +++ b/app/Commands/Airlock/EnableGlobalAirlock.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CopilotHere.Infrastructure; namespace CopilotHere.Commands.Airlock; @@ -8,14 +7,9 @@ public sealed partial class AirlockCommands private static Command SetEnableGlobalAirlockCommand() { var command = new Command("--enable-global-airlock", "Enable Airlock with global rules (~/.config/copilot_here/network.json)"); - command.SetAction(_ => - { - var paths = AppPaths.Resolve(); - AirlockConfig.EnableGlobal(paths); - Console.WriteLine("✅ Airlock enabled (global)"); - Console.WriteLine($" 🌍 Rules: {AirlockConfig.GetGlobalRulesPath(paths)}"); - return 0; - }); + command.SetAction(_ => RunToggle( + "✅ Airlock enabled (global)", + paths => (AirlockConfig.EnableGlobal(paths), AirlockConfig.GetGlobalRulesPath(paths)))); return command; } } diff --git a/app/Commands/Airlock/NetworkConfig.cs b/app/Commands/Airlock/NetworkConfig.cs index d69ccea..5e8b3a6 100644 --- a/app/Commands/Airlock/NetworkConfig.cs +++ b/app/Commands/Airlock/NetworkConfig.cs @@ -51,10 +51,15 @@ public sealed class NetworkRule /// /// JSON source generator context for AOT-compatible serialization. /// +// network.json is a hand-edited file, so the reader tolerates comments and trailing +// commas. Anything that loads here must also survive a toggle, and the toggle's +// Utf8JsonReader is configured to match. [JsonSourceGenerationOptions( WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true)] [JsonSerializable(typeof(NetworkConfig))] [JsonSerializable(typeof(NetworkRule))] [JsonSerializable(typeof(List))] diff --git a/app/Commands/Airlock/_AirlockCommands.cs b/app/Commands/Airlock/_AirlockCommands.cs index 465123a..bf0dba6 100644 --- a/app/Commands/Airlock/_AirlockCommands.cs +++ b/app/Commands/Airlock/_AirlockCommands.cs @@ -1,4 +1,6 @@ using System.CommandLine; +using System.Text.Json; +using CopilotHere.Infrastructure; namespace CopilotHere.Commands.Airlock; @@ -17,4 +19,36 @@ public void Configure(RootCommand root) root.Add(SetEditAirlockRulesCommand()); root.Add(SetEditGlobalAirlockRulesCommand()); } + + /// + /// Runs an Airlock toggle and reports which file it wrote and how. + /// + /// Returns the outcome and the path it wrote. + private static int RunToggle(string successMessage, Func toggle) + { + var paths = AppPaths.Resolve(); + + try + { + var (outcome, path) = toggle(paths); + + Console.WriteLine(successMessage); + Console.WriteLine($" 📁 Rules: {path}"); + + if (outcome == AirlockToggleOutcome.SeededFromGlobal) + { + Console.WriteLine($" ↳ seeded from global config ({paths.GetGlobalPath("network.json")})"); + Console.WriteLine(" Local config replaces global entirely, so its rules were copied across."); + } + + return 0; + } + catch (JsonException ex) + { + Console.Error.WriteLine("❌ Could not update the Airlock config."); + Console.Error.WriteLine($" {ex.Message}"); + Console.Error.WriteLine(" No changes were written."); + return 1; + } + } } diff --git a/app/Commands/Airlock/_AirlockConfig.cs b/app/Commands/Airlock/_AirlockConfig.cs index 18da7be..8a9e3c7 100644 --- a/app/Commands/Airlock/_AirlockConfig.cs +++ b/app/Commands/Airlock/_AirlockConfig.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using CopilotHere.Infrastructure; @@ -24,6 +25,18 @@ public sealed record AirlockConfig private const string RulesFileName = "network.json"; + /// UTF-8 BOM. Some Windows editors add one; it must survive a rewrite. + private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF]; + + /// + /// Matches the deserializer's leniency, so any file that loads can also be toggled. + /// + private static readonly JsonReaderOptions ReaderOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + /// /// Loads Airlock configuration from config files. /// The enabled flag is read from within the network.json file. @@ -93,45 +106,236 @@ internal static void WriteNetworkConfig(string path, NetworkConfig config) } /// Enables Airlock in local config by setting enabled:true in network.json. - public static void EnableLocal(AppPaths paths) + public static AirlockToggleOutcome EnableLocal(AppPaths paths) => SetEnabledLocal(paths, true); + + /// Enables Airlock in global config by setting enabled:true in network.json. + public static AirlockToggleOutcome EnableGlobal(AppPaths paths) => SetEnabledGlobal(paths, true); + + /// Disables Airlock in local config by setting enabled:false in network.json. + public static AirlockToggleOutcome DisableLocal(AppPaths paths) => SetEnabledLocal(paths, false); + + /// Disables Airlock in global config by setting enabled:false in network.json. + public static AirlockToggleOutcome DisableGlobal(AppPaths paths) => SetEnabledGlobal(paths, false); + + private static AirlockToggleOutcome SetEnabledLocal(AppPaths paths, bool enabled) => + SetEnabledInJson(paths.GetLocalPath(RulesFileName), enabled, seedFrom: paths.GetGlobalPath(RulesFileName)); + + private static AirlockToggleOutcome SetEnabledGlobal(AppPaths paths, bool enabled) => + SetEnabledInJson(paths.GetGlobalPath(RulesFileName), enabled, seedFrom: null); + + /// + /// Sets the enabled flag in a network.json file, leaving every other byte of the + /// file alone. Comments, key order, formatting and keys this app doesn't model all + /// survive, because a rules file people hand-edit shouldn't be reformatted under them. + /// + /// + /// Config file to copy when doesn't exist yet. A local file + /// shadows the global one entirely (see ), so creating an empty + /// local file would silently drop the user out of their global ruleset. + /// + private static AirlockToggleOutcome SetEnabledInJson(string path, bool enabled, string? seedFrom) { - SetEnabledInJson(paths.GetLocalPath(RulesFileName), true); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + if (File.Exists(path)) + { + WriteEnabled(path, readFrom: path, enabled); + return AirlockToggleOutcome.UpdatedExisting; + } + + if (!string.IsNullOrEmpty(seedFrom) && File.Exists(seedFrom)) + { + WriteEnabled(path, readFrom: seedFrom, enabled); + return AirlockToggleOutcome.SeededFromGlobal; + } + + WriteNetworkConfig(path, NetworkConfig.CreateDefault(enabled)); + return AirlockToggleOutcome.CreatedDefault; } - /// Enables Airlock in global config by setting enabled:true in network.json. - public static void EnableGlobal(AppPaths paths) + /// + /// Rewrites 's bytes with the new enabled flag and saves + /// them to . The whole edit happens in memory first, so a file + /// that fails to parse is never partially written. When the two paths differ, a broken + /// source leaves no new file behind at all. + /// + private static void WriteEnabled(string path, string readFrom, bool enabled) { - SetEnabledInJson(paths.GetGlobalPath(RulesFileName), true); + byte[] updated; + try + { + updated = SetEnabledInJsonBytes(File.ReadAllBytes(readFrom), enabled); + } + catch (JsonException ex) + { + throw new JsonException($"{readFrom}: {ex.Message}", ex); + } + + File.WriteAllBytes(path, updated); } - /// Disables Airlock in local config by setting enabled:false in network.json. - public static void DisableLocal(AppPaths paths) + /// + /// Returns with the root object's "enabled" value replaced, + /// inserting the property if it isn't there. Every other byte is copied verbatim. + /// + internal static byte[] SetEnabledInJsonBytes(byte[] json, bool enabled) { - SetEnabledInJson(paths.GetLocalPath(RulesFileName), false); + var bomLength = json.AsSpan().StartsWith(Utf8Bom) ? Utf8Bom.Length : 0; + var body = json.AsSpan(bomLength); + + // The search below stops as soon as it finds "enabled". Without a full pass first, + // a file that is broken further down would still get rewritten, handing the user + // back a config that won't load. + ValidateJson(body); + + ReadOnlySpan replacement = enabled ? "true"u8 : "false"u8; + + var existing = FindRootEnabledValue(body); + if (existing is var (start, length)) + return Splice(json, bomLength + start, length, replacement); + + return InsertRootEnabled(json, bomLength, replacement); } - /// Disables Airlock in global config by setting enabled:false in network.json. - public static void DisableGlobal(AppPaths paths) + /// Throws JsonException if the document isn't well-formed. + private static void ValidateJson(ReadOnlySpan body) { - SetEnabledInJson(paths.GetGlobalPath(RulesFileName), false); + var reader = new Utf8JsonReader(body, ReaderOptions); + while (reader.Read()) + { + } } - /// Sets the enabled flag in a network.json file. - private static void SetEnabledInJson(string path, bool enabled) + /// + /// Locates the value of the root object's "enabled" property. + /// Offsets are relative to . Returns null when absent. + /// + private static (int Start, int Length)? FindRootEnabledValue(ReadOnlySpan body) { - NetworkConfig config; + var reader = new Utf8JsonReader(body, ReaderOptions); + (int Start, int Length)? match = null; - if (File.Exists(path)) + while (reader.Read()) + { + // Depth 1 is a property of the root object. Without this check, a host or + // path containing "enabled" inside allowed_rules would match instead. + if (reader.TokenType != JsonTokenType.PropertyName || reader.CurrentDepth != 1) + continue; + + if (!reader.ValueTextEquals("enabled"u8)) + continue; + + if (!reader.Read()) + break; + + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + throw new JsonException( + "The \"enabled\" property must be a boolean, but it holds an object or array."); + } + + var start = (int)reader.TokenStartIndex; + match = (start, (int)reader.BytesConsumed - start); + + // Keep scanning instead of returning here: a hand-edited file can carry a + // duplicate root "enabled" key, and System.Text.Json's deserializer resolves + // that to the last occurrence, so the splice has to target the same one + // Load() will actually read - otherwise the two disagree after a toggle. + } + + return match; + } + + /// + /// Adds an "enabled" property as the first entry of the root object, matching the + /// file's existing newline style and indentation. + /// + private static byte[] InsertRootEnabled(byte[] json, int bomLength, ReadOnlySpan value) + { + var body = json.AsSpan(bomLength); + var reader = new Utf8JsonReader(body, ReaderOptions); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("network.json must have a JSON object at its root."); + + var afterBrace = (int)reader.BytesConsumed; + + if (!reader.Read()) + throw new JsonException("network.json must have a JSON object at its root."); + + var nextTokenStart = (int)reader.TokenStartIndex; + + // A file with no newline anywhere is deliberately single-line; inserting our + // usual newline+indent would reformat it onto multiple lines for no reason. + string newline; + string indent; + if (body.IndexOf((byte)'\n') >= 0 || body.IndexOf((byte)'\r') >= 0) { - config = ReadNetworkConfig(path) ?? NetworkConfig.CreateDefault(enabled); - config.Enabled = enabled; + newline = body.IndexOf((byte)'\r') >= 0 ? "\r\n" : "\n"; + indent = DetectIndent(body, nextTokenStart); } else { - config = NetworkConfig.CreateDefault(enabled); + newline = ""; + indent = " "; } - WriteNetworkConfig(path, config); + // EndObject means no property follows, so there's never a comma to add. + // A root object holding nothing but comments still lands here too (the + // comments aren't tokens), which is why the two sub-cases below insert + // ahead of any existing content rather than replacing it outright. + if (reader.TokenType == JsonTokenType.EndObject) + { + if (nextTokenStart == afterBrace) + { + // Truly empty root object - nothing at all between the braces, not even + // whitespace - so synthesize the whole line ourselves. + var text = $"{newline}{indent}\"enabled\": {Encoding.UTF8.GetString(value)}{newline}"; + return Splice(json, bomLength + afterBrace, 0, Encoding.UTF8.GetBytes(text)); + } + + // The object holds only whitespace or comments - insert ahead of that + // content so it survives. No trailing comma: nothing follows the new + // property, and one here would make the file strict-JSON-invalid even + // though our own lenient reader would tolerate it. + var noFollowingProperty = $"{newline}{indent}\"enabled\": {Encoding.UTF8.GetString(value)}"; + return Splice(json, bomLength + afterBrace, 0, Encoding.UTF8.GetBytes(noFollowingProperty)); + } + + // A real property follows, so the comma is required. + var inserted = $"{newline}{indent}\"enabled\": {Encoding.UTF8.GetString(value)},"; + return Splice(json, bomLength + afterBrace, 0, Encoding.UTF8.GetBytes(inserted)); + } + + /// + /// Reads the whitespace at the start of the line holding , + /// so an inserted property lines up with its siblings. Falls back to two spaces. + /// + private static string DetectIndent(ReadOnlySpan body, int tokenStart) + { + var lineStart = tokenStart; + while (lineStart > 0 && body[lineStart - 1] is not ((byte)'\n' or (byte)'\r')) + lineStart--; + + var indent = body[lineStart..tokenStart]; + foreach (var b in indent) + { + if (b is not ((byte)' ' or (byte)'\t')) + return " "; + } + + return indent.IsEmpty ? " " : Encoding.UTF8.GetString(indent); + } + + private static byte[] Splice(byte[] source, int start, int length, ReadOnlySpan replacement) + { + var result = new byte[source.Length - length + replacement.Length]; + source.AsSpan(0, start).CopyTo(result); + replacement.CopyTo(result.AsSpan(start)); + source.AsSpan(start + length).CopyTo(result.AsSpan(start + replacement.Length)); + return result; } /// Gets the path to the local rules file (creates dir if needed). @@ -161,3 +365,16 @@ public enum AirlockConfigSource Global, Local } + +/// What a toggle did to the config file, so commands can say so. +public enum AirlockToggleOutcome +{ + /// The file already existed and only its enabled flag changed. + UpdatedExisting, + + /// A new local file was created from the global one, carrying its rules across. + SeededFromGlobal, + + /// No config existed anywhere, so a default one was written. + CreatedDefault +} diff --git a/tests/CopilotHere.UnitTests/AirlockConfigTests.cs b/tests/CopilotHere.UnitTests/AirlockConfigTests.cs index eea2327..cb1657f 100644 --- a/tests/CopilotHere.UnitTests/AirlockConfigTests.cs +++ b/tests/CopilotHere.UnitTests/AirlockConfigTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using CopilotHere.Commands.Airlock; using CopilotHere.Infrastructure; using TUnit.Core; @@ -182,4 +183,355 @@ public async Task ReadNetworkConfig_NonexistentFile_ReturnsNull() // Assert await Assert.That(config).IsNull(); } + + [Test] + public async Task Toggle_KeepsUnknownKeysAndFormatting() + { + // Arrange + var localRulesPath = _paths.GetLocalPath("network.json"); + const string original = """ + { + "enabled": true, + "mode": "monitor", + "allowed_rules": [ + { + "host": "api.nuget.org", + "allowed_paths": ["/v3/*"], + "note": "a key copilot_here doesn't model" + } + ], + "my_custom_top_level": { "a": 1 } + } + """; + File.WriteAllText(localRulesPath, original); + + // Act + AirlockConfig.DisableLocal(_paths); + AirlockConfig.EnableLocal(_paths); + + // Assert - back to the original byte for byte + await Assert.That(File.ReadAllText(localRulesPath)).IsEqualTo(original); + } + + [Test] + public async Task Toggle_KeepsComments() + { + // Arrange + var localRulesPath = _paths.GetLocalPath("network.json"); + const string original = """ + { + // nuget is needed for restore + "enabled": true, + "allowed_rules": [ { "host": "api.nuget.org", "allowed_paths": ["*"] } ] + } + """; + File.WriteAllText(localRulesPath, original); + + // Act + AirlockConfig.DisableLocal(_paths); + + // Assert + await Assert.That(File.ReadAllText(localRulesPath)) + .IsEqualTo(original.Replace("\"enabled\": true", "\"enabled\": false")); + } + + [Test] + public async Task Toggle_IgnoresEnabledNestedInsideARule() + { + // Arrange + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, """ + { + "allowed_rules": [ + { "host": "a.com", "enabled": false, "allowed_paths": ["*"] } + ], + "enabled": false + } + """); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert - only the root flag flipped + var updated = File.ReadAllText(localRulesPath); + await Assert.That(updated).Contains("\"host\": \"a.com\", \"enabled\": false"); + await Assert.That(updated).Contains("\"enabled\": true"); + } + + [Test] + public async Task Toggle_InsertsEnabledWhenMissing() + { + // Arrange + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, """ + { + "mode": "monitor", + "allowed_rules": [] + } + """); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert + var updated = File.ReadAllText(localRulesPath); + await Assert.That(updated).Contains("\"enabled\": true"); + await Assert.That(updated).Contains("\"mode\": \"monitor\""); + await Assert.That(AirlockConfig.Load(_paths).Enabled).IsTrue(); + } + + [Test] + public async Task Toggle_MalformedJson_ThrowsAndLeavesFileAlone() + { + // Arrange + var localRulesPath = _paths.GetLocalPath("network.json"); + const string broken = """{ "enabled": true, "allowed_rules": [ }"""; + File.WriteAllText(localRulesPath, broken); + + // Act & Assert + await Assert.That(() => AirlockConfig.DisableLocal(_paths)).Throws(); + await Assert.That(File.ReadAllText(localRulesPath)).IsEqualTo(broken); + } + + [Test] + public async Task Toggle_EnabledHoldsObject_ThrowsShapeError_NotSyntaxError() + { + // Arrange - structurally valid JSON, but "enabled" holds the wrong shape. + // Distinguishing this from a syntax error is what lets RunToggle's message + // avoid the wrong claim that the file "isn't valid JSON". + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, """{ "enabled": {} }"""); + + // Act + JsonException? caught = null; + try + { + AirlockConfig.EnableLocal(_paths); + } + catch (JsonException ex) + { + caught = ex; + } + + // Assert + await Assert.That(caught).IsNotNull(); + await Assert.That(caught!.Message).Contains("must be a boolean"); + } + + [Test] + public async Task DisableLocal_NoLocalFile_SeedsFromGlobalRules() + { + // Arrange - rules live globally only, with nothing in the project yet + var globalRulesPath = _paths.GetGlobalPath("network.json"); + File.WriteAllText(globalRulesPath, """ + { + "enabled": true, + "mode": "monitor", + "allowed_rules": [ + { "host": "api.nuget.org", "allowed_paths": ["*"] }, + { "host": "registry.npmjs.org", "allowed_paths": ["*"] } + ] + } + """); + + // Act + var outcome = AirlockConfig.DisableLocal(_paths); + + // Assert - the global rules came across instead of being shadowed by an empty file + await Assert.That(outcome).IsEqualTo(AirlockToggleOutcome.SeededFromGlobal); + + var local = AirlockConfig.ReadNetworkConfig(_paths.GetLocalPath("network.json")); + await Assert.That(local!.Enabled).IsFalse(); + await Assert.That(local.Mode).IsEqualTo("monitor"); + await Assert.That(local.AllowedRules.Count).IsEqualTo(2); + await Assert.That(local.AllowedRules[0].Host).IsEqualTo("api.nuget.org"); + } + + [Test] + public async Task EnableLocal_NoLocalFile_SeedsFromGlobalRules() + { + // Arrange + var globalRulesPath = _paths.GetGlobalPath("network.json"); + File.WriteAllText(globalRulesPath, """ + { + "enabled": false, + "allowed_rules": [ { "host": "api.nuget.org", "allowed_paths": ["*"] } ] + } + """); + + // Act + var outcome = AirlockConfig.EnableLocal(_paths); + + // Assert + await Assert.That(outcome).IsEqualTo(AirlockToggleOutcome.SeededFromGlobal); + + var config = AirlockConfig.Load(_paths); + await Assert.That(config.Enabled).IsTrue(); + await Assert.That(config.EnabledSource).IsEqualTo(AirlockConfigSource.Local); + + var local = AirlockConfig.ReadNetworkConfig(_paths.GetLocalPath("network.json")); + await Assert.That(local!.AllowedRules.Count).IsEqualTo(1); + } + + [Test] + public async Task DisableLocal_BrokenGlobalFile_CreatesNoLocalFile() + { + // Arrange + File.WriteAllText(_paths.GetGlobalPath("network.json"), """{ "enabled": true, ]"""); + + // Act & Assert - better to fail loudly than seed a project from a broken source + await Assert.That(() => AirlockConfig.DisableLocal(_paths)).Throws(); + await Assert.That(File.Exists(_paths.GetLocalPath("network.json"))).IsFalse(); + } + + [Test] + public async Task DisableLocal_NoConfigAnywhere_WritesDefault() + { + // Act + var outcome = AirlockConfig.DisableLocal(_paths); + + // Assert + await Assert.That(outcome).IsEqualTo(AirlockToggleOutcome.CreatedDefault); + + var local = AirlockConfig.ReadNetworkConfig(_paths.GetLocalPath("network.json")); + await Assert.That(local!.Enabled).IsFalse(); + await Assert.That(local.AllowedRules.Count).IsEqualTo(0); + } + + [Test] + public async Task EnableLocal_ExistingLocalFile_ReportsUpdatedExisting() + { + // Arrange + File.WriteAllText(_paths.GetLocalPath("network.json"), """{ "enabled": false }"""); + + // Act + var outcome = AirlockConfig.EnableLocal(_paths); + + // Assert + await Assert.That(outcome).IsEqualTo(AirlockToggleOutcome.UpdatedExisting); + await Assert.That(AirlockConfig.Load(_paths).Enabled).IsTrue(); + } + + [Test] + public async Task Toggle_InsertsEnabledPreservesCommentInOtherwiseEmptyObject() + { + // Arrange - the root object has no real properties, only a comment + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, """ + { + // explanation + } + """); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert - the comment survives alongside the inserted property, and the + // result is well-formed strict JSON (no trailing comma left dangling + // ahead of a comment with nothing else following it) + var updated = File.ReadAllText(localRulesPath); + await Assert.That(updated).Contains("// explanation"); + await Assert.That(updated).Contains("\"enabled\": true"); + AssertWellFormedStrictJson(updated); + } + + [Test] + public async Task Toggle_InsertsEnabledOnEmptyObject_StaysWellFormed() + { + // Arrange - truly empty: nothing at all between the braces + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, "{}"); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert + var updated = File.ReadAllText(localRulesPath); + await Assert.That(updated).Contains("\"enabled\": true"); + AssertWellFormedStrictJson(updated); + } + + [Test] + public async Task Toggle_InsertsEnabledOnWhitespaceOnlyEmptyObject_StaysWellFormed() + { + // Arrange - empty object spread across lines, no comment, just whitespace. + // This is the case that regressed: the insert has nothing following it, + // so a trailing comma here would make the file strict-JSON-invalid even + // though our own lenient reader tolerates it. + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, "{\n}"); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert + var updated = File.ReadAllText(localRulesPath); + await Assert.That(updated).Contains("\"enabled\": true"); + AssertWellFormedStrictJson(updated); + } + + [Test] + public async Task Toggle_InsertsEnabledOnSingleLineFile_StaysSingleLine() + { + // Arrange - a file with no newlines anywhere is deliberately single-line + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, """{ "mode": "monitor" }"""); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert - stays on one line instead of being reformatted + var updated = File.ReadAllText(localRulesPath); + await Assert.That(updated).DoesNotContain("\n"); + await Assert.That(updated).Contains("\"enabled\": true"); + await Assert.That(updated).Contains("\"mode\": \"monitor\""); + AssertWellFormedStrictJson(updated); + } + + /// + /// Parses with comments allowed but trailing commas rejected, so a splice that + /// only happens to be readable by our own lenient reader still fails the test. + /// + private static void AssertWellFormedStrictJson(string json) + { + var options = new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = false }; + using var _ = JsonDocument.Parse(json, options); + } + + [Test] + public async Task Toggle_DuplicateRootEnabled_UpdatesTheOccurrenceLoadWillRead() + { + // Arrange - a hand-edited duplicate key. System.Text.Json's deserializer + // resolves duplicates to the last occurrence, so the splice must target + // that one or Load() would disagree with what the toggle just reported. + var localRulesPath = _paths.GetLocalPath("network.json"); + File.WriteAllText(localRulesPath, """{ "enabled": false, "enabled": false }"""); + + // Act + AirlockConfig.EnableLocal(_paths); + + // Assert + await Assert.That(AirlockConfig.Load(_paths).Enabled).IsTrue(); + } + + [Test] + public async Task Load_FileWithCommentsAndTrailingCommas_Reads() + { + // Arrange + File.WriteAllText(_paths.GetLocalPath("network.json"), """ + { + // hand-edited configs happen + "enabled": true, + "allowed_rules": [ + { "host": "api.nuget.org", "allowed_paths": ["*"] }, + ], + } + """); + + // Act + var config = AirlockConfig.Load(_paths); + + // Assert + await Assert.That(config.Enabled).IsTrue(); + } }