using Microsoft.Extensions.Logging; using StellaOps.Cryptography; using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Text.Json; namespace StellaOps.Scanner.Storage.Oci; public sealed class OciArtifactPusher { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; private static readonly byte[] EmptyConfigBlob = "{}"u8.ToArray(); private readonly HttpClient _httpClient; private readonly ICryptoHash _cryptoHash; private readonly OciRegistryOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public OciArtifactPusher( HttpClient httpClient, ICryptoHash cryptoHash, OciRegistryOptions options, ILogger logger, TimeProvider? timeProvider = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } public async Task PushAsync( OciArtifactPushRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.Reference); ArgumentException.ThrowIfNullOrWhiteSpace(request.ArtifactType); if (request.Layers.Count == 0) { return OciArtifactPushResult.Failed("No layers supplied for OCI push."); } var reference = OciImageReference.Parse(request.Reference, _options.DefaultRegistry); if (reference is null) { return OciArtifactPushResult.Failed($"Invalid OCI reference: {request.Reference}"); } var auth = OciRegistryAuthorization.FromOptions(reference.Registry, _options.Auth); try { var configDigest = ComputeDigest(EmptyConfigBlob); var layerDescriptors = new List(); var layerDigests = new List(); foreach (var layer in request.Layers) { var digest = ComputeDigest(layer.Content); layerDescriptors.Add(new OciDescriptor { MediaType = layer.MediaType, Digest = digest, Size = layer.Content.Length, Annotations = NormalizeAnnotations(layer.Annotations) }); layerDigests.Add(digest); } var manifest = BuildManifest(request, configDigest, layerDescriptors); var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, SerializerOptions); var manifestDigest = ComputeDigest(manifestBytes); var tag = request.Tag; if (string.IsNullOrWhiteSpace(tag)) { tag = reference.Tag ?? manifestDigest.Replace("sha256:", string.Empty, StringComparison.Ordinal); } if (request.SkipIfTagExists) { var existingDigest = await TryGetExistingManifestDigestAsync(reference, tag, auth, cancellationToken) .ConfigureAwait(false); if (existingDigest is not null) { if (string.IsNullOrWhiteSpace(existingDigest)) { existingDigest = manifestDigest; } var existingReference = $"{reference.Registry}/{reference.Repository}@{existingDigest}"; _logger.LogInformation("OCI artifact already exists for tag {Tag}: {Reference}", tag, existingReference); return new OciArtifactPushResult { Success = true, AlreadyExists = true, ManifestDigest = existingDigest, ManifestReference = existingReference, LayerDigests = layerDigests }; } } await PushBlobAsync(reference, EmptyConfigBlob, OciMediaTypes.EmptyConfig, auth, cancellationToken) .ConfigureAwait(false); foreach (var layer in request.Layers) { await PushBlobAsync(reference, layer.Content, layer.MediaType, auth, cancellationToken) .ConfigureAwait(false); } await PushManifestAsync(reference, manifestBytes, tag, auth, cancellationToken).ConfigureAwait(false); var manifestReference = $"{reference.Registry}/{reference.Repository}@{manifestDigest}"; _logger.LogInformation("Pushed OCI artifact {Reference}", manifestReference); return new OciArtifactPushResult { Success = true, AlreadyExists = false, ManifestDigest = manifestDigest, ManifestReference = manifestReference, LayerDigests = layerDigests }; } catch (OciRegistryException ex) { _logger.LogError(ex, "OCI push failed: {Message}", ex.Message); return OciArtifactPushResult.Failed(ex.Message); } catch (HttpRequestException ex) { _logger.LogError(ex, "OCI push HTTP error: {Message}", ex.Message); return OciArtifactPushResult.Failed($"HTTP error: {ex.Message}"); } } private async Task TryGetExistingManifestDigestAsync( OciImageReference reference, string tag, OciRegistryAuthorization auth, CancellationToken cancellationToken) { var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}"); using var request = new HttpRequestMessage(HttpMethod.Head, manifestUri); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(OciMediaTypes.ImageManifest)); auth.ApplyTo(request); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { throw new OciRegistryException($"Manifest HEAD failed with {response.StatusCode}", "ERR_OCI_MANIFEST_HEAD"); } if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)) { var digest = digestValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(digest)) { return digest; } } return string.Empty; } private OciArtifactManifest BuildManifest( OciArtifactPushRequest request, string configDigest, IReadOnlyList layers) { var annotations = NormalizeAnnotations(request.Annotations); if (annotations is null) { annotations = new SortedDictionary(StringComparer.Ordinal); } annotations["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture); annotations["org.opencontainers.image.title"] = request.ArtifactType; return new OciArtifactManifest { MediaType = OciMediaTypes.ImageManifest, ArtifactType = request.ArtifactType, Config = new OciDescriptor { MediaType = OciMediaTypes.EmptyConfig, Digest = configDigest, Size = EmptyConfigBlob.Length }, Layers = layers, Subject = string.IsNullOrWhiteSpace(request.SubjectDigest) ? null : new OciDescriptor { MediaType = OciMediaTypes.ImageManifest, Digest = request.SubjectDigest!, Size = 0 }, Annotations = annotations }; } private async Task PushBlobAsync( OciImageReference reference, byte[] content, string mediaType, OciRegistryAuthorization auth, CancellationToken cancellationToken) { var digest = ComputeDigest(content); var blobUri = BuildRegistryUri(reference, $"blobs/{digest}"); using (var head = new HttpRequestMessage(HttpMethod.Head, blobUri)) { auth.ApplyTo(head); using var headResponse = await _httpClient.SendAsync(head, cancellationToken).ConfigureAwait(false); if (headResponse.IsSuccessStatusCode) { return digest; } if (headResponse.StatusCode != HttpStatusCode.NotFound) { throw new OciRegistryException($"Blob HEAD failed with {headResponse.StatusCode}", "ERR_OCI_BLOB_HEAD"); } } var startUploadUri = BuildRegistryUri(reference, "blobs/uploads/"); using var postRequest = new HttpRequestMessage(HttpMethod.Post, startUploadUri); auth.ApplyTo(postRequest); using var postResponse = await _httpClient.SendAsync(postRequest, cancellationToken).ConfigureAwait(false); if (!postResponse.IsSuccessStatusCode) { throw new OciRegistryException($"Blob upload start failed with {postResponse.StatusCode}", "ERR_OCI_UPLOAD_START"); } if (postResponse.Headers.Location is null) { throw new OciRegistryException("Blob upload start did not return a Location header.", "ERR_OCI_UPLOAD_LOCATION"); } var uploadUri = ResolveUploadUri(reference, postResponse.Headers.Location); uploadUri = AppendDigest(uploadUri, digest); using var putRequest = new HttpRequestMessage(HttpMethod.Put, uploadUri) { Content = new ByteArrayContent(content) }; putRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); auth.ApplyTo(putRequest); using var putResponse = await _httpClient.SendAsync(putRequest, cancellationToken).ConfigureAwait(false); if (!putResponse.IsSuccessStatusCode) { throw new OciRegistryException($"Blob upload failed with {putResponse.StatusCode}", "ERR_OCI_UPLOAD_PUT"); } return digest; } private async Task PushManifestAsync( OciImageReference reference, byte[] manifestBytes, string tag, OciRegistryAuthorization auth, CancellationToken cancellationToken) { var manifestUri = BuildRegistryUri(reference, $"manifests/{tag}"); using var request = new HttpRequestMessage(HttpMethod.Put, manifestUri) { Content = new ByteArrayContent(manifestBytes) }; request.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.ImageManifest); auth.ApplyTo(request); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { throw new OciRegistryException($"Manifest upload failed with {response.StatusCode}", "ERR_OCI_MANIFEST"); } } private string ComputeDigest(ReadOnlySpan content) { return _cryptoHash.ComputePrefixedHashForPurpose(content, HashPurpose.Interop); } private Uri BuildRegistryUri(OciImageReference reference, string path) { var scheme = reference.Scheme; if (_options.AllowInsecure) { scheme = "http"; } return new Uri($"{scheme}://{reference.Registry}/v2/{reference.Repository}/{path}"); } private static Uri ResolveUploadUri(OciImageReference reference, Uri location) { if (location.IsAbsoluteUri) { return location; } return new Uri($"{reference.Scheme}://{reference.Registry}{location}"); } private static Uri AppendDigest(Uri uploadUri, string digest) { if (uploadUri.Query.Contains("digest=", StringComparison.OrdinalIgnoreCase)) { return uploadUri; } var delimiter = string.IsNullOrEmpty(uploadUri.Query) ? "?" : "&"; var uri = new Uri($"{uploadUri}{delimiter}digest={Uri.EscapeDataString(digest)}"); return uri; } private static SortedDictionary? NormalizeAnnotations(IReadOnlyDictionary? annotations) { if (annotations is null || annotations.Count == 0) { return null; } var normalized = new SortedDictionary(StringComparer.Ordinal); foreach (var (key, value) in annotations) { if (string.IsNullOrWhiteSpace(key) || value is null) { continue; } normalized[key.Trim()] = value.Trim(); } return normalized.Count == 0 ? null : normalized; } }