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:
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
|
||||
|
||||
public sealed class DevPortalOfflineStorageOptions
|
||||
{
|
||||
[Required]
|
||||
public string RootPath { get; set; } = null!;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user