feat: Implement DevPortal Offline Export Job

- Added DevPortalOfflineJob to coordinate bundle construction, manifest signing, and artifact persistence.
- Introduced DevPortalOfflineWorkerOptions for configuration of the offline export job.
- Enhanced the Worker class to utilize DevPortalOfflineJob and handle execution based on configuration.
- Implemented HmacDevPortalOfflineManifestSigner for signing manifests with HMAC SHA256.
- Created FileSystemDevPortalOfflineObjectStore for storing artifacts in the file system.
- Updated appsettings.json to include configuration options for the DevPortal offline export.
- Added unit tests for DevPortalOfflineJob and HmacDevPortalOfflineManifestSigner to ensure functionality.
- Refactored existing tests to accommodate changes in method signatures and new dependencies.
This commit is contained in:
master
2025-11-05 21:57:46 +02:00
parent c467b4d4b7
commit 21a2759412
20 changed files with 1141 additions and 89 deletions

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
public sealed class DevPortalOfflineManifestSigningOptions
{
[Required]
public string KeyId { get; set; } = "devportal-offline-local";
[Required]
public string Secret { get; set; } = null!;
[Required]
public string Algorithm { get; set; } = "HMACSHA256";
[Required]
public string PayloadType { get; set; } = "application/vnd.stella.devportal.manifest+json";
}

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
public sealed class DevPortalOfflineStorageOptions
{
[Required]
public string RootPath { get; set; } = null!;
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Buffers;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.ExportCenter.Core.DevPortalOffline;
namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
public sealed class FileSystemDevPortalOfflineObjectStore : IDevPortalOfflineObjectStore
{
private readonly IOptionsMonitor<DevPortalOfflineStorageOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FileSystemDevPortalOfflineObjectStore> _logger;
public FileSystemDevPortalOfflineObjectStore(
IOptionsMonitor<DevPortalOfflineStorageOptions> options,
TimeProvider timeProvider,
ILogger<FileSystemDevPortalOfflineObjectStore> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DevPortalOfflineStorageMetadata> StoreAsync(
Stream content,
DevPortalOfflineObjectStoreOptions storeOptions,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(storeOptions);
var root = EnsureRootPath();
var storageKey = SanitizeKey(storeOptions.StorageKey);
var fullPath = GetFullPath(root, storageKey);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
content.Seek(0, SeekOrigin.Begin);
using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024);
long totalBytes = 0;
try
{
int read;
while ((read = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
hash.AppendData(buffer, 0, read);
totalBytes += read;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false);
content.Seek(0, SeekOrigin.Begin);
var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
var createdAt = _timeProvider.GetUtcNow();
_logger.LogDebug("Stored devportal artefact at {Path} ({Bytes} bytes).", fullPath, totalBytes);
return new DevPortalOfflineStorageMetadata(
storageKey,
storeOptions.ContentType,
totalBytes,
sha,
createdAt);
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
{
var root = EnsureRootPath();
var fullPath = GetFullPath(root, SanitizeKey(storageKey));
var exists = File.Exists(fullPath);
return Task.FromResult(exists);
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
var root = EnsureRootPath();
var fullPath = GetFullPath(root, SanitizeKey(storageKey));
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"DevPortal offline artefact '{storageKey}' was not found.", fullPath);
}
Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return Task.FromResult(stream);
}
private string EnsureRootPath()
{
var root = _options.CurrentValue.RootPath;
if (string.IsNullOrWhiteSpace(root))
{
throw new InvalidOperationException("DevPortal offline storage root path is not configured.");
}
var full = Path.GetFullPath(root);
if (!Directory.Exists(full))
{
Directory.CreateDirectory(full);
}
if (!full.EndsWith(Path.DirectorySeparatorChar))
{
full += Path.DirectorySeparatorChar;
}
return full;
}
private static string SanitizeKey(string storageKey)
{
if (string.IsNullOrWhiteSpace(storageKey))
{
throw new ArgumentException("Storage key cannot be empty.", nameof(storageKey));
}
if (storageKey.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException("Storage key cannot contain path traversal sequences.", nameof(storageKey));
}
var trimmed = storageKey.Trim().Trim('/').Replace('\\', '/');
return trimmed;
}
private static string GetFullPath(string root, string storageKey)
{
var combined = Path.Combine(root, storageKey.Replace('/', Path.DirectorySeparatorChar));
var fullPath = Path.GetFullPath(combined);
if (!fullPath.StartsWith(root, StringComparison.Ordinal))
{
throw new InvalidOperationException("Storage key resolves outside of configured root path.");
}
return fullPath;
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Buffers.Binary;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.ExportCenter.Core.DevPortalOffline;
namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
public sealed class HmacDevPortalOfflineManifestSigner : IDevPortalOfflineManifestSigner
{
private readonly IOptionsMonitor<DevPortalOfflineManifestSigningOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HmacDevPortalOfflineManifestSigner> _logger;
public HmacDevPortalOfflineManifestSigner(
IOptionsMonitor<DevPortalOfflineManifestSigningOptions> options,
TimeProvider timeProvider,
ILogger<HmacDevPortalOfflineManifestSigner> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<DevPortalOfflineManifestSignatureDocument> SignAsync(
Guid bundleId,
string manifestJson,
string rootHash,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(manifestJson))
{
throw new ArgumentException("Manifest JSON is required.", nameof(manifestJson));
}
if (string.IsNullOrWhiteSpace(rootHash))
{
throw new ArgumentException("Root hash is required.", nameof(rootHash));
}
var options = _options.CurrentValue;
ValidateOptions(options);
var signedAt = _timeProvider.GetUtcNow();
var payloadBytes = Encoding.UTF8.GetBytes(manifestJson);
var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes);
var signature = ComputeSignature(options, pae);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
_logger.LogDebug("Signed devportal manifest for bundle {BundleId}.", bundleId);
var envelope = new DevPortalOfflineManifestDsseEnvelope(
options.PayloadType,
payloadBase64,
new[]
{
new DevPortalOfflineManifestDsseSignature(signature, options.KeyId)
});
var document = new DevPortalOfflineManifestSignatureDocument(
bundleId,
rootHash,
signedAt,
options.Algorithm,
options.KeyId,
envelope);
return Task.FromResult(document);
}
private static void ValidateOptions(DevPortalOfflineManifestSigningOptions options)
{
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
if (!string.Equals(options.Algorithm, "HMACSHA256", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"Algorithm '{options.Algorithm}' is not supported for devportal manifest signing.");
}
}
private static string ComputeSignature(DevPortalOfflineManifestSigningOptions options, byte[] pae)
{
var secretBytes = Convert.FromBase64String(options.Secret);
using var hmac = new HMACSHA256(secretBytes);
var signatureBytes = hmac.ComputeHash(pae);
return Convert.ToBase64String(signatureBytes);
}
private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payloadBytes)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
const string prefix = "DSSEv1";
var totalLength = prefix.Length +
sizeof(ulong) +
sizeof(ulong) + typeBytes.Length +
sizeof(ulong) + payloadBytes.Length;
var buffer = new byte[totalLength];
var span = buffer.AsSpan();
var offset = Encoding.UTF8.GetBytes(prefix, span);
BinaryPrimitives.WriteUInt64BigEndian(span[offset..], 2);
offset += sizeof(ulong);
BinaryPrimitives.WriteUInt64BigEndian(span[offset..], (ulong)typeBytes.Length);
offset += sizeof(ulong);
typeBytes.CopyTo(span[offset..]);
offset += typeBytes.Length;
BinaryPrimitives.WriteUInt64BigEndian(span[offset..], (ulong)payloadBytes.Length);
offset += sizeof(ulong);
payloadBytes.CopyTo(span[offset..]);
return buffer;
}
}

View File

@@ -1,28 +1,19 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>