up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Windows container runtime client using Docker Engine API over named pipe.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class DockerWindowsRuntimeClient : IWindowsContainerRuntimeClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private const string DefaultPipeName = "docker_engine";
|
||||
|
||||
private readonly string _pipeName;
|
||||
private readonly TimeSpan _connectTimeout;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<DockerWindowsRuntimeClient> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public DockerWindowsRuntimeClient(
|
||||
ILogger<DockerWindowsRuntimeClient> logger,
|
||||
string? pipeName = null,
|
||||
TimeSpan? connectTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_pipeName = pipeName ?? DefaultPipeName;
|
||||
_connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5);
|
||||
_httpClient = CreateHttpClient(_pipeName, _connectTimeout);
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(string pipeName, TimeSpan connectTimeout)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
var pipe = new NamedPipeClientStream(
|
||||
".",
|
||||
pipeName,
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
await pipe.ConnectAsync((int)connectTimeout.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
return pipe;
|
||||
},
|
||||
ConnectTimeout = connectTimeout
|
||||
};
|
||||
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost/"),
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_connectTimeout);
|
||||
|
||||
var response = await _httpClient.GetAsync("/_ping", cts.Token).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Docker Windows ping failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WindowsRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _httpClient.GetAsync("/version", cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var version = JsonSerializer.Deserialize<DockerVersionResponse>(content, JsonOptions);
|
||||
|
||||
return new WindowsRuntimeIdentity
|
||||
{
|
||||
RuntimeName = "docker",
|
||||
RuntimeVersion = version?.Version,
|
||||
OsVersion = version?.Os,
|
||||
OsBuild = TryParseOsBuild(version?.KernelVersion),
|
||||
HyperVAvailable = true // Assume available on Windows Server
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WindowsContainerInfo>> ListContainersAsync(
|
||||
WindowsContainerState? stateFilter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = stateFilter == null || stateFilter == WindowsContainerState.Running
|
||||
? "/containers/json"
|
||||
: "/containers/json?all=true";
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var containers = JsonSerializer.Deserialize<List<DockerContainerListItem>>(content, JsonOptions)
|
||||
?? new List<DockerContainerListItem>();
|
||||
|
||||
var result = new List<WindowsContainerInfo>();
|
||||
foreach (var container in containers)
|
||||
{
|
||||
var state = MapDockerState(container.State);
|
||||
if (stateFilter.HasValue && state != stateFilter.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new WindowsContainerInfo
|
||||
{
|
||||
Id = container.Id,
|
||||
Name = container.Names?.FirstOrDefault()?.TrimStart('/') ?? container.Id[..12],
|
||||
ImageRef = container.Image,
|
||||
ImageId = container.ImageID,
|
||||
State = state,
|
||||
ProcessId = 0, // Not available from list
|
||||
CreatedAt = DateTimeOffset.FromUnixTimeSeconds(container.Created),
|
||||
Command = container.Command?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
|
||||
Labels = container.Labels ?? new Dictionary<string, string>(),
|
||||
RuntimeType = "windows"
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<WindowsContainerInfo?> GetContainerAsync(string containerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(containerId);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/containers/{containerId}/json", cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var inspect = JsonSerializer.Deserialize<DockerContainerInspectResponse>(content, JsonOptions);
|
||||
|
||||
if (inspect == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WindowsContainerInfo
|
||||
{
|
||||
Id = inspect.Id,
|
||||
Name = inspect.Name?.TrimStart('/') ?? inspect.Id[..12],
|
||||
ImageRef = inspect.Config?.Image,
|
||||
ImageId = inspect.Image,
|
||||
State = MapDockerState(inspect.State?.Status),
|
||||
ProcessId = inspect.State?.Pid ?? 0,
|
||||
CreatedAt = DateTimeOffset.TryParse(inspect.Created, out var created) ? created : DateTimeOffset.MinValue,
|
||||
StartedAt = DateTimeOffset.TryParse(inspect.State?.StartedAt, out var started) ? started : null,
|
||||
FinishedAt = DateTimeOffset.TryParse(inspect.State?.FinishedAt, out var finished) ? finished : null,
|
||||
ExitCode = inspect.State?.ExitCode,
|
||||
Command = CombineEntrypointAndCmd(inspect.Config?.Entrypoint, inspect.Config?.Cmd),
|
||||
Labels = inspect.Config?.Labels ?? new Dictionary<string, string>(),
|
||||
HyperVIsolated = inspect.HostConfig?.Isolation?.Equals("hyperv", StringComparison.OrdinalIgnoreCase) == true,
|
||||
RuntimeType = "windows"
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<WindowsContainerEvent> StreamEventsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var filters = new Dictionary<string, IList<string>>
|
||||
{
|
||||
["type"] = new List<string> { "container" },
|
||||
["event"] = new List<string> { "start", "stop", "die", "destroy", "create" }
|
||||
};
|
||||
|
||||
var filterJson = JsonSerializer.Serialize(filters, JsonOptions);
|
||||
var url = $"/events?filters={Uri.EscapeDataString(filterJson)}";
|
||||
|
||||
_logger.LogDebug("Starting Windows Docker event stream: {Url}", url);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DockerEventResponse? evt;
|
||||
try
|
||||
{
|
||||
evt = JsonSerializer.Deserialize<DockerEventResponse>(line, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Docker event: {Line}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (evt == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new WindowsContainerEvent
|
||||
{
|
||||
Type = MapDockerEventAction(evt.Action),
|
||||
ContainerId = evt.Actor?.Id ?? evt.Id ?? "unknown",
|
||||
ContainerName = evt.Actor?.Attributes?.GetValueOrDefault("name"),
|
||||
ImageRef = evt.Actor?.Attributes?.GetValueOrDefault("image") ?? evt.From,
|
||||
Timestamp = DateTimeOffset.FromUnixTimeSeconds(evt.Time),
|
||||
Data = evt.Actor?.Attributes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static WindowsContainerState MapDockerState(string? state)
|
||||
{
|
||||
return state?.ToLowerInvariant() switch
|
||||
{
|
||||
"created" => WindowsContainerState.Created,
|
||||
"running" => WindowsContainerState.Running,
|
||||
"paused" => WindowsContainerState.Paused,
|
||||
"exited" or "dead" => WindowsContainerState.Stopped,
|
||||
_ => WindowsContainerState.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static WindowsContainerEventType MapDockerEventAction(string? action)
|
||||
{
|
||||
return action?.ToLowerInvariant() switch
|
||||
{
|
||||
"create" => WindowsContainerEventType.ContainerCreated,
|
||||
"start" => WindowsContainerEventType.ContainerStarted,
|
||||
"stop" or "die" => WindowsContainerEventType.ContainerStopped,
|
||||
"destroy" => WindowsContainerEventType.ContainerDeleted,
|
||||
"exec_start" => WindowsContainerEventType.ProcessStarted,
|
||||
"exec_die" => WindowsContainerEventType.ProcessExited,
|
||||
_ => WindowsContainerEventType.ContainerCreated
|
||||
};
|
||||
}
|
||||
|
||||
private static int? TryParseOsBuild(string? kernelVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kernelVersion))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows kernel version is like "10.0 20348 (20348.1.amd64fre.fe_release.210507-1500)"
|
||||
var parts = kernelVersion.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var build))
|
||||
{
|
||||
return build;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> CombineEntrypointAndCmd(string[]? entrypoint, string[]? cmd)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (entrypoint != null)
|
||||
{
|
||||
result.AddRange(entrypoint);
|
||||
}
|
||||
if (cmd != null)
|
||||
{
|
||||
result.AddRange(cmd);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_httpClient.Dispose();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region Docker API DTOs
|
||||
|
||||
private sealed class DockerVersionResponse
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public string? ApiVersion { get; set; }
|
||||
public string? Os { get; set; }
|
||||
public string? Arch { get; set; }
|
||||
public string? KernelVersion { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerContainerListItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string[]? Names { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public string? ImageID { get; set; }
|
||||
public string? Command { get; set; }
|
||||
public long Created { get; set; }
|
||||
public string? State { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerContainerInspectResponse
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public string? Created { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public DockerContainerState? State { get; set; }
|
||||
public DockerContainerConfig? Config { get; set; }
|
||||
public DockerHostConfig? HostConfig { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerContainerState
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
public bool Running { get; set; }
|
||||
public int Pid { get; set; }
|
||||
public int ExitCode { get; set; }
|
||||
public string? StartedAt { get; set; }
|
||||
public string? FinishedAt { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerContainerConfig
|
||||
{
|
||||
public string? Image { get; set; }
|
||||
public string[]? Entrypoint { get; set; }
|
||||
public string[]? Cmd { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerHostConfig
|
||||
{
|
||||
public string? Isolation { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerEventResponse
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? Action { get; set; }
|
||||
public DockerEventActor? Actor { get; set; }
|
||||
public long Time { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? From { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerEventActor
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for Windows container runtime (HCS or Docker Windows).
|
||||
/// </summary>
|
||||
internal interface IWindowsContainerRuntimeClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Check if the Windows container runtime is available.
|
||||
/// </summary>
|
||||
Task<bool> IsAvailableAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get runtime identity information.
|
||||
/// </summary>
|
||||
Task<WindowsRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// List containers matching the specified state filter.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WindowsContainerInfo>> ListContainersAsync(
|
||||
WindowsContainerState? stateFilter,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get detailed information about a specific container.
|
||||
/// </summary>
|
||||
Task<WindowsContainerInfo?> GetContainerAsync(string containerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stream container lifecycle events.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<WindowsContainerEvent> StreamEventsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows container runtime identity.
|
||||
/// </summary>
|
||||
internal sealed class WindowsRuntimeIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime name: docker, containerd, hcs.
|
||||
/// </summary>
|
||||
public required string RuntimeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime version.
|
||||
/// </summary>
|
||||
public string? RuntimeVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Windows OS version (e.g., "10.0.20348").
|
||||
/// </summary>
|
||||
public string? OsVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Windows OS build number.
|
||||
/// </summary>
|
||||
public int? OsBuild { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Hyper-V isolation is available.
|
||||
/// </summary>
|
||||
public bool HyperVAvailable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows container lifecycle event.
|
||||
/// </summary>
|
||||
internal sealed class WindowsContainerEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event type: ContainerCreated, ContainerStarted, ContainerStopped, ContainerDeleted.
|
||||
/// </summary>
|
||||
public required WindowsContainerEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container ID.
|
||||
/// </summary>
|
||||
public required string ContainerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container name.
|
||||
/// </summary>
|
||||
public string? ContainerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image reference.
|
||||
/// </summary>
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional event data.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Data { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows container event types.
|
||||
/// </summary>
|
||||
internal enum WindowsContainerEventType
|
||||
{
|
||||
ContainerCreated,
|
||||
ContainerStarted,
|
||||
ContainerStopped,
|
||||
ContainerDeleted,
|
||||
ProcessStarted,
|
||||
ProcessExited
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Windows container information from HCS or Docker Windows API.
|
||||
/// </summary>
|
||||
internal sealed class WindowsContainerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Container ID (GUID for HCS, or Docker container ID).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image reference.
|
||||
/// </summary>
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image ID/digest.
|
||||
/// </summary>
|
||||
public string? ImageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container state: Created, Running, Stopped.
|
||||
/// </summary>
|
||||
public WindowsContainerState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Process ID of the container's main process.
|
||||
/// </summary>
|
||||
public int ProcessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container start timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container exit timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? FinishedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exit code if container has stopped.
|
||||
/// </summary>
|
||||
public int? ExitCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container command/entrypoint.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Command { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Container labels.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Container owner (namespace/pod in Kubernetes).
|
||||
/// </summary>
|
||||
public WindowsContainerOwner? Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a Hyper-V isolated container.
|
||||
/// </summary>
|
||||
public bool HyperVIsolated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime type: windows, hyperv.
|
||||
/// </summary>
|
||||
public string RuntimeType { get; init; } = "windows";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows container state.
|
||||
/// </summary>
|
||||
internal enum WindowsContainerState
|
||||
{
|
||||
Unknown,
|
||||
Created,
|
||||
Running,
|
||||
Paused,
|
||||
Stopped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows container owner information (for Kubernetes scenarios).
|
||||
/// </summary>
|
||||
internal sealed class WindowsContainerOwner
|
||||
{
|
||||
public string? Kind { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Namespace { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Collects loaded library hashes from Windows processes (PE format).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class WindowsLibraryHashCollector
|
||||
{
|
||||
private readonly ILogger<WindowsLibraryHashCollector> _logger;
|
||||
private readonly int _maxLibraries;
|
||||
private readonly long _maxFileBytes;
|
||||
private readonly long _maxTotalHashBytes;
|
||||
|
||||
public WindowsLibraryHashCollector(
|
||||
ILogger<WindowsLibraryHashCollector> logger,
|
||||
int maxLibraries = 256,
|
||||
long maxFileBytes = 33554432, // 32 MiB
|
||||
long maxTotalHashBytes = 64_000_000) // ~61 MiB
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_maxLibraries = maxLibraries;
|
||||
_maxFileBytes = maxFileBytes;
|
||||
_maxTotalHashBytes = maxTotalHashBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collect loaded libraries from a Windows process.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<WindowsLoadedLibrary>> CollectAsync(
|
||||
int processId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var libraries = new List<WindowsLoadedLibrary>();
|
||||
var totalBytesHashed = 0L;
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
var modules = GetProcessModules(process);
|
||||
|
||||
foreach (var module in modules.Take(_maxLibraries))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(module.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var library = new WindowsLoadedLibrary
|
||||
{
|
||||
Path = module.Path,
|
||||
ModuleName = module.ModuleName,
|
||||
BaseAddress = module.BaseAddress,
|
||||
ModuleSize = module.ModuleSize
|
||||
};
|
||||
|
||||
// Try to hash the file if within limits
|
||||
if (File.Exists(module.Path) && totalBytesHashed < _maxTotalHashBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(module.Path);
|
||||
if (fileInfo.Length <= _maxFileBytes && totalBytesHashed + fileInfo.Length <= _maxTotalHashBytes)
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(module.Path, cancellationToken).ConfigureAwait(false);
|
||||
library = library with { Sha256 = hash };
|
||||
totalBytesHashed += fileInfo.Length;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to hash library: {Path}", module.Path);
|
||||
}
|
||||
}
|
||||
|
||||
libraries.Add(library);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Process {ProcessId} not found or inaccessible", processId);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Process {ProcessId} has exited", processId);
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Access denied to process {ProcessId} modules", processId);
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WindowsModuleInfo> GetProcessModules(Process process)
|
||||
{
|
||||
var modules = new List<WindowsModuleInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (ProcessModule module in process.Modules)
|
||||
{
|
||||
modules.Add(new WindowsModuleInfo
|
||||
{
|
||||
Path = module.FileName,
|
||||
ModuleName = module.ModuleName,
|
||||
BaseAddress = module.BaseAddress.ToInt64(),
|
||||
ModuleSize = module.ModuleMemorySize
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
// Access denied - return what we have
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite,
|
||||
bufferSize: 81920,
|
||||
useAsync: true);
|
||||
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed class WindowsModuleInfo
|
||||
{
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string ModuleName { get; init; } = string.Empty;
|
||||
public long BaseAddress { get; init; }
|
||||
public int ModuleSize { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loaded library information from a Windows process.
|
||||
/// </summary>
|
||||
internal sealed record WindowsLoadedLibrary
|
||||
{
|
||||
/// <summary>
|
||||
/// Full path to the DLL/EXE.
|
||||
/// </summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module name (filename without path).
|
||||
/// </summary>
|
||||
public string? ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base address where the module is loaded.
|
||||
/// </summary>
|
||||
public long BaseAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the module in memory.
|
||||
/// </summary>
|
||||
public int ModuleSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the file (sha256:...).
|
||||
/// </summary>
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Collects Java classpath information from a running JVM process.
|
||||
/// Parses /proc/<pid>/cmdline for -cp/-classpath arguments and extracts JAR metadata.
|
||||
/// </summary>
|
||||
internal sealed partial class JavaClasspathCollector
|
||||
{
|
||||
private static readonly Regex JavaRegex = GenerateJavaRegex();
|
||||
private static readonly char[] ClasspathSeparators = { ':', ';' };
|
||||
private const int MaxJarFiles = 256;
|
||||
private const long MaxJarSize = 100 * 1024 * 1024; // 100 MiB
|
||||
|
||||
private readonly string _procRoot;
|
||||
private readonly ILogger<JavaClasspathCollector> _logger;
|
||||
|
||||
public JavaClasspathCollector(string procRoot, ILogger<JavaClasspathCollector> logger)
|
||||
{
|
||||
_procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
?? throw new ArgumentNullException(nameof(procRoot));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a process appears to be a Java process.
|
||||
/// </summary>
|
||||
public async Task<bool> IsJavaProcessAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (cmdline.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return JavaRegex.IsMatch(cmdline[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collect classpath entries from a Java process.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ClasspathEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (cmdline.Count == 0)
|
||||
{
|
||||
return Array.Empty<ClasspathEntry>();
|
||||
}
|
||||
|
||||
if (!JavaRegex.IsMatch(cmdline[0]))
|
||||
{
|
||||
_logger.LogDebug("Process {Pid} is not a Java process", pid);
|
||||
return Array.Empty<ClasspathEntry>();
|
||||
}
|
||||
|
||||
var classpathValue = ExtractClasspath(cmdline);
|
||||
if (string.IsNullOrWhiteSpace(classpathValue))
|
||||
{
|
||||
// Try to find classpath from environment or use jcmd if available
|
||||
classpathValue = await TryGetClasspathFromJcmdAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(classpathValue))
|
||||
{
|
||||
_logger.LogDebug("No classpath found for Java process {Pid}", pid);
|
||||
return Array.Empty<ClasspathEntry>();
|
||||
}
|
||||
|
||||
var entries = new List<ClasspathEntry>();
|
||||
var paths = classpathValue.Split(ClasspathSeparators, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var path in paths.Take(MaxJarFiles))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var entry = await ProcessClasspathEntryAsync(path.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (entry != null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Collected {Count} classpath entries for Java process {Pid}", entries.Count, pid);
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ReadCmdlineAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
|
||||
if (!File.Exists(cmdlinePath))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content.Length == 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(content)
|
||||
.Split('\0', StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read cmdline for PID {Pid}", pid);
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractClasspath(IReadOnlyList<string> cmdline)
|
||||
{
|
||||
for (var i = 0; i < cmdline.Count; i++)
|
||||
{
|
||||
var arg = cmdline[i];
|
||||
|
||||
// -cp <classpath> or -classpath <classpath>
|
||||
if ((string.Equals(arg, "-cp", StringComparison.Ordinal) ||
|
||||
string.Equals(arg, "-classpath", StringComparison.Ordinal)) &&
|
||||
i + 1 < cmdline.Count)
|
||||
{
|
||||
return cmdline[i + 1];
|
||||
}
|
||||
|
||||
// -cp:<classpath> or -classpath:<classpath> (some JVMs)
|
||||
if (arg.StartsWith("-cp:", StringComparison.Ordinal))
|
||||
{
|
||||
return arg[4..];
|
||||
}
|
||||
|
||||
if (arg.StartsWith("-classpath:", StringComparison.Ordinal))
|
||||
{
|
||||
return arg[11..];
|
||||
}
|
||||
|
||||
// -jar <jarfile> - the jar file is effectively the classpath
|
||||
if (string.Equals(arg, "-jar", StringComparison.Ordinal) && i + 1 < cmdline.Count)
|
||||
{
|
||||
return cmdline[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> TryGetClasspathFromJcmdAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
// Try to use jcmd to get the classpath
|
||||
// This requires jcmd to be available and the process to be accessible
|
||||
try
|
||||
{
|
||||
var jcmdPath = FindJcmd();
|
||||
if (jcmdPath == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = jcmdPath,
|
||||
Arguments = $"{pid} VM.system_properties",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(startInfo);
|
||||
if (process == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse java.class.path from output
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
if (line.StartsWith("java.class.path=", StringComparison.Ordinal))
|
||||
{
|
||||
return line[16..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to get classpath from jcmd for PID {Pid}", pid);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindJcmd()
|
||||
{
|
||||
var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME");
|
||||
if (!string.IsNullOrWhiteSpace(javaHome))
|
||||
{
|
||||
var jcmdPath = Path.Combine(javaHome, "bin", "jcmd");
|
||||
if (File.Exists(jcmdPath))
|
||||
{
|
||||
return jcmdPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Try common paths
|
||||
var paths = new[]
|
||||
{
|
||||
"/usr/bin/jcmd",
|
||||
"/usr/local/bin/jcmd",
|
||||
"/opt/java/bin/jcmd"
|
||||
};
|
||||
|
||||
return paths.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private async Task<ClasspathEntry?> ProcessClasspathEntryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var type = DetermineEntryType(path);
|
||||
|
||||
if (type == "jar" && File.Exists(path))
|
||||
{
|
||||
return await ProcessJarFileAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (type == "directory" && Directory.Exists(path))
|
||||
{
|
||||
return new ClasspathEntry
|
||||
{
|
||||
Path = path,
|
||||
Type = "directory"
|
||||
};
|
||||
}
|
||||
|
||||
// Entry doesn't exist or is a wildcard
|
||||
return new ClasspathEntry
|
||||
{
|
||||
Path = path,
|
||||
Type = type
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ClasspathEntry> ProcessJarFileAsync(string jarPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = new ClasspathEntry
|
||||
{
|
||||
Path = jarPath,
|
||||
Type = "jar"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(jarPath);
|
||||
entry = entry with { SizeBytes = fileInfo.Length };
|
||||
|
||||
if (fileInfo.Length <= MaxJarSize)
|
||||
{
|
||||
// Compute hash
|
||||
var hash = await ComputeFileHashAsync(jarPath, cancellationToken).ConfigureAwait(false);
|
||||
entry = entry with { Sha256 = hash };
|
||||
|
||||
// Try to extract Maven coordinates from manifest
|
||||
var (groupId, artifactId, version) = await ExtractMavenCoordinatesAsync(jarPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
var coordinate = string.IsNullOrWhiteSpace(version)
|
||||
? $"{groupId}:{artifactId}"
|
||||
: $"{groupId}:{artifactId}:{version}";
|
||||
entry = entry with
|
||||
{
|
||||
MavenCoordinate = coordinate,
|
||||
Purl = $"pkg:maven/{groupId}/{artifactId}" + (string.IsNullOrWhiteSpace(version) ? "" : $"@{version}")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process JAR file: {Path}", jarPath);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static async Task<(string? groupId, string? artifactId, string? version)> ExtractMavenCoordinatesAsync(
|
||||
string jarPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
|
||||
// Try to find pom.properties in META-INF/maven/
|
||||
var pomProperties = archive.Entries
|
||||
.FirstOrDefault(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.FullName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (pomProperties != null)
|
||||
{
|
||||
await using var entryStream = pomProperties.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? groupId = null, artifactId = null, version = null;
|
||||
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("groupId=", StringComparison.Ordinal))
|
||||
{
|
||||
groupId = trimmed[8..];
|
||||
}
|
||||
else if (trimmed.StartsWith("artifactId=", StringComparison.Ordinal))
|
||||
{
|
||||
artifactId = trimmed[11..];
|
||||
}
|
||||
else if (trimmed.StartsWith("version=", StringComparison.Ordinal))
|
||||
{
|
||||
version = trimmed[8..];
|
||||
}
|
||||
}
|
||||
|
||||
return (groupId, artifactId, version);
|
||||
}
|
||||
|
||||
// Fallback: try to parse from MANIFEST.MF
|
||||
var manifest = archive.GetEntry("META-INF/MANIFEST.MF");
|
||||
if (manifest != null)
|
||||
{
|
||||
await using var entryStream = manifest.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? implTitle = null, implVersion = null, implVendor = null;
|
||||
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("Implementation-Title:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
implTitle = trimmed[21..].Trim();
|
||||
}
|
||||
else if (trimmed.StartsWith("Implementation-Version:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
implVersion = trimmed[23..].Trim();
|
||||
}
|
||||
else if (trimmed.StartsWith("Implementation-Vendor-Id:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
implVendor = trimmed[25..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(implTitle))
|
||||
{
|
||||
return (implVendor, implTitle, implVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors extracting coordinates
|
||||
}
|
||||
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
private static string DetermineEntryType(string path)
|
||||
{
|
||||
if (path.EndsWith(".jar", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "jar";
|
||||
}
|
||||
|
||||
if (path.EndsWith(".jmod", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "jmod";
|
||||
}
|
||||
|
||||
if (path.EndsWith("/*", StringComparison.Ordinal) || path.EndsWith("\\*", StringComparison.Ordinal))
|
||||
{
|
||||
return "wildcard";
|
||||
}
|
||||
|
||||
return "directory";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(^|/)(java|javaw)(\.exe)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex GenerateJavaRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user