Extend Vexer attestation/export stack and Concelier OSV fixes
This commit is contained in:
		
							
								
								
									
										181
									
								
								src/StellaOps.Vexer.Export/S3ArtifactStore.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								src/StellaOps.Vexer.Export/S3ArtifactStore.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,181 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Vexer.Core;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Vexer.Export;
 | 
			
		||||
 | 
			
		||||
public sealed class S3ArtifactStoreOptions
 | 
			
		||||
{
 | 
			
		||||
    public string BucketName { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    public string? Prefix { get; set; }
 | 
			
		||||
        = null;
 | 
			
		||||
 | 
			
		||||
    public bool OverwriteExisting { get; set; }
 | 
			
		||||
        = true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public interface IS3ArtifactClient
 | 
			
		||||
{
 | 
			
		||||
    Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public sealed class S3ArtifactStore : IVexArtifactStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IS3ArtifactClient _client;
 | 
			
		||||
    private readonly S3ArtifactStoreOptions _options;
 | 
			
		||||
    private readonly ILogger<S3ArtifactStore> _logger;
 | 
			
		||||
 | 
			
		||||
    public S3ArtifactStore(
 | 
			
		||||
        IS3ArtifactClient client,
 | 
			
		||||
        IOptions<S3ArtifactStoreOptions> options,
 | 
			
		||||
        ILogger<S3ArtifactStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _client = client ?? throw new ArgumentNullException(nameof(client));
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(options);
 | 
			
		||||
        _options = options.Value;
 | 
			
		||||
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(_options.BucketName))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("BucketName must be provided for S3ArtifactStore.", nameof(options));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(artifact);
 | 
			
		||||
        var key = BuildObjectKey(artifact.ContentAddress, artifact.Format);
 | 
			
		||||
 | 
			
		||||
        if (!_options.OverwriteExisting)
 | 
			
		||||
        {
 | 
			
		||||
            var exists = await _client.ObjectExistsAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (exists)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogInformation("S3 object {Bucket}/{Key} already exists; skipping upload.", _options.BucketName, key);
 | 
			
		||||
                return new VexStoredArtifact(artifact.ContentAddress, key, artifact.Content.Length, artifact.Metadata);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        using var contentStream = new MemoryStream(artifact.Content.ToArray());
 | 
			
		||||
        await _client.PutObjectAsync(
 | 
			
		||||
            _options.BucketName,
 | 
			
		||||
            key,
 | 
			
		||||
            contentStream,
 | 
			
		||||
            BuildObjectMetadata(artifact),
 | 
			
		||||
            cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        _logger.LogInformation("Uploaded export artifact {Digest} to {Bucket}/{Key}", artifact.ContentAddress.ToUri(), _options.BucketName, key);
 | 
			
		||||
 | 
			
		||||
        return new VexStoredArtifact(
 | 
			
		||||
            artifact.ContentAddress,
 | 
			
		||||
            key,
 | 
			
		||||
            artifact.Content.Length,
 | 
			
		||||
            artifact.Metadata);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(contentAddress);
 | 
			
		||||
        foreach (var key in BuildCandidateKeys(contentAddress))
 | 
			
		||||
        {
 | 
			
		||||
            await _client.DeleteObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        _logger.LogInformation("Deleted export artifact {Digest} from {Bucket}", contentAddress.ToUri(), _options.BucketName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(contentAddress);
 | 
			
		||||
        foreach (var key in BuildCandidateKeys(contentAddress))
 | 
			
		||||
        {
 | 
			
		||||
            var stream = await _client.GetObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (stream is not null)
 | 
			
		||||
            {
 | 
			
		||||
                return stream;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private string BuildObjectKey(VexContentAddress address, VexExportFormat format)
 | 
			
		||||
    {
 | 
			
		||||
        var sanitizedDigest = address.Digest.Replace(':', '_');
 | 
			
		||||
        var prefix = string.IsNullOrWhiteSpace(_options.Prefix) ? string.Empty : _options.Prefix.TrimEnd('/') + "/";
 | 
			
		||||
        var formatSegment = format.ToString().ToLowerInvariant();
 | 
			
		||||
        return $"{prefix}{formatSegment}/{sanitizedDigest}{GetExtension(format)}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private IEnumerable<string> BuildCandidateKeys(VexContentAddress address)
 | 
			
		||||
    {
 | 
			
		||||
        foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
 | 
			
		||||
        {
 | 
			
		||||
            yield return BuildObjectKey(address, format);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(_options.Prefix))
 | 
			
		||||
        {
 | 
			
		||||
            yield return $"{_options.Prefix.TrimEnd('/')}/{address.Digest.Replace(':', '_')}";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        yield return address.Digest.Replace(':', '_');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IDictionary<string, string> BuildObjectMetadata(VexExportArtifact artifact)
 | 
			
		||||
    {
 | 
			
		||||
        var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
        {
 | 
			
		||||
            ["vex-format"] = artifact.Format.ToString().ToLowerInvariant(),
 | 
			
		||||
            ["vex-digest"] = artifact.ContentAddress.ToUri(),
 | 
			
		||||
            ["content-type"] = artifact.Format switch
 | 
			
		||||
            {
 | 
			
		||||
                VexExportFormat.Json => "application/json",
 | 
			
		||||
                VexExportFormat.JsonLines => "application/json",
 | 
			
		||||
                VexExportFormat.OpenVex => "application/vnd.openvex+json",
 | 
			
		||||
                VexExportFormat.Csaf => "application/json",
 | 
			
		||||
                _ => "application/octet-stream",
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        foreach (var kvp in artifact.Metadata)
 | 
			
		||||
        {
 | 
			
		||||
            metadata[$"meta-{kvp.Key}"] = kvp.Value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return metadata;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string GetExtension(VexExportFormat format)
 | 
			
		||||
        => format switch
 | 
			
		||||
        {
 | 
			
		||||
            VexExportFormat.Json => ".json",
 | 
			
		||||
            VexExportFormat.JsonLines => ".jsonl",
 | 
			
		||||
            VexExportFormat.OpenVex => ".json",
 | 
			
		||||
            VexExportFormat.Csaf => ".json",
 | 
			
		||||
            _ => ".bin",
 | 
			
		||||
        };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public static class S3ArtifactStoreServiceCollectionExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static IServiceCollection AddVexS3ArtifactStore(this IServiceCollection services, Action<S3ArtifactStoreOptions> configure)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(configure);
 | 
			
		||||
        services.Configure(configure);
 | 
			
		||||
        services.AddSingleton<IVexArtifactStore, S3ArtifactStore>();
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user