diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs new file mode 100644 index 000000000..d3d0afa3a --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs @@ -0,0 +1,99 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using Docker.DotNet; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// Host ports for every container a test publishes, Testcontainers' resource reaper included. Docker's own choice, and +/// a port-0 bind, both come from Linux's ephemeral range (32768–60999), which a loaded runner's outbound sockets and +/// their TIME_WAITs crowd, so a start fails with "address already in use" however often it is retried there. Below +/// that range only an explicit bind takes a port, so a pick is checked free here and walks on from where the last one +/// ended, which keeps two starts in one process from racing for the same port. +/// +public static class ContainerHostPorts { + public const int First = 20000; + public const int Last = 32767; + + const int Span = Last - First + 1; + + /// How many attempts a container start gets while each failure is a host-port clash. + const int MaxAttempts = 10; + + static int _cursor = Random.Shared.Next(Span); + + /// Marked obsolete, yet the reaper's port binding still reads it; the reaper's own pin fails the day a + /// Testcontainers upgrade stops honouring it. + [ModuleInitializer] + internal static void PublishTheResourceReaperHere() { +#pragma warning disable CS0618 + TestcontainersSettings.ResourceReaperPublicHostPort = _ => Next(); +#pragma warning restore CS0618 + } + + /// The next port in the range that nothing on this machine holds. + public static ushort Next() { + for (var tried = 0; tried < Span; tried++) { + var port = First + (int)((uint)Interlocked.Increment(ref _cursor) % Span); + if (IsFree(port)) return (ushort)port; + } + + throw new InvalidOperationException($"Every host port from {First} to {Last} is held"); + } + + /// Starts a container publishes on a fresh pick each time, + /// rebuilding it after each clash with another process that took the picked port first. + public static async Task StartAsync(Func build, CancellationToken ct = default) where T : IContainer { + for (var attempt = 1; ; attempt++) { + var container = build(); + + try { + await container.StartAsync(ct); + + return container; + } catch (Exception ex) when (IsClash(ex) && attempt < MaxAttempts) { + await Console.Error.WriteLineAsync($"{container.Image.FullName}: start attempt {attempt} hit a host-port clash, retrying: {ex.Message}"); + await container.DisposeAsync(); + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } catch { + await container.DisposeAsync(); + throw; + } + } + } + + /// "address already in use" when a process holds the picked port, "port is already allocated" when another + /// container published it first. + internal static bool IsClash(Exception ex) => + ex is DockerApiException { StatusCode: HttpStatusCode.InternalServerError } api + && (api.ResponseBody.Contains("address already in use", StringComparison.Ordinal) + || api.ResponseBody.Contains("port is already allocated", StringComparison.Ordinal)); + + // Docker publishes on IPv6 as well as IPv4, so a port held on either is taken. A host without IPv6 refuses that + // bind for its own reasons, which say nothing about the port. + static bool IsFree(int port) { + try { + using var v4 = new TcpListener(IPAddress.Any, port); + v4.Start(); + } catch (SocketException) { + return false; + } + + if (!Socket.OSSupportsIPv6) return true; + + try { + using var v6 = new TcpListener(IPAddress.IPv6Any, port); + v6.Server.DualMode = false; + v6.Start(); + } catch (SocketException ex) when (ex.SocketErrorCode != SocketError.AddressAlreadyInUse) { + return true; + } catch (SocketException) { + return false; + } + + return true; + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs new file mode 100644 index 000000000..bb63950c1 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs @@ -0,0 +1,96 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using Docker.DotNet; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; + +namespace Capacitor.Cli.Tests.Integration; + +/// Every container a test starts is published below Linux's ephemeral range, from which Docker's own choice +/// and a port-0 bind are drawn, on a port nothing here holds. The pick walks a process-wide cursor, so the test that +/// needs to know which port comes next runs alone. +public class ContainerHostPortsTests { + const int EphemeralFloor = 32768; + + [Test] + [NotInParallel] + public async Task A_full_cycle_of_picks_stays_in_range_and_never_hands_out_a_held_port() { + var (held, holder) = HoldTheNextPort(); + using var _ = holder; + + var drawn = Enumerable.Range(0, ContainerHostPorts.Last - ContainerHostPorts.First + 1) + .Select(_ => (int)ContainerHostPorts.Next()) + .ToList(); + + await Assert.That(drawn).DoesNotContain(held); + await Assert.That(drawn.All(p => p is >= ContainerHostPorts.First and < EphemeralFloor)).IsTrue(); + } + + /// Docker publishes on IPv6 as well as IPv4, so a port something holds on IPv6 alone is just as taken. + [Test] + [NotInParallel] + public async Task A_port_held_on_ipv6_alone_is_passed_over() { + Skip.When(!Socket.OSSupportsIPv6, "this host has no IPv6"); + + var (held, holder) = HoldTheNextPort(ipv6Only: true); + using var _ = holder; + + await Assert.That((int)ContainerHostPorts.Next()).IsNotEqualTo(held); + } + + /// Docker refuses a taken host port in two words: the kernel's, when another process holds it, and its own, + /// when another container already published it. + [Test] + [Arguments("failed to bind port 0.0.0.0:33201/tcp: Error starting userland proxy: listen tcp4 0.0.0.0:33201: bind: address already in use", true)] + [Arguments("Bind for 0.0.0.0:20417 failed: port is already allocated", true)] + [Arguments("could not kill container: abc is zombie and can not be killed", false)] + public async Task Only_a_bind_refusal_is_a_host_port_clash(string message, bool clash) => + await Assert.That(ContainerHostPorts.IsClash(new DockerApiException(HttpStatusCode.InternalServerError, + $"{{\"message\":\"driver failed programming external connectivity on endpoint gifted_wozniak (8b56): {message}\"}}"))).IsEqualTo(clash); + + /// The resource reaper starts with the first container of the process, so it is running by the time the + /// emulator is up. + [Test] + [Timeout(300_000)] + public async Task The_emulator_and_the_resource_reaper_publish_below_the_ephemeral_range(CancellationToken ct) { + Skip.When(Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true" && !OperatingSystem.IsLinux(), + "this CI runner cannot run Linux containers"); + + var unavailable = await WorkOSEmulator.UnavailableReasonAsync(); + Skip.When(unavailable is not null, unavailable ?? ""); + + await using var emulator = await WorkOSEmulator.StartAsync(ct); + + await Assert.That(new Uri(emulator.BaseUrl).Port).IsLessThan(EphemeralFloor); + + // A host that turns the reaper off has none to inspect. + if (!TestcontainersSettings.ResourceReaperEnabled) return; + + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.Empty).Build(); + var reaper = await docker.Containers.InspectContainerAsync($"testcontainers-ryuk-{ResourceReaper.DefaultSessionId:D}", ct); + var reaperPort = int.Parse(reaper.NetworkSettings.Ports["8080/tcp"][0].HostPort, CultureInfo.InvariantCulture); + + await Assert.That(reaperPort).IsLessThan(EphemeralFloor); + } + + /// Holds the port one past the latest pick, which is the one the next pick lands on. A candidate something + /// else already holds could be let go mid-test, so the test picks again until it holds one itself. + static (int Port, TcpListener Holder) HoldTheNextPort(bool ipv6Only = false) { + for (var attempt = 0; ; attempt++) { + var port = ContainerHostPorts.Next() + 1; + if (port > ContainerHostPorts.Last) port = ContainerHostPorts.First; + + var holder = new TcpListener(ipv6Only ? IPAddress.IPv6Any : IPAddress.Any, port); + if (ipv6Only) holder.Server.DualMode = false; + + try { + holder.Start(); + + return (port, holder); + } catch (SocketException) when (attempt < 20) { + holder.Dispose(); + } + } + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs index e3991f209..fee862cde 100644 --- a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs +++ b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs @@ -1,8 +1,8 @@ using System.Text; -using Capacitor.Cli.Core.Auth; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; namespace Capacitor.Cli.Tests.Integration; @@ -15,7 +15,6 @@ public sealed class WorkOSEmulator : IAsyncDisposable { public const string OwnerId = "user_01K0000000000000000000000A"; public const string OrganizationId = "org_01K0000000000000000000000K"; - const string Image = "ghcr.io/workos/emulate:0.15.0"; const int Port = 4100; const string SeedPath = "/app/workos-emulate.config.yaml"; @@ -68,31 +67,22 @@ public sealed class WorkOSEmulator : IAsyncDisposable { } public static async Task StartAsync(CancellationToken ct) { - // A fixed host port, so a restart keeps the base a stored token names. Passing any argument - // replaces the image's defaults, and without --host 0.0.0.0 it listens on its own loopback. - var hostPort = OAuthLoginFlow.GetAvailablePort(); - var container = new ContainerBuilder(Image) - .WithPortBinding(hostPort, Port) + // The host port stays fixed across a restart, because a stored token names the base URL; something that takes + // the port while the emulator is stopped fails that one test. Passing any argument replaces the image's + // defaults, and without --host 0.0.0.0 it listens on its own loopback. + var container = await ContainerHostPorts.StartAsync(() => new ContainerBuilder(new DockerImage("ghcr.io/workos/emulate", tag: "0.15.0")) + .WithPortBinding(ContainerHostPorts.Next(), Port) .WithResourceMapping(Encoding.UTF8.GetBytes(Seed), SeedPath) .WithCommand("--host", "0.0.0.0", "--seed", SeedPath) .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(Port).ForPath("/health"))) - .Build(); + .Build(), ct); - var emulator = new WorkOSEmulator(container, hostPort); - - try { - await container.StartAsync(ct); - } catch { - await emulator.DisposeAsync(); - - throw; - } - - return emulator; + return new WorkOSEmulator(container, container.GetMappedPublicPort(Port)); } - /// Stops and starts the same container: the emulator keeps all state in memory, so every - /// token it issued is forgotten. + /// Stops and starts the same container on the same host port: the emulator keeps all state in memory, so + /// every token it issued is forgotten. The port is not retried, so one taken while it is stopped fails the + /// start. public async Task RestartAsync(CancellationToken ct) { await _container.StopAsync(ct); await _container.StartAsync(ct); diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs new file mode 100644 index 000000000..b2415c1a7 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -0,0 +1,128 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Docker's own host-port choice, and a port-0 bind, share Linux's ephemeral range with a loaded runner's sockets, so +/// every published container port takes its host port from ContainerHostPorts.Next(). Pinned as a guard because +/// a binding on Docker's choice passes everywhere a machine is quiet and fails only on a busy runner. The KurrentDB and +/// Postgres builders publish their port on Docker's choice unless told otherwise, so each builder's own chain needs a +/// picked binding in the statement that constructs it. +/// +public partial class ContainerHostPortGuardTests { + // The (ushort, bool) overload with true, or assignRandomHostPort named, hands the host port back to Docker. + [GeneratedRegex(@"\.WithPortBinding\s*\((?!\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,(?!\s*(?:true\b|assignRandomHostPort\s*:)))")] + private static partial Regex DockerChosenBinding(); + + [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\s*\(|(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\??\s+\w+\s*=\s*(?:[^;=]*?[?:]\s*)?new\s*\(")] + private static partial Regex PublishingBuilder(); + + [GeneratedRegex(@"\G(?:new\s+(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\s*\(|(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\??\s+\w+\s*=\s*(?:[^;=]*?[?:]\s*)?new\s*\()")] + private static partial Regex PublishingBuilderHere(); + + [GeneratedRegex(@"\.WithPortBinding\s*\(\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,(?!\s*(?:true\b|assignRandomHostPort\s*:))")] + private static partial Regex PickedBinding(); + + [GeneratedRegex(@"\G\.Build\s*\(\s*\)")] + private static partial Regex Build(); + + [Test] + [Arguments(".WithPortBinding(4100, assignRandomHostPort: true)")] + [Arguments(".WithPortBinding(4100, true)")] + [Arguments(".WithPortBinding(hostPort, Port)")] + [Arguments(".WithPortBinding(OAuthLoginFlow.GetAvailablePort(), Port)")] + [Arguments("new KurrentDbBuilder(image).Build()")] + [Arguments("KurrentDbBuilder b = new(image); b.Build()")] + [Arguments("new PostgreSqlBuilder(image).Build(); new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80).Build()")] + [Arguments("KurrentDbBuilder b = new(image); b = b.WithPortBinding(ContainerHostPorts.Next(), 2113); b.Build()")] + [Arguments("KurrentDbBuilder Unbuilt() => new KurrentDbBuilder(image); var c = new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80).Build();")] + [Arguments("Pair(new KurrentDbBuilder(a), new PostgreSqlBuilder(b).WithPortBinding(ContainerHostPorts.Next(), 5432).Build())")] + [Arguments("new KurrentDbBuilder(image).WithEnvironment(\"NOTE\", \".WithPortBinding(ContainerHostPorts.Next(), 2113)\").Build()")] + [Arguments("var q = '\"'; new KurrentDbBuilder(image).Build(); var s = \"x\";")] + [Arguments("/* \" */ new KurrentDbBuilder(image).Build(); var s = \"x\";")] + [Arguments(".WithPortBinding(ContainerHostPorts.Next(), true)")] + [Arguments("new Testcontainers.KurrentDb.KurrentDbBuilder(image).Build()")] + [Arguments("KurrentDbBuilder? b = new(image); b.Build()")] + [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(_ => Side(new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))).Build()")] + [Arguments("var s = \"\"\"\"x\"\"\"\"; new KurrentDbBuilder(image).Build(); var t = \"\"\"\"y\"\"\"\";")] + [Arguments("(new KurrentDbBuilder(image), new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))")] + [Arguments("var b = flag ? new KurrentDbBuilder(image) : new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113);")] + [Arguments("KurrentDbBuilder b = flag ? new(image) : new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113);")] + public async Task Pattern_flags_a_port_docker_chooses(string code) => + await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); + + [Test] + [Arguments(".WithPortBinding(ContainerHostPorts.Next(), Port)")] + [Arguments("new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(p => { p.Name = \"k\"; }).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("// .WithPortBinding(2113, true)\n.WithPortBinding(ContainerHostPorts.Next(), Port)")] + [Arguments("new KurrentDbBuilder(ImageFactory().Build()).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("new KurrentDbBuilder(image).WithX(new PostgreSqlBuilder(p).WithPortBinding(ContainerHostPorts.Next(), 5432).Build()).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range(string code) => + await Assert.That(PublishesOnDockersChoice(code)).IsFalse(); + + [Test] + public async Task No_test_publishes_a_container_port_on_dockers_choice() { + var root = RepoRoot(); + + var offenders = Directory.EnumerateFiles(Path.Combine(root, "test"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(s => s is "bin" or "obj")) + .Where(path => PublishesOnDockersChoice(File.ReadAllText(path))) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .ToList(); + + await Assert.That(offenders).IsEmpty() + .Because("bind each published port with .WithPortBinding(ContainerHostPorts.Next(), containerPort)"); + } + + static bool PublishesOnDockersChoice(string source) { + var code = SourceCode.WithoutCommentsOrStrings(source); + + return DockerChosenBinding().IsMatch(code) + || PublishingBuilder().Matches(code).Any(builder => !BindsItsOwnPort(code, builder)); + } + + /// Whether the builder's own chain, from its construction to its .Build() or the end of the expression + /// holding it (a ;, a ,, a conditional's ? or :, a sibling builder, or the bracket closing + /// around it), carries a picked binding. Only a call chained on the builder itself counts, outside every bracket + /// opened after it: a binding, a .Build() or another builder inside an argument or a lambda in the chain + /// belongs to something else, and a binding added in a later statement does not count. + static bool BindsItsOwnPort(string code, Match builder) { + var start = builder.Index + builder.Length; + + // Counting from 1 inside the builder's constructor call. + var depth = 1; + + for (var i = start; i < code.Length; i++) { + switch (code[i]) { + case '(' or '[' or '{': depth++; continue; + case ')' or ']' or '}' when depth == 0: return false; + case ')' or ']' or '}': depth--; continue; + case ';' or ',' or ':' when depth == 0: return false; + case '?' when depth == 0 && (i + 1 >= code.Length || code[i + 1] != '.'): return false; + } + + if (depth != 0) continue; + + // A sibling builder at the same depth, such as the other branch of a conditional, has a chain of its own. + if (PublishingBuilderHere().IsMatch(code[i..Math.Min(code.Length, i + 200)])) return false; + + if (code[i] != '.') continue; + + if (Build().IsMatch(code[i..Math.Min(code.Length, i + 16)])) return false; + + if (PickedBinding().Match(code, i) is { Success: true } binding && binding.Index == i) return true; + } + + return false; + } + + static string RepoRoot([CallerFilePath] string here = "") { + var dir = Path.GetDirectoryName(here); + while (dir is not null && !File.Exists(Path.Combine(dir, "Capacitor.slnx"))) + dir = Path.GetDirectoryName(dir); + + return dir ?? throw new InvalidOperationException($"Could not locate repo root walking up from {here}"); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs new file mode 100644 index 000000000..b0d72aec3 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs @@ -0,0 +1,95 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Testcontainers parses an image string with a regex whose 5-second match timeout a starved runner can exceed; only +/// new DockerImage(repository, tag: …) skips the parse. So a builder or WithImage argument must be that +/// call, matched with string contents blanked so a literal containing tag: cannot pass for the named argument. +/// +public partial class ContainerImageStringGuardTests { + [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\s*\((?!\s*new\s+DockerImage\s*\()|(?:[\w.]+\.)?(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\??\s+\w+\s*=\s*new\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()")] + private static partial Regex ImageString(); + + [GeneratedRegex(@"new\s+DockerImage\s*\(")] + private static partial Regex DockerImage(); + + [GeneratedRegex(@"\G\btag\s*:")] + private static partial Regex TagArgument(); + + [Test] + [Arguments("new ContainerBuilder(Image)")] + [Arguments("new ContainerBuilder(\"ghcr.io/workos/emulate:0.15.0\")")] + [Arguments("new ContainerBuilder($\"alpine:{tag}\")")] + [Arguments("new ContainerBuilder(\"\"\"alpine:3\"\"\")")] + [Arguments("new ContainerBuilder(\"tag:3\")")] + [Arguments("new DockerImage(\"alpine:3\")")] + [Arguments("new DockerImage(\"tag:3\")")] + [Arguments(".WithImage(\"alpine:3\")")] + [Arguments("ContainerBuilder b = new(\"alpine:3\");")] + [Arguments("new KurrentDbBuilder(\"kurrentdb:26\")")] + [Arguments("new PostgreSqlBuilder(\"postgres:18\")")] + [Arguments("new DockerImage(Repository(\"tag: x\"))")] + [Arguments("var s = \"\"\"\"x\"\"\"\"; new ContainerBuilder(\"alpine:3\"); var t = \"\"\"\"y\"\"\"\";")] + [Arguments("Testcontainers.KurrentDb.KurrentDbBuilder b = new(\"kurrentdb:26\");")] + public async Task Pattern_matches_an_untyped_image(string code) => + await Assert.That(UsesAnImageString(code)).IsTrue(); + + [Test] + [Arguments("new ContainerBuilder(new DockerImage(\"ghcr.io/workos/emulate\", tag: \"0.15.0\"))")] + [Arguments(".WithImage(new DockerImage(\"alpine\", tag: \"3\"))")] + [Arguments("new StringBuilder(\"text\")")] + [Arguments("ContainerBuilder b = new(new DockerImage(\"alpine\", tag: \"3\"));")] + [Arguments("new DockerImage(GetRepository(), tag: GetTag())")] + [Arguments("new KurrentDbBuilder(new DockerImage(\"kurrentdb\", tag: \"26\"))")] + public async Task Pattern_passes_a_typed_image(string code) => + await Assert.That(UsesAnImageString(code)).IsFalse(); + + [Test] + public async Task No_test_builds_a_container_from_an_untyped_image() { + var root = RepoRoot(); + + var offenders = Directory.EnumerateFiles(Path.Combine(root, "test"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(s => s is "bin" or "obj")) + .Where(path => UsesAnImageString(File.ReadAllText(path))) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .ToList(); + + await Assert.That(offenders).IsEmpty() + .Because("pass new DockerImage(repository, tag: …) so Testcontainers never runs its timed image-name regex on it"); + } + + static bool UsesAnImageString(string source) { + var code = SourceCode.WithoutCommentsOrStrings(source); + + return ImageString().IsMatch(code) || DockerImage().Matches(code).Any(call => !NamesItsTag(code, call.Index + call.Length)); + } + + /// Whether the DockerImage call whose arguments start at passes tag: + /// as one of its own arguments, not inside a nested call's. + static bool NamesItsTag(string code, int start) { + var depth = 0; + + for (var i = start; i < code.Length; i++) { + switch (code[i]) { + case '(' or '[' or '{': depth++; break; + case ')' or ']' or '}' when depth == 0: return false; + case ')' or ']' or '}': depth--; break; + default: + if (depth == 0 && TagArgument().IsMatch(code[i..Math.Min(code.Length, i + 8)])) return true; + break; + } + } + + return false; + } + + static string RepoRoot([CallerFilePath] string here = "") { + var dir = Path.GetDirectoryName(here); + while (dir is not null && !File.Exists(Path.Combine(dir, "Capacitor.slnx"))) + dir = Path.GetDirectoryName(dir); + + return dir ?? throw new InvalidOperationException($"Could not locate repo root walking up from {here}"); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs new file mode 100644 index 000000000..2588e8365 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs @@ -0,0 +1,18 @@ +using System.Text.RegularExpressions; + +namespace Capacitor.Cli.Tests.Unit; + +/// Source text for a guard that matches code shapes, with every comment removed and every string literal's content +/// blanked, so neither can spell the shape or hide its absence. One left-to-right scan takes whichever starts first, so a +/// quote inside a comment or a char literal such as '"' never opens a string that swallows the code after it. +public static partial class SourceCode { + [GeneratedRegex("//[^\\n]*|/\\*[\\s\\S]*?\\*/|'(?:\\\\u[0-9A-Fa-f]{4}|\\\\.|[^'\\\\\\n])'|(?\"{3,})[\\s\\S]*?\\k(?!\")|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] + private static partial Regex Token(); + + public static string WithoutCommentsOrStrings(string code) => + Token().Replace(code, m => m.Value[0] switch { + '/' => " ", + '\'' => "' '", + _ => "\"\"" + }); +}