diff --git a/CHANGELOG.md b/CHANGELOG.md index 645291d..b5505ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the OneWare Container Extension are documented here. This format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.14] - 2026-08-20 + +An internal maintainability pass over 1.0.13. The monolithic `DockerExecutionStrategy` is decomposed +into focused, independently-testable collaborators. Container execution, telemetry, and security-gate +behavior are unchanged: method bodies were preserved and only their dependencies re-targeted. + +### Changed + +- `DockerExecutionStrategy` is refactored from a single ~3,300-line class into a coordinator that composes dedicated collaborators, each owning one concern: bind-mount validation and canonicalization (`BindValidator`), `docker run` command rendering with environment-value masking (`DockerRunCommandFormatter`), daemon endpoint verification — named-pipe trust and Unix socket probing (`DaemonEndpointValidator`), dangling-container reaping on exit/Ctrl-C/dispose (`ContainerReaper`), daemon bootstrap and API-version negotiation (`DockerConnectionFactory`), the container run loop (`ContainerRunner`), host-native fallback execution (`NativeFallbackExecutor`), and level-gated tool-console logging/output handling (`DockerToolConsole`). The public surface, container-execution semantics, and all security gates are unchanged. + +### Tests + +- Per-collaborator unit tests accompany the extracted `BindValidator`, `DockerRunCommandFormatter`, and `DaemonEndpointValidator`, moved from reflection-based access to direct calls; `DockerRunCommandFormatter` gains explicit coverage of environment-value masking. A new daemon-backed smoke test runs a real container end-to-end through `ExecuteAsync` using a small cached image and no toolchain fixtures, providing a fast regression anchor for the execution engine. + ## [1.0.13] - 2026-07-15 A follow-up hardening and maintenance pass over 1.0.12: the remaining low-severity items from the diff --git a/Directory.Build.props b/Directory.Build.props index 9f81871..9c67ece 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,22 @@ + + + false + + + + + false + $(MSBuildProjectDirectory)\obj\packages.local.lock.json + + 13.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index ce5aa21..eace240 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,7 +1,10 @@ true - true + + true diff --git a/docker/oss-cad-suite/Dockerfile b/docker/oss-cad-suite/Dockerfile index 0fa5713..5aafc6a 100644 --- a/docker/oss-cad-suite/Dockerfile +++ b/docker/oss-cad-suite/Dockerfile @@ -1,7 +1,7 @@ # OSS CAD Suite Build # Stage 1 -FROM ubuntu@sha256:b7f48194d4d8b763a478a621cdc81c27be222ba2206ca3ca6bc42b49685f3d9e AS downloader +FROM ubuntu@sha256:3131b4cc82a783df6c9df078f86e01819a13594b865c2cad47bd1bca2b7063bb AS downloader ENV DEBIAN_FRONTEND=noninteractive @@ -28,7 +28,7 @@ RUN wget --progress=dot:giga --retry-connrefused --read-timeout=60 --timeout=30 && rm -rf /opt/oss-cad-suite/lib/python3*/test # Stage 2 -FROM ubuntu@sha256:b7f48194d4d8b763a478a621cdc81c27be222ba2206ca3ca6bc42b49685f3d9e +FROM ubuntu@sha256:3131b4cc82a783df6c9df078f86e01819a13594b865c2cad47bd1bca2b7063bb LABEL org.opencontainers.image.authors="YosysHQ, Mert Torun" LABEL org.opencontainers.image.title="OSS CAD Suite" diff --git a/oneware-extension.json b/oneware-extension.json index 88bc850..377b0f5 100644 --- a/oneware-extension.json +++ b/oneware-extension.json @@ -3,7 +3,7 @@ "type": "Plugin", "name": "OneWare Container Extension", "id": "ContainerExtension", - "version": "1.0.13", + "version": "1.0.14", "description": "Transparent containerized execution of FPGA toolchains (GHDL, Yosys, nextpnr) with auto-runtime detection, live Docker dashboard, execution telemetry, and hardened container infrastructure.", "license": "MIT", "iconUrl": "https://raw.githubusercontent.com/FEntwumS/FEntwumS.ContainerExtension/main/Icon.svg", @@ -25,6 +25,14 @@ ], "sourceUrl": "https://github.com/FEntwumS/FEntwumS.ContainerExtension/releases/download", "versions": [ + { + "version": "1.0.14", + "targets": [ + { + "target": "all" + } + ] + }, { "version": "1.0.13", "targets": [ diff --git a/src/ContainerExtension/ContainerExtension.csproj b/src/ContainerExtension/ContainerExtension.csproj index bd50c9b..567f519 100644 --- a/src/ContainerExtension/ContainerExtension.csproj +++ b/src/ContainerExtension/ContainerExtension.csproj @@ -1,7 +1,7 @@ - 1.0.13 + 1.0.14 net10.0 enable enable @@ -10,11 +10,13 @@ false true $(NoWarn);CS1591;MA0051;S3358;MA0048;MA0016;MA0011;MA0009;S1075;S3903;S3267;S3010;S2696;S125;S1244;S1118;MA0134;MA0047;S1905;S3168;S6966 + - @@ -36,7 +38,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - @@ -45,6 +46,14 @@ + + + + + + + + diff --git a/src/ContainerExtension/DockerExecutionStrategy.cs b/src/ContainerExtension/DockerExecutionStrategy.cs index 7385b14..fefd661 100644 --- a/src/ContainerExtension/DockerExecutionStrategy.cs +++ b/src/ContainerExtension/DockerExecutionStrategy.cs @@ -1,22 +1,15 @@ -using System; using System.Buffers; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Globalization; using System.Text; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; using Docker.DotNet; using Docker.DotNet.Models; -using Microsoft.Extensions.DependencyInjection; using OneWare.Essentials.Services; using OneWare.Essentials.ToolEngine; using ContainerExtension.Services.Docker; +using static ContainerExtension.Services.Docker.DockerToolConsole; -using System.Runtime.InteropServices; using System.Text.RegularExpressions; namespace ContainerExtension; @@ -34,265 +27,19 @@ public DockerExecutionException(string message, Exception innerException) : base public sealed partial class DockerExecutionStrategy : IToolExecutionStrategy, IDisposable { private const string ToolKey = "DockerExecutionStrategy"; - private const string ContainerWorkDir = "/workspace"; private static readonly System.Diagnostics.ActivitySource DockerActivitySource = new("OneWare.ContainerExtension"); [GeneratedRegex(@"(?<=://)[^/\s@]+:[^/\s@]+(?=@)", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] private static partial Regex UriCredentialsRegex(); - [GeneratedRegex(@"^[a-zA-Z0-9][-a-zA-Z0-9.]*(?::\d{1,5})?$", RegexOptions.IgnoreCase | RegexOptions.NonBacktracking, matchTimeoutMilliseconds: 1000)] - private static partial Regex HostOnlyRegex(); - - private static readonly SearchValues ShellSpecialAndWhitespaceChars = SearchValues.Create(";&|<>*?[]{}()$\\'\"#~`! \t\n\r\v\f"); private static readonly SearchValues DisallowedPathChars = SearchValues.Create(";&|<>*?[]{}()$\\'\"#~`!\t\n\r"); private string? _cachedRuntimePath; private Uri? _daemonUri; - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool GetNamedPipeServerProcessId(Microsoft.Win32.SafeHandles.SafePipeHandle Pipe, out uint ServerProcessId); - - [LibraryImport("advapi32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool OpenProcessToken(Microsoft.Win32.SafeHandles.SafeProcessHandle ProcessHandle, uint DesiredAccess, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle TokenHandle); - - [LibraryImport("kernel32.dll", SetLastError = true)] - private static partial Microsoft.Win32.SafeHandles.SafeProcessHandle OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId); - - [LibraryImport("libc", EntryPoint = "geteuid")] - private static partial uint geteuid(); - - [LibraryImport("libc", EntryPoint = "getegid")] - private static partial uint getegid(); - private readonly ReaderWriterLockSlim _strategyLock = new(); - private enum PipeServerTrust - { - Untrusted, - CurrentUser, - Elevated, - } - - // Classify the process on the far end of a named pipe. Elevated (SYSTEM / Administrators) is the - // Docker service itself. CurrentUser covers rootless / user-mode runtimes (podman, colima, ssh - // proxies) that legitimately run as the caller, but which a same-user process could also impersonate, - // so the caller gates CurrentUser on a known runtime name rather than trusting it outright. On a query - // failure fail open (Elevated) to match the prior behaviour and avoid breaking connections whose - // identity cannot be read; on an explicit denial fail closed (Untrusted). - private static PipeServerTrust GetPipeServerTrust(uint pid) - { - if (!OperatingSystem.IsWindows()) return PipeServerTrust.Elevated; - const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; - const uint TOKEN_QUERY = 0x0008; - - try - { - using var hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); - if (hProcess == null || hProcess.IsInvalid) - { - return PipeServerTrust.Untrusted; // Fail closed - } - - if (OpenProcessToken(hProcess, TOKEN_QUERY, out var hToken)) - { - using (hToken) - { -#pragma warning disable S3869 - using var identity = new System.Security.Principal.WindowsIdentity(hToken.DangerousGetHandle()); -#pragma warning restore S3869 - var principal = new System.Security.Principal.WindowsPrincipal(identity); - bool isAdmin = false; - try - { - isAdmin = principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); - } - catch (Exception ex) when (ex is System.Security.SecurityException || ex is UnauthorizedAccessException) - { - isAdmin = false; - } - if (isAdmin || identity.IsSystem) - { - return PipeServerTrust.Elevated; - } - using var currentIdentity = System.Security.Principal.WindowsIdentity.GetCurrent(); - if (identity.User != null && currentIdentity.User != null && identity.User.Equals(currentIdentity.User)) - { - return PipeServerTrust.CurrentUser; - } - return PipeServerTrust.Untrusted; - } - } - } - catch (PlatformNotSupportedException) - { - return PipeServerTrust.Elevated; // Fail-open where identity queries are not supported - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetPipeServerTrust failed for pid {pid}", ex); - } - return PipeServerTrust.Untrusted; - } - - private async Task VerifyWindowsNamedPipeAsync(string pipeName, int timeoutMs = 200, CancellationToken ct = default) - { - if (!OperatingSystem.IsWindows()) - { - return true; - } - if (_settingsService.SafeGetSetting(ContainerExtensionModule.BypassNamedPipeCheckSetting, false)) - { - return true; - } - var connectTime = DateTime.Now; - try - { - using var pipeStream = new System.IO.Pipes.NamedPipeClientStream( - ".", - pipeName, - System.IO.Pipes.PipeDirection.InOut, - System.IO.Pipes.PipeOptions.None, - System.Security.Principal.TokenImpersonationLevel.Identification); - await pipeStream.ConnectAsync(timeoutMs, ct).ConfigureAwait(false); - var safeHandle = pipeStream.SafePipeHandle; - if (safeHandle != null && !safeHandle.IsInvalid) - { - if (GetNamedPipeServerProcessId(safeHandle, out var pid)) - { - System.Diagnostics.Process? process = null; - try - { - process = System.Diagnostics.Process.GetProcessById((int)pid); - } - catch (ArgumentException) - { - return false; - } - catch (PlatformNotSupportedException) - { - return true; // Fail-open on platforms that do not support process by ID lookups - } - - if (process != null) - { - using (process) - { - if (!process.HasExited) - { - try - { - var startTime = process.StartTime; - if (startTime > connectTime.AddMilliseconds(500)) - { - return false; // PID reuse detected: process started after pipe connection - } - - var name = process.ProcessName; - var isNameWhitelisted = name.Contains("docker", StringComparison.OrdinalIgnoreCase) || - name.Contains("podman", StringComparison.OrdinalIgnoreCase) || - name.Contains("wsl", StringComparison.OrdinalIgnoreCase) || - name.Contains("vmmember", StringComparison.OrdinalIgnoreCase) || - name.Contains("win-sshproxy", StringComparison.OrdinalIgnoreCase) || - name.Contains("System", StringComparison.OrdinalIgnoreCase) || - name.Contains("svchost", StringComparison.OrdinalIgnoreCase) || - name.Contains("rancher", StringComparison.OrdinalIgnoreCase) || - name.Contains("lima", StringComparison.OrdinalIgnoreCase) || - name.Contains("com.docker", StringComparison.OrdinalIgnoreCase) || - name.Contains("orbstack", StringComparison.OrdinalIgnoreCase) || - name.Contains("socat", StringComparison.OrdinalIgnoreCase) || - name.Contains("ssh", StringComparison.OrdinalIgnoreCase); - - var trust = GetPipeServerTrust(pid); - if (trust == PipeServerTrust.Elevated) - { - return true; - } - if (trust == PipeServerTrust.CurrentUser) - { - if (isNameWhitelisted) - { - return true; - } - // The whitelist is a gate here, not merely advisory: a current-user - // process whose name matches no known runtime could be a pipe squatter. - ContainerTelemetry.TrackError("DockerExecutionStrategy", - $"Named pipe host process '{name}' (PID: {pid}) runs as the current user but matches no known runtime; refusing the connection.", null); - } - else - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", - $"Named pipe verification failed for pipe '{pipeName}'. Host process: '{name}' (PID: {pid}) is NOT trusted.", null); - } - } - catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 5 || ex.Message.Contains("Access is denied", StringComparison.OrdinalIgnoreCase)) - { - // Name/start-time were unreadable (access denied); fall back to the token - // classification alone, staying lenient (elevated or current-user) as before. - return GetPipeServerTrust(pid) != PipeServerTrust.Untrusted; - } - catch (PlatformNotSupportedException) - { - return true; - } - catch (InvalidOperationException) - { - return false; - } - } - } - } - } - return false; - } - return false; - } - catch (FileNotFoundException) - { - return true; - } - catch (IOException ex) when (ex.InnerException is FileNotFoundException) - { - return true; - } - catch (TimeoutException) - { - return false; - } - catch (IOException) - { - return false; - } - catch - { - return false; - } - } - - private static readonly SemaphoreSlim UnixIdSemaphore = new(1, 1); - private static readonly ConcurrentDictionary OwnerCache = new(StringComparer.Ordinal); - private static volatile string? _cachedUid; - private static volatile string? _cachedGid; - - internal static async Task EnsureUnixIdsLoadedAsync(CancellationToken ct = default) - { - if (OperatingSystem.IsWindows()) return; - if (_cachedUid != null && _cachedGid != null) return; - - await UnixIdSemaphore.WaitAsync(ct).ConfigureAwait(false); - try - { - _cachedUid ??= await GetUnixIdInternalAsync("-u", "1000", ct).ConfigureAwait(false); - _cachedGid ??= await GetUnixIdInternalAsync("-g", "1000", ct).ConfigureAwait(false); - } - finally - { - UnixIdSemaphore.Release(); - } - } - private readonly ISettingsService _settingsService; private DockerClient? _client; @@ -320,52 +67,9 @@ private void ThrowIfDisposed() private string _detectedRuntime = ""; private readonly Task _initTask; - - private const int RankOff = 0, RankErrors = 1, RankInfo = 2, RankVerbose = 3; - - private static int LogLevelRank(string level) => level switch - { - "Verbose" => RankVerbose, - "Info" => RankInfo, - "Errors Only" => RankErrors, - _ => RankOff - }; - - private static void SafeInvoke(Action action) - { - if (Avalonia.Application.Current != null) - { - Avalonia.Threading.Dispatcher.UIThread.Post(action); - } - else - { - action(); - } - } - - private bool IsLogEnabled(int minRank) - { - return _currentLogLevelRank.Value >= minRank; - } - - private void SdkLog(ToolCommand command, string message, int minRank = RankVerbose) - { - if (IsLogEnabled(minRank)) - { - var line = _currentShowTimestamps.Value - ? string.Create(CultureInfo.InvariantCulture, $"[{DateTime.Now:HH:mm:ss.fff}] {message}") - : message; - SafeInvoke(() => { (command.OutputHandler ?? command.ErrorHandler)?.Invoke(line); }); - } - } - - private readonly AsyncLocal _currentLogLevelRank = new(); - private readonly AsyncLocal _currentShowTimestamps = new(); - - private static readonly ConcurrentDictionary ActiveContainers = new(StringComparer.Ordinal); - private static DockerClient? _staticClientForCleanup; - private static int _cleanupExecuted; - private static ConsoleCancelEventHandler? _cancelKeyPressHandler; + private readonly DockerToolConsole _console = new(); + private ContainerRunner? _runner; + private readonly NativeFallbackExecutor _nativeFallback; internal async Task EnsureInitializedAsync(CancellationToken ct = default) { @@ -375,213 +79,26 @@ internal async Task EnsureInitializedAsync(CancellationToken ct = default) public DockerExecutionStrategy(IServiceProvider serviceProvider) { _settingsService = serviceProvider.Resolve(); + _nativeFallback = new NativeFallbackExecutor(_settingsService, _console); _initTask = Task.Run(InitializeInternalAsync); } + // Delegates the daemon bootstrap to DockerConnectionFactory, then adopts the resulting client + managers + // and arms the container reaper. On failure the factory returns a connection with a null client (having + // logged the fault), leaving the strategy in the offline state ExecuteAsync already handles. private async Task InitializeInternalAsync() { - try - { - var customSocket = _settingsService.SafeGetSetting(ContainerExtensionModule.DaemonSocketSetting, ""); - var envDockerHost = Environment.GetEnvironmentVariable("DOCKER_HOST"); - - var uriText = !string.IsNullOrWhiteSpace(customSocket) ? customSocket : (!string.IsNullOrWhiteSpace(envDockerHost) ? envDockerHost : null); - Uri? uri = null; - string runtime = ""; - var resolved = false; - - if (!string.IsNullOrWhiteSpace(uriText)) - { - try - { - if (uriText.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) - { - bool isLocal = uriText.Contains("localhost", StringComparison.OrdinalIgnoreCase) || - uriText.Contains("127.0.0.1", StringComparison.OrdinalIgnoreCase) || - uriText.Contains("[::1]", StringComparison.Ordinal); - if (!isLocal) - { - await Console.Out.WriteLineAsync("[WARN] Insecure HTTP custom daemon socket requested. Upgrading to https://").ConfigureAwait(false); - uriText = "https" + uriText[4..]; - } - } - - // A Windows device-path pipe (\\.\pipe\) is a valid daemon socket but not a valid - // URI, so new Uri() would throw and the value would silently fall through to the default - // docker_engine pipe below. Convert it to the equivalent npipe URI form so a custom pipe - // is honored. (DaemonSocketValidation accepts the device-path form.) - if (uriText.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase)) - { - uriText = "npipe://./pipe/" + uriText[@"\\.\pipe\".Length..].Replace('\\', '/'); - } - - uri = new Uri(uriText); - if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) - { - bool isLocal = uri.Host != null && ( - uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || - uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || - uri.Host.Equals("::1", StringComparison.Ordinal)); - if (!isLocal) - { - await Console.Out.WriteLineAsync("[WARN] Insecure HTTP custom daemon socket scheme. Upgrading to HTTPS.").ConfigureAwait(false); - uri = new UriBuilder(uri) { Scheme = "https" }.Uri; - } - } - - if (uri.Scheme.Equals("ssh", StringComparison.OrdinalIgnoreCase)) - { - var hostOnly = uri.Host; - if (string.IsNullOrEmpty(hostOnly) || !HostOnlyRegex().IsMatch(hostOnly)) - { - throw new UriFormatException("Insecure or invalid SSH tunnel hostname."); - } - } - - var isNetworkScheme = uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) || - uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || - uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase); - if (isNetworkScheme && uri.Host != null) - { - var hostType = Uri.CheckHostName(uri.Host); - if (hostType == UriHostNameType.Unknown) - { - throw new UriFormatException("Invalid remote daemon hostname."); - } - - if (!uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) && - !uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) && - !uri.Host.Equals("::1", StringComparison.Ordinal)) - { - var warningMsg = $"[SECURITY WARNING] Connecting to a remote Docker daemon at '{uri.Host}'. Outbound traffic may expose credentials."; - await Console.Error.WriteLineAsync(warningMsg).ConfigureAwait(false); - ContainerTelemetry.TrackError("DockerExecutionStrategy", "RemoteDaemonWarning", null, warningMsg); - } - } - runtime = uriText.Contains("podman", StringComparison.OrdinalIgnoreCase) ? "podman" : "docker (custom)"; - resolved = true; - } - catch (UriFormatException) - { - resolved = false; - } - } - else - { - runtime = ""; - } - - if (!resolved) - { - if (OperatingSystem.IsWindows()) - { - uri = new Uri("npipe://./pipe/docker_engine"); - runtime = "docker"; - } - else - { - using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(_strategyCts.Token); - probeCts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - (uri, runtime) = await ProbeUnixSocketAsync(probeCts.Token).ConfigureAwait(false); - } - catch (Exception ex) - { - uri = new Uri("unix:///var/run/docker.sock"); - runtime = "docker (default)"; - ContainerTelemetry.TrackError("DockerExecutionStrategy", "ProbeUnixSocket failed, falling back to default", ex); - } - } - } - - _detectedRuntime = runtime; - if (uri is null) - { - throw new DockerExecutionException("Could not resolve a Docker daemon URI. Ensure Docker is installed and running, or set the DOCKER_HOST environment variable."); - } - _daemonUri = uri; - - if (uri.Scheme.Equals("npipe", StringComparison.OrdinalIgnoreCase)) - { - var pipeName = uri.AbsolutePath.TrimStart('/'); - if (pipeName.StartsWith("pipe/", StringComparison.OrdinalIgnoreCase)) - { - pipeName = pipeName[5..]; - } - if (string.IsNullOrEmpty(pipeName)) - { - pipeName = "docker_engine"; - } - if (!await VerifyWindowsNamedPipeAsync(pipeName, ct: _strategyCts.Token).ConfigureAwait(false)) - { - throw new DockerExecutionException($"Insecure named pipe connection detected for '{pipeName}'. Connection aborted. If this is a false positive, you can bypass this check in OneWare Studio Settings under 'Binary Management' -> 'Container Engine' -> check 'Bypass Named Pipe Security Check'."); - } - } - - using var config = uri.Scheme.Equals("npipe", StringComparison.OrdinalIgnoreCase) - ? new DockerClientConfiguration(uri, new SecureNamedPipeCredentials(uri)) - : new DockerClientConfiguration(uri); - // Negotiate Docker API Version - System.Version apiVersion = new System.Version(1, 44); - var tempClient = config.CreateClient(); - try - { - // Cold daemons routinely need well over the previous 500 ms for the first socket connect - // and version round-trip; too tight a budget misnegotiates a healthy-but-slow daemon down - // to the fallback API version and adds a cold-start confounder to overhead measurements. - // Bound the probe at 3 s, honour strategy shutdown, and fall back only on a genuine failure. - // Docker.DotNet honours the cancellation token, so a direct await cannot hang past the budget. - using var verCts = CancellationTokenSource.CreateLinkedTokenSource(_strategyCts.Token); - verCts.CancelAfter(TimeSpan.FromSeconds(3)); - var version = await tempClient.System.GetVersionAsync(verCts.Token).ConfigureAwait(false); - var apiVerStr = version?.APIVersion; - if (!string.IsNullOrEmpty(apiVerStr)) - { - int endIdx = 0; - while (endIdx < apiVerStr.Length && (char.IsDigit(apiVerStr[endIdx]) || apiVerStr[endIdx] == '.')) - { - endIdx++; - } - if (System.Version.TryParse(apiVerStr[..endIdx], out var parsedVersion)) - { - apiVersion = parsedVersion; - } - } - } - catch (Exception ex) - { - var isOffline = ex is OperationCanceledException or System.Net.Sockets.SocketException || - ex.InnerException is System.Net.Sockets.SocketException || - (ex is HttpRequestException httpEx && (httpEx.InnerException is System.Net.Sockets.SocketException || httpEx.Message.Contains("connection refused", StringComparison.OrdinalIgnoreCase))); - if (!isOffline) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", "API version negotiation failed; falling back to 1.45", ex); - } - apiVersion = new System.Version(1, 45); - } - finally - { - tempClient.Dispose(); - } - _client = config.CreateClient(apiVersion); - _connectionProvider = new DockerConnectionProvider(_client); - _imageManager = new DockerImageManager(_client, _settingsService); - _containerManager = new DockerContainerManager(_client); - - if (Interlocked.CompareExchange(ref _staticClientForCleanup, _client, null) is null) - { - AppDomain.CurrentDomain.ProcessExit += CleanupDanglingContainers; - _cancelKeyPressHandler = (s, e) => CleanupDanglingContainers(s, e); - Console.CancelKeyPress += _cancelKeyPressHandler; - } - } - catch (Exception ex) - { - _connectionProvider?.Dispose(); - _client?.Dispose(); - _client = null; - ContainerTelemetry.TrackError("DockerExecutionStrategy", "Asynchronous daemon connection initialization failed", ex); + var conn = await Services.Docker.DockerConnectionFactory.CreateAsync(_settingsService, _strategyCts.Token).ConfigureAwait(false); + _detectedRuntime = conn.DetectedRuntime; + _daemonUri = conn.DaemonUri; + _client = conn.Client; + _connectionProvider = conn.ConnectionProvider; + _imageManager = conn.ImageManager; + _containerManager = conn.ContainerManager; + if (_client != null) + { + ContainerReaper.TryArm(_client); + _runner = new ContainerRunner(_client, _settingsService, _console, _daemonUri!); } } @@ -692,783 +209,318 @@ public async Task PrePullImageAsync(string image, CancellationToken ct) } public string GenerateDockerRunCommand() - { - var image = GetDefaultImage(); - var runtimePath = GetRuntimePath(); - var memMb = _settingsService.SafeGetSetting(ContainerExtensionModule.MemoryLimitSetting, 0.0); - var cpuCores = _settingsService.SafeGetSetting(ContainerExtensionModule.CpuLimitSetting, 0.0); - var network = _settingsService.SafeGetSetting(ContainerExtensionModule.NetworkModeSetting, "bridge"); - var autoRemove = _settingsService.SafeGetSetting(ContainerExtensionModule.AutoRemoveSetting, true); - var platform = _settingsService.SafeGetSetting(ContainerExtensionModule.PlatformSetting, "auto"); - var namePrefix = _settingsService.SafeGetSetting(ContainerExtensionModule.ContainerNamePrefixSetting, "containerextension-"); - var extraFlags = _settingsService.SafeGetSetting(ContainerExtensionModule.ExtraFlagsSetting, ""); - - var sb = new StringBuilder(); - sb.Append(CultureInfo.InvariantCulture, $"{runtimePath} run"); - if (autoRemove) - { - sb.Append(" --rm"); - } - if (!string.IsNullOrWhiteSpace(namePrefix)) - { - sb.Append(CultureInfo.InvariantCulture, $" --name {namePrefix.TrimEnd('-')}--"); - } - sb.Append(CultureInfo.InvariantCulture, $" -v \"$(pwd)\":{ContainerWorkDir} -w {ContainerWorkDir}"); - if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) - { - sb.Append(" --user $(id -u):$(id -g)"); - } - if (memMb > 0) - { - sb.Append(CultureInfo.InvariantCulture, $" --memory {memMb:F0}m --memory-swap {memMb:F0}m"); - } - if (cpuCores > 0) - { - sb.Append(CultureInfo.InvariantCulture, $" --cpus {cpuCores:N1}"); - } - sb.Append(" --init"); - if (!string.Equals(network, "bridge", StringComparison.OrdinalIgnoreCase)) - { - sb.Append(CultureInfo.InvariantCulture, $" --network {network}"); - } - if (!string.IsNullOrWhiteSpace(platform) && !string.Equals(platform, "auto", StringComparison.OrdinalIgnoreCase)) - { - sb.Append(CultureInfo.InvariantCulture, $" --platform {platform}"); - } - if (!string.IsNullOrWhiteSpace(extraFlags)) - { - foreach (var flag in extraFlags.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - sb.Append(CultureInfo.InvariantCulture, $" --label {flag}"); - } - } - sb.Append(CultureInfo.InvariantCulture, $" {image} "); - - return sb.ToString(); - } + => Services.Docker.DockerRunCommandFormatter.Generate(_settingsService, GetRuntimePath(), GetDefaultImage()); // The exact docker run command from the most recent execution this session, with REAL env values and // paths (unmasked). Kept in memory only — never logged or persisted — so the dashboard can copy a // verbatim, runnable command to the clipboard while the on-disk telemetry log stays scrubbed. internal string? LastRawDockerRunCommand { get; private set; } - private string ReconstructDockerRunCommand(CreateContainerParameters p, bool maskEnvValues = true) - { - var sb = new StringBuilder(); - sb.Append(CultureInfo.InvariantCulture, $"{GetRuntimePath()} run"); + private static readonly System.Threading.Lock WeakProcessLock = new(); - if (p.HostConfig?.AutoRemove == true) - { - sb.Append(" --rm"); - } - if (!string.IsNullOrEmpty(p.Name)) - { - var escapedName = p.Name.Replace("\"", "\\\""); - sb.Append(CultureInfo.InvariantCulture, $" --name \"{escapedName}\""); - } - if (!string.IsNullOrEmpty(p.User)) + public WeakReference StartWeakProcess(ToolCommand command) + { + lock (WeakProcessLock) { - var escapedUser = p.User.Replace("\"", "\\\""); - sb.Append(CultureInfo.InvariantCulture, $" --user \"{escapedUser}\""); - } + var runCts = new CancellationTokenSource(); - if (p.HostConfig?.Binds != null) - { - foreach (var bind in p.HostConfig.Binds) + var dummyProcess = new Process(); + dummyProcess.StartInfo = new ProcessStartInfo { - var escapedBind = bind.Replace("\"", "\\\"").Replace('\\', '/'); - sb.Append(CultureInfo.InvariantCulture, $" -v \"{escapedBind}\""); - } - } - - if (!string.IsNullOrEmpty(p.WorkingDir)) - { - var escapedWorkingDir = p.WorkingDir.Replace("\"", "\\\""); - sb.Append(CultureInfo.InvariantCulture, $" -w \"{escapedWorkingDir}\""); - } - - if (p.HostConfig?.Memory > 0) - { - sb.Append(CultureInfo.InvariantCulture, $" --memory {p.HostConfig.Memory / (1024 * 1024)}m"); - if (p.HostConfig.MemorySwap == p.HostConfig.Memory) + FileName = OperatingSystem.IsWindows() ? "ping" : "sleep", + Arguments = OperatingSystem.IsWindows() ? "127.0.0.1 -n 86400" : "86400", + CreateNoWindow = true, + UseShellExecute = false + }; + dummyProcess.EnableRaisingEvents = true; + dummyProcess.Exited += (s, e) => { - sb.Append(CultureInfo.InvariantCulture, $" --memory-swap {p.HostConfig.MemorySwap / (1024 * 1024)}m"); - } - } - if (p.HostConfig?.NanoCPUs > 0) - { - sb.Append(CultureInfo.InvariantCulture, $" --cpus {p.HostConfig.NanoCPUs / 1_000_000_000.0:N1}"); - } - if (p.HostConfig?.Init == true) - { - sb.Append(" --init"); - } - - if (!string.IsNullOrEmpty(p.HostConfig?.NetworkMode) && - !p.HostConfig.NetworkMode.Equals("bridge", StringComparison.OrdinalIgnoreCase)) - { - var escapedNetworkMode = p.HostConfig.NetworkMode.Replace("\"", "\\\""); - sb.Append(CultureInfo.InvariantCulture, $" --network \"{escapedNetworkMode}\""); - } - - if (p.Env != null) - { - foreach (var env in p.Env) - { - var eqIdx = env.IndexOf('='); - if (eqIdx > 0) + try { - // Record the variable NAME only; the value is always masked. This command is - // persisted to the telemetry log, and environment values can carry secrets - // (license keys, tokens) under arbitrary, non-obvious names that a keyword - // denylist cannot catch reliably — so no value is ever written. - var key = env[..eqIdx]; - // Logged/persisted commands always mask the value (it can carry secrets). The in-session - // exact-copy path (maskEnvValues:false) renders the real value for a verbatim, runnable - // command placed only on the clipboard — never written to the telemetry log. - var rendered = maskEnvValues ? $"{key}=********" : env; - var escapedEnv = rendered.Replace("\"", "\\\"", StringComparison.Ordinal); - sb.Append(CultureInfo.InvariantCulture, $" -e \"{escapedEnv}\""); + runCts.Cancel(); } - else + catch (ObjectDisposedException) { - var escapedEnv = env.Replace("\"", "\\\"", StringComparison.Ordinal); - sb.Append(CultureInfo.InvariantCulture, $" -e \"{escapedEnv}\""); + // Exception is intentionally ignored because runCts may have already been disposed when container execution finishes naturally. } + }; + + try + { + dummyProcess.Start(); + } + catch (Exception ex) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", "Failed to start dummy process in StartWeakProcess", ex); + // The sentinel never started, so the returned dummy's Exited event can no longer relay a + // kill into runCts. Cancel here so killing the replacement cannot leave the container + // running with its only host cancellation handle severed. + try { runCts.Cancel(); } catch (ObjectDisposedException) { /* already finished */ } + try { dummyProcess.Dispose(); } catch { /* original handle is being discarded */ } + dummyProcess = new Process(); } - } - sb.Append(CultureInfo.InvariantCulture, $" {p.Image}"); - if (p.Cmd != null) - { - foreach (var arg in p.Cmd) + _ = Task.Run(async () => { - if (string.IsNullOrEmpty(arg)) + try { - sb.Append(" \"\""); + await ExecuteAsync(command, runCts.Token).ConfigureAwait(false); } - else if (arg.AsSpan().ContainsAny(ShellSpecialAndWhitespaceChars)) + catch (Exception ex) { - var escapedArg = arg.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); - sb.Append(CultureInfo.InvariantCulture, $" \"{escapedArg}\""); + try + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", "StartWeakProcess task crashed", ex, command.Executable); + var errMsg = $"[ERROR] Execution of background task '{command.Executable}' failed: {ex.Message}"; + SafeInvoke(() => + { + (command.ErrorHandler ?? command.OutputHandler)?.Invoke(errMsg); + }); + } + catch (Exception) + { + // Exception is intentionally ignored because error handling failure during shutdown/crash is non-critical. + } } - else + finally { - sb.Append(CultureInfo.InvariantCulture, $" {arg}"); + try + { + if (!dummyProcess.HasExited) + { + dummyProcess.Kill(true); + } + } + catch (Exception) + { + // Exception is intentionally ignored because dummy process may have already exited. + } + try + { + runCts.Dispose(); + } + catch (Exception) + { + // Exception is intentionally ignored because token source disposal errors are non-critical. + } } - } - } + }, CancellationToken.None); - return sb.ToString(); + return new WeakReference(dummyProcess); + } } - private static async Task<(bool live, string? errorMessage)> IsUnixSocketLiveAndWritableAsync(string path, CancellationToken ct = default) + public string GetStrategyName() => "Docker Container (DotNet API)"; + + public string GetStrategyKey() => ToolKey; + + public void Dispose() { - if (!File.Exists(path)) + if (Interlocked.Exchange(ref _disposed, 1) == 1) { - return (false, null); + return; } + try { - using var socket = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.Unix, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Unspecified); - var ep = new System.Net.Sockets.UnixDomainSocketEndPoint(path); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(1000); - try - { - await socket.ConnectAsync(ep, timeoutCts.Token).ConfigureAwait(false); - return (true, null); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) - { - return (false, $"Timeout connecting to UNIX socket '{path}'."); - } + _strategyCts.Cancel(); + _strategyCts.Dispose(); } - catch (System.Net.Sockets.SocketException ex) + catch { - var nativeCode = ex.NativeErrorCode; - var socketCode = ex.SocketErrorCode; - string? errorMessage; - if (socketCode == System.Net.Sockets.SocketError.AccessDenied || - nativeCode == 13 || - nativeCode == 1 || - nativeCode == 10013) - { - errorMessage = $"Access Denied: Current user does not have permission to access socket '{path}'. Ensure correct group membership (e.g. 'docker')."; - } - else if (socketCode == System.Net.Sockets.SocketError.ConnectionRefused || - nativeCode == 111 || - nativeCode == 61) - { - errorMessage = $"Connection Refused: Docker daemon socket at '{path}' is not running or active."; - } - else - { - errorMessage = $"Socket Error ({socketCode}, Native: {nativeCode}): {ex.Message}"; - } - return (false, errorMessage); + // Ignore cancel/dispose errors } - catch (Exception ex) when (ex is not OutOfMemoryException) + + // Reap this client's tracked containers and release the process-exit hooks it may own. + ContainerReaper.Disarm(_client); + + _imageManager?.Dispose(); + _containerManager?.Dispose(); + _connectionProvider?.Dispose(); + _client?.Dispose(); + _strategyLock.Dispose(); + ContainerTelemetry.Shutdown(); + } + + public async IAsyncEnumerable StreamContainerLogsAsync(string containerId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + ThrowIfDisposed(); + await EnsureInitializedAsync(ct).ConfigureAwait(false); + await foreach (var log in ContainerManager.StreamContainerLogsAsync(containerId, ct).ConfigureAwait(false)) { - return (false, $"Unknown connection failure for socket '{path}': {ex.Message}"); + yield return log; } } - private static void ValidateBinds(IList? binds) + private static string ScrubUserPaths(string? input) { - if (binds == null) return; - - string[] blockedPaths; - if (OperatingSystem.IsWindows()) + if (string.IsNullOrEmpty(input)) { - blockedPaths = new[] - { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows)), - @"C:\Windows", - @"\\.\pipe" - }; + return ""; } - else + try { - blockedPaths = new[] - { - "/etc", - "/var/run", - "/var/run/docker.sock", - "/var/run/containerd", - "/proc", - "/sys", - "/dev", - "/boot", - "/bin", - "/sbin", - "/usr/bin", - "/usr/sbin" - }; + input = UriCredentialsRegex().Replace(input, "***:***"); } - - // Enforce both the raw and the canonical form of each blocked path. If canonicalization of a - // hardcoded blocked path fails, the raw form is still compared, so the gate cannot be weakened - // by a symlinked or differently-cased equivalent slipping past an un-canonicalized entry. - var blockedForms = new List(blockedPaths.Length * 2); - foreach (var blockedPath in blockedPaths) + catch { /* Ignore regex errors */ } + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) { - blockedForms.Add(blockedPath); - try - { - var canonical = GetCanonicalPath(blockedPath); - if (!string.Equals(canonical, blockedPath, StringComparison.OrdinalIgnoreCase)) - { - blockedForms.Add(canonical); - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - // The raw form is already enrolled above, so the gate stays effective. - } + input = input.Replace(home, "~", StringComparison.OrdinalIgnoreCase); } - - for (int i = 0; i < binds.Count; i++) + var user = Environment.UserName; + // Replace the account name only at identifier boundaries with a 3-character floor, mirroring + // ContainerTelemetry.ScrubSensitiveInfo: a bare substring replace corrupts unrelated text such as + // "max-frequency" when the username is short or a common token. Escape the name so it is matched + // literally, never as a regex pattern. + if (!string.IsNullOrWhiteSpace(user) && user.Length >= 3) { - var bind = binds[i]; - if (string.IsNullOrWhiteSpace(bind)) continue; - - // Docker bind spec: HOST:CONTAINER[:OPTIONS]. Split drive-letter-aware so a Windows host - // path such as "C:\proj" is not severed at its drive-letter colon (which would reduce the - // host path to "C" and defeat both the rewrite and the critical-path security checks). - var (hostPart, containerPart, optionsPart) = SplitDockerBind(bind); - var hostPath = hostPart.Trim(); - if (!string.IsNullOrEmpty(hostPath)) + try { - string fullPath; - try - { - fullPath = GetCanonicalPath(hostPath); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - throw new DockerExecutionException($"Invalid mount path: '{hostPath}'. Details: {ex.Message}", ex); - } - - var reconstructed = fullPath; - if (containerPart != null) - { - reconstructed += ":" + containerPart.Trim(); - } - if (optionsPart != null) - { - reconstructed += ":" + optionsPart.Trim(); - } - binds[i] = reconstructed; - - foreach (var blocked in blockedForms) - { - if (string.Equals(fullPath, blocked, StringComparison.OrdinalIgnoreCase)) - { - throw new DockerExecutionException($"Mounting critical host path '{hostPath}' is blocked for security reasons."); - } - - if (fullPath.StartsWith(blocked + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - fullPath.StartsWith(blocked + "/", StringComparison.OrdinalIgnoreCase)) - { - throw new DockerExecutionException($"Mounting paths under critical host directory '{blocked}' is blocked for security reasons."); - } - } - - if (containerPart != null) - { - var containerPath = containerPart.Trim(); - if (containerPath.StartsWith("/sys", StringComparison.OrdinalIgnoreCase) || - containerPath.StartsWith("/proc", StringComparison.OrdinalIgnoreCase) || - containerPath.StartsWith("/dev", StringComparison.OrdinalIgnoreCase) || - containerPath.StartsWith("/etc", StringComparison.OrdinalIgnoreCase)) - { - throw new DockerExecutionException($"Mapping to container path '{containerPath}' is blocked for security reasons."); - } - } + input = System.Text.RegularExpressions.Regex.Replace(input, + $@"(?= 2 && char.IsLetter(bind[0]) && bind[1] == ':' ? 2 : 0; - int firstSep = bind.IndexOf(':', hostStart); - if (firstSep < 0) - { - return (bind, null, null); - } - - var host = bind[..firstSep]; - var remainder = bind[(firstSep + 1)..]; - int secondSep = remainder.IndexOf(':'); - return secondSep < 0 - ? (host, remainder, null) - : (host, remainder[..secondSep], remainder[(secondSep + 1)..]); + public string Image = string.Empty; + public string Executable = string.Empty; + public double Duration; + public long ExitCode; + public string? ImageDigest; + public bool WasCancelled; + public string? RunCommand; + // In-memory only, never persisted: the exact unmasked command for the dashboard's verbatim copy. + public string? RawRunCommand; + public long? PeakMemory; + public double? MaxCpu; + public bool OomKilled; + public int MaxEntries; + public string? ErrorMessage; } - private static string GetCanonicalPath(string path) => PathCanonicalizer.GetCanonicalPath(path); - -#pragma warning disable S3011 - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075")] - private sealed class SecureNamedPipeCredentials : Docker.DotNet.Credentials + internal static bool IsTargetingEmptyGhdlLibrary(ToolCommand command) { - private readonly Uri _endpoint; - - public SecureNamedPipeCredentials(Uri endpoint) + var exe = command.Executable ?? command.ToolName ?? ""; + if (!exe.Contains("ghdl", StringComparison.OrdinalIgnoreCase)) { - _endpoint = endpoint; + return false; } - public override bool IsTlsCredentials() => false; + if (command.Arguments == null) + { + return false; + } - public override System.Net.Http.HttpMessageHandler GetHandler(System.Net.Http.HttpMessageHandler innerHandler) + var args = command.Arguments.ToList(); + bool isElabOrMakeOrRun = args.Any(a => a != null && (a.Equals("-m", StringComparison.Ordinal) || a.Equals("-e", StringComparison.Ordinal) || a.Equals("-r", StringComparison.Ordinal))); + if (!isElabOrMakeOrRun) { - if (string.Equals(innerHandler.GetType().FullName, "Microsoft.Net.Http.Client.ManagedHandler", StringComparison.Ordinal)) - { - var field = innerHandler.GetType().GetField("_streamOpener", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (field != null) - { - var delegateType = field.FieldType; - var method = typeof(SecureNamedPipeCredentials).GetMethod(nameof(SecureStreamOpenerAsync), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (method != null) - { - var d = System.Delegate.CreateDelegate(delegateType, this, method); - field.SetValue(innerHandler, d); - } - else - { - // Do not fail open silently: if our own opener cannot be bound, the pipe would be - // dialled without the impersonation cap. Surface it rather than downgrade unseen. - ContainerTelemetry.TrackError("DockerExecutionStrategy", - "SecureNamedPipeCredentials: SecureStreamOpenerAsync not found; named-pipe impersonation cap NOT installed.", null); - } - } - else - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", - "SecureNamedPipeCredentials: ManagedHandler._streamOpener field not found (Docker.DotNet drift); named-pipe impersonation cap NOT installed.", null); - } - } - return innerHandler; + return false; } -#pragma warning disable S1172 - private async System.Threading.Tasks.Task SecureStreamOpenerAsync(string host, int port, System.Threading.CancellationToken token) + string? libraryName = null; + for (int i = 0; i < args.Count; i++) { - var pipeName = _endpoint.LocalPath; - var serverName = "."; - if (pipeName.StartsWith(@"\\", StringComparison.Ordinal)) + var a = args[i]; + if (a == null) continue; + + if (a.StartsWith("--work=", StringComparison.OrdinalIgnoreCase) || a.StartsWith("-work=", StringComparison.OrdinalIgnoreCase)) { - var parts = pipeName.Split('\\', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3) + var parts = a.Split('=', 2); + if (parts.Length > 1) { - serverName = parts[0]; - pipeName = parts[parts.Length - 1]; + var val = parts[1].Replace('\\', '/').TrimEnd('/'); + libraryName = Path.GetFileName(val); } + break; } - else + if ((a.Equals("--work", StringComparison.OrdinalIgnoreCase) || a.Equals("-work", StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Count) { - if (pipeName.StartsWith("pipe/", StringComparison.OrdinalIgnoreCase)) - { - pipeName = pipeName[5..]; - } - else if (pipeName.StartsWith("/pipe/", StringComparison.OrdinalIgnoreCase)) + var val = args[i + 1]?.Replace('\\', '/').TrimEnd('/'); + if (val != null) { - pipeName = pipeName[6..]; + libraryName = Path.GetFileName(val); } + break; } + } + + if (string.IsNullOrWhiteSpace(libraryName)) + { + return false; + } - var pipe = new System.IO.Pipes.NamedPipeClientStream( - serverName, - pipeName, - System.IO.Pipes.PipeDirection.InOut, - System.IO.Pipes.PipeOptions.Asynchronous, - System.Security.Principal.TokenImpersonationLevel.Identification); + string? workdir = null; + for (int i = 0; i < args.Count; i++) + { + var a = args[i]; + if (a == null) continue; - try + if (a.StartsWith("--workdir=", StringComparison.OrdinalIgnoreCase)) { - await pipe.ConnectAsync(token).ConfigureAwait(false); - // Verify the server on the SAME handle that carries traffic, not just the throwaway probe - // in VerifyWindowsNamedPipeAsync — otherwise a squatter that lost the probe race could still - // win the data connection. Fail open on any ambiguity (unreadable handle/pid) to match the - // probe's posture; reject only a definitively untrusted server. - if (OperatingSystem.IsWindows() - && pipe.SafePipeHandle is { IsInvalid: false } dataHandle - && GetNamedPipeServerProcessId(dataHandle, out var serverPid) - && GetPipeServerTrust(serverPid) == PipeServerTrust.Untrusted) + var parts = a.Split('=', 2); + if (parts.Length > 1) { - // The catch below disposes the pipe on the way out. - throw new IOException($"Refusing to use Docker named pipe: data-stream server process (PID {serverPid}) is not trusted."); + workdir = parts[1]; } - return pipe; + break; } - catch + if (a.Equals("-P", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Count) { - await pipe.DisposeAsync().ConfigureAwait(false); - throw; - } - } -#pragma warning restore S1172 - } -#pragma warning restore S3011 - - private static async Task<(Uri uri, string runtime)> ProbeUnixSocketAsync(CancellationToken ct = default) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - await EnsureUnixIdsLoadedAsync(ct).ConfigureAwait(false); - var uid = _cachedUid ?? "1000"; - - var candidates = new (string path, string name)[] - { - ("/var/run/docker.sock", "docker"), - (Path.Combine(home, ".docker/run/docker.sock"), "docker (user)"), - ($"/run/user/{uid}/podman/podman.sock", "podman"), - (Path.Combine(home, ".colima/default/docker.sock"), "colima"), - (Path.Combine(home, ".local/share/containers/podman/machine/podman.sock"), "podman (machine)"), - (Path.Combine(home, ".orbstack/run/docker.sock"), "orbstack"), - }; - - foreach (var (path, name) in candidates) - { - ct.ThrowIfCancellationRequested(); - if (File.Exists(path)) - { - var owner = await GetUnixFileOwnerAsync(path, ct).ConfigureAwait(false); - if (owner != null && !string.Equals(owner, uid, StringComparison.Ordinal) && !string.Equals(owner, "0", StringComparison.Ordinal)) - { - Console.WriteLine($"[WARN] Insecure socket owner '{owner}' for socket '{path}'. Expected owner {uid} or 0."); - continue; - } - } - ct.ThrowIfCancellationRequested(); - var (live, error) = await IsUnixSocketLiveAndWritableAsync(path, ct).ConfigureAwait(false); - if (live) - { - return (new Uri($"unix://{path}"), RefineRuntimeLabel(path, name)); - } - else if (error != null && error.StartsWith("Access Denied", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine($"[WARN] {error}"); - } - } - - // If no candidate is active/live, see if any candidate file exists on disk - // Checked in reverse order to prefer specific runtimes (orbstack, colima, podman) over generic defaults. - for (int i = candidates.Length - 1; i >= 0; i--) - { - var (path, name) = candidates[i]; - if (File.Exists(path)) - { - // Re-apply the live-probe ownership gate here too: a socket the probe loop skipped as - // insecurely owned must not be silently re-selected by the file-exists fallback. Null-tolerant - // (stat unavailable / unresolved owner is accepted) to match the probe loop and avoid - // regressing hosts where ownership cannot be determined. - var owner = await GetUnixFileOwnerAsync(path, ct).ConfigureAwait(false); - if (owner != null && !string.Equals(owner, uid, StringComparison.Ordinal) && !string.Equals(owner, "0", StringComparison.Ordinal)) - { - continue; - } - return (new Uri($"unix://{path}"), RefineRuntimeLabel(path, name)); + workdir = args[i + 1]; + break; } - } - - // If files are deleted when offline, check if the parent directories exist (specific to user home) - for (int i = candidates.Length - 1; i >= 0; i--) - { - var (path, name) = candidates[i]; - if (!string.IsNullOrEmpty(home) && path.Contains(home, StringComparison.Ordinal)) + if (a.StartsWith("-P", StringComparison.OrdinalIgnoreCase) && a.Length > 2) { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) - { - return (new Uri($"unix://{path}"), name); - } + workdir = a[2..]; + break; } } - return (new Uri("unix:///var/run/docker.sock"), RefineRuntimeLabel("/var/run/docker.sock", "docker (default)")); - } - - // The probe candidates carry a static label, but /var/run/docker.sock is commonly a symlink into a - // specific runtime's directory (OrbStack, Colima, Podman). Resolve the link chain so DetectedRuntime — - // and thus the dashboard's "Open Desktop" button, title, and offline guidance — names the real runtime - // instead of the generic "docker" the path was merely reached through. - private static string RefineRuntimeLabel(string socketPath, string defaultName) - { - try - { - var resolved = socketPath; - for (int hop = 0; hop < 16; hop++) - { - var target = new FileInfo(resolved).LinkTarget; - if (string.IsNullOrEmpty(target)) break; - resolved = Path.IsPathRooted(target) - ? target - : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(resolved) ?? "/", target)); - } - var r = resolved.Replace('\\', '/'); - if (r.Contains("/.orbstack/", StringComparison.OrdinalIgnoreCase)) return "orbstack"; - if (r.Contains("/.colima/", StringComparison.OrdinalIgnoreCase)) return "colima"; - if (r.Contains("podman", StringComparison.OrdinalIgnoreCase)) return "podman"; - } - catch + var baseDir = string.IsNullOrWhiteSpace(command.WorkingDirectory) ? Directory.GetCurrentDirectory() : command.WorkingDirectory; + var targetDir = baseDir; + if (!string.IsNullOrWhiteSpace(workdir)) { - // Resolution failed (missing file, permission, symlink loop) — fall back to the static label. + targetDir = Path.IsPathRooted(workdir) ? workdir : Path.GetFullPath(Path.Combine(baseDir, workdir)); } - return defaultName; - } - // Resolve a Unix system utility to a trusted absolute path instead of a bare name (Sonar S4036). - // A bare "stat"/"id"/"open" is resolved against $PATH, so a writable directory earlier on PATH could - // shadow the real binary. Prefer /usr/bin then /bin (usr-merged on modern Linux; both fixed on macOS). - // On a non-FHS layout where neither exists (e.g. NixOS) return the canonical absolute path anyway, so - // the launch fails cleanly and degrades to the caller's fallback rather than resolving through PATH. - internal static string ResolveTrustedUnixBinary(string name) - { - string[] candidates = [$"/usr/bin/{name}", $"/bin/{name}"]; - foreach (var candidate in candidates) + if (!Directory.Exists(targetDir)) { - if (File.Exists(candidate)) - { - return candidate; - } + return false; } - return candidates[0]; - } - private static async Task GetUnixFileOwnerAsync(string path, CancellationToken ct = default) - { - if (OperatingSystem.IsWindows()) - { - return null; - } - if (OwnerCache.TryGetValue(path, out var cachedOwner)) - { - return cachedOwner; - } try { - var isMac = OperatingSystem.IsMacOS(); - using (var p = new Process()) + var files = Directory.GetFiles(targetDir, $"{libraryName}-obj*.cf"); + if (files.Length > 0) { - p.StartInfo = new ProcessStartInfo - { - FileName = ResolveTrustedUnixBinary("stat"), - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - if (isMac) - { - p.StartInfo.ArgumentList.Add("-f"); - p.StartInfo.ArgumentList.Add("%u"); - } - else - { - p.StartInfo.ArgumentList.Add("-c"); - p.StartInfo.ArgumentList.Add("%u"); - } - p.StartInfo.ArgumentList.Add(path); - - ct.ThrowIfCancellationRequested(); - p.Start(); - await p.WaitForExitAsync(ct).ConfigureAwait(false); - var output = (await p.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false)).Trim(); - _ = await p.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); - if (p.ExitCode != 0) - { - OwnerCache[path] = null; - return null; - } - if (string.IsNullOrWhiteSpace(output)) + bool allEmpty = true; + foreach (var file in files) { - OwnerCache[path] = null; - return null; + var info = new FileInfo(file); + if (info.Exists && info.Length > 4) + { + allEmpty = false; + break; + } } - OwnerCache[path] = output; - return output; + return allEmpty; } } - catch (System.ComponentModel.Win32Exception ex) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed with Win32Exception for '{path}'", ex); - return null; - } - catch (IOException ex) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed with IOException for '{path}'", ex); - return null; - } - catch (UnauthorizedAccessException ex) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed with UnauthorizedAccessException for '{path}'", ex); - return null; - } - catch (Exception ex) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed for '{path}'", ex); - return null; - } - } - - private static async Task GetUnixIdInternalAsync(string arg, string fallback, CancellationToken ct) - { - if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + catch { - try - { - if (string.Equals(arg, "-u", StringComparison.Ordinal)) - { - return geteuid().ToString(System.Globalization.CultureInfo.InvariantCulture); - } - if (string.Equals(arg, "-g", StringComparison.Ordinal)) - { - return getegid().ToString(System.Globalization.CultureInfo.InvariantCulture); - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - // Fall back - } + // Fallback } - Process? p = null; - try - { - p = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = ResolveTrustedUnixBinary("id"), - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - p.StartInfo.ArgumentList.Add(arg); - p.Start(); - - var readOutTask = p.StandardOutput.ReadToEndAsync(ct); - var readErrTask = p.StandardError.ReadToEndAsync(ct); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(1000); - try - { - await p.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); - var id = (await readOutTask.ConfigureAwait(false)).Trim(); - _ = await readErrTask.ConfigureAwait(false); - if (!string.IsNullOrEmpty(id) && int.TryParse(id, out _)) - { - return id; - } - } - catch (OperationCanceledException) - { - try - { - p.Kill(); - await p.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Ignore - } - try - { - await Task.WhenAny(Task.WhenAll(readOutTask, readErrTask), Task.Delay(500, CancellationToken.None)).ConfigureAwait(false); - } - catch - { - // Ignore - } - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"UID/GID probe for '{arg}' timed out", null); - } - } - catch (System.ComponentModel.Win32Exception) - { - // 'id' binary could not be launched on this platform; the caller falls back to the - // default 1000:1000 mapping. - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"UID/GID probe failed for '{arg}'", ex); - } - finally - { - if (p != null) - { - try - { - if (!p.HasExited) - { - p.Kill(); - await p.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); - } - } - catch - { - // Ignore - } - try - { - p.Dispose(); - } - catch - { - // Ignore - } - } - } - return fallback; + return false; } + public async ValueTask PingAsync(CancellationToken ct = default) { ThrowIfDisposed(); @@ -1563,182 +615,6 @@ public static (int imageCount, long totalSizeBytes, long reclaimableBytes) Compu return await ImageManager.GetDiskUsageSummaryAsync(ct).ConfigureAwait(false); } - private static void CleanupDanglingContainers(object? sender, EventArgs e) - { - if (Interlocked.Exchange(ref _cleanupExecuted, 1) != 0) - { - return; - } - var client = _staticClientForCleanup; - if (client == null) - { - return; - } - CleanupContainers(client); - } - - // Stops and force-removes every tracked container using the supplied client. Kept separate - // from the ProcessExit/CancelKeyPress handler so the Dispose path can pass its still-valid - // client explicitly: by the time Dispose runs the cleanup the CAS has already nulled the - // static field, which would otherwise make the field-reading handler a silent no-op. - private static void CleanupContainers(DockerClient client) - { - var keys = ActiveContainers.Keys; - if (keys.Count == 0) - { - return; - } - - // Stop/remove all tracked containers concurrently under a single shared time budget, rather than - // blocking the caller (Dispose can run on the UI thread) for up to 2 s PER container in sequence. - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var tasks = new List(); - foreach (var key in keys) - { - if (ActiveContainers.TryRemove(key, out var shouldAutoRemove)) - { - tasks.Add(StopAndRemoveContainerAsync(client, key, shouldAutoRemove, cts.Token)); - } - } - - try - { - // Synchronous block is intentional: the reaper runs from Dispose and the ProcessExit handler, - // neither of which has an async context to await into. Scope the suppression to this one site - // rather than blanketing the whole file, which formerly hid a real teardown fault. -#pragma warning disable VSTHRD002 - Task.WhenAll(tasks).GetAwaiter().GetResult(); -#pragma warning restore VSTHRD002 - } - catch (Exception) - { - // Best effort on exit - } - } - - private static async Task StopAndRemoveContainerAsync(DockerClient client, string key, bool shouldAutoRemove, CancellationToken ct) - { - try - { - await client.Containers.StopContainerAsync(key, new ContainerStopParameters { WaitBeforeKillSeconds = 1 }, ct).ConfigureAwait(false); - if (shouldAutoRemove) - { - await client.Containers.RemoveContainerAsync(key, new ContainerRemoveParameters { Force = true }, ct).ConfigureAwait(false); - } - } - catch (Exception) - { - // Best effort per container - } - } - - internal static void DrainLines(StringBuilder buffer, ReadOnlySpan textSpan, Func? handler) - { - if (textSpan.IsEmpty) - { - return; - } - - string[]? batchArray = null; - int batchCount = 0; - - void AddLine(string line) - { - if (handler != null) - { - if (batchArray == null) - { - batchArray = System.Buffers.ArrayPool.Shared.Rent(16); - } - if (batchCount >= batchArray.Length) - { - var newArray = System.Buffers.ArrayPool.Shared.Rent(batchArray.Length * 2); - Array.Copy(batchArray, newArray, batchCount); - System.Buffers.ArrayPool.Shared.Return(batchArray); - batchArray = newArray; - } - batchArray[batchCount++] = line; - } - } - - int start = 0; - while (start < textSpan.Length) - { - int newlineIdx = textSpan[start..].IndexOf('\n'); - if (newlineIdx < 0) - { - break; - } - - int lineEndRelative = newlineIdx; - int absoluteLineEnd = start + lineEndRelative; - - int lineEndTrimmed = absoluteLineEnd; - if (lineEndTrimmed > start && textSpan[lineEndTrimmed - 1] == '\r') - { - lineEndTrimmed--; - } - - string completedLine; - if (buffer.Length > 0) - { - buffer.Append(textSpan[start..lineEndTrimmed]); - completedLine = buffer.ToString(); - buffer.Clear(); - } - else - { - completedLine = textSpan[start..lineEndTrimmed].ToString(); - } - - AddLine(completedLine); - start = absoluteLineEnd + 1; - } - - if (start < textSpan.Length) - { - buffer.Append(textSpan[start..]); - } - - if (batchCount > 0 && batchArray != null) - { - var finalCount = batchCount; - var finalArray = batchArray; - SafeInvoke(() => - { - try - { - for (int idx = 0; idx < finalCount; idx++) - { - try - { - handler!(finalArray[idx]); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", "DrainLines callback handler failed", ex); - } - } - } - finally - { - for (int idx = 0; idx < finalCount; idx++) - { - finalArray[idx] = null!; - } - System.Buffers.ArrayPool.Shared.Return(finalArray); - } - }); - } - - // Defensive OOM Shield: If a container goes rogue and outputs endless text - // without newlines, prevent the StringBuilder from crashing the host IDE. - if (buffer.Length > 8 * 1024 * 1024) // 8 MB limit - { - buffer.Clear(); - ContainerTelemetry.TrackError("DockerExecutionStrategy", "OOM Protection triggered: buffer exceeded 8MB threshold without newlines", null); - } - } private string ResolveImage(string toolName) { @@ -1774,605 +650,21 @@ private CreateContainerParameters BuildContainerParameters(string image, ToolCom image, command, _settingsService, - _cachedUid, - _cachedGid, - (cmd, msg) => SdkLog(cmd, msg), + Services.Docker.DaemonEndpointValidator.CachedUid, + Services.Docker.DaemonEndpointValidator.CachedGid, + (cmd, msg) => _console.SdkLog(cmd, msg), remoteCpuCores, isRootless); } - private async Task EnsureImageAsync(string image, ToolCommand command, CancellationToken ct) - { - string? imageDigest = null; - var platform = _settingsService.SafeGetSetting(ContainerExtensionModule.PlatformSetting, "auto")?.Trim(); - var pullPolicy = _settingsService.SafeGetSetting(ContainerExtensionModule.PullPolicySetting, "if-not-present"); - - bool imageExistsLocally = false; - try - { - var inspectResponse = await Client.Images.InspectImageAsync(image, ct).ConfigureAwait(false); - imageDigest = inspectResponse.ID; - imageExistsLocally = true; - } - catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - imageExistsLocally = false; - } - - bool shouldPull = pullPolicy switch - { - "always" => true, - "never" => false, - _ => !imageExistsLocally - }; - - if (!imageExistsLocally && string.Equals(pullPolicy, "never", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"Image '{image}' not found locally and pull policy is 'never'."); - } - - if (shouldPull) - { - SdkLog(command, string.Equals(pullPolicy, "always", StringComparison.Ordinal) && imageExistsLocally - ? $"[Docker SDK] Pull policy 'always' — refreshing '{image}'..." - : $"[Docker SDK] Image '{image}' not found locally. Pulling..."); - - var pullParams = new ImagesCreateParameters { FromImage = image }; - if (!string.IsNullOrWhiteSpace(platform) && !string.Equals(platform, "auto", StringComparison.OrdinalIgnoreCase)) - { - pullParams.Platform = platform; - } - - // The daemon reports registry pull failures as in-band JSON error frames over an HTTP 200 - // response. Capture the first one so the real reason survives to the post-pull check below; - // CreateImageAsync itself returns successfully even when the pull failed. - string? lastPullError = null; - var progressHandler = new Progress(msg => - { - if (msg == null) - { - return; - } - try - { - if (msg.Error != null || !string.IsNullOrEmpty(msg.ErrorMessage)) - { - Volatile.Write(ref lastPullError, msg.ErrorMessage ?? msg.Error?.Message); - } - - var progressText = string.IsNullOrWhiteSpace(msg.ProgressMessage) - ? msg.Status - : $"{msg.Status} {msg.ProgressMessage}"; - - if (!string.IsNullOrWhiteSpace(progressText)) - { - SdkLog(command, $"[Docker Pull] {progressText}"); - } - } - catch (Exception) - { - // Keep the image pull task running through status formatting errors - } - }); - - try - { - try - { - await Client.Images.CreateImageAsync(pullParams, null, progressHandler, ct).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException && !string.IsNullOrWhiteSpace(pullParams.Platform)) - { - SdkLog(command, $"[Docker Pull Warning] Pull failed with platform '{pullParams.Platform}': {ex.Message}. Falling back to default host architecture."); - pullParams.Platform = null; - await Client.Images.CreateImageAsync(pullParams, null, progressHandler, ct).ConfigureAwait(false); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - if (imageExistsLocally) - { - SdkLog(command, $"[Docker Pull Warning] Pull failed for '{image}': {ex.Message}. Falling back to cached local version."); - } - else - { - throw; - } - } - - var capturedPullError = Volatile.Read(ref lastPullError); - - // Confirm the image materialized locally. A NotFound here means the pull failed despite - // CreateImageAsync returning normally, unless a cached local copy is being relied upon. - try - { - var postPull = await Client.Images.InspectImageAsync(image, ct).ConfigureAwait(false); - imageDigest = postPull.ID; - } - catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound && !imageExistsLocally) - { - throw new DockerExecutionException( - $"Failed to pull image '{image}': {capturedPullError ?? "image not found on registry."}", ex); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"Post-pull digest inspect failed for '{image}'", ex); - } - - if (capturedPullError == null) - { - SdkLog(command, $"[Docker SDK] Pull complete for '{image}'."); - } - } - - if (imageDigest != null) - { - var shortDigest = imageDigest.ShortId(); - SdkLog(command, $"[Docker SDK] Resolved digest: {shortDigest}..."); - } - - return imageDigest; - } - - internal record ResourceProfile(long PeakMemoryBytes, double MaxCpuPercent, int SampleCount, bool OomKilled); - - // Merge a late-arriving stats profile into whatever the run already captured. An earlier capture always - // wins, so the OOM correction applied after container inspect is never overwritten by the stats sampler - // (which reports OomKilled=false); the late profile is adopted only when nothing was captured yet. - internal static ResourceProfile? MergeLateResourceProfile(ResourceProfile? captured, ResourceProfile? late) - => captured ?? late; - - // Hard cap on the in-memory output string returned to the host. The live stream is still - // forwarded to the tool console in full via the output/error handlers; only the aggregated - // return value is bounded, so a runaway or hostile container cannot exhaust IDE memory. - private const int MaxCapturedOutputChars = 32 * 1024 * 1024; - - // Appends to the captured-output buffer up to the cap, then stops after a one-time marker. - // The caller must hold the lock on . - private static void AppendCapped(StringBuilder sb, ReadOnlySpan text) - { - if (sb.Length >= MaxCapturedOutputChars) return; - var remaining = MaxCapturedOutputChars - sb.Length; - if (text.Length <= remaining) - { - sb.Append(text); - } - else - { - sb.Append(text[..remaining]); - sb.Append("\n[output truncated: capture limit reached; full output was streamed to the tool console]\n"); - } - } - - private async Task CollectResourceStatsAsync( - string containerId, ToolCommand command, CancellationToken ct) - { - long peakMemory = 0; - double maxCpu = 0; - int sampleCount = 0; - long prevCpuTotal = 0; - long prevSystemTotal = 0; - var statsLock = new System.Threading.Lock(); - - try - { - var progress = new StatelessProgress(stats => - { - if (stats.MemoryStats?.Usage > 0) - { - var currentMem = (long)stats.MemoryStats.Usage; - long current; - do { current = Interlocked.Read(ref peakMemory); } - while (currentMem > current && Interlocked.CompareExchange(ref peakMemory, currentMem, current) != current); - } - - if (stats.CPUStats?.CPUUsage?.TotalUsage > 0 && stats.CPUStats?.SystemUsage > 0) - { - var cpuTotal = (long)stats.CPUStats.CPUUsage.TotalUsage; - var systemTotal = (long)stats.CPUStats.SystemUsage; - var onlineCpus = (int)(stats.CPUStats.OnlineCPUs > 0 ? stats.CPUStats.OnlineCPUs : 1); - - lock (statsLock) - { - if (prevCpuTotal > 0 && prevSystemTotal > 0) - { - var cpuDelta = (double)(cpuTotal - prevCpuTotal); - var systemDelta = (double)(systemTotal - prevSystemTotal); - if (systemDelta > 0 && onlineCpus > 0) - { - var cpuPercent = (cpuDelta / systemDelta) * onlineCpus * 100.0; - var currentMax = Volatile.Read(ref maxCpu); - while (cpuPercent > currentMax && Interlocked.CompareExchange(ref maxCpu, cpuPercent, currentMax) != currentMax) - { - currentMax = Volatile.Read(ref maxCpu); - } - } - } - prevCpuTotal = cpuTotal; - prevSystemTotal = systemTotal; - } - } - Interlocked.Increment(ref sampleCount); - }); - - await Client.Containers.GetContainerStatsAsync( - containerId, new ContainerStatsParameters { Stream = true }, progress, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) { /* Ignore */ } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - SdkLog(command, $"[Docker SDK] Stats collection ended: {ex.Message}", RankInfo); - } - - if (Interlocked.CompareExchange(ref sampleCount, 0, 0) == 0) return null; - return new ResourceProfile(Interlocked.Read(ref peakMemory), Math.Round(Volatile.Read(ref maxCpu), 1), sampleCount, false); - } - - private sealed class StatelessProgress(Action handler) : IProgress - { - public void Report(T value) => handler(value); - } - - private async Task<(long exitCode, string output, bool wasCancelled, ResourceProfile? profile)> RunContainerAsync( - CreateContainerParameters createParams, ToolCommand command, CancellationToken ct) - { - var outputBuilder = new StringBuilder(); - var executable = (command.Executable ?? command.ToolName ?? string.Empty).Replace("\r", ""); - long exitCode = -1; - // Written from the cancellation-callback thread and read on the main path; accessed via - // Volatile to establish the cross-thread happens-before the EOF-drain decision relies on. - int wasCancelledFlag = 0; - - // Docker.DotNet's AttachContainerAsync only accepts a write-closable (socket) transport: it - // rejects any hijacked stream whose CanCloseWrite is false with - // NotSupportedException("Cannot shutdown write on this transport"). The Windows named-pipe - // stream reports CanCloseWrite == false, so attach is unusable over npipe — the Docker Desktop - // default endpoint. Since the strategy only ever reads stdout/stderr and never writes stdin, on - // npipe it streams output through the non-hijacked logs-follow endpoint instead: same - // multiplexed framing, no write-close requirement. That stream is opened after the container - // starts, so auto-remove is disabled on this path to stop a fast-exiting container from being - // reaped before its logs drain; it is force-removed explicitly in the finally. - var useLogsStreaming = _daemonUri?.Scheme.Equals("npipe", StringComparison.OrdinalIgnoreCase) == true; - // The reaper's force-remove gate must reflect the user's actual Auto-Remove intent, not the - // daemon-side npipe workaround. Capture it before the override below clobbers HostConfig.AutoRemove - // to false: otherwise an npipe container still tracked at teardown would be stopped but never - // removed, diverging from the socket path which inherits the user's setting verbatim. - var autoRemove = createParams.HostConfig?.AutoRemove ?? true; - if (useLogsStreaming && createParams.HostConfig is not null) - { - createParams.HostConfig.AutoRemove = false; - } - - // Track the container by its unique name BEFORE creating it, closing the window in which the - // container exists on the daemon but is not yet tracked by ID: if the process is torn down in that - // window the exit reaper (CleanupContainers) can still stop/remove it, since Docker accepts a name - // or an ID. Once the ID is tracked, drop the name entry so teardown and the reaper key off the ID. - var containerName = createParams.Name; - var trackByName = !string.IsNullOrEmpty(containerName); - if (trackByName) - { - ActiveContainers.TryAdd(containerName!, autoRemove); - } - - string containerId; - try - { - var container = await Client.Containers.CreateContainerAsync(createParams, ct).ConfigureAwait(false); - containerId = container.ID; - } - catch - { - if (trackByName) - { - ActiveContainers.TryRemove(containerName!, out _); - } - throw; - } - ActiveContainers.TryAdd(containerId, autoRemove); - if (trackByName) - { - ActiveContainers.TryRemove(containerName!, out _); - } - - var cancelRegistration = ct.CanBeCanceled - ? ct.Register(() => - { - Volatile.Write(ref wasCancelledFlag, 1); - try - { - var stopTask = Client.Containers.StopContainerAsync(containerId, - new ContainerStopParameters { WaitBeforeKillSeconds = 2 }); -#pragma warning disable VSTHRD110 - stopTask.ContinueWith(t => - { - if (t.IsFaulted) - ContainerTelemetry.TrackError("DockerExecutionStrategy", - $"Async container stop failed for '{containerId.ShortId()}'", t.Exception?.InnerException); - }, TaskScheduler.Default); -#pragma warning restore VSTHRD110 - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"Cancel-time container stop failed for '{containerId.ShortId()}'", ex); - } - }) - : (CancellationTokenRegistration?)null; - - ResourceProfile? profile = null; - Task? readTask = null; - Task? statsTask = null; - CancellationTokenSource? statsCts = null; - CancellationTokenSource? readCts = null; - // Hoisted out of a using-declaration so it is disposed in the finally AFTER readTask drains; a using - // here would dispose the stream while the read loop could still touch it on an early-throw path. - MultiplexedStream? stream = null; - bool ranToCompletion = false; - - try - { - SdkLog(command, $"[Docker SDK] Spawning {executable} in {createParams.Image}..."); - SdkLog(command, $"[Docker SDK] Command: {string.Join(" ", createParams.Cmd ?? [])}", RankInfo); - - readCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var readToken = readCts.Token; - - var containerStopwatch = Stopwatch.StartNew(); - if (useLogsStreaming) - { - // npipe: no hijacked attach. Start first, then follow the container's log stream. The - // logging driver captures stdout/stderr from process start, so opening the stream after - // the start call loses no output; the framing is identical to a non-TTY attach. - await Client.Containers.StartContainerAsync(containerId, new ContainerStartParameters(), ct).ConfigureAwait(false); - SdkLog(command, $"[Docker SDK] Container {containerId.ShortId()} started.", RankInfo); - stream = await Client.Containers.GetContainerLogsAsync( - containerId, false, - new ContainerLogsParameters { ShowStdout = true, ShowStderr = true, Follow = true, Timestamps = false }, ct).ConfigureAwait(false); - } - else - { - // Socket transports (unix/tcp): attach before start so no early output can be missed. - stream = await Client.Containers.AttachContainerAsync( - containerId, false, - new ContainerAttachParameters { Stream = true, Stdout = true, Stderr = true }, ct).ConfigureAwait(false); - await Client.Containers.StartContainerAsync(containerId, new ContainerStartParameters(), ct).ConfigureAwait(false); - SdkLog(command, $"[Docker SDK] Container {containerId.ShortId()} started.", RankInfo); - } - - readTask = Task.Run(async () => - { - var buffer = new byte[8192]; - var stdoutBuf = new StringBuilder(); - var stderrBuf = new StringBuilder(); - var stdoutDecoder = Encoding.UTF8.GetDecoder(); - var stderrDecoder = Encoding.UTF8.GetDecoder(); - var charBuf = System.Buffers.ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxCharCount(buffer.Length)); - - try - { - while (!readToken.IsCancellationRequested) - { - readToken.ThrowIfCancellationRequested(); - var result = await stream.ReadOutputAsync(buffer, 0, buffer.Length, readToken).ConfigureAwait(false); - if (result.EOF) break; - - int charCount; - if (result.Target == MultiplexedStream.TargetStream.StandardError) - { - charCount = stderrDecoder.GetChars(buffer, 0, result.Count, charBuf, 0, flush: false); - var textSpan = charBuf.AsSpan(0, charCount); - lock (outputBuilder) - { - AppendCapped(outputBuilder, textSpan); - } - DrainLines(stderrBuf, textSpan, command.ErrorHandler); - } - else - { - charCount = stdoutDecoder.GetChars(buffer, 0, result.Count, charBuf, 0, flush: false); - var textSpan = charBuf.AsSpan(0, charCount); - lock (outputBuilder) - { - AppendCapped(outputBuilder, textSpan); - } - DrainLines(stdoutBuf, textSpan, command.OutputHandler); - } - } - } - catch (OperationCanceledException) { /* Ignore */ } - finally - { - // Flush any bytes held back by the decoders (an incomplete trailing multibyte - // sequence at EOF) before returning the rented buffer, so no output is lost. - try - { - var tailOut = stdoutDecoder.GetChars(buffer, 0, 0, charBuf, 0, flush: true); - if (tailOut > 0) - { - var tailSpan = charBuf.AsSpan(0, tailOut); - lock (outputBuilder) { AppendCapped(outputBuilder, tailSpan); } - DrainLines(stdoutBuf, tailSpan, command.OutputHandler); - } - var tailErr = stderrDecoder.GetChars(buffer, 0, 0, charBuf, 0, flush: true); - if (tailErr > 0) - { - var tailSpan = charBuf.AsSpan(0, tailErr); - lock (outputBuilder) { AppendCapped(outputBuilder, tailSpan); } - DrainLines(stderrBuf, tailSpan, command.ErrorHandler); - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - // Best-effort decoder flush; ignore decoding faults on the tail. - } - - System.Buffers.ArrayPool.Shared.Return(charBuf); - if (stdoutBuf.Length > 0) - { - var finalStdout = stdoutBuf.ToString(); - SafeInvoke(() => command.OutputHandler?.Invoke(finalStdout)); - } - if (stderrBuf.Length > 0) - { - var finalStderr = stderrBuf.ToString(); - SafeInvoke(() => command.ErrorHandler?.Invoke(finalStderr)); - } - } - }, CancellationToken.None); - - statsCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - statsTask = CollectResourceStatsAsync(containerId, command, statsCts.Token); - - var logRank = _currentLogLevelRank.Value; - try - { - var wait = await Client.Containers.WaitContainerAsync(containerId, ct).ConfigureAwait(false); - exitCode = wait.StatusCode; - } - catch (OperationCanceledException) - { - Volatile.Write(ref wasCancelledFlag, 1); - if (logRank >= RankErrors) - SafeInvoke(() => command.ErrorHandler?.Invoke("[Docker SDK] Container execution was cancelled.")); - } - - if (statsCts != null) await statsCts.CancelAsync().ConfigureAwait(false); - - // On a normal container exit, drain the attach stream to EOF instead of cancelling - // the read loop immediately — output written just before the container stopped may - // still be buffered in the stream and would otherwise be lost. Only force-cancel the - // read loop on the genuine cancellation/timeout path, or if EOF does not arrive in - // a bounded window. - if (readTask != null) - { - if (Volatile.Read(ref wasCancelledFlag) != 0) - { - if (readCts != null) await readCts.CancelAsync().ConfigureAwait(false); - await readTask.ConfigureAwait(false); - } - else - { - try - { - await readTask.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None).ConfigureAwait(false); - } - catch (TimeoutException) - { - if (readCts != null) await readCts.CancelAsync().ConfigureAwait(false); - await readTask.ConfigureAwait(false); - } - } - } - - try { profile = await statsTask.WaitAsync(TimeSpan.FromSeconds(2), CancellationToken.None).ConfigureAwait(false); } - catch (TimeoutException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", "Resource stats collection timed out", null); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", "Resource stats collection failed", ex); - } - - try - { - var inspect = await Client.Containers.InspectContainerAsync(containerId, ct).ConfigureAwait(false); - if (inspect.State.OOMKilled) - { - // Synthesize a minimal profile when OOM is detected but no stats sample was - // captured (very short-lived container), so the OOM condition is never dropped. - profile = profile is null ? new ResourceProfile(0, 0, 0, true) : profile with { OomKilled = true }; - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - if (exitCode == 137) - { - profile = profile is null ? new ResourceProfile(0, 0, 0, true) : profile with { OomKilled = true }; - } - } - - containerStopwatch.Stop(); - - var peakInfo = profile != null - ? $", peak RAM: {profile.PeakMemoryBytes / (1024 * 1024)} MB, max CPU: {profile.MaxCpuPercent:F1}%" - + (profile.OomKilled ? " (OOM-killed)" : "") - : ""; - SdkLog(command, $"[Docker SDK] Container {containerId.ShortId()} stopped — exit code {exitCode}, ran {containerStopwatch.Elapsed.TotalSeconds:F2}s{peakInfo}.", RankInfo); - ranToCompletion = true; - } - finally - { - // DisposeAsync awaits an in-flight cancellation callback instead of blocking the thread on it. - if (cancelRegistration is { } cancelReg) - { - await cancelReg.DisposeAsync().ConfigureAwait(false); - } - - try { if (readCts != null) { await readCts.CancelAsync().ConfigureAwait(false); readCts.Dispose(); } } catch { /* Ignore */ } - try { if (statsCts != null) { await statsCts.CancelAsync().ConfigureAwait(false); statsCts.Dispose(); } } catch { /* Ignore */ } - - if (readTask != null) - try { await readTask.ConfigureAwait(false); } catch { /* Ignore */ } - - // Dispose the attach stream only after readTask has fully drained, so the read loop never - // touches a disposed stream on the early-throw path (the using-declaration this replaced would - // have disposed it as the try-scope unwound, before this finally awaited readTask). - try { stream?.Dispose(); } catch { /* Ignore */ } - - // Observe statsTask so a late-completing collection cannot fault unobserved. MergeLateResourceProfile - // keeps any earlier capture — in particular the OOM correction the inspect block applied above — and - // adopts the late profile only when nothing was captured yet, so the OOM flag is never clobbered by - // re-awaiting the (already-completed, OomKilled=false) stats task. - if (statsTask != null) - { - try - { - var lateProfile = await statsTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); - profile = MergeLateResourceProfile(profile, lateProfile); - } - catch { /* Best-effort late capture; ignore. */ } - } - - ActiveContainers.TryRemove(containerId, out _); - - // Defense in depth: if auto-remove was requested but the container never reached a - // clean auto-removing exit, force-remove it so it does not linger. This covers two - // paths: (1) start/attach/wait threw before completion; and (2) cancellation/timeout, - // where the wait and inspect OperationCanceledExceptions are caught rather than - // rethrown, so ranToCompletion is still set — without the wasCancelled clause the - // container would be untracked here yet never force-removed, leaking it if the - // fire-and-forget cancel-time stop also failed. Harmless 404 if Docker already reaped - // it. Containers the user explicitly opted to keep (auto-remove off) are left untouched. - // On the npipe logs-streaming path auto-remove was forced off (above) so the log stream - // could drain, so that container is always force-removed here regardless of outcome. - if (useLogsStreaming || (autoRemove && (!ranToCompletion || Volatile.Read(ref wasCancelledFlag) != 0))) - { - try - { - await Client.Containers.RemoveContainerAsync(containerId, - new ContainerRemoveParameters { Force = true }, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - // Already removed, or daemon unreachable — nothing more to do. - } - } - } - - string finalOutput; - lock (outputBuilder) { finalOutput = outputBuilder.ToString(); } - - return (exitCode, finalOutput, Volatile.Read(ref wasCancelledFlag) != 0, profile); - } - - /// - /// The resource profile (peak container memory, max CPU, OOM flag) of the most recently completed - /// container execution. A diagnostic side channel for the benchmark harness, which needs the real - /// in-container memory the stats stream captures; can only - /// return success and output through the IToolExecutionStrategy contract. Not read in - /// production, so the overwrite under concurrent executions is benign. - /// - internal ResourceProfile? LastResourceProfile { get; private set; } + /// + /// The resource profile (peak container memory, max CPU, OOM flag) of the most recently completed + /// container execution. A diagnostic side channel for the benchmark harness, which needs the real + /// in-container memory the stats stream captures; can only + /// return success and output through the IToolExecutionStrategy contract. Not read in + /// production, so the overwrite under concurrent executions is benign. + /// + internal ContainerRunner.ResourceProfile? LastResourceProfile { get; private set; } /// /// Translates a host tool command into an ephemeral container execution payload. @@ -2462,7 +754,7 @@ await Client.Containers.RemoveContainerAsync(containerId, if (IsTargetingEmptyGhdlLibrary(command)) { - SdkLog(command, "[Docker SDK] Bypassing GHDL make/elaboration on empty library targeting to prevent compilation failures.", RankInfo); + _console.SdkLog(command, "[Docker SDK] Bypassing GHDL make/elaboration on empty library targeting to prevent compilation failures.", RankInfo); return (true, string.Empty); } @@ -2473,7 +765,7 @@ await Client.Containers.RemoveContainerAsync(containerId, long exitCode = -1; bool nativeFallbackUsed = false; bool wasCancelled = false; - ResourceProfile? resourceProfile = null; + ContainerRunner.ResourceProfile? resourceProfile = null; var timeoutMinutes = _settingsService.SafeGetSetting(ContainerExtensionModule.TimeoutSetting, 0); if (double.IsNaN(timeoutMinutes) || double.IsInfinity(timeoutMinutes) || timeoutMinutes < 0) @@ -2497,10 +789,11 @@ await Client.Containers.RemoveContainerAsync(containerId, } var ct = cts.Token; - _currentLogLevelRank.Value = LogLevelRank(_settingsService.SafeGetSetting(ContainerExtensionModule.LogLevelSetting, "Errors Only")); - _currentShowTimestamps.Value = _settingsService.SafeGetSetting(ContainerExtensionModule.ShowTimestampsSetting, true); + _console.BeginScope( + _settingsService.SafeGetSetting(ContainerExtensionModule.LogLevelSetting, "Errors Only"), + _settingsService.SafeGetSetting(ContainerExtensionModule.ShowTimestampsSetting, true)); - SdkLog(command, $"[Docker SDK] ExecuteAsync started for '{executable}'.", RankInfo); + _console.SdkLog(command, $"[Docker SDK] ExecuteAsync started for '{executable}'.", RankInfo); string? errorMessage = null; @@ -2522,7 +815,7 @@ await Client.Containers.RemoveContainerAsync(containerId, else if (_daemonUri.Scheme.Equals("unix", StringComparison.OrdinalIgnoreCase)) { var socketPath = _daemonUri.LocalPath; - var (live, socketErr) = await IsUnixSocketLiveAndWritableAsync(socketPath, ct).ConfigureAwait(false); + var (live, socketErr) = await Services.Docker.DaemonEndpointValidator.IsUnixSocketLiveAndWritableAsync(socketPath, ct).ConfigureAwait(false); if (!live) { isDockerOffline = true; @@ -2563,7 +856,7 @@ await Client.Containers.RemoveContainerAsync(containerId, { pipeName = "docker_engine"; } - if (!await VerifyWindowsNamedPipeAsync(pipeName, ct: ct).ConfigureAwait(false)) + if (!await Services.Docker.DaemonEndpointValidator.VerifyWindowsNamedPipeAsync(pipeName, _settingsService.SafeGetSetting(ContainerExtensionModule.BypassNamedPipeCheckSetting, false), ct: ct).ConfigureAwait(false)) { isDockerOffline = true; dockerConnectionEx = new DockerExecutionException($"Insecure or unreachable named pipe connection detected for '{pipeName}'. If this is a false positive, you can bypass this check in OneWare Studio Settings under 'Binary Management' -> 'Container Engine' -> check 'Bypass Named Pipe Security Check'."); @@ -2592,18 +885,18 @@ await Client.Containers.RemoveContainerAsync(containerId, var allowNative = _settingsService.SafeGetSetting(ContainerExtensionModule.AllowNativeFallbackSetting, false); if (allowNative) { - var resolvedPath = FindExecutableInPath(executable); + var resolvedPath = NativeFallbackExecutor.FindExecutableInPath(executable); if (resolvedPath != null) { // ExecuteNativelyAsync logs its own host-native telemetry entry; flag the fallback so // the finally does not also log a phantom container entry (exit -1) for a run that // never happened, nor wipe the real one under retention=None. nativeFallbackUsed = true; - return await ExecuteNativelyAsync(command, resolvedPath, stopwatch, ct).ConfigureAwait(false); + return await _nativeFallback.ExecuteNativelyAsync(command, resolvedPath, stopwatch, ct).ConfigureAwait(false); } else { - SdkLog(command, $"[Docker SDK Fallback Warning] Allow Native Fallback is enabled, but '{executable}' was not found on the host system PATH.", RankInfo); + _console.SdkLog(command, $"[Docker SDK Fallback Warning] Allow Native Fallback is enabled, but '{executable}' was not found on the host system PATH.", RankInfo); } } throw dockerConnectionEx ?? new DockerExecutionException("Docker daemon is offline."); @@ -2615,15 +908,15 @@ await Client.Containers.RemoveContainerAsync(containerId, if (!OperatingSystem.IsWindows()) { - await EnsureUnixIdsLoadedAsync(ct).ConfigureAwait(false); + await Services.Docker.DaemonEndpointValidator.EnsureUnixIdsLoadedAsync(ct).ConfigureAwait(false); } - SdkLog(command, $"[Docker SDK] Resolving image for tool '{executable}'...", RankInfo); + _console.SdkLog(command, $"[Docker SDK] Resolving image for tool '{executable}'...", RankInfo); image = ResolveImage(command.ToolName ?? string.Empty); - SdkLog(command, $"[Docker SDK] Resolved image: {image}", RankInfo); + _console.SdkLog(command, $"[Docker SDK] Resolved image: {image}", RankInfo); - SdkLog(command, $"[Docker SDK] Building container parameters...", RankInfo); + _console.SdkLog(command, $"[Docker SDK] Building container parameters...", RankInfo); var createParams = BuildContainerParameters(image, command); var allowPrivileged = _settingsService.SafeGetSetting(ContainerExtensionModule.AllowPrivilegedSetting, false); @@ -2650,21 +943,21 @@ await Client.Containers.RemoveContainerAsync(containerId, } } - ValidateBinds(createParams.HostConfig?.Binds); + Services.Docker.BindValidator.ValidateBinds(createParams.HostConfig?.Binds); - SdkLog(command, $"[Docker SDK] Cmd = [{string.Join(", ", createParams.Cmd ?? [])}]", RankInfo); - SdkLog(command, $"[Docker SDK] WorkingDir = {createParams.WorkingDir}, Binds = [{string.Join(", ", createParams.HostConfig?.Binds ?? [])}]", RankInfo); + _console.SdkLog(command, $"[Docker SDK] Cmd = [{string.Join(", ", createParams.Cmd ?? [])}]", RankInfo); + _console.SdkLog(command, $"[Docker SDK] WorkingDir = {createParams.WorkingDir}, Binds = [{string.Join(", ", createParams.HostConfig?.Binds ?? [])}]", RankInfo); - SdkLog(command, $"[Docker SDK] Ensuring image '{image}' is available...", RankInfo); - imageDigest = await EnsureImageAsync(image, command, ct).ConfigureAwait(false); - SdkLog(command, $"[Docker SDK] Image ready. Digest = {imageDigest ?? "(none)"}", RankInfo); + _console.SdkLog(command, $"[Docker SDK] Ensuring image '{image}' is available...", RankInfo); + imageDigest = await _runner!.EnsureImageAsync(image, command, ct).ConfigureAwait(false); + _console.SdkLog(command, $"[Docker SDK] Image ready. Digest = {imageDigest ?? "(none)"}", RankInfo); - reconstructedDockerRun = ReconstructDockerRunCommand(createParams); - LastRawDockerRunCommand = ReconstructDockerRunCommand(createParams, maskEnvValues: false); - SdkLog(command, $"[Docker SDK] Equivalent CLI: {reconstructedDockerRun}", RankInfo); + reconstructedDockerRun = Services.Docker.DockerRunCommandFormatter.Reconstruct(createParams, GetRuntimePath()); + LastRawDockerRunCommand = Services.Docker.DockerRunCommandFormatter.Reconstruct(createParams, GetRuntimePath(), maskEnvValues: false); + _console.SdkLog(command, $"[Docker SDK] Equivalent CLI: {reconstructedDockerRun}", RankInfo); - SdkLog(command, $"[Docker SDK] Creating and starting container...", RankInfo); - var result = await RunContainerAsync(createParams, command, ct).ConfigureAwait(false); + _console.SdkLog(command, $"[Docker SDK] Creating and starting container...", RankInfo); + var result = await _runner!.RunContainerAsync(createParams, command, ct).ConfigureAwait(false); exitCode = result.exitCode; wasCancelled = result.wasCancelled; resourceProfile = result.profile; @@ -2684,7 +977,7 @@ await Client.Containers.RemoveContainerAsync(containerId, return (false, result.output); } - SdkLog(command, $"[Docker SDK] Container finished. Exit code: {exitCode}", RankInfo); + _console.SdkLog(command, $"[Docker SDK] Container finished. Exit code: {exitCode}", RankInfo); return (exitCode == 0, result.output); } catch (OperationCanceledException) @@ -2825,504 +1118,4 @@ ex is UnauthorizedAccessException || } } } - - private static readonly System.Threading.Lock WeakProcessLock = new(); - - public WeakReference StartWeakProcess(ToolCommand command) - { - lock (WeakProcessLock) - { - var runCts = new CancellationTokenSource(); - - var dummyProcess = new Process(); - dummyProcess.StartInfo = new ProcessStartInfo - { - FileName = OperatingSystem.IsWindows() ? "ping" : "sleep", - Arguments = OperatingSystem.IsWindows() ? "127.0.0.1 -n 86400" : "86400", - CreateNoWindow = true, - UseShellExecute = false - }; - dummyProcess.EnableRaisingEvents = true; - dummyProcess.Exited += (s, e) => - { - try - { - runCts.Cancel(); - } - catch (ObjectDisposedException) - { - // Exception is intentionally ignored because runCts may have already been disposed when container execution finishes naturally. - } - }; - - try - { - dummyProcess.Start(); - } - catch (Exception ex) - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", "Failed to start dummy process in StartWeakProcess", ex); - // The sentinel never started, so the returned dummy's Exited event can no longer relay a - // kill into runCts. Cancel here so killing the replacement cannot leave the container - // running with its only host cancellation handle severed. - try { runCts.Cancel(); } catch (ObjectDisposedException) { /* already finished */ } - try { dummyProcess.Dispose(); } catch { /* original handle is being discarded */ } - dummyProcess = new Process(); - } - - _ = Task.Run(async () => - { - try - { - await ExecuteAsync(command, runCts.Token).ConfigureAwait(false); - } - catch (Exception ex) - { - try - { - ContainerTelemetry.TrackError("DockerExecutionStrategy", "StartWeakProcess task crashed", ex, command.Executable); - var errMsg = $"[ERROR] Execution of background task '{command.Executable}' failed: {ex.Message}"; - SafeInvoke(() => - { - (command.ErrorHandler ?? command.OutputHandler)?.Invoke(errMsg); - }); - } - catch (Exception) - { - // Exception is intentionally ignored because error handling failure during shutdown/crash is non-critical. - } - } - finally - { - try - { - if (!dummyProcess.HasExited) - { - dummyProcess.Kill(true); - } - } - catch (Exception) - { - // Exception is intentionally ignored because dummy process may have already exited. - } - try - { - runCts.Dispose(); - } - catch (Exception) - { - // Exception is intentionally ignored because token source disposal errors are non-critical. - } - } - }, CancellationToken.None); - - return new WeakReference(dummyProcess); - } - } - - public string GetStrategyName() => "Docker Container (DotNet API)"; - - public string GetStrategyKey() => ToolKey; - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) == 1) - { - return; - } - - try - { - _strategyCts.Cancel(); - _strategyCts.Dispose(); - } - catch - { - // Ignore cancel/dispose errors - } - - var clientForCleanup = _client; - if (clientForCleanup != null && - Interlocked.CompareExchange(ref _staticClientForCleanup, null, clientForCleanup) == clientForCleanup) - { - AppDomain.CurrentDomain.ProcessExit -= CleanupDanglingContainers; - if (_cancelKeyPressHandler != null) - { - try - { - Console.CancelKeyPress -= _cancelKeyPressHandler; - } - catch - { - // Ignore Console unregistration errors on shutdown - } - _cancelKeyPressHandler = null; - } - - // Run dangling-container cleanup with the captured client. The CAS above already - // nulled the static field (so the unregistered ProcessExit handler is a no-op), - // therefore the field-reading handler cannot perform the cleanup here. Guard against - // a ProcessExit firing concurrently, then re-arm so a later strategy instance still - // cleans up on process exit. - if (Interlocked.Exchange(ref _cleanupExecuted, 1) == 0) - { - CleanupContainers(clientForCleanup); - } - Volatile.Write(ref _cleanupExecuted, 0); - } - - _imageManager?.Dispose(); - _containerManager?.Dispose(); - _connectionProvider?.Dispose(); - _client?.Dispose(); - _strategyLock.Dispose(); - ContainerTelemetry.Shutdown(); - } - - public async IAsyncEnumerable StreamContainerLogsAsync(string containerId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - ThrowIfDisposed(); - await EnsureInitializedAsync(ct).ConfigureAwait(false); - await foreach (var log in ContainerManager.StreamContainerLogsAsync(containerId, ct).ConfigureAwait(false)) - { - yield return log; - } - } - - private static string ScrubUserPaths(string? input) - { - if (string.IsNullOrEmpty(input)) - { - return ""; - } - try - { - input = UriCredentialsRegex().Replace(input, "***:***"); - } - catch { /* Ignore regex errors */ } - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (!string.IsNullOrWhiteSpace(home)) - { - input = input.Replace(home, "~", StringComparison.OrdinalIgnoreCase); - } - var user = Environment.UserName; - // Replace the account name only at identifier boundaries with a 3-character floor, mirroring - // ContainerTelemetry.ScrubSensitiveInfo: a bare substring replace corrupts unrelated text such as - // "max-frequency" when the username is short or a common token. Escape the name so it is matched - // literally, never as a regex pattern. - if (!string.IsNullOrWhiteSpace(user) && user.Length >= 3) - { - try - { - input = System.Text.RegularExpressions.Regex.Replace(input, - $@"(? a != null && (a.Equals("-m", StringComparison.Ordinal) || a.Equals("-e", StringComparison.Ordinal) || a.Equals("-r", StringComparison.Ordinal))); - if (!isElabOrMakeOrRun) - { - return false; - } - - string? libraryName = null; - for (int i = 0; i < args.Count; i++) - { - var a = args[i]; - if (a == null) continue; - - if (a.StartsWith("--work=", StringComparison.OrdinalIgnoreCase) || a.StartsWith("-work=", StringComparison.OrdinalIgnoreCase)) - { - var parts = a.Split('=', 2); - if (parts.Length > 1) - { - var val = parts[1].Replace('\\', '/').TrimEnd('/'); - libraryName = Path.GetFileName(val); - } - break; - } - if ((a.Equals("--work", StringComparison.OrdinalIgnoreCase) || a.Equals("-work", StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Count) - { - var val = args[i + 1]?.Replace('\\', '/').TrimEnd('/'); - if (val != null) - { - libraryName = Path.GetFileName(val); - } - break; - } - } - - if (string.IsNullOrWhiteSpace(libraryName)) - { - return false; - } - - string? workdir = null; - for (int i = 0; i < args.Count; i++) - { - var a = args[i]; - if (a == null) continue; - - if (a.StartsWith("--workdir=", StringComparison.OrdinalIgnoreCase)) - { - var parts = a.Split('=', 2); - if (parts.Length > 1) - { - workdir = parts[1]; - } - break; - } - if (a.Equals("-P", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Count) - { - workdir = args[i + 1]; - break; - } - if (a.StartsWith("-P", StringComparison.OrdinalIgnoreCase) && a.Length > 2) - { - workdir = a[2..]; - break; - } - } - - var baseDir = string.IsNullOrWhiteSpace(command.WorkingDirectory) ? Directory.GetCurrentDirectory() : command.WorkingDirectory; - var targetDir = baseDir; - if (!string.IsNullOrWhiteSpace(workdir)) - { - targetDir = Path.IsPathRooted(workdir) ? workdir : Path.GetFullPath(Path.Combine(baseDir, workdir)); - } - - if (!Directory.Exists(targetDir)) - { - return false; - } - - try - { - var files = Directory.GetFiles(targetDir, $"{libraryName}-obj*.cf"); - if (files.Length > 0) - { - bool allEmpty = true; - foreach (var file in files) - { - var info = new FileInfo(file); - if (info.Exists && info.Length > 4) - { - allEmpty = false; - break; - } - } - return allEmpty; - } - } - catch - { - // Fallback - } - - return false; - } - - /// - /// Searches the host system's environment PATH variable to locate the specified executable. - /// Supports relative/absolute path checking and handles Windows-specific file extensions. - /// - /// The file name or path of the executable to search for. - /// The resolved absolute path of the executable if found; otherwise, null. - public static string? FindExecutableInPath(string executable) - { - if (string.IsNullOrWhiteSpace(executable)) return null; - - if (Path.IsPathRooted(executable) || executable.Contains('/') || executable.Contains('\\')) - { - if (File.Exists(executable)) return executable; - return null; - } - - var pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrEmpty(pathEnv)) return null; - - var paths = pathEnv.Split(OperatingSystem.IsWindows() ? ';' : ':'); - string[] extensions = OperatingSystem.IsWindows() ? ["", ".exe", ".bat", ".cmd", ".com"] : [""]; - - foreach (var path in paths) - { - var cleanedPath = path.Trim('\"'); - foreach (var ext in extensions) - { - var fullPath = Path.Combine(cleanedPath, executable + ext); - if (File.Exists(fullPath)) - { - return fullPath; - } - } - } - - return null; - } - - /// - /// Fallback path that runs the tool natively on the host when the Docker daemon is unreachable. - /// Captures stdout and stderr, forwards cancellation to a process kill, and returns the combined output. - /// - /// The tool command payload detailing working directory and arguments. - /// The absolute host file path of the executable binary. - /// The stopwatch tracking elapsed execution duration. - /// The token used to signal operation cancellation. - /// A tuple indicating success status and accumulated terminal output. - private async Task<(bool success, string output)> ExecuteNativelyAsync(ToolCommand command, string resolvedExecutable, Stopwatch stopwatch, CancellationToken ct) - { - var executableName = Path.GetFileNameWithoutExtension(resolvedExecutable); - var args = command.Arguments != null ? string.Join(" ", command.Arguments) : string.Empty; - // Unlike the container path (which rejects a non-absolute working directory because it becomes a - // bind mount), the native fallback runs the tool as a host process, so the current directory is an - // acceptable default when no working directory was supplied. - var workingDir = string.IsNullOrWhiteSpace(command.WorkingDirectory) ? Directory.GetCurrentDirectory() : command.WorkingDirectory; - - SdkLog(command, $"[Docker SDK Fallback] Docker connection failed. Falling back to native execution of '{resolvedExecutable}'...", RankInfo); - SdkLog(command, $"[Docker SDK Fallback] Native command: {resolvedExecutable} {args}", RankInfo); - - var processStartInfo = new ProcessStartInfo - { - FileName = resolvedExecutable, - WorkingDirectory = workingDir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (command.Arguments != null) - { - foreach (var arg in command.Arguments) - { - processStartInfo.ArgumentList.Add(arg); - } - } - - using var process = new Process { StartInfo = processStartInfo }; - var outputBuilder = new StringBuilder(); - - // stdout and stderr fire on separate threadpool threads; StringBuilder is not thread-safe, - // so guard both appends with the same lock the container path uses. - process.OutputDataReceived += (sender, e) => - { - if (e.Data != null) - { - lock (outputBuilder) { AppendCapped(outputBuilder, e.Data); AppendCapped(outputBuilder, "\n"); } - SafeInvoke(() => command.OutputHandler?.Invoke(e.Data)); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data != null) - { - lock (outputBuilder) { AppendCapped(outputBuilder, e.Data); AppendCapped(outputBuilder, "\n"); } - SafeInvoke(() => command.ErrorHandler?.Invoke(e.Data)); - } - }; - - try - { - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using (ct.Register(() => - { - try - { - if (!process.HasExited) - { - process.Kill(true); - } - } - catch - { - // Ignore - } - })) - { - await process.WaitForExitAsync(ct).ConfigureAwait(false); - } - - var success = process.ExitCode == 0; - var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; - SdkLog(command, $"[Docker SDK Fallback] Native execution finished. Exit code: {process.ExitCode} (ran {elapsedSeconds:F2}s)", RankInfo); - - string finalOutput; - lock (outputBuilder) { finalOutput = outputBuilder.ToString(); } - - // Mirror the container path's retention semantics: "Unlimited" (and the opted-out - // "None") map to 0, which disables trimming (maxEntries > 0 gates the trim). A - // numeric value is the entry cap; anything unparseable falls back to 100. - var retentionStr = _settingsService.SafeGetSetting(ContainerExtensionModule.TelemetryRetentionSetting, "25"); - var maxEntries = string.Equals(retentionStr, "Unlimited", StringComparison.Ordinal) ? 0 - : string.Equals(retentionStr, "None", StringComparison.Ordinal) ? 0 - : int.TryParse(retentionStr, out var parsedRetention) ? parsedRetention : 100; - - try - { - ContainerTelemetry.LogExecution( - image: "native-fallback", - tool: executableName, - durationSeconds: elapsedSeconds, - exitCode: process.ExitCode, - imageDigest: "host-native", - wasCancelled: ct.IsCancellationRequested, - dockerRunCommand: $"[Native] {resolvedExecutable} {args}", - maxEntries: maxEntries, - errorMessage: success ? null : "Native fallback execution failed." - ); - } - catch (Exception telemetryEx) when (telemetryEx is not OutOfMemoryException) - { - System.Diagnostics.Debug.WriteLine($"Telemetry logging failed: {telemetryEx.Message}"); - } - - return (success, finalOutput); - } - catch (Exception ex) - { - var errMsg = $"[Docker SDK Fallback Error] Native execution failed for '{resolvedExecutable}': {ex.Message}"; - SafeInvoke(() => command.ErrorHandler?.Invoke(errMsg)); - ContainerTelemetry.TrackError("DockerExecutionStrategy", $"Native fallback execution failed for '{resolvedExecutable}'", ex); - return (false, errMsg); - } - } } diff --git a/src/ContainerExtension/Services/Docker/BindValidator.cs b/src/ContainerExtension/Services/Docker/BindValidator.cs new file mode 100644 index 0000000..c524067 --- /dev/null +++ b/src/ContainerExtension/Services/Docker/BindValidator.cs @@ -0,0 +1,152 @@ +namespace ContainerExtension.Services.Docker; + +/// +/// Validates and canonicalizes Docker bind mounts before they reach the daemon. Rewrites each host +/// path to its symlink-resolved canonical form and blocks mounts of (or under) critical host +/// directories, as well as mappings onto sensitive container paths. This is a security gate: a bind +/// that survives has been proven not to touch a blocked location by either +/// its raw or its canonical form. +/// +internal static class BindValidator +{ + /// + /// Canonicalizes every host path in in place and throws + /// if any bind targets a blocked host or container path. + /// A null list is a no-op. + /// + internal static void ValidateBinds(IList? binds) + { + if (binds == null) return; + + string[] blockedPaths; + if (OperatingSystem.IsWindows()) + { + blockedPaths = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows)), + @"C:\Windows", + @"\\.\pipe" + }; + } + else + { + blockedPaths = new[] + { + "/etc", + "/var/run", + "/var/run/docker.sock", + "/var/run/containerd", + "/proc", + "/sys", + "/dev", + "/boot", + "/bin", + "/sbin", + "/usr/bin", + "/usr/sbin" + }; + } + + // Enforce both the raw and the canonical form of each blocked path. If canonicalization of a + // hardcoded blocked path fails, the raw form is still compared, so the gate cannot be weakened + // by a symlinked or differently-cased equivalent slipping past an un-canonicalized entry. + var blockedForms = new List(blockedPaths.Length * 2); + foreach (var blockedPath in blockedPaths) + { + blockedForms.Add(blockedPath); + try + { + var canonical = PathCanonicalizer.GetCanonicalPath(blockedPath); + if (!string.Equals(canonical, blockedPath, StringComparison.OrdinalIgnoreCase)) + { + blockedForms.Add(canonical); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // The raw form is already enrolled above, so the gate stays effective. + } + } + + for (int i = 0; i < binds.Count; i++) + { + var bind = binds[i]; + if (string.IsNullOrWhiteSpace(bind)) continue; + + // Docker bind spec: HOST:CONTAINER[:OPTIONS]. Split drive-letter-aware so a Windows host + // path such as "C:\proj" is not severed at its drive-letter colon (which would reduce the + // host path to "C" and defeat both the rewrite and the critical-path security checks). + var (hostPart, containerPart, optionsPart) = SplitDockerBind(bind); + var hostPath = hostPart.Trim(); + if (!string.IsNullOrEmpty(hostPath)) + { + string fullPath; + try + { + fullPath = PathCanonicalizer.GetCanonicalPath(hostPath); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + throw new DockerExecutionException($"Invalid mount path: '{hostPath}'. Details: {ex.Message}", ex); + } + + var reconstructed = fullPath; + if (containerPart != null) + { + reconstructed += ":" + containerPart.Trim(); + } + if (optionsPart != null) + { + reconstructed += ":" + optionsPart.Trim(); + } + binds[i] = reconstructed; + + foreach (var blocked in blockedForms) + { + if (string.Equals(fullPath, blocked, StringComparison.OrdinalIgnoreCase)) + { + throw new DockerExecutionException($"Mounting critical host path '{hostPath}' is blocked for security reasons."); + } + + if (fullPath.StartsWith(blocked + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + fullPath.StartsWith(blocked + "/", StringComparison.OrdinalIgnoreCase)) + { + throw new DockerExecutionException($"Mounting paths under critical host directory '{blocked}' is blocked for security reasons."); + } + } + + if (containerPart != null) + { + var containerPath = containerPart.Trim(); + if (containerPath.StartsWith("/sys", StringComparison.OrdinalIgnoreCase) || + containerPath.StartsWith("/proc", StringComparison.OrdinalIgnoreCase) || + containerPath.StartsWith("/dev", StringComparison.OrdinalIgnoreCase) || + containerPath.StartsWith("/etc", StringComparison.OrdinalIgnoreCase)) + { + throw new DockerExecutionException($"Mapping to container path '{containerPath}' is blocked for security reasons."); + } + } + } + } + } + + // Split a Docker bind spec "HOST:CONTAINER[:OPTIONS]" into its components. A leading Windows + // drive-letter colon (e.g. "C:\path") is treated as part of the host path rather than the + // host/container separator. The container path is always POSIX, so it carries no drive letter. + internal static (string host, string? container, string? options) SplitDockerBind(string bind) + { + int hostStart = bind.Length >= 2 && char.IsLetter(bind[0]) && bind[1] == ':' ? 2 : 0; + int firstSep = bind.IndexOf(':', hostStart); + if (firstSep < 0) + { + return (bind, null, null); + } + + var host = bind[..firstSep]; + var remainder = bind[(firstSep + 1)..]; + int secondSep = remainder.IndexOf(':'); + return secondSep < 0 + ? (host, remainder, null) + : (host, remainder[..secondSep], remainder[(secondSep + 1)..]); + } +} diff --git a/src/ContainerExtension/Services/Docker/ContainerReaper.cs b/src/ContainerExtension/Services/Docker/ContainerReaper.cs new file mode 100644 index 0000000..aa8e204 --- /dev/null +++ b/src/ContainerExtension/Services/Docker/ContainerReaper.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Docker.DotNet; +using Docker.DotNet.Models; + +namespace ContainerExtension.Services.Docker; + +/// +/// Tracks live containers and force-reaps them on process exit, Ctrl-C, or strategy disposal, so an abrupt +/// shutdown cannot leave orphaned containers behind. Process-global by nature — a single set of exit +/// handlers and one tracked-container registry — hence static: the first client to +/// becomes the owner and drives teardown. +/// +internal static class ContainerReaper +{ + private static readonly ConcurrentDictionary Active = new(StringComparer.Ordinal); + private static DockerClient? _clientForCleanup; + private static int _cleanupExecuted; + private static ConsoleCancelEventHandler? _cancelKeyPressHandler; + + /// Begin tracking a container (by ID or name) so it is stopped, and optionally removed, on shutdown. + internal static void Track(string idOrName, bool autoRemove) => Active.TryAdd(idOrName, autoRemove); + + /// Stop tracking a container that has already been dealt with. + internal static void Untrack(string idOrName) => Active.TryRemove(idOrName, out _); + + /// + /// Installs process-exit and Ctrl-C reaping for , once per process. Returns + /// true if this call became the owner (installed the handlers); false if another client already owns + /// teardown. + /// + internal static bool TryArm(DockerClient client) + { + if (Interlocked.CompareExchange(ref _clientForCleanup, client, null) is null) + { + AppDomain.CurrentDomain.ProcessExit += OnShutdown; + _cancelKeyPressHandler = (s, e) => OnShutdown(s, e); + Console.CancelKeyPress += _cancelKeyPressHandler; + return true; + } + return false; + } + + /// + /// Disposal counterpart of : if is the current owner, + /// unregisters the handlers and reaps its tracked containers with the still-valid client, then re-enables + /// reaping so a later owner can still clean up on exit. A null or non-owning client is a no-op. + /// + internal static void Disarm(DockerClient? client) + { + if (client != null && + Interlocked.CompareExchange(ref _clientForCleanup, null, client) == client) + { + AppDomain.CurrentDomain.ProcessExit -= OnShutdown; + if (_cancelKeyPressHandler != null) + { + try + { + Console.CancelKeyPress -= _cancelKeyPressHandler; + } + catch + { + // Ignore Console unregistration errors on shutdown + } + _cancelKeyPressHandler = null; + } + + // The CAS above already nulled the owner field, so the unregistered ProcessExit handler is now a + // no-op and cannot perform this cleanup; run it here with the captured client. Guard against a + // ProcessExit firing concurrently, then re-arm so a later strategy instance still cleans up on exit. + if (Interlocked.Exchange(ref _cleanupExecuted, 1) == 0) + { + ReapAll(client); + } + Volatile.Write(ref _cleanupExecuted, 0); + } + } + + private static void OnShutdown(object? sender, EventArgs e) + { + if (Interlocked.Exchange(ref _cleanupExecuted, 1) != 0) + { + return; + } + var client = _clientForCleanup; + if (client == null) + { + return; + } + ReapAll(client); + } + + // Stops and force-removes every tracked container using the supplied client, concurrently, under a + // single shared time budget, rather than blocking the caller (Dispose can run on the UI thread) for up + // to 2 s per container in sequence. + private static void ReapAll(DockerClient client) + { + var keys = Active.Keys; + if (keys.Count == 0) + { + return; + } + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var tasks = new List(); + foreach (var key in keys) + { + if (Active.TryRemove(key, out var shouldAutoRemove)) + { + tasks.Add(StopAndRemoveContainerAsync(client, key, shouldAutoRemove, cts.Token)); + } + } + + try + { + // Synchronous block is intentional: the reaper runs from Dispose and the ProcessExit handler, + // neither of which has an async context to await into. Scope the suppression to this one site. +#pragma warning disable VSTHRD002 + Task.WhenAll(tasks).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + } + catch (Exception) + { + // Best effort on exit + } + } + + private static async Task StopAndRemoveContainerAsync(DockerClient client, string key, bool shouldAutoRemove, CancellationToken ct) + { + try + { + await client.Containers.StopContainerAsync(key, new ContainerStopParameters { WaitBeforeKillSeconds = 1 }, ct).ConfigureAwait(false); + if (shouldAutoRemove) + { + await client.Containers.RemoveContainerAsync(key, new ContainerRemoveParameters { Force = true }, ct).ConfigureAwait(false); + } + } + catch (Exception) + { + // Best effort per container + } + } +} diff --git a/src/ContainerExtension/Services/Docker/ContainerRunner.cs b/src/ContainerExtension/Services/Docker/ContainerRunner.cs new file mode 100644 index 0000000..727375c --- /dev/null +++ b/src/ContainerExtension/Services/Docker/ContainerRunner.cs @@ -0,0 +1,603 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Docker.DotNet; +using Docker.DotNet.Models; +using OneWare.Essentials.Services; +using OneWare.Essentials.ToolEngine; +using static ContainerExtension.Services.Docker.DockerToolConsole; + +namespace ContainerExtension.Services.Docker; + +/// +/// Owns the container run mechanics for a connected daemon: pulling the image per the configured pull +/// policy, creating/starting the container, streaming and capturing stdout/stderr, sampling resource +/// usage, and stopping/removing on completion or cancellation. Extracted from +/// , which prepares the inputs and orchestrates fallback/telemetry +/// around each run. +/// +internal sealed class ContainerRunner +{ + private readonly DockerClient _client; + private readonly ISettingsService _settings; + private readonly DockerToolConsole _console; + private readonly Uri _daemonUri; + + internal ContainerRunner(DockerClient client, ISettingsService settings, DockerToolConsole console, Uri daemonUri) + { + _client = client; + _settings = settings; + _console = console; + _daemonUri = daemonUri; + } + + internal async Task EnsureImageAsync(string image, ToolCommand command, CancellationToken ct) + { + string? imageDigest = null; + var platform = _settings.SafeGetSetting(ContainerExtensionModule.PlatformSetting, "auto")?.Trim(); + var pullPolicy = _settings.SafeGetSetting(ContainerExtensionModule.PullPolicySetting, "if-not-present"); + + bool imageExistsLocally = false; + try + { + var inspectResponse = await _client.Images.InspectImageAsync(image, ct).ConfigureAwait(false); + imageDigest = inspectResponse.ID; + imageExistsLocally = true; + } + catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + imageExistsLocally = false; + } + + bool shouldPull = pullPolicy switch + { + "always" => true, + "never" => false, + _ => !imageExistsLocally + }; + + if (!imageExistsLocally && string.Equals(pullPolicy, "never", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Image '{image}' not found locally and pull policy is 'never'."); + } + + if (shouldPull) + { + _console.SdkLog(command, string.Equals(pullPolicy, "always", StringComparison.Ordinal) && imageExistsLocally + ? $"[Docker SDK] Pull policy 'always' — refreshing '{image}'..." + : $"[Docker SDK] Image '{image}' not found locally. Pulling..."); + + var pullParams = new ImagesCreateParameters { FromImage = image }; + if (!string.IsNullOrWhiteSpace(platform) && !string.Equals(platform, "auto", StringComparison.OrdinalIgnoreCase)) + { + pullParams.Platform = platform; + } + + // The daemon reports registry pull failures as in-band JSON error frames over an HTTP 200 + // response. Capture the first one so the real reason survives to the post-pull check below; + // CreateImageAsync itself returns successfully even when the pull failed. + string? lastPullError = null; + var progressHandler = new Progress(msg => + { + if (msg == null) + { + return; + } + try + { + if (msg.Error != null || !string.IsNullOrEmpty(msg.ErrorMessage)) + { + Volatile.Write(ref lastPullError, msg.ErrorMessage ?? msg.Error?.Message); + } + + var progressText = string.IsNullOrWhiteSpace(msg.ProgressMessage) + ? msg.Status + : $"{msg.Status} {msg.ProgressMessage}"; + + if (!string.IsNullOrWhiteSpace(progressText)) + { + _console.SdkLog(command, $"[Docker Pull] {progressText}"); + } + } + catch (Exception) + { + // Keep the image pull task running through status formatting errors + } + }); + + try + { + try + { + await _client.Images.CreateImageAsync(pullParams, null, progressHandler, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException && !string.IsNullOrWhiteSpace(pullParams.Platform)) + { + _console.SdkLog(command, $"[Docker Pull Warning] Pull failed with platform '{pullParams.Platform}': {ex.Message}. Falling back to default host architecture."); + pullParams.Platform = null; + await _client.Images.CreateImageAsync(pullParams, null, progressHandler, ct).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (imageExistsLocally) + { + _console.SdkLog(command, $"[Docker Pull Warning] Pull failed for '{image}': {ex.Message}. Falling back to cached local version."); + } + else + { + throw; + } + } + + var capturedPullError = Volatile.Read(ref lastPullError); + + // Confirm the image materialized locally. A NotFound here means the pull failed despite + // CreateImageAsync returning normally, unless a cached local copy is being relied upon. + try + { + var postPull = await _client.Images.InspectImageAsync(image, ct).ConfigureAwait(false); + imageDigest = postPull.ID; + } + catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound && !imageExistsLocally) + { + throw new DockerExecutionException( + $"Failed to pull image '{image}': {capturedPullError ?? "image not found on registry."}", ex); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"Post-pull digest inspect failed for '{image}'", ex); + } + + if (capturedPullError == null) + { + _console.SdkLog(command, $"[Docker SDK] Pull complete for '{image}'."); + } + } + + if (imageDigest != null) + { + var shortDigest = imageDigest.ShortId(); + _console.SdkLog(command, $"[Docker SDK] Resolved digest: {shortDigest}..."); + } + + return imageDigest; + } + + internal record ResourceProfile(long PeakMemoryBytes, double MaxCpuPercent, int SampleCount, bool OomKilled); + + // Merge a late-arriving stats profile into whatever the run already captured. An earlier capture always + // wins, so the OOM correction applied after container inspect is never overwritten by the stats sampler + // (which reports OomKilled=false); the late profile is adopted only when nothing was captured yet. + internal static ResourceProfile? MergeLateResourceProfile(ResourceProfile? captured, ResourceProfile? late) + => captured ?? late; + + private async Task CollectResourceStatsAsync( + string containerId, ToolCommand command, CancellationToken ct) + { + long peakMemory = 0; + double maxCpu = 0; + int sampleCount = 0; + long prevCpuTotal = 0; + long prevSystemTotal = 0; + var statsLock = new System.Threading.Lock(); + + try + { + var progress = new StatelessProgress(stats => + { + if (stats.MemoryStats?.Usage > 0) + { + var currentMem = (long)stats.MemoryStats.Usage; + long current; + do { current = Interlocked.Read(ref peakMemory); } + while (currentMem > current && Interlocked.CompareExchange(ref peakMemory, currentMem, current) != current); + } + + if (stats.CPUStats?.CPUUsage?.TotalUsage > 0 && stats.CPUStats?.SystemUsage > 0) + { + var cpuTotal = (long)stats.CPUStats.CPUUsage.TotalUsage; + var systemTotal = (long)stats.CPUStats.SystemUsage; + var onlineCpus = (int)(stats.CPUStats.OnlineCPUs > 0 ? stats.CPUStats.OnlineCPUs : 1); + + lock (statsLock) + { + if (prevCpuTotal > 0 && prevSystemTotal > 0) + { + var cpuDelta = (double)(cpuTotal - prevCpuTotal); + var systemDelta = (double)(systemTotal - prevSystemTotal); + if (systemDelta > 0 && onlineCpus > 0) + { + var cpuPercent = (cpuDelta / systemDelta) * onlineCpus * 100.0; + var currentMax = Volatile.Read(ref maxCpu); + while (cpuPercent > currentMax && Interlocked.CompareExchange(ref maxCpu, cpuPercent, currentMax) != currentMax) + { + currentMax = Volatile.Read(ref maxCpu); + } + } + } + prevCpuTotal = cpuTotal; + prevSystemTotal = systemTotal; + } + } + Interlocked.Increment(ref sampleCount); + }); + + await _client.Containers.GetContainerStatsAsync( + containerId, new ContainerStatsParameters { Stream = true }, progress, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { /* Ignore */ } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _console.SdkLog(command, $"[Docker SDK] Stats collection ended: {ex.Message}", RankInfo); + } + + if (Interlocked.CompareExchange(ref sampleCount, 0, 0) == 0) return null; + return new ResourceProfile(Interlocked.Read(ref peakMemory), Math.Round(Volatile.Read(ref maxCpu), 1), sampleCount, false); + } + + private sealed class StatelessProgress(Action handler) : IProgress + { + public void Report(T value) => handler(value); + } + + internal async Task<(long exitCode, string output, bool wasCancelled, ResourceProfile? profile)> RunContainerAsync( + CreateContainerParameters createParams, ToolCommand command, CancellationToken ct) + { + var outputBuilder = new StringBuilder(); + var executable = (command.Executable ?? command.ToolName ?? string.Empty).Replace("\r", ""); + long exitCode = -1; + // Written from the cancellation-callback thread and read on the main path; accessed via + // Volatile to establish the cross-thread happens-before the EOF-drain decision relies on. + int wasCancelledFlag = 0; + + // Docker.DotNet's AttachContainerAsync only accepts a write-closable (socket) transport: it + // rejects any hijacked stream whose CanCloseWrite is false with + // NotSupportedException("Cannot shutdown write on this transport"). The Windows named-pipe + // stream reports CanCloseWrite == false, so attach is unusable over npipe — the Docker Desktop + // default endpoint. Since the strategy only ever reads stdout/stderr and never writes stdin, on + // npipe it streams output through the non-hijacked logs-follow endpoint instead: same + // multiplexed framing, no write-close requirement. That stream is opened after the container + // starts, so auto-remove is disabled on this path to stop a fast-exiting container from being + // reaped before its logs drain; it is force-removed explicitly in the finally. + var useLogsStreaming = _daemonUri?.Scheme.Equals("npipe", StringComparison.OrdinalIgnoreCase) == true; + // The reaper's force-remove gate must reflect the user's actual Auto-Remove intent, not the + // daemon-side npipe workaround. Capture it before the override below clobbers HostConfig.AutoRemove + // to false: otherwise an npipe container still tracked at teardown would be stopped but never + // removed, diverging from the socket path which inherits the user's setting verbatim. + var autoRemove = createParams.HostConfig?.AutoRemove ?? true; + if (useLogsStreaming && createParams.HostConfig is not null) + { + createParams.HostConfig.AutoRemove = false; + } + + // Track the container by its unique name BEFORE creating it, closing the window in which the + // container exists on the daemon but is not yet tracked by ID: if the process is torn down in that + // window the exit reaper (CleanupContainers) can still stop/remove it, since Docker accepts a name + // or an ID. Once the ID is tracked, drop the name entry so teardown and the reaper key off the ID. + var containerName = createParams.Name; + var trackByName = !string.IsNullOrEmpty(containerName); + if (trackByName) + { + ContainerReaper.Track(containerName!, autoRemove); + } + + string containerId; + try + { + var container = await _client.Containers.CreateContainerAsync(createParams, ct).ConfigureAwait(false); + containerId = container.ID; + } + catch + { + if (trackByName) + { + ContainerReaper.Untrack(containerName!); + } + throw; + } + ContainerReaper.Track(containerId, autoRemove); + if (trackByName) + { + ContainerReaper.Untrack(containerName!); + } + + var cancelRegistration = ct.CanBeCanceled + ? ct.Register(() => + { + Volatile.Write(ref wasCancelledFlag, 1); + try + { + var stopTask = _client.Containers.StopContainerAsync(containerId, + new ContainerStopParameters { WaitBeforeKillSeconds = 2 }); +#pragma warning disable VSTHRD110 + stopTask.ContinueWith(t => + { + if (t.IsFaulted) + ContainerTelemetry.TrackError("DockerExecutionStrategy", + $"Async container stop failed for '{containerId.ShortId()}'", t.Exception?.InnerException); + }, TaskScheduler.Default); +#pragma warning restore VSTHRD110 + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"Cancel-time container stop failed for '{containerId.ShortId()}'", ex); + } + }) + : (CancellationTokenRegistration?)null; + + ResourceProfile? profile = null; + Task? readTask = null; + Task? statsTask = null; + CancellationTokenSource? statsCts = null; + CancellationTokenSource? readCts = null; + // Hoisted out of a using-declaration so it is disposed in the finally AFTER readTask drains; a using + // here would dispose the stream while the read loop could still touch it on an early-throw path. + MultiplexedStream? stream = null; + bool ranToCompletion = false; + + try + { + _console.SdkLog(command, $"[Docker SDK] Spawning {executable} in {createParams.Image}..."); + _console.SdkLog(command, $"[Docker SDK] Command: {string.Join(" ", createParams.Cmd ?? [])}", RankInfo); + + readCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var readToken = readCts.Token; + + var containerStopwatch = Stopwatch.StartNew(); + if (useLogsStreaming) + { + // npipe: no hijacked attach. Start first, then follow the container's log stream. The + // logging driver captures stdout/stderr from process start, so opening the stream after + // the start call loses no output; the framing is identical to a non-TTY attach. + await _client.Containers.StartContainerAsync(containerId, new ContainerStartParameters(), ct).ConfigureAwait(false); + _console.SdkLog(command, $"[Docker SDK] Container {containerId.ShortId()} started.", RankInfo); + stream = await _client.Containers.GetContainerLogsAsync( + containerId, false, + new ContainerLogsParameters { ShowStdout = true, ShowStderr = true, Follow = true, Timestamps = false }, ct).ConfigureAwait(false); + } + else + { + // Socket transports (unix/tcp): attach before start so no early output can be missed. + stream = await _client.Containers.AttachContainerAsync( + containerId, false, + new ContainerAttachParameters { Stream = true, Stdout = true, Stderr = true }, ct).ConfigureAwait(false); + await _client.Containers.StartContainerAsync(containerId, new ContainerStartParameters(), ct).ConfigureAwait(false); + _console.SdkLog(command, $"[Docker SDK] Container {containerId.ShortId()} started.", RankInfo); + } + + readTask = Task.Run(async () => + { + var buffer = new byte[8192]; + var stdoutBuf = new StringBuilder(); + var stderrBuf = new StringBuilder(); + var stdoutDecoder = Encoding.UTF8.GetDecoder(); + var stderrDecoder = Encoding.UTF8.GetDecoder(); + var charBuf = System.Buffers.ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxCharCount(buffer.Length)); + + try + { + while (!readToken.IsCancellationRequested) + { + readToken.ThrowIfCancellationRequested(); + var result = await stream.ReadOutputAsync(buffer, 0, buffer.Length, readToken).ConfigureAwait(false); + if (result.EOF) break; + + int charCount; + if (result.Target == MultiplexedStream.TargetStream.StandardError) + { + charCount = stderrDecoder.GetChars(buffer, 0, result.Count, charBuf, 0, flush: false); + var textSpan = charBuf.AsSpan(0, charCount); + lock (outputBuilder) + { + AppendCapped(outputBuilder, textSpan); + } + DrainLines(stderrBuf, textSpan, command.ErrorHandler); + } + else + { + charCount = stdoutDecoder.GetChars(buffer, 0, result.Count, charBuf, 0, flush: false); + var textSpan = charBuf.AsSpan(0, charCount); + lock (outputBuilder) + { + AppendCapped(outputBuilder, textSpan); + } + DrainLines(stdoutBuf, textSpan, command.OutputHandler); + } + } + } + catch (OperationCanceledException) { /* Ignore */ } + finally + { + // Flush any bytes held back by the decoders (an incomplete trailing multibyte + // sequence at EOF) before returning the rented buffer, so no output is lost. + try + { + var tailOut = stdoutDecoder.GetChars(buffer, 0, 0, charBuf, 0, flush: true); + if (tailOut > 0) + { + var tailSpan = charBuf.AsSpan(0, tailOut); + lock (outputBuilder) { AppendCapped(outputBuilder, tailSpan); } + DrainLines(stdoutBuf, tailSpan, command.OutputHandler); + } + var tailErr = stderrDecoder.GetChars(buffer, 0, 0, charBuf, 0, flush: true); + if (tailErr > 0) + { + var tailSpan = charBuf.AsSpan(0, tailErr); + lock (outputBuilder) { AppendCapped(outputBuilder, tailSpan); } + DrainLines(stderrBuf, tailSpan, command.ErrorHandler); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Best-effort decoder flush; ignore decoding faults on the tail. + } + + System.Buffers.ArrayPool.Shared.Return(charBuf); + if (stdoutBuf.Length > 0) + { + var finalStdout = stdoutBuf.ToString(); + SafeInvoke(() => command.OutputHandler?.Invoke(finalStdout)); + } + if (stderrBuf.Length > 0) + { + var finalStderr = stderrBuf.ToString(); + SafeInvoke(() => command.ErrorHandler?.Invoke(finalStderr)); + } + } + }, CancellationToken.None); + + statsCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + statsTask = CollectResourceStatsAsync(containerId, command, statsCts.Token); + + var logRank = _console.CurrentLevelRank; + try + { + var wait = await _client.Containers.WaitContainerAsync(containerId, ct).ConfigureAwait(false); + exitCode = wait.StatusCode; + } + catch (OperationCanceledException) + { + Volatile.Write(ref wasCancelledFlag, 1); + if (logRank >= RankErrors) + SafeInvoke(() => command.ErrorHandler?.Invoke("[Docker SDK] Container execution was cancelled.")); + } + + if (statsCts != null) await statsCts.CancelAsync().ConfigureAwait(false); + + // On a normal container exit, drain the attach stream to EOF instead of cancelling + // the read loop immediately — output written just before the container stopped may + // still be buffered in the stream and would otherwise be lost. Only force-cancel the + // read loop on the genuine cancellation/timeout path, or if EOF does not arrive in + // a bounded window. + if (readTask != null) + { + if (Volatile.Read(ref wasCancelledFlag) != 0) + { + if (readCts != null) await readCts.CancelAsync().ConfigureAwait(false); + await readTask.ConfigureAwait(false); + } + else + { + try + { + await readTask.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None).ConfigureAwait(false); + } + catch (TimeoutException) + { + if (readCts != null) await readCts.CancelAsync().ConfigureAwait(false); + await readTask.ConfigureAwait(false); + } + } + } + + try { profile = await statsTask.WaitAsync(TimeSpan.FromSeconds(2), CancellationToken.None).ConfigureAwait(false); } + catch (TimeoutException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", "Resource stats collection timed out", null); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", "Resource stats collection failed", ex); + } + + try + { + var inspect = await _client.Containers.InspectContainerAsync(containerId, ct).ConfigureAwait(false); + if (inspect.State.OOMKilled) + { + // Synthesize a minimal profile when OOM is detected but no stats sample was + // captured (very short-lived container), so the OOM condition is never dropped. + profile = profile is null ? new ResourceProfile(0, 0, 0, true) : profile with { OomKilled = true }; + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + if (exitCode == 137) + { + profile = profile is null ? new ResourceProfile(0, 0, 0, true) : profile with { OomKilled = true }; + } + } + + containerStopwatch.Stop(); + + var peakInfo = profile != null + ? $", peak RAM: {profile.PeakMemoryBytes / (1024 * 1024)} MB, max CPU: {profile.MaxCpuPercent:F1}%" + + (profile.OomKilled ? " (OOM-killed)" : "") + : ""; + _console.SdkLog(command, $"[Docker SDK] Container {containerId.ShortId()} stopped — exit code {exitCode}, ran {containerStopwatch.Elapsed.TotalSeconds:F2}s{peakInfo}.", RankInfo); + ranToCompletion = true; + } + finally + { + // DisposeAsync awaits an in-flight cancellation callback instead of blocking the thread on it. + if (cancelRegistration is { } cancelReg) + { + await cancelReg.DisposeAsync().ConfigureAwait(false); + } + + try { if (readCts != null) { await readCts.CancelAsync().ConfigureAwait(false); readCts.Dispose(); } } catch { /* Ignore */ } + try { if (statsCts != null) { await statsCts.CancelAsync().ConfigureAwait(false); statsCts.Dispose(); } } catch { /* Ignore */ } + + if (readTask != null) + try { await readTask.ConfigureAwait(false); } catch { /* Ignore */ } + + // Dispose the attach stream only after readTask has fully drained, so the read loop never + // touches a disposed stream on the early-throw path (the using-declaration this replaced would + // have disposed it as the try-scope unwound, before this finally awaited readTask). + try { stream?.Dispose(); } catch { /* Ignore */ } + + // Observe statsTask so a late-completing collection cannot fault unobserved. MergeLateResourceProfile + // keeps any earlier capture — in particular the OOM correction the inspect block applied above — and + // adopts the late profile only when nothing was captured yet, so the OOM flag is never clobbered by + // re-awaiting the (already-completed, OomKilled=false) stats task. + if (statsTask != null) + { + try + { + var lateProfile = await statsTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); + profile = MergeLateResourceProfile(profile, lateProfile); + } + catch { /* Best-effort late capture; ignore. */ } + } + + ContainerReaper.Untrack(containerId); + + // Defense in depth: if auto-remove was requested but the container never reached a + // clean auto-removing exit, force-remove it so it does not linger. This covers two + // paths: (1) start/attach/wait threw before completion; and (2) cancellation/timeout, + // where the wait and inspect OperationCanceledExceptions are caught rather than + // rethrown, so ranToCompletion is still set — without the wasCancelled clause the + // container would be untracked here yet never force-removed, leaking it if the + // fire-and-forget cancel-time stop also failed. Harmless 404 if Docker already reaped + // it. Containers the user explicitly opted to keep (auto-remove off) are left untouched. + // On the npipe logs-streaming path auto-remove was forced off (above) so the log stream + // could drain, so that container is always force-removed here regardless of outcome. + if (useLogsStreaming || (autoRemove && (!ranToCompletion || Volatile.Read(ref wasCancelledFlag) != 0))) + { + try + { + await _client.Containers.RemoveContainerAsync(containerId, + new ContainerRemoveParameters { Force = true }, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Already removed, or daemon unreachable — nothing more to do. + } + } + } + + string finalOutput; + lock (outputBuilder) { finalOutput = outputBuilder.ToString(); } + + return (exitCode, finalOutput, Volatile.Read(ref wasCancelledFlag) != 0, profile); + } +} diff --git a/src/ContainerExtension/Services/Docker/DaemonEndpointValidator.cs b/src/ContainerExtension/Services/Docker/DaemonEndpointValidator.cs new file mode 100644 index 0000000..24c4ec1 --- /dev/null +++ b/src/ContainerExtension/Services/Docker/DaemonEndpointValidator.cs @@ -0,0 +1,731 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace ContainerExtension.Services.Docker; + +/// +/// Verifies that a Docker daemon endpoint is safe to connect to and probes the host for a usable +/// runtime socket. On Windows it classifies the process behind a named pipe and rejects untrusted pipe +/// squatters; on Unix it probes candidate sockets, checks their ownership, and resolves the current +/// uid/gid used for container --user mapping. Extracted from +/// so these security-critical endpoint checks can be reasoned about and tested in isolation. +/// +internal static partial class DaemonEndpointValidator +{ + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetNamedPipeServerProcessId(Microsoft.Win32.SafeHandles.SafePipeHandle Pipe, out uint ServerProcessId); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool OpenProcessToken(Microsoft.Win32.SafeHandles.SafeProcessHandle ProcessHandle, uint DesiredAccess, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle TokenHandle); + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial Microsoft.Win32.SafeHandles.SafeProcessHandle OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessId); + + [LibraryImport("libc", EntryPoint = "geteuid")] + private static partial uint geteuid(); + + [LibraryImport("libc", EntryPoint = "getegid")] + private static partial uint getegid(); + + private enum PipeServerTrust + { + Untrusted, + CurrentUser, + Elevated, + } + + // Classify the process on the far end of a named pipe. Elevated (SYSTEM / Administrators) is the + // Docker service itself. CurrentUser covers rootless / user-mode runtimes (podman, colima, ssh + // proxies) that legitimately run as the caller, but which a same-user process could also impersonate, + // so the caller gates CurrentUser on a known runtime name rather than trusting it outright. On a query + // failure fail open (Elevated) to match the prior behaviour and avoid breaking connections whose + // identity cannot be read; on an explicit denial fail closed (Untrusted). + private static PipeServerTrust GetPipeServerTrust(uint pid) + { + if (!OperatingSystem.IsWindows()) return PipeServerTrust.Elevated; + const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + const uint TOKEN_QUERY = 0x0008; + + try + { + using var hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (hProcess == null || hProcess.IsInvalid) + { + return PipeServerTrust.Untrusted; // Fail closed + } + + if (OpenProcessToken(hProcess, TOKEN_QUERY, out var hToken)) + { + using (hToken) + { +#pragma warning disable S3869 + using var identity = new System.Security.Principal.WindowsIdentity(hToken.DangerousGetHandle()); +#pragma warning restore S3869 + var principal = new System.Security.Principal.WindowsPrincipal(identity); + bool isAdmin = false; + try + { + isAdmin = principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + } + catch (Exception ex) when (ex is System.Security.SecurityException || ex is UnauthorizedAccessException) + { + isAdmin = false; + } + if (isAdmin || identity.IsSystem) + { + return PipeServerTrust.Elevated; + } + using var currentIdentity = System.Security.Principal.WindowsIdentity.GetCurrent(); + if (identity.User != null && currentIdentity.User != null && identity.User.Equals(currentIdentity.User)) + { + return PipeServerTrust.CurrentUser; + } + return PipeServerTrust.Untrusted; + } + } + } + catch (PlatformNotSupportedException) + { + return PipeServerTrust.Elevated; // Fail-open where identity queries are not supported + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetPipeServerTrust failed for pid {pid}", ex); + } + return PipeServerTrust.Untrusted; + } + + internal static async Task VerifyWindowsNamedPipeAsync(string pipeName, bool bypassCheck, int timeoutMs = 200, CancellationToken ct = default) + { + if (!OperatingSystem.IsWindows()) + { + return true; + } + if (bypassCheck) + { + return true; + } + var connectTime = DateTime.Now; + try + { + using var pipeStream = new System.IO.Pipes.NamedPipeClientStream( + ".", + pipeName, + System.IO.Pipes.PipeDirection.InOut, + System.IO.Pipes.PipeOptions.None, + System.Security.Principal.TokenImpersonationLevel.Identification); + await pipeStream.ConnectAsync(timeoutMs, ct).ConfigureAwait(false); + var safeHandle = pipeStream.SafePipeHandle; + if (safeHandle != null && !safeHandle.IsInvalid) + { + if (GetNamedPipeServerProcessId(safeHandle, out var pid)) + { + System.Diagnostics.Process? process = null; + try + { + process = System.Diagnostics.Process.GetProcessById((int)pid); + } + catch (ArgumentException) + { + return false; + } + catch (PlatformNotSupportedException) + { + return true; // Fail-open on platforms that do not support process by ID lookups + } + + if (process != null) + { + using (process) + { + if (!process.HasExited) + { + try + { + var startTime = process.StartTime; + if (startTime > connectTime.AddMilliseconds(500)) + { + return false; // PID reuse detected: process started after pipe connection + } + + var name = process.ProcessName; + var isNameWhitelisted = name.Contains("docker", StringComparison.OrdinalIgnoreCase) || + name.Contains("podman", StringComparison.OrdinalIgnoreCase) || + name.Contains("wsl", StringComparison.OrdinalIgnoreCase) || + name.Contains("vmmember", StringComparison.OrdinalIgnoreCase) || + name.Contains("win-sshproxy", StringComparison.OrdinalIgnoreCase) || + name.Contains("System", StringComparison.OrdinalIgnoreCase) || + name.Contains("svchost", StringComparison.OrdinalIgnoreCase) || + name.Contains("rancher", StringComparison.OrdinalIgnoreCase) || + name.Contains("lima", StringComparison.OrdinalIgnoreCase) || + name.Contains("com.docker", StringComparison.OrdinalIgnoreCase) || + name.Contains("orbstack", StringComparison.OrdinalIgnoreCase) || + name.Contains("socat", StringComparison.OrdinalIgnoreCase) || + name.Contains("ssh", StringComparison.OrdinalIgnoreCase); + + var trust = GetPipeServerTrust(pid); + if (trust == PipeServerTrust.Elevated) + { + return true; + } + if (trust == PipeServerTrust.CurrentUser) + { + if (isNameWhitelisted) + { + return true; + } + // The whitelist is a gate here, not merely advisory: a current-user + // process whose name matches no known runtime could be a pipe squatter. + ContainerTelemetry.TrackError("DockerExecutionStrategy", + $"Named pipe host process '{name}' (PID: {pid}) runs as the current user but matches no known runtime; refusing the connection.", null); + } + else + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", + $"Named pipe verification failed for pipe '{pipeName}'. Host process: '{name}' (PID: {pid}) is NOT trusted.", null); + } + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 5 || ex.Message.Contains("Access is denied", StringComparison.OrdinalIgnoreCase)) + { + // Name/start-time were unreadable (access denied); fall back to the token + // classification alone, staying lenient (elevated or current-user) as before. + return GetPipeServerTrust(pid) != PipeServerTrust.Untrusted; + } + catch (PlatformNotSupportedException) + { + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + } + } + } + return false; + } + return false; + } + catch (FileNotFoundException) + { + return true; + } + catch (IOException ex) when (ex.InnerException is FileNotFoundException) + { + return true; + } + catch (TimeoutException) + { + return false; + } + catch (IOException) + { + return false; + } + catch + { + return false; + } + } + + private static readonly SemaphoreSlim UnixIdSemaphore = new(1, 1); + private static readonly ConcurrentDictionary OwnerCache = new(StringComparer.Ordinal); + private static volatile string? _cachedUid; + private static volatile string? _cachedGid; + + /// The resolved current-user uid used for container --user mapping, or null if not yet probed. + internal static string? CachedUid => _cachedUid; + + /// The resolved current-user gid used for container --user mapping, or null if not yet probed. + internal static string? CachedGid => _cachedGid; + + internal static async Task EnsureUnixIdsLoadedAsync(CancellationToken ct = default) + { + if (OperatingSystem.IsWindows()) return; + if (_cachedUid != null && _cachedGid != null) return; + + await UnixIdSemaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + _cachedUid ??= await GetUnixIdInternalAsync("-u", "1000", ct).ConfigureAwait(false); + _cachedGid ??= await GetUnixIdInternalAsync("-g", "1000", ct).ConfigureAwait(false); + } + finally + { + UnixIdSemaphore.Release(); + } + } + + internal static async Task<(bool live, string? errorMessage)> IsUnixSocketLiveAndWritableAsync(string path, CancellationToken ct = default) + { + if (!File.Exists(path)) + { + return (false, null); + } + try + { + using var socket = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.Unix, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Unspecified); + var ep = new System.Net.Sockets.UnixDomainSocketEndPoint(path); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(1000); + try + { + await socket.ConnectAsync(ep, timeoutCts.Token).ConfigureAwait(false); + return (true, null); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + return (false, $"Timeout connecting to UNIX socket '{path}'."); + } + } + catch (System.Net.Sockets.SocketException ex) + { + var nativeCode = ex.NativeErrorCode; + var socketCode = ex.SocketErrorCode; + string? errorMessage; + if (socketCode == System.Net.Sockets.SocketError.AccessDenied || + nativeCode == 13 || + nativeCode == 1 || + nativeCode == 10013) + { + errorMessage = $"Access Denied: Current user does not have permission to access socket '{path}'. Ensure correct group membership (e.g. 'docker')."; + } + else if (socketCode == System.Net.Sockets.SocketError.ConnectionRefused || + nativeCode == 111 || + nativeCode == 61) + { + errorMessage = $"Connection Refused: Docker daemon socket at '{path}' is not running or active."; + } + else + { + errorMessage = $"Socket Error ({socketCode}, Native: {nativeCode}): {ex.Message}"; + } + return (false, errorMessage); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return (false, $"Unknown connection failure for socket '{path}': {ex.Message}"); + } + } + +#pragma warning disable S3011 + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075")] + internal sealed class SecureNamedPipeCredentials : global::Docker.DotNet.Credentials + { + private readonly Uri _endpoint; + + public SecureNamedPipeCredentials(Uri endpoint) + { + _endpoint = endpoint; + } + + public override bool IsTlsCredentials() => false; + + public override System.Net.Http.HttpMessageHandler GetHandler(System.Net.Http.HttpMessageHandler innerHandler) + { + if (string.Equals(innerHandler.GetType().FullName, "Microsoft.Net.Http.Client.ManagedHandler", StringComparison.Ordinal)) + { + var field = innerHandler.GetType().GetField("_streamOpener", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (field != null) + { + var delegateType = field.FieldType; + var method = typeof(SecureNamedPipeCredentials).GetMethod(nameof(SecureStreamOpenerAsync), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (method != null) + { + var d = System.Delegate.CreateDelegate(delegateType, this, method); + field.SetValue(innerHandler, d); + } + else + { + // Do not fail open silently: if our own opener cannot be bound, the pipe would be + // dialled without the impersonation cap. Surface it rather than downgrade unseen. + ContainerTelemetry.TrackError("DockerExecutionStrategy", + "SecureNamedPipeCredentials: SecureStreamOpenerAsync not found; named-pipe impersonation cap NOT installed.", null); + } + } + else + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", + "SecureNamedPipeCredentials: ManagedHandler._streamOpener field not found (Docker.DotNet drift); named-pipe impersonation cap NOT installed.", null); + } + } + return innerHandler; + } + +#pragma warning disable S1172 + private async System.Threading.Tasks.Task SecureStreamOpenerAsync(string host, int port, System.Threading.CancellationToken token) + { + var pipeName = _endpoint.LocalPath; + var serverName = "."; + if (pipeName.StartsWith(@"\\", StringComparison.Ordinal)) + { + var parts = pipeName.Split('\\', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) + { + serverName = parts[0]; + pipeName = parts[parts.Length - 1]; + } + } + else + { + if (pipeName.StartsWith("pipe/", StringComparison.OrdinalIgnoreCase)) + { + pipeName = pipeName[5..]; + } + else if (pipeName.StartsWith("/pipe/", StringComparison.OrdinalIgnoreCase)) + { + pipeName = pipeName[6..]; + } + } + + var pipe = new System.IO.Pipes.NamedPipeClientStream( + serverName, + pipeName, + System.IO.Pipes.PipeDirection.InOut, + System.IO.Pipes.PipeOptions.Asynchronous, + System.Security.Principal.TokenImpersonationLevel.Identification); + + try + { + await pipe.ConnectAsync(token).ConfigureAwait(false); + // Verify the server on the SAME handle that carries traffic, not just the throwaway probe + // in VerifyWindowsNamedPipeAsync — otherwise a squatter that lost the probe race could still + // win the data connection. Fail open on any ambiguity (unreadable handle/pid) to match the + // probe's posture; reject only a definitively untrusted server. + if (OperatingSystem.IsWindows() + && pipe.SafePipeHandle is { IsInvalid: false } dataHandle + && GetNamedPipeServerProcessId(dataHandle, out var serverPid) + && GetPipeServerTrust(serverPid) == PipeServerTrust.Untrusted) + { + // The catch below disposes the pipe on the way out. + throw new IOException($"Refusing to use Docker named pipe: data-stream server process (PID {serverPid}) is not trusted."); + } + return pipe; + } + catch + { + await pipe.DisposeAsync().ConfigureAwait(false); + throw; + } + } +#pragma warning restore S1172 + } +#pragma warning restore S3011 + + internal static async Task<(Uri uri, string runtime)> ProbeUnixSocketAsync(CancellationToken ct = default) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + await EnsureUnixIdsLoadedAsync(ct).ConfigureAwait(false); + var uid = _cachedUid ?? "1000"; + + var candidates = new (string path, string name)[] + { + ("/var/run/docker.sock", "docker"), + (Path.Combine(home, ".docker/run/docker.sock"), "docker (user)"), + ($"/run/user/{uid}/podman/podman.sock", "podman"), + (Path.Combine(home, ".colima/default/docker.sock"), "colima"), + (Path.Combine(home, ".local/share/containers/podman/machine/podman.sock"), "podman (machine)"), + (Path.Combine(home, ".orbstack/run/docker.sock"), "orbstack"), + }; + + foreach (var (path, name) in candidates) + { + ct.ThrowIfCancellationRequested(); + if (File.Exists(path)) + { + var owner = await GetUnixFileOwnerAsync(path, ct).ConfigureAwait(false); + if (owner != null && !string.Equals(owner, uid, StringComparison.Ordinal) && !string.Equals(owner, "0", StringComparison.Ordinal)) + { + Console.WriteLine($"[WARN] Insecure socket owner '{owner}' for socket '{path}'. Expected owner {uid} or 0."); + continue; + } + } + ct.ThrowIfCancellationRequested(); + var (live, error) = await IsUnixSocketLiveAndWritableAsync(path, ct).ConfigureAwait(false); + if (live) + { + return (new Uri($"unix://{path}"), RefineRuntimeLabel(path, name)); + } + else if (error != null && error.StartsWith("Access Denied", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"[WARN] {error}"); + } + } + + // If no candidate is active/live, see if any candidate file exists on disk + // Checked in reverse order to prefer specific runtimes (orbstack, colima, podman) over generic defaults. + for (int i = candidates.Length - 1; i >= 0; i--) + { + var (path, name) = candidates[i]; + if (File.Exists(path)) + { + // Re-apply the live-probe ownership gate here too: a socket the probe loop skipped as + // insecurely owned must not be silently re-selected by the file-exists fallback. Null-tolerant + // (stat unavailable / unresolved owner is accepted) to match the probe loop and avoid + // regressing hosts where ownership cannot be determined. + var owner = await GetUnixFileOwnerAsync(path, ct).ConfigureAwait(false); + if (owner != null && !string.Equals(owner, uid, StringComparison.Ordinal) && !string.Equals(owner, "0", StringComparison.Ordinal)) + { + continue; + } + return (new Uri($"unix://{path}"), RefineRuntimeLabel(path, name)); + } + } + + // If files are deleted when offline, check if the parent directories exist (specific to user home) + for (int i = candidates.Length - 1; i >= 0; i--) + { + var (path, name) = candidates[i]; + if (!string.IsNullOrEmpty(home) && path.Contains(home, StringComparison.Ordinal)) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) + { + return (new Uri($"unix://{path}"), name); + } + } + } + + return (new Uri("unix:///var/run/docker.sock"), RefineRuntimeLabel("/var/run/docker.sock", "docker (default)")); + } + + // The probe candidates carry a static label, but /var/run/docker.sock is commonly a symlink into a + // specific runtime's directory (OrbStack, Colima, Podman). Resolve the link chain so DetectedRuntime — + // and thus the dashboard's "Open Desktop" button, title, and offline guidance — names the real runtime + // instead of the generic "docker" the path was merely reached through. + private static string RefineRuntimeLabel(string socketPath, string defaultName) + { + try + { + var resolved = socketPath; + for (int hop = 0; hop < 16; hop++) + { + var target = new FileInfo(resolved).LinkTarget; + if (string.IsNullOrEmpty(target)) break; + resolved = Path.IsPathRooted(target) + ? target + : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(resolved) ?? "/", target)); + } + var r = resolved.Replace('\\', '/'); + if (r.Contains("/.orbstack/", StringComparison.OrdinalIgnoreCase)) return "orbstack"; + if (r.Contains("/.colima/", StringComparison.OrdinalIgnoreCase)) return "colima"; + if (r.Contains("podman", StringComparison.OrdinalIgnoreCase)) return "podman"; + } + catch + { + // Resolution failed (missing file, permission, symlink loop) — fall back to the static label. + } + return defaultName; + } + + // Resolve a Unix system utility to a trusted absolute path instead of a bare name (Sonar S4036). + // A bare "stat"/"id"/"open" is resolved against $PATH, so a writable directory earlier on PATH could + // shadow the real binary. Prefer /usr/bin then /bin (usr-merged on modern Linux; both fixed on macOS). + // On a non-FHS layout where neither exists (e.g. NixOS) return the canonical absolute path anyway, so + // the launch fails cleanly and degrades to the caller's fallback rather than resolving through PATH. + internal static string ResolveTrustedUnixBinary(string name) + { + string[] candidates = [$"/usr/bin/{name}", $"/bin/{name}"]; + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + return candidates[0]; + } + + private static async Task GetUnixFileOwnerAsync(string path, CancellationToken ct = default) + { + if (OperatingSystem.IsWindows()) + { + return null; + } + if (OwnerCache.TryGetValue(path, out var cachedOwner)) + { + return cachedOwner; + } + try + { + var isMac = OperatingSystem.IsMacOS(); + using (var p = new Process()) + { + p.StartInfo = new ProcessStartInfo + { + FileName = ResolveTrustedUnixBinary("stat"), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + if (isMac) + { + p.StartInfo.ArgumentList.Add("-f"); + p.StartInfo.ArgumentList.Add("%u"); + } + else + { + p.StartInfo.ArgumentList.Add("-c"); + p.StartInfo.ArgumentList.Add("%u"); + } + p.StartInfo.ArgumentList.Add(path); + + ct.ThrowIfCancellationRequested(); + p.Start(); + await p.WaitForExitAsync(ct).ConfigureAwait(false); + var output = (await p.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false)).Trim(); + _ = await p.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); + if (p.ExitCode != 0) + { + OwnerCache[path] = null; + return null; + } + if (string.IsNullOrWhiteSpace(output)) + { + OwnerCache[path] = null; + return null; + } + OwnerCache[path] = output; + return output; + } + } + catch (System.ComponentModel.Win32Exception ex) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed with Win32Exception for '{path}'", ex); + return null; + } + catch (IOException ex) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed with IOException for '{path}'", ex); + return null; + } + catch (UnauthorizedAccessException ex) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed with UnauthorizedAccessException for '{path}'", ex); + return null; + } + catch (Exception ex) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"GetUnixFileOwner failed for '{path}'", ex); + return null; + } + } + + private static async Task GetUnixIdInternalAsync(string arg, string fallback, CancellationToken ct) + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + try + { + if (string.Equals(arg, "-u", StringComparison.Ordinal)) + { + return geteuid().ToString(System.Globalization.CultureInfo.InvariantCulture); + } + if (string.Equals(arg, "-g", StringComparison.Ordinal)) + { + return getegid().ToString(System.Globalization.CultureInfo.InvariantCulture); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Fall back + } + } + + Process? p = null; + try + { + p = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ResolveTrustedUnixBinary("id"), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + p.StartInfo.ArgumentList.Add(arg); + p.Start(); + + var readOutTask = p.StandardOutput.ReadToEndAsync(ct); + var readErrTask = p.StandardError.ReadToEndAsync(ct); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(1000); + try + { + await p.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + var id = (await readOutTask.ConfigureAwait(false)).Trim(); + _ = await readErrTask.ConfigureAwait(false); + if (!string.IsNullOrEmpty(id) && int.TryParse(id, out _)) + { + return id; + } + } + catch (OperationCanceledException) + { + try + { + p.Kill(); + await p.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Ignore + } + try + { + await Task.WhenAny(Task.WhenAll(readOutTask, readErrTask), Task.Delay(500, CancellationToken.None)).ConfigureAwait(false); + } + catch + { + // Ignore + } + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"UID/GID probe for '{arg}' timed out", null); + } + } + catch (System.ComponentModel.Win32Exception) + { + // 'id' binary could not be launched on this platform; the caller falls back to the + // default 1000:1000 mapping. + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"UID/GID probe failed for '{arg}'", ex); + } + finally + { + if (p != null) + { + try + { + if (!p.HasExited) + { + p.Kill(); + await p.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + } + catch + { + // Ignore + } + try + { + p.Dispose(); + } + catch + { + // Ignore + } + } + } + return fallback; + } +} diff --git a/src/ContainerExtension/Services/Docker/DockerCommandBuilder.cs b/src/ContainerExtension/Services/Docker/DockerCommandBuilder.cs index 6244c64..da6f3ed 100644 --- a/src/ContainerExtension/Services/Docker/DockerCommandBuilder.cs +++ b/src/ContainerExtension/Services/Docker/DockerCommandBuilder.cs @@ -160,6 +160,15 @@ public static CreateContainerParameters BuildContainerParameters( var executablePath = NormalizeSeparators(rawExePath); var executable = Path.GetFileName(executablePath); + // OneWare hands us the host tool path, which on Windows carries sometimes contains ".exe" suffix + // (e.g. ".../ghdl/bin/ghdl.exe"). The container is always Linux, where the binary is + // named without that suffix, so execve("ghdl.exe", ...) fails with ENOENT (exit 127). + // Strip the Windows executable extension so the container command matches the Linux binary. + if (executable.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + executable = executable[..^4]; + } + // The caller must supply an absolute working directory (the project root). Resolving a relative // or empty value against the process directory would silently mount the plugin's own bin folder // (e.g. "/Debug/net10.0/C:/Users/.../Project:/workspace"), producing a broken bind. Reject it diff --git a/src/ContainerExtension/Services/Docker/DockerConnectionFactory.cs b/src/ContainerExtension/Services/Docker/DockerConnectionFactory.cs new file mode 100644 index 0000000..048c4fc --- /dev/null +++ b/src/ContainerExtension/Services/Docker/DockerConnectionFactory.cs @@ -0,0 +1,240 @@ +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Docker.DotNet; +using OneWare.Essentials.Services; + +namespace ContainerExtension.Services.Docker; + +/// +/// Resolves the Docker daemon endpoint (custom socket / DOCKER_HOST / OS defaults), verifies it, +/// negotiates the Docker API version, and constructs a connected client together with its connection/image/ +/// container managers. Extracted from so daemon bootstrap is a single +/// self-contained step with an explicit result. +/// +internal static partial class DockerConnectionFactory +{ + /// + /// Outcome of a connection attempt. and the managers are non-null only on success; + /// on failure they are null while and still reflect + /// whatever was resolved before the failure, so the dashboard can report the intended runtime/endpoint. + /// + internal sealed record Connection( + string DetectedRuntime, + Uri? DaemonUri, + DockerClient? Client, + DockerConnectionProvider? ConnectionProvider, + DockerImageManager? ImageManager, + DockerContainerManager? ContainerManager); + + [GeneratedRegex(@"^[a-zA-Z0-9][-a-zA-Z0-9.]*(?::\d{1,5})?$", RegexOptions.IgnoreCase | RegexOptions.NonBacktracking, matchTimeoutMilliseconds: 1000)] + private static partial Regex HostOnlyRegex(); + + internal static async Task CreateAsync(ISettingsService settings, CancellationToken ct) + { + string runtime = ""; + Uri? uri = null; + DockerClient? client = null; + DockerConnectionProvider? connectionProvider = null; + try + { + var customSocket = settings.SafeGetSetting(ContainerExtensionModule.DaemonSocketSetting, ""); + var envDockerHost = Environment.GetEnvironmentVariable("DOCKER_HOST"); + + var uriText = !string.IsNullOrWhiteSpace(customSocket) ? customSocket : (!string.IsNullOrWhiteSpace(envDockerHost) ? envDockerHost : null); + var resolved = false; + + if (!string.IsNullOrWhiteSpace(uriText)) + { + try + { + if (uriText.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + bool isLocal = uriText.Contains("localhost", StringComparison.OrdinalIgnoreCase) || + uriText.Contains("127.0.0.1", StringComparison.OrdinalIgnoreCase) || + uriText.Contains("[::1]", StringComparison.Ordinal); + if (!isLocal) + { + await Console.Out.WriteLineAsync("[WARN] Insecure HTTP custom daemon socket requested. Upgrading to https://").ConfigureAwait(false); + uriText = "https" + uriText[4..]; + } + } + + // A Windows device-path pipe (\\.\pipe\) is a valid daemon socket but not a valid + // URI, so new Uri() would throw and the value would silently fall through to the default + // docker_engine pipe below. Convert it to the equivalent npipe URI form so a custom pipe + // is honored. (DaemonSocketValidation accepts the device-path form.) + if (uriText.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase)) + { + uriText = "npipe://./pipe/" + uriText[@"\\.\pipe\".Length..].Replace('\\', '/'); + } + + uri = new Uri(uriText); + if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) + { + bool isLocal = uri.Host != null && ( + uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) || + uri.Host.Equals("::1", StringComparison.Ordinal)); + if (!isLocal) + { + await Console.Out.WriteLineAsync("[WARN] Insecure HTTP custom daemon socket scheme. Upgrading to HTTPS.").ConfigureAwait(false); + uri = new UriBuilder(uri) { Scheme = "https" }.Uri; + } + } + + if (uri.Scheme.Equals("ssh", StringComparison.OrdinalIgnoreCase)) + { + var hostOnly = uri.Host; + if (string.IsNullOrEmpty(hostOnly) || !HostOnlyRegex().IsMatch(hostOnly)) + { + throw new UriFormatException("Insecure or invalid SSH tunnel hostname."); + } + } + + var isNetworkScheme = uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase); + if (isNetworkScheme && uri.Host != null) + { + var hostType = Uri.CheckHostName(uri.Host); + if (hostType == UriHostNameType.Unknown) + { + throw new UriFormatException("Invalid remote daemon hostname."); + } + + if (!uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) && + !uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) && + !uri.Host.Equals("::1", StringComparison.Ordinal)) + { + var warningMsg = $"[SECURITY WARNING] Connecting to a remote Docker daemon at '{uri.Host}'. Outbound traffic may expose credentials."; + await Console.Error.WriteLineAsync(warningMsg).ConfigureAwait(false); + ContainerTelemetry.TrackError("DockerExecutionStrategy", "RemoteDaemonWarning", null, warningMsg); + } + } + runtime = uriText.Contains("podman", StringComparison.OrdinalIgnoreCase) ? "podman" : "docker (custom)"; + resolved = true; + } + catch (UriFormatException) + { + resolved = false; + } + } + else + { + runtime = ""; + } + + if (!resolved) + { + if (OperatingSystem.IsWindows()) + { + uri = new Uri("npipe://./pipe/docker_engine"); + runtime = "docker"; + } + else + { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + (uri, runtime) = await DaemonEndpointValidator.ProbeUnixSocketAsync(probeCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + uri = new Uri("unix:///var/run/docker.sock"); + runtime = "docker (default)"; + ContainerTelemetry.TrackError("DockerExecutionStrategy", "ProbeUnixSocket failed, falling back to default", ex); + } + } + } + + if (uri is null) + { + throw new DockerExecutionException("Could not resolve a Docker daemon URI. Ensure Docker is installed and running, or set the DOCKER_HOST environment variable."); + } + + if (uri.Scheme.Equals("npipe", StringComparison.OrdinalIgnoreCase)) + { + var pipeName = uri.AbsolutePath.TrimStart('/'); + if (pipeName.StartsWith("pipe/", StringComparison.OrdinalIgnoreCase)) + { + pipeName = pipeName[5..]; + } + if (string.IsNullOrEmpty(pipeName)) + { + pipeName = "docker_engine"; + } + if (!await DaemonEndpointValidator.VerifyWindowsNamedPipeAsync(pipeName, settings.SafeGetSetting(ContainerExtensionModule.BypassNamedPipeCheckSetting, false), ct: ct).ConfigureAwait(false)) + { + throw new DockerExecutionException($"Insecure named pipe connection detected for '{pipeName}'. Connection aborted. If this is a false positive, you can bypass this check in OneWare Studio Settings under 'Binary Management' -> 'Container Engine' -> check 'Bypass Named Pipe Security Check'."); + } + } + + using var config = uri.Scheme.Equals("npipe", StringComparison.OrdinalIgnoreCase) + ? new DockerClientConfiguration(uri, new DaemonEndpointValidator.SecureNamedPipeCredentials(uri)) + : new DockerClientConfiguration(uri); + var apiVersion = await NegotiateApiVersionAsync(config, ct).ConfigureAwait(false); + + client = config.CreateClient(apiVersion); + connectionProvider = new DockerConnectionProvider(client); + var imageManager = new DockerImageManager(client, settings); + var containerManager = new DockerContainerManager(client); + + return new Connection(runtime, uri, client, connectionProvider, imageManager, containerManager); + } + catch (Exception ex) + { + connectionProvider?.Dispose(); + client?.Dispose(); + ContainerTelemetry.TrackError("DockerExecutionStrategy", "Asynchronous daemon connection initialization failed", ex); + return new Connection(runtime, uri, null, null, null, null); + } + } + + // Ask the daemon for its API version, bounded by a short timeout, and fall back to a safe default on any + // genuine failure. A cold daemon can need well over the first-connect budget, so this is bounded at 3 s + // (honouring shutdown) rather than misnegotiating a healthy-but-slow daemon down to the fallback version. + private static async Task NegotiateApiVersionAsync(DockerClientConfiguration config, CancellationToken ct) + { + System.Version apiVersion = new System.Version(1, 44); + var tempClient = config.CreateClient(); + try + { + using var verCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + verCts.CancelAfter(TimeSpan.FromSeconds(3)); + var version = await tempClient.System.GetVersionAsync(verCts.Token).ConfigureAwait(false); + var apiVerStr = version?.APIVersion; + if (!string.IsNullOrEmpty(apiVerStr)) + { + int endIdx = 0; + while (endIdx < apiVerStr.Length && (char.IsDigit(apiVerStr[endIdx]) || apiVerStr[endIdx] == '.')) + { + endIdx++; + } + if (System.Version.TryParse(apiVerStr[..endIdx], out var parsedVersion)) + { + apiVersion = parsedVersion; + } + } + } + catch (Exception ex) + { + var isOffline = ex is OperationCanceledException or System.Net.Sockets.SocketException || + ex.InnerException is System.Net.Sockets.SocketException || + (ex is HttpRequestException httpEx && (httpEx.InnerException is System.Net.Sockets.SocketException || httpEx.Message.Contains("connection refused", StringComparison.OrdinalIgnoreCase))); + if (!isOffline) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", "API version negotiation failed; falling back to 1.45", ex); + } + apiVersion = new System.Version(1, 45); + } + finally + { + tempClient.Dispose(); + } + return apiVersion; + } +} diff --git a/src/ContainerExtension/Services/Docker/DockerRunCommandFormatter.cs b/src/ContainerExtension/Services/Docker/DockerRunCommandFormatter.cs new file mode 100644 index 0000000..f930ab7 --- /dev/null +++ b/src/ContainerExtension/Services/Docker/DockerRunCommandFormatter.cs @@ -0,0 +1,194 @@ +using System.Buffers; +using System.Globalization; +using System.Text; +using Docker.DotNet.Models; +using OneWare.Essentials.Services; + +namespace ContainerExtension.Services.Docker; + +/// +/// Renders human-readable docker run command lines. builds a generic +/// template from the current settings (with <tool>/<args> placeholders) for the +/// dashboard; renders the exact command for a specific +/// from a real execution. Environment values are masked unless +/// the caller explicitly opts into the verbatim, clipboard-only form. +/// +internal static class DockerRunCommandFormatter +{ + private const string ContainerWorkDir = "/workspace"; + private static readonly SearchValues ShellSpecialAndWhitespaceChars = SearchValues.Create(";&|<>*?[]{}()$\\'\"#~`! \t\n\r\v\f"); + + /// + /// Builds a generic, copy-pasteable docker run template from the active settings, using + /// <tool>/<args> placeholders in place of a concrete command. + /// + internal static string Generate(ISettingsService settings, string runtimePath, string image) + { + var memMb = settings.SafeGetSetting(ContainerExtensionModule.MemoryLimitSetting, 0.0); + var cpuCores = settings.SafeGetSetting(ContainerExtensionModule.CpuLimitSetting, 0.0); + var network = settings.SafeGetSetting(ContainerExtensionModule.NetworkModeSetting, "bridge"); + var autoRemove = settings.SafeGetSetting(ContainerExtensionModule.AutoRemoveSetting, true); + var platform = settings.SafeGetSetting(ContainerExtensionModule.PlatformSetting, "auto"); + var namePrefix = settings.SafeGetSetting(ContainerExtensionModule.ContainerNamePrefixSetting, "containerextension-"); + var extraFlags = settings.SafeGetSetting(ContainerExtensionModule.ExtraFlagsSetting, ""); + + var sb = new StringBuilder(); + sb.Append(CultureInfo.InvariantCulture, $"{runtimePath} run"); + if (autoRemove) + { + sb.Append(" --rm"); + } + if (!string.IsNullOrWhiteSpace(namePrefix)) + { + sb.Append(CultureInfo.InvariantCulture, $" --name {namePrefix.TrimEnd('-')}--"); + } + sb.Append(CultureInfo.InvariantCulture, $" -v \"$(pwd)\":{ContainerWorkDir} -w {ContainerWorkDir}"); + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + sb.Append(" --user $(id -u):$(id -g)"); + } + if (memMb > 0) + { + sb.Append(CultureInfo.InvariantCulture, $" --memory {memMb:F0}m --memory-swap {memMb:F0}m"); + } + if (cpuCores > 0) + { + sb.Append(CultureInfo.InvariantCulture, $" --cpus {cpuCores:N1}"); + } + sb.Append(" --init"); + if (!string.Equals(network, "bridge", StringComparison.OrdinalIgnoreCase)) + { + sb.Append(CultureInfo.InvariantCulture, $" --network {network}"); + } + if (!string.IsNullOrWhiteSpace(platform) && !string.Equals(platform, "auto", StringComparison.OrdinalIgnoreCase)) + { + sb.Append(CultureInfo.InvariantCulture, $" --platform {platform}"); + } + if (!string.IsNullOrWhiteSpace(extraFlags)) + { + foreach (var flag in extraFlags.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + sb.Append(CultureInfo.InvariantCulture, $" --label {flag}"); + } + } + sb.Append(CultureInfo.InvariantCulture, $" {image} "); + + return sb.ToString(); + } + + /// + /// Renders the exact docker run command corresponding to . When + /// is true (the default, used for anything logged or persisted), + /// environment values are replaced with ********; pass false only for the in-session, + /// clipboard-only verbatim command. + /// + internal static string Reconstruct(CreateContainerParameters p, string runtimePath, bool maskEnvValues = true) + { + var sb = new StringBuilder(); + sb.Append(CultureInfo.InvariantCulture, $"{runtimePath} run"); + + if (p.HostConfig?.AutoRemove == true) + { + sb.Append(" --rm"); + } + if (!string.IsNullOrEmpty(p.Name)) + { + var escapedName = p.Name.Replace("\"", "\\\""); + sb.Append(CultureInfo.InvariantCulture, $" --name \"{escapedName}\""); + } + if (!string.IsNullOrEmpty(p.User)) + { + var escapedUser = p.User.Replace("\"", "\\\""); + sb.Append(CultureInfo.InvariantCulture, $" --user \"{escapedUser}\""); + } + + if (p.HostConfig?.Binds != null) + { + foreach (var bind in p.HostConfig.Binds) + { + var escapedBind = bind.Replace("\"", "\\\"").Replace('\\', '/'); + sb.Append(CultureInfo.InvariantCulture, $" -v \"{escapedBind}\""); + } + } + + if (!string.IsNullOrEmpty(p.WorkingDir)) + { + var escapedWorkingDir = p.WorkingDir.Replace("\"", "\\\""); + sb.Append(CultureInfo.InvariantCulture, $" -w \"{escapedWorkingDir}\""); + } + + if (p.HostConfig?.Memory > 0) + { + sb.Append(CultureInfo.InvariantCulture, $" --memory {p.HostConfig.Memory / (1024 * 1024)}m"); + if (p.HostConfig.MemorySwap == p.HostConfig.Memory) + { + sb.Append(CultureInfo.InvariantCulture, $" --memory-swap {p.HostConfig.MemorySwap / (1024 * 1024)}m"); + } + } + if (p.HostConfig?.NanoCPUs > 0) + { + sb.Append(CultureInfo.InvariantCulture, $" --cpus {p.HostConfig.NanoCPUs / 1_000_000_000.0:N1}"); + } + if (p.HostConfig?.Init == true) + { + sb.Append(" --init"); + } + + if (!string.IsNullOrEmpty(p.HostConfig?.NetworkMode) && + !p.HostConfig.NetworkMode.Equals("bridge", StringComparison.OrdinalIgnoreCase)) + { + var escapedNetworkMode = p.HostConfig.NetworkMode.Replace("\"", "\\\""); + sb.Append(CultureInfo.InvariantCulture, $" --network \"{escapedNetworkMode}\""); + } + + if (p.Env != null) + { + foreach (var env in p.Env) + { + var eqIdx = env.IndexOf('='); + if (eqIdx > 0) + { + // Record the variable NAME only; the value is always masked. This command is + // persisted to the telemetry log, and environment values can carry secrets + // (license keys, tokens) under arbitrary, non-obvious names that a keyword + // denylist cannot catch reliably — so no value is ever written. + var key = env[..eqIdx]; + // Logged/persisted commands always mask the value (it can carry secrets). The in-session + // exact-copy path (maskEnvValues:false) renders the real value for a verbatim, runnable + // command placed only on the clipboard — never written to the telemetry log. + var rendered = maskEnvValues ? $"{key}=********" : env; + var escapedEnv = rendered.Replace("\"", "\\\"", StringComparison.Ordinal); + sb.Append(CultureInfo.InvariantCulture, $" -e \"{escapedEnv}\""); + } + else + { + var escapedEnv = env.Replace("\"", "\\\"", StringComparison.Ordinal); + sb.Append(CultureInfo.InvariantCulture, $" -e \"{escapedEnv}\""); + } + } + } + + sb.Append(CultureInfo.InvariantCulture, $" {p.Image}"); + if (p.Cmd != null) + { + foreach (var arg in p.Cmd) + { + if (string.IsNullOrEmpty(arg)) + { + sb.Append(" \"\""); + } + else if (arg.AsSpan().ContainsAny(ShellSpecialAndWhitespaceChars)) + { + var escapedArg = arg.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + sb.Append(CultureInfo.InvariantCulture, $" \"{escapedArg}\""); + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" {arg}"); + } + } + } + + return sb.ToString(); + } +} diff --git a/src/ContainerExtension/Services/Docker/DockerToolConsole.cs b/src/ContainerExtension/Services/Docker/DockerToolConsole.cs new file mode 100644 index 0000000..0b6dcc9 --- /dev/null +++ b/src/ContainerExtension/Services/Docker/DockerToolConsole.cs @@ -0,0 +1,196 @@ +using System; +using System.Buffers; +using System.Globalization; +using System.Text; +using System.Threading; +using OneWare.Essentials.ToolEngine; + +namespace ContainerExtension.Services.Docker; + +/// +/// Forwards container/tool output to the OneWare tool console with per-execution level gating and optional +/// timestamps, and provides the shared output-handling utilities (UI-thread dispatch, capture-size capping, +/// and newline-delimited line draining). One instance is shared by and +/// the container run loop; the current log level flows via , so a +/// call on the strategy is observed within the same execution. +/// +internal sealed class DockerToolConsole +{ + internal const int RankOff = 0, RankErrors = 1, RankInfo = 2, RankVerbose = 3; + + // Hard cap on the in-memory output string returned to the host. The live stream is still forwarded to the + // tool console in full via the output/error handlers; only the aggregated return value is bounded, so a + // runaway or hostile container cannot exhaust IDE memory. + internal const int MaxCapturedOutputChars = 32 * 1024 * 1024; + + private readonly AsyncLocal _currentLogLevelRank = new(); + private readonly AsyncLocal _currentShowTimestamps = new(); + + internal static int LogLevelRank(string level) => level switch + { + "Verbose" => RankVerbose, + "Info" => RankInfo, + "Errors Only" => RankErrors, + _ => RankOff + }; + + /// Configure the log level and timestamp preference for the current execution flow. + internal void BeginScope(string logLevel, bool showTimestamps) + { + _currentLogLevelRank.Value = LogLevelRank(logLevel); + _currentShowTimestamps.Value = showTimestamps; + } + + internal bool IsLogEnabled(int minRank) => _currentLogLevelRank.Value >= minRank; + + /// The log-level rank in effect for the current execution flow (see the Rank* constants). + internal int CurrentLevelRank => _currentLogLevelRank.Value; + + internal void SdkLog(ToolCommand command, string message, int minRank = RankVerbose) + { + if (IsLogEnabled(minRank)) + { + var line = _currentShowTimestamps.Value + ? string.Create(CultureInfo.InvariantCulture, $"[{DateTime.Now:HH:mm:ss.fff}] {message}") + : message; + SafeInvoke(() => { (command.OutputHandler ?? command.ErrorHandler)?.Invoke(line); }); + } + } + + internal static void SafeInvoke(Action action) + { + if (Avalonia.Application.Current != null) + { + Avalonia.Threading.Dispatcher.UIThread.Post(action); + } + else + { + action(); + } + } + + // Appends to the captured-output buffer up to the cap, then stops after a one-time marker. + // The caller must hold the lock on . + internal static void AppendCapped(StringBuilder sb, ReadOnlySpan text) + { + if (sb.Length >= MaxCapturedOutputChars) return; + var remaining = MaxCapturedOutputChars - sb.Length; + if (text.Length <= remaining) + { + sb.Append(text); + } + else + { + sb.Append(text[..remaining]); + sb.Append("\n[output truncated: capture limit reached; full output was streamed to the tool console]\n"); + } + } + + internal static void DrainLines(StringBuilder buffer, ReadOnlySpan textSpan, Func? handler) + { + if (textSpan.IsEmpty) + { + return; + } + + string[]? batchArray = null; + int batchCount = 0; + + void AddLine(string line) + { + if (handler != null) + { + if (batchArray == null) + { + batchArray = System.Buffers.ArrayPool.Shared.Rent(16); + } + if (batchCount >= batchArray.Length) + { + var newArray = System.Buffers.ArrayPool.Shared.Rent(batchArray.Length * 2); + Array.Copy(batchArray, newArray, batchCount); + System.Buffers.ArrayPool.Shared.Return(batchArray); + batchArray = newArray; + } + batchArray[batchCount++] = line; + } + } + + int start = 0; + while (start < textSpan.Length) + { + int newlineIdx = textSpan[start..].IndexOf('\n'); + if (newlineIdx < 0) + { + break; + } + + int lineEndRelative = newlineIdx; + int absoluteLineEnd = start + lineEndRelative; + + int lineEndTrimmed = absoluteLineEnd; + if (lineEndTrimmed > start && textSpan[lineEndTrimmed - 1] == '\r') + { + lineEndTrimmed--; + } + + string completedLine; + if (buffer.Length > 0) + { + buffer.Append(textSpan[start..lineEndTrimmed]); + completedLine = buffer.ToString(); + buffer.Clear(); + } + else + { + completedLine = textSpan[start..lineEndTrimmed].ToString(); + } + + AddLine(completedLine); + start = absoluteLineEnd + 1; + } + + if (start < textSpan.Length) + { + buffer.Append(textSpan[start..]); + } + + if (batchCount > 0 && batchArray != null) + { + var finalCount = batchCount; + var finalArray = batchArray; + SafeInvoke(() => + { + try + { + for (int idx = 0; idx < finalCount; idx++) + { + try + { + handler!(finalArray[idx]); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + ContainerTelemetry.TrackError("DockerExecutionStrategy", "DrainLines callback handler failed", ex); + } + } + } + finally + { + for (int idx = 0; idx < finalCount; idx++) + { + finalArray[idx] = null!; + } + System.Buffers.ArrayPool.Shared.Return(finalArray); + } + }); + } + + // Defensive OOM Shield: If a container goes rogue and outputs endless text + // without newlines, prevent the StringBuilder from crashing the host IDE. + if (buffer.Length > 8 * 1024 * 1024) // 8 MB limit + { + buffer.Clear(); + ContainerTelemetry.TrackError("DockerExecutionStrategy", "OOM Protection triggered: buffer exceeded 8MB threshold without newlines", null); + } + } +} diff --git a/src/ContainerExtension/Services/Docker/NativeFallbackExecutor.cs b/src/ContainerExtension/Services/Docker/NativeFallbackExecutor.cs new file mode 100644 index 0000000..482a6c1 --- /dev/null +++ b/src/ContainerExtension/Services/Docker/NativeFallbackExecutor.cs @@ -0,0 +1,197 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OneWare.Essentials.Services; +using OneWare.Essentials.ToolEngine; +using static ContainerExtension.Services.Docker.DockerToolConsole; + +namespace ContainerExtension.Services.Docker; + +/// +/// Host-native fallback: locates an executable on PATH and runs the tool directly on the host when the +/// container runtime is unavailable and the user has opted into native fallback. Constructed without a +/// daemon connection so it remains usable precisely when the container path cannot be. +/// +internal sealed class NativeFallbackExecutor +{ + private readonly ISettingsService _settings; + private readonly DockerToolConsole _console; + + internal NativeFallbackExecutor(ISettingsService settings, DockerToolConsole console) + { + _settings = settings; + _console = console; + } + + /// + /// Searches the host system's environment PATH variable to locate the specified executable. + /// Supports relative/absolute path checking and handles Windows-specific file extensions. + /// + /// The file name or path of the executable to search for. + /// The resolved absolute path of the executable if found; otherwise, null. + internal static string? FindExecutableInPath(string executable) + { + if (string.IsNullOrWhiteSpace(executable)) return null; + + if (Path.IsPathRooted(executable) || executable.Contains('/') || executable.Contains('\\')) + { + if (File.Exists(executable)) return executable; + return null; + } + + var pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathEnv)) return null; + + var paths = pathEnv.Split(OperatingSystem.IsWindows() ? ';' : ':'); + string[] extensions = OperatingSystem.IsWindows() ? ["", ".exe", ".bat", ".cmd", ".com"] : [""]; + + foreach (var path in paths) + { + var cleanedPath = path.Trim('\"'); + foreach (var ext in extensions) + { + var fullPath = Path.Combine(cleanedPath, executable + ext); + if (File.Exists(fullPath)) + { + return fullPath; + } + } + } + + return null; + } + + /// + /// Runs the tool natively on the host when the Docker daemon is unreachable. Captures stdout and stderr, + /// forwards cancellation to a process kill, and returns the combined output. + /// + /// The tool command payload detailing working directory and arguments. + /// The absolute host file path of the executable binary. + /// The stopwatch tracking elapsed execution duration. + /// The token used to signal operation cancellation. + /// A tuple indicating success status and accumulated terminal output. + internal async Task<(bool success, string output)> ExecuteNativelyAsync(ToolCommand command, string resolvedExecutable, Stopwatch stopwatch, CancellationToken ct) + { + var executableName = Path.GetFileNameWithoutExtension(resolvedExecutable); + var args = command.Arguments != null ? string.Join(" ", command.Arguments) : string.Empty; + // Unlike the container path (which rejects a non-absolute working directory because it becomes a + // bind mount), the native fallback runs the tool as a host process, so the current directory is an + // acceptable default when no working directory was supplied. + var workingDir = string.IsNullOrWhiteSpace(command.WorkingDirectory) ? Directory.GetCurrentDirectory() : command.WorkingDirectory; + + _console.SdkLog(command, $"[Docker SDK Fallback] Docker connection failed. Falling back to native execution of '{resolvedExecutable}'...", RankInfo); + _console.SdkLog(command, $"[Docker SDK Fallback] Native command: {resolvedExecutable} {args}", RankInfo); + + var processStartInfo = new ProcessStartInfo + { + FileName = resolvedExecutable, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + if (command.Arguments != null) + { + foreach (var arg in command.Arguments) + { + processStartInfo.ArgumentList.Add(arg); + } + } + + using var process = new Process { StartInfo = processStartInfo }; + var outputBuilder = new StringBuilder(); + + // stdout and stderr fire on separate threadpool threads; StringBuilder is not thread-safe, + // so guard both appends with the same lock the container path uses. + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + lock (outputBuilder) { AppendCapped(outputBuilder, e.Data); AppendCapped(outputBuilder, "\n"); } + SafeInvoke(() => command.OutputHandler?.Invoke(e.Data)); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + lock (outputBuilder) { AppendCapped(outputBuilder, e.Data); AppendCapped(outputBuilder, "\n"); } + SafeInvoke(() => command.ErrorHandler?.Invoke(e.Data)); + } + }; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using (ct.Register(() => + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch + { + // Ignore + } + })) + { + await process.WaitForExitAsync(ct).ConfigureAwait(false); + } + + var success = process.ExitCode == 0; + var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; + _console.SdkLog(command, $"[Docker SDK Fallback] Native execution finished. Exit code: {process.ExitCode} (ran {elapsedSeconds:F2}s)", RankInfo); + + string finalOutput; + lock (outputBuilder) { finalOutput = outputBuilder.ToString(); } + + // Mirror the container path's retention semantics: "Unlimited" (and the opted-out + // "None") map to 0, which disables trimming (maxEntries > 0 gates the trim). A + // numeric value is the entry cap; anything unparseable falls back to 100. + var retentionStr = _settings.SafeGetSetting(ContainerExtensionModule.TelemetryRetentionSetting, "25"); + var maxEntries = string.Equals(retentionStr, "Unlimited", StringComparison.Ordinal) ? 0 + : string.Equals(retentionStr, "None", StringComparison.Ordinal) ? 0 + : int.TryParse(retentionStr, out var parsedRetention) ? parsedRetention : 100; + + try + { + ContainerTelemetry.LogExecution( + image: "native-fallback", + tool: executableName, + durationSeconds: elapsedSeconds, + exitCode: process.ExitCode, + imageDigest: "host-native", + wasCancelled: ct.IsCancellationRequested, + dockerRunCommand: $"[Native] {resolvedExecutable} {args}", + maxEntries: maxEntries, + errorMessage: success ? null : "Native fallback execution failed." + ); + } + catch (Exception telemetryEx) when (telemetryEx is not OutOfMemoryException) + { + System.Diagnostics.Debug.WriteLine($"Telemetry logging failed: {telemetryEx.Message}"); + } + + return (success, finalOutput); + } + catch (Exception ex) + { + var errMsg = $"[Docker SDK Fallback Error] Native execution failed for '{resolvedExecutable}': {ex.Message}"; + SafeInvoke(() => command.ErrorHandler?.Invoke(errMsg)); + ContainerTelemetry.TrackError("DockerExecutionStrategy", $"Native fallback execution failed for '{resolvedExecutable}'", ex); + return (false, errMsg); + } + } +} diff --git a/src/ContainerExtension/Views/DockerDiagnosticsView.Helpers.cs b/src/ContainerExtension/Views/DockerDiagnosticsView.Helpers.cs index 6610230..7d15379 100644 --- a/src/ContainerExtension/Views/DockerDiagnosticsView.Helpers.cs +++ b/src/ContainerExtension/Views/DockerDiagnosticsView.Helpers.cs @@ -84,7 +84,7 @@ private static void OpenWithSystemDefault(string path) { if (OperatingSystem.IsMacOS()) { - var psi = new ProcessStartInfo(DockerExecutionStrategy.ResolveTrustedUnixBinary("open")); + var psi = new ProcessStartInfo(ContainerExtension.Services.Docker.DaemonEndpointValidator.ResolveTrustedUnixBinary("open")); // A local file with no default app association (e.g. the .jsonl telemetry log) makes a bare // `open ` exit non-zero and nothing opens. `-t` opens it in the default TEXT editor. // URLs (which do not exist as files) keep the plain `open` so the browser handles them. @@ -101,7 +101,7 @@ private static void OpenWithSystemDefault(string path) } else { - var psi = new ProcessStartInfo(DockerExecutionStrategy.ResolveTrustedUnixBinary("xdg-open")); + var psi = new ProcessStartInfo(ContainerExtension.Services.Docker.DaemonEndpointValidator.ResolveTrustedUnixBinary("xdg-open")); psi.ArgumentList.Add(path); using var _ = Process.Start(psi); } @@ -160,7 +160,7 @@ private static bool LaunchDesktopApp(string runtime) if (OperatingSystem.IsMacOS()) { if (GetMacOsAppBundle(runtime) is not { } bundle) return false; - using var proc = Process.Start(DockerExecutionStrategy.ResolveTrustedUnixBinary("open"), new[] { "-a", bundle }); + using var proc = Process.Start(ContainerExtension.Services.Docker.DaemonEndpointValidator.ResolveTrustedUnixBinary("open"), new[] { "-a", bundle }); if (proc is null) return false; // `open` exits non-zero when the app bundle is not found; a still-running process // after the grace period means it launched. diff --git a/src/ContainerExtension/Views/DockerDiagnosticsView.cs b/src/ContainerExtension/Views/DockerDiagnosticsView.cs index 8c7154d..49c1100 100644 --- a/src/ContainerExtension/Views/DockerDiagnosticsView.cs +++ b/src/ContainerExtension/Views/DockerDiagnosticsView.cs @@ -51,7 +51,10 @@ public partial class DockerDiagnosticsView : UserControl // Prepended to every command injected into the interactive terminal so a non-empty input line (e.g. a // half-typed "v") cannot corrupt it into "vdocker ...". Ctrl-E moves the cursor to the end of the line // and Ctrl-U discards the whole line, leaving a clean prompt before the command is typed. - private const string TerminalLineReset = "\u0005\u0015"; + // These are readline (bash/zsh) line-editing keys: PowerShell and cmd on Windows do not interpret them + // and would echo them literally, turning "docker ..." into "^E^Udocker ..." (command not found), so the + // reset is suppressed on Windows. + private static readonly string TerminalLineReset = OperatingSystem.IsWindows() ? string.Empty : "\u0005\u0015"; // The project's toolchain image is produced locally (Build Local Image / build_oss_cad_suite.sh) and is // NOT published to a registry, so Pull / Check-for-Updates cannot fetch it. Used to redirect those diff --git a/tests/ContainerExtension.UnitTests/BindValidatorTests.cs b/tests/ContainerExtension.UnitTests/BindValidatorTests.cs new file mode 100644 index 0000000..c1e6223 --- /dev/null +++ b/tests/ContainerExtension.UnitTests/BindValidatorTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ContainerExtension; +using ContainerExtension.Services.Docker; +using Xunit; + +namespace ContainerExtension.UnitTests; + +/// +/// Coverage for : rejection of critical host/container mount targets and +/// in-place canonicalization of benign binds. Reached directly through InternalsVisibleTo. +/// +public sealed class BindValidatorTests +{ + [Theory] + [InlineData("/etc")] + [InlineData("/proc")] + [InlineData("/sys")] + public void ValidateBinds_RejectsCriticalHostMounts(string hostPath) + { + if (OperatingSystem.IsWindows()) + { + return; // The blocked-path set differs on Windows; these POSIX roots do not apply. + } + var binds = new List { $"{hostPath}:/workspace:ro" }; + Assert.Throws(() => BindValidator.ValidateBinds(binds)); + } + + [Fact] + public void ValidateBinds_RewritesBenignBindToCanonicalForm() + { + var tempDir = Path.Combine(Path.GetTempPath(), "BindTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var canonical = PathCanonicalizer.GetCanonicalPath(tempDir); + + var binds = new List { $"{tempDir}:/workspace:rw" }; + BindValidator.ValidateBinds(binds); + Assert.Equal($"{canonical}:/workspace:rw", binds[0]); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { /* best-effort teardown */ } + } + } + + [Fact] + public void ValidateBinds_NullList_NoOp() + { + BindValidator.ValidateBinds(null); + } +} diff --git a/tests/ContainerExtension.UnitTests/ContainerExtensionTests.cs b/tests/ContainerExtension.UnitTests/ContainerExtensionTests.cs index 89fd3bb..8b81822 100644 --- a/tests/ContainerExtension.UnitTests/ContainerExtensionTests.cs +++ b/tests/ContainerExtension.UnitTests/ContainerExtensionTests.cs @@ -365,7 +365,7 @@ public void DrainLines_SingleLine_InvokedWithoutNewline() { var buffer = new StringBuilder(); var lines = new List(); - DockerExecutionStrategy.DrainLines(buffer, "hello\n", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, "hello\n", s => { lines.Add(s); return true; }); Assert.Single(lines); Assert.Equal("hello", lines[0]); Assert.Equal(0, buffer.Length); @@ -376,7 +376,7 @@ public void DrainLines_MultiLine_SplitsCorrectly() { var buffer = new StringBuilder(); var lines = new List(); - DockerExecutionStrategy.DrainLines(buffer, "line1\nline2\nline3\n", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, "line1\nline2\nline3\n", s => { lines.Add(s); return true; }); Assert.Equal(3, lines.Count); Assert.Equal("line1", lines[0]); Assert.Equal("line2", lines[1]); @@ -389,11 +389,11 @@ public void DrainLines_CarryOver_BuffersIncompleteLines() var buffer = new StringBuilder(); var lines = new List(); - DockerExecutionStrategy.DrainLines(buffer, "partial", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, "partial", s => { lines.Add(s); return true; }); Assert.Empty(lines); Assert.Equal("partial", buffer.ToString()); - DockerExecutionStrategy.DrainLines(buffer, " line\n", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, " line\n", s => { lines.Add(s); return true; }); Assert.Single(lines); Assert.Equal("partial line", lines[0]); } @@ -403,7 +403,7 @@ public void DrainLines_CrLf_StripsCarriageReturn() { var buffer = new StringBuilder(); var lines = new List(); - DockerExecutionStrategy.DrainLines(buffer, "windows\r\n", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, "windows\r\n", s => { lines.Add(s); return true; }); Assert.Single(lines); Assert.Equal("windows", lines[0]); } @@ -412,7 +412,7 @@ public void DrainLines_CrLf_StripsCarriageReturn() public void DrainLines_NullHandler_DoesNotThrow() { var buffer = new StringBuilder(); - var ex = Record.Exception(() => DockerExecutionStrategy.DrainLines(buffer, "text\n", null)); + var ex = Record.Exception(() => DockerToolConsole.DrainLines(buffer, "text\n", null)); Assert.Null(ex); } @@ -421,7 +421,7 @@ public void DrainLines_EmptyInput_NoInvocation() { var buffer = new StringBuilder(); var lines = new List(); - DockerExecutionStrategy.DrainLines(buffer, "", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, "", s => { lines.Add(s); return true; }); Assert.Empty(lines); } @@ -430,7 +430,7 @@ public void DrainLines_NoTrailingNewline_BufferedNotEmitted() { var buffer = new StringBuilder(); var lines = new List(); - DockerExecutionStrategy.DrainLines(buffer, "no newline", s => { lines.Add(s); return true; }); + DockerToolConsole.DrainLines(buffer, "no newline", s => { lines.Add(s); return true; }); Assert.Empty(lines); Assert.Equal("no newline", buffer.ToString()); } @@ -2581,7 +2581,7 @@ await Assert.ThrowsAnyAsync(async () => [Fact] public void FindExecutableInPath_ResolvesGitOnSystem() { - var gitPath = DockerExecutionStrategy.FindExecutableInPath("git"); + var gitPath = NativeFallbackExecutor.FindExecutableInPath("git"); Assert.NotNull(gitPath); Assert.True(File.Exists(gitPath)); } @@ -2717,12 +2717,8 @@ public void GetCanonicalPath_CircularSymlink_ThrowsDockerExecutionException() File.CreateSymbolicLink(pathA, pathB); File.CreateSymbolicLink(pathB, pathA); - var method = typeof(DockerExecutionStrategy).GetMethod("GetCanonicalPath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - Assert.NotNull(method); - - var ex = Assert.Throws(() => method.Invoke(null, new object[] { pathA })); - Assert.IsType(ex.InnerException); - Assert.Contains("Circular", ex.InnerException.Message, StringComparison.OrdinalIgnoreCase); + var ex = Assert.Throws(() => PathCanonicalizer.GetCanonicalPath(pathA)); + Assert.Contains("Circular", ex.Message, StringComparison.OrdinalIgnoreCase); } } finally @@ -2738,15 +2734,12 @@ public void GetCanonicalPath_NonExistentSuffix_ResolvesLongestAncestor() Directory.CreateDirectory(tempDir); try { - var method = typeof(DockerExecutionStrategy).GetMethod("GetCanonicalPath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - Assert.NotNull(method); - - var canonicalTempDir = (string)method.Invoke(null, new object[] { tempDir })!; + var canonicalTempDir = PathCanonicalizer.GetCanonicalPath(tempDir); var testPath = Path.Combine(tempDir, "nonexistent", "subdir"); var expectedPath = Path.Combine(canonicalTempDir, "nonexistent", "subdir"); - var result = (string)method.Invoke(null, new object[] { testPath })!; + var result = PathCanonicalizer.GetCanonicalPath(testPath); Assert.Equal(expectedPath, result); } finally @@ -2767,11 +2760,8 @@ public void GetCanonicalPath_SymlinkTraversalBypass_FailsToCanonicalizeCorrectly { File.CreateSymbolicLink(symlinkPath, "/private"); - var method = typeof(DockerExecutionStrategy).GetMethod("GetCanonicalPath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - Assert.NotNull(method); - var inputPath = Path.Combine(tempDir, "symlink_to_private", "..", "etc"); - var result = (string)method.Invoke(null, new object[] { inputPath })!; + var result = PathCanonicalizer.GetCanonicalPath(inputPath); // The actual OS path of inputPath is /private/etc (because symlink_to_private points to /private, and its parent is /) // But if the resolver resolves it textually first, it returns tempDir/etc. diff --git a/tests/ContainerExtension.UnitTests/ContainerRunSmokeTests.cs b/tests/ContainerExtension.UnitTests/ContainerRunSmokeTests.cs new file mode 100644 index 0000000..82ff4fa --- /dev/null +++ b/tests/ContainerExtension.UnitTests/ContainerRunSmokeTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using ContainerExtension; +using OneWare.Essentials.ToolEngine; +using Xunit; + +namespace ContainerExtension.UnitTests; + +/// +/// Lightweight real-container smoke test. Runs echo in the tiny, commonly-cached busybox +/// image through the full path against the +/// local Docker daemon. Unlike the HDL end-to-end suite it needs no fixtures and no image pull, so it can +/// validate the container run loop anywhere Docker is available — which makes it a fast regression anchor +/// for refactors of the execution engine. +/// +[Collection("TelemetryTests")] +public sealed class ContainerRunSmokeTests : IDisposable +{ + private readonly string _telemetryDir; + + public ContainerRunSmokeTests() + { + _telemetryDir = Path.Combine(Path.GetTempPath(), "OneWareTests_Smoke", Guid.NewGuid().ToString("N")); + ContainerTelemetry.InitializeTestEnvironment(_telemetryDir); + ContainerTelemetry.LogLevelChecker = () => "Verbose"; + } + + public void Dispose() + { + try + { + ContainerTelemetry.Shutdown(); + if (Directory.Exists(_telemetryDir)) + { + Directory.Delete(_telemetryDir, true); + } + } + catch { /* best effort */ } + } + + [FactIfNoCI] + public async Task Busybox_Echo_RunsInContainerAndCapturesOutput() + { + using var provider = new E2ETestServiceProvider(); + provider.SettingsService.SetSettingValue(ContainerExtensionModule.DefaultImageSetting, "busybox:latest"); + provider.SettingsService.SetSettingValue(ContainerExtensionModule.PullPolicySetting, "never"); + provider.SettingsService.SetSettingValue(ContainerExtensionModule.BypassNamedPipeCheckSetting, true); + + using var strategy = new DockerExecutionStrategy(provider); + var command = new ToolCommand + { + Executable = "echo", + ToolName = "echo", + WorkingDirectory = Path.GetTempPath(), + CommandArguments = new List { new E2ETestCommandArgument("hello-from-container") } + }; + + var (success, output) = await strategy.ExecuteAsync(command); + + Assert.True(success, $"expected container run to succeed; output was: {output}"); + Assert.Contains("hello-from-container", output, StringComparison.Ordinal); + } +} diff --git a/tests/ContainerExtension.UnitTests/DaemonEndpointValidatorTests.cs b/tests/ContainerExtension.UnitTests/DaemonEndpointValidatorTests.cs new file mode 100644 index 0000000..4c5caf8 --- /dev/null +++ b/tests/ContainerExtension.UnitTests/DaemonEndpointValidatorTests.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using ContainerExtension.Services.Docker; +using Xunit; + +namespace ContainerExtension.UnitTests; + +/// +/// Coverage for . Focuses on the absolute-path resolution of +/// system utilities that defeats PATH hijacking; the named-pipe/socket trust checks require a live +/// endpoint and are exercised by the integration and hardening-challenge suites. +/// +public sealed class DaemonEndpointValidatorTests +{ + [Theory] + [InlineData("stat")] + [InlineData("id")] + [InlineData("open")] + [InlineData("xdg-open")] + public void ResolveTrustedUnixBinary_ReturnsRootedAbsolutePath(string name) + { + var resolved = DaemonEndpointValidator.ResolveTrustedUnixBinary(name); + Assert.True(Path.IsPathRooted(resolved), $"'{resolved}' must be absolute, never resolved via PATH"); + Assert.EndsWith("/" + name, resolved, StringComparison.Ordinal); + } + + [Fact] + public void ResolveTrustedUnixBinary_PrefersAnExistingTrustedLocation() + { + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsLinux()) + { + return; // POSIX-only: /usr/bin or /bin + } + var resolved = DaemonEndpointValidator.ResolveTrustedUnixBinary("stat"); + // stat is a coreutils/BSD staple present on every supported POSIX host. + Assert.True(File.Exists(resolved), $"expected a real binary at '{resolved}'"); + } +} diff --git a/tests/ContainerExtension.UnitTests/DockerExecutionE2ETests.cs b/tests/ContainerExtension.UnitTests/DockerExecutionE2ETests.cs index 3211744..5034ce8 100644 --- a/tests/ContainerExtension.UnitTests/DockerExecutionE2ETests.cs +++ b/tests/ContainerExtension.UnitTests/DockerExecutionE2ETests.cs @@ -1269,7 +1269,7 @@ public void F5_Telemetry_OOMDetection_Boundary() buffer.Append(new string('a', 9 * 1024 * 1024)); // > 8MB ContainerTelemetry.ClearEntries(); - DockerExecutionStrategy.DrainLines(buffer, "a", _ => true); // textSpan must not be empty to trigger OOM shield + DockerToolConsole.DrainLines(buffer, "a", _ => true); // textSpan must not be empty to trigger OOM shield Assert.Equal(0, buffer.Length); // should be cleared } @@ -1308,24 +1308,6 @@ public void F5_Telemetry_ClearReset_Boundary() Assert.Equal(0, total); } - [FactIfNoCI] - public void F5_Telemetry_CommandTracing_Boundary() - { - using var provider = new E2ETestServiceProvider(); - provider.SettingsService.SetSettingValue(ContainerExtensionModule.ExtraFlagsSetting, "--label custom=val"); - - using var strategy = new DockerExecutionStrategy(provider); - var cmd = new ToolCommand - { - Executable = "ghdl", - ToolName = "ghdl", - WorkingDirectory = "/dummy", - CommandArguments = BuildArgs("-a", "file.vhd") - }; - - var runCommand = strategy.GenerateDockerRunCommand(); - Assert.Contains("--label custom=val", runCommand); - } [FactIfNoCI] public void F6_Diagnostics_InvalidImageFormat_Boundary() diff --git a/tests/ContainerExtension.UnitTests/DockerRunCommandFormatterTests.cs b/tests/ContainerExtension.UnitTests/DockerRunCommandFormatterTests.cs new file mode 100644 index 0000000..106714e --- /dev/null +++ b/tests/ContainerExtension.UnitTests/DockerRunCommandFormatterTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using ContainerExtension.Services.Docker; +using Docker.DotNet.Models; +using Xunit; + +namespace ContainerExtension.UnitTests; + +/// +/// Daemon-free coverage for : the settings-driven template and +/// the exact-command reconstruction, including the environment-value masking that keeps secrets out of +/// the persisted telemetry log. +/// +public sealed class DockerRunCommandFormatterTests +{ + [Fact] + public void Generate_RendersExtraFlagsAsLabels() + { + var settings = new MockSettingsService(); + settings.SetSettingValue(ContainerExtensionModule.ExtraFlagsSetting, "--label custom=val"); + + var runCommand = DockerRunCommandFormatter.Generate(settings, "docker", "myimage"); + + Assert.StartsWith("docker run", runCommand); + Assert.Contains("--label custom=val", runCommand); + Assert.Contains("myimage ", runCommand); + } + + [Fact] + public void Reconstruct_MasksEnvValuesByDefault() + { + var p = new CreateContainerParameters + { + Image = "myimage", + Env = new List { "LICENSE_KEY=super-secret-value" }, + }; + + var masked = DockerRunCommandFormatter.Reconstruct(p, "docker"); + + Assert.Contains("LICENSE_KEY=********", masked); + Assert.DoesNotContain("super-secret-value", masked); + } + + [Fact] + public void Reconstruct_RendersRealEnvValuesWhenMaskingDisabled() + { + var p = new CreateContainerParameters + { + Image = "myimage", + Env = new List { "LICENSE_KEY=super-secret-value" }, + }; + + var verbatim = DockerRunCommandFormatter.Reconstruct(p, "docker", maskEnvValues: false); + + Assert.Contains("LICENSE_KEY=super-secret-value", verbatim); + } + + [Fact] + public void Reconstruct_QuotesArgumentsWithShellMetacharacters() + { + var p = new CreateContainerParameters + { + Image = "myimage", + Cmd = new List { "sh", "-c", "echo hi; rm -rf /" }, + }; + + var command = DockerRunCommandFormatter.Reconstruct(p, "docker"); + + Assert.Contains("\"echo hi; rm -rf /\"", command); + } +} diff --git a/tests/ContainerExtension.UnitTests/ExternalCommandHardeningTests.cs b/tests/ContainerExtension.UnitTests/ExternalCommandHardeningTests.cs index 642d016..eb810fc 100644 --- a/tests/ContainerExtension.UnitTests/ExternalCommandHardeningTests.cs +++ b/tests/ContainerExtension.UnitTests/ExternalCommandHardeningTests.cs @@ -8,7 +8,8 @@ namespace ContainerExtension.UnitTests; /// /// Regression suite for external-command hardening: strict SHA-256 digest validation before a build-arg -/// reaches the terminal, and absolute-path resolution of system utilities to defeat PATH hijacking. +/// reaches the terminal. (Absolute-path resolution of system utilities now lives in +/// .) /// public sealed class ExternalCommandHardeningTests { @@ -39,28 +40,4 @@ public void NormalizeSha256Digest_RejectsShellMetacharacterPayloadOfExactLength( Assert.Equal(64, payload.Length); Assert.Null(GitHubReleaseClient.NormalizeSha256Digest(payload)); } - - [Theory] - [InlineData("stat")] - [InlineData("id")] - [InlineData("open")] - [InlineData("xdg-open")] - public void ResolveTrustedUnixBinary_ReturnsRootedAbsolutePath(string name) - { - var resolved = DockerExecutionStrategy.ResolveTrustedUnixBinary(name); - Assert.True(Path.IsPathRooted(resolved), $"'{resolved}' must be absolute, never resolved via PATH"); - Assert.EndsWith("/" + name, resolved, StringComparison.Ordinal); - } - - [Fact] - public void ResolveTrustedUnixBinary_PrefersAnExistingTrustedLocation() - { - if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsLinux()) - { - return; // POSIX-only: /usr/bin or /bin - } - var resolved = DockerExecutionStrategy.ResolveTrustedUnixBinary("stat"); - // stat is a coreutils/BSD staple present on every supported POSIX host. - Assert.True(File.Exists(resolved), $"expected a real binary at '{resolved}'"); - } } diff --git a/tests/ContainerExtension.UnitTests/HardeningChallengeTests.cs b/tests/ContainerExtension.UnitTests/HardeningChallengeTests.cs index 5dc4eea..bde6c7b 100644 --- a/tests/ContainerExtension.UnitTests/HardeningChallengeTests.cs +++ b/tests/ContainerExtension.UnitTests/HardeningChallengeTests.cs @@ -15,11 +15,7 @@ namespace ContainerExtension.UnitTests; public sealed class HardeningChallengeTests { private static string InvokeGetCanonicalPath(string path) - { - var method = typeof(DockerExecutionStrategy).GetMethod("GetCanonicalPath", BindingFlags.NonPublic | BindingFlags.Static); - if (method == null) throw new InvalidOperationException("GetCanonicalPath method not found"); - return (string)method.Invoke(null, new object[] { path })!; - } + => PathCanonicalizer.GetCanonicalPath(path); [Fact] public void GetCanonicalPath_NestedSymlinks_ResolvesCorrectly() @@ -67,9 +63,8 @@ public void GetCanonicalPath_CircularSymlink_Deep_ThrowsDockerExecutionException File.CreateSymbolicLink(linkB, linkC); File.CreateSymbolicLink(linkC, linkA); - var ex = Assert.Throws(() => InvokeGetCanonicalPath(linkA)); - Assert.IsType(ex.InnerException); - Assert.Contains("Circular", ex.InnerException.Message, StringComparison.OrdinalIgnoreCase); + var ex = Assert.Throws(() => InvokeGetCanonicalPath(linkA)); + Assert.Contains("Circular", ex.Message, StringComparison.OrdinalIgnoreCase); } finally { diff --git a/tests/ContainerExtension.UnitTests/QualityVerificationTests.cs b/tests/ContainerExtension.UnitTests/QualityVerificationTests.cs index f7f8754..cc0c557 100644 --- a/tests/ContainerExtension.UnitTests/QualityVerificationTests.cs +++ b/tests/ContainerExtension.UnitTests/QualityVerificationTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using ContainerExtension; +using ContainerExtension.Services.Docker; using OneWare.Essentials.ToolEngine; using Xunit; @@ -130,10 +131,10 @@ public void LazyInitialization_DoesNotBlockUIOrPropertyGetters() [Fact] public void MergeLateResourceProfile_RetainsOomCorrection_OverLateStatsProfile() { - var corrected = new DockerExecutionStrategy.ResourceProfile(0, 0, 0, OomKilled: true); - var lateStats = new DockerExecutionStrategy.ResourceProfile(1024, 42.0, 5, OomKilled: false); + var corrected = new ContainerRunner.ResourceProfile(0, 0, 0, OomKilled: true); + var lateStats = new ContainerRunner.ResourceProfile(1024, 42.0, 5, OomKilled: false); - var merged = DockerExecutionStrategy.MergeLateResourceProfile(corrected, lateStats); + var merged = ContainerRunner.MergeLateResourceProfile(corrected, lateStats); Assert.Same(corrected, merged); Assert.True(merged is { OomKilled: true }); @@ -142,16 +143,16 @@ public void MergeLateResourceProfile_RetainsOomCorrection_OverLateStatsProfile() [Fact] public void MergeLateResourceProfile_AdoptsLateProfile_WhenNothingCapturedYet() { - var lateStats = new DockerExecutionStrategy.ResourceProfile(2048, 12.5, 3, OomKilled: false); + var lateStats = new ContainerRunner.ResourceProfile(2048, 12.5, 3, OomKilled: false); - Assert.Same(lateStats, DockerExecutionStrategy.MergeLateResourceProfile(null, lateStats)); + Assert.Same(lateStats, ContainerRunner.MergeLateResourceProfile(null, lateStats)); } [Fact] public void MergeLateResourceProfile_RetainsCapture_WhenLateProfileMissing() { - var captured = new DockerExecutionStrategy.ResourceProfile(4096, 7.5, 9, OomKilled: false); + var captured = new ContainerRunner.ResourceProfile(4096, 7.5, 9, OomKilled: false); - Assert.Same(captured, DockerExecutionStrategy.MergeLateResourceProfile(captured, null)); + Assert.Same(captured, ContainerRunner.MergeLateResourceProfile(captured, null)); } } diff --git a/tests/ContainerExtension.UnitTests/SecurityAndParsingTests.cs b/tests/ContainerExtension.UnitTests/SecurityAndParsingTests.cs index 761d39c..3ccee3b 100644 --- a/tests/ContainerExtension.UnitTests/SecurityAndParsingTests.cs +++ b/tests/ContainerExtension.UnitTests/SecurityAndParsingTests.cs @@ -372,65 +372,6 @@ public void ResolveImage_FallbackImage_UsedWhenNothingResolves() } } - // -- DockerExecutionStrategy.ValidateBinds --------------------------- - - private static Exception? InvokeValidateBinds(IList? binds) - { - var method = typeof(DockerExecutionStrategy).GetMethod("ValidateBinds", StaticNonPublic); - Assert.NotNull(method); - try - { - method!.Invoke(null, new object?[] { binds }); - return null; - } - catch (TargetInvocationException ex) - { - return ex.InnerException; - } - } - - [Theory] - [InlineData("/etc")] - [InlineData("/proc")] - [InlineData("/sys")] - public void ValidateBinds_RejectsCriticalHostMounts(string hostPath) - { - if (OperatingSystem.IsWindows()) - { - return; // The blocked-path set differs on Windows; these POSIX roots do not apply. - } - var binds = new List { $"{hostPath}:/workspace:ro" }; - var ex = InvokeValidateBinds(binds); - Assert.IsType(ex); - } - - [Fact] - public void ValidateBinds_RewritesBenignBindToCanonicalForm() - { - var tempDir = Path.Combine(Path.GetTempPath(), "BindTest_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - try - { - var canonicalMethod = typeof(DockerExecutionStrategy).GetMethod("GetCanonicalPath", StaticNonPublic); - Assert.NotNull(canonicalMethod); - var canonical = (string)canonicalMethod!.Invoke(null, new object[] { tempDir })!; - - var binds = new List { $"{tempDir}:/workspace:rw" }; - var ex = InvokeValidateBinds(binds); - Assert.Null(ex); - Assert.Equal($"{canonical}:/workspace:rw", binds[0]); - } - finally - { - try { Directory.Delete(tempDir, true); } catch { /* best-effort teardown */ } - } - } - - [Fact] - public void ValidateBinds_NullList_NoOp() - { - Assert.Null(InvokeValidateBinds(null)); - } } // ISettingsService stub whose value lookups throw, exercising the SafeGetSetting failure path.