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:
@@ -19,7 +19,7 @@ Depends on: Sprint 110.A - AdvisoryAI, Sprint 120.A - AirGap, Sprint 130.A - Sca
|
||||
Summary: Export & Evidence focus on ExportCenter (phase I).
|
||||
Task ID | State | Task description | Owners (Source)
|
||||
--- | --- | --- | ---
|
||||
DVOFF-64-001 | DOING (2025-11-04) | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | DevPortal Offline Guild, Exporter Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md)
|
||||
DVOFF-64-001 | DONE (2025-11-05) | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest.<br>2025-11-05: Worker builds reproducible bundle, persists manifest/checksum/DSSE signature under `<prefix>/<bundleId>/`, and documents verification flow in `devportal-offline.md`. Unit coverage added for job + signer. | DevPortal Offline Guild, Exporter Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md)
|
||||
DVOFF-64-002 | TODO | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. Dependencies: DVOFF-64-001. | DevPortal Offline Guild, AirGap Controller Guild (src/ExportCenter/StellaOps.ExportCenter.DevPortalOffline/TASKS.md)
|
||||
EXPORT-AIRGAP-56-001 | TODO | Extend Export Center to build Mirror Bundles as export profiles, including advisories/VEX/policy packs manifesting DSSE/TUF metadata. | Exporter Service Guild, Mirror Creator Guild (src/ExportCenter/StellaOps.ExportCenter/TASKS.md)
|
||||
EXPORT-AIRGAP-56-002 | TODO | Package Bootstrap Pack (images + charts) into OCI archives with signed manifests for air-gapped deployment. Dependencies: EXPORT-AIRGAP-56-001. | Exporter Service Guild, DevOps Guild (src/ExportCenter/StellaOps.ExportCenter/TASKS.md)
|
||||
|
||||
@@ -84,7 +84,8 @@ All endpoints require Authority-issued JWT + DPoP tokens with scopes `export:run
|
||||
- Supports optional encryption of `/data` subtree (age/AES-GCM) with key wrapping stored in `provenance.json`.
|
||||
- **DevPortal (`devportal:offline`).**
|
||||
- Packages developer portal static assets, OpenAPI specs, SDK releases, and changelog content into a reproducible archive with manifest/checksum pairs.
|
||||
- Emits `manifest.json`, `checksums.txt`, and helper scripts described in [DevPortal Offline Bundle Specification](devportal-offline.md); signing/DSSE wiring follows the shared Export Center signing service.
|
||||
- Emits `manifest.json`, `checksums.txt`, helper scripts, and a DSSE signature document (`manifest.dsse.json`) as described in [DevPortal Offline Bundle Specification](devportal-offline.md).
|
||||
- Stores artefacts under `<storagePrefix>/<bundleId>/` and signs manifests via the Export Center signing adapter (HMAC-SHA256 v1, tenant scoped).
|
||||
|
||||
Adapters expose structured telemetry events (`adapter.start`, `adapter.chunk`, `adapter.complete`) with record counts and byte totals per chunk. Failures emit `adapter.error` with reason codes.
|
||||
|
||||
|
||||
@@ -81,7 +81,14 @@ root <sha256(manifest.json)>
|
||||
|
||||
The `root` value is the SHA-256 hash of the serialized manifest and is exposed separately in the result object for downstream signing.
|
||||
|
||||
## 4. Verification script
|
||||
## 4. DSSE signature and storage
|
||||
|
||||
- The export job signs `manifest.json` with an HMAC-SHA256 based DSSE envelope, producing `manifest.dsse.json` alongside the bundle artefacts.
|
||||
- The signature document captures the bundle identifier, manifest root hash, signing timestamp, algorithm metadata, and the DSSE payload/signature.
|
||||
- Operators verify the manifest by validating `manifest.dsse.json`, then cross-checking the `payload` base64 with the downloaded manifest and the `rootHash` in `checksums.txt`.
|
||||
- All artefacts are written under a deterministic storage prefix `<storagePrefix>/<bundleId>/` with the bundle archive stored as `bundle.tgz` (or the configured file name).
|
||||
|
||||
## 5. Verification script
|
||||
|
||||
`verify-offline.sh` is a POSIX-compatible helper that:
|
||||
|
||||
@@ -91,7 +98,7 @@ The `root` value is the SHA-256 hash of the serialized manifest and is exposed s
|
||||
|
||||
Operators can override the archive name via the first argument (`./verify-offline.sh mybundle.tgz`).
|
||||
|
||||
## 5. Content categories
|
||||
## 6. Content categories
|
||||
|
||||
| Category | Target prefix | Notes |
|
||||
|-----------|---------------|-------|
|
||||
@@ -102,14 +109,13 @@ Operators can override the archive name via the first argument (`./verify-offlin
|
||||
|
||||
Paths are normalised to forward slashes and guarded against directory traversal.
|
||||
|
||||
## 6. Determinism and hashing rules
|
||||
## 7. Determinism and hashing rules
|
||||
|
||||
- Files are enumerated and emitted in ordinal path order.
|
||||
- SHA-256 digests use lowercase hex encoding.
|
||||
- Optional directories (specs, SDKs, changelog) are skipped when absent; at least one category must contain files or the builder fails fast.
|
||||
|
||||
## 7. Next steps
|
||||
## 8. Next steps
|
||||
|
||||
- Attach DSSE signing + timestamping (`signature.json`) once Export Center signing infrastructure is ready.
|
||||
- Integrate the builder into the Export Center worker profile (`devportal --offline`) and plumb orchestration/persistence.
|
||||
- Produce CLI validation tooling (`stella devportal verify`) per DVOFF-64-002 and document operator workflows under `docs/airgap/devportal-offline.md`.
|
||||
- Expand `stella devportal verify` (DVOFF-64-002) to validate DSSE signatures and bundle integrity offline.
|
||||
- Document operator workflow under `docs/airgap/devportal-offline.md`.
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
## Sprint 64 – Bundle Implementation
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| DVOFF-64-001 | DOING (2025-11-04) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
|
||||
| DVOFF-64-001 | DONE (2025-11-05) | DevPortal Offline Guild, Exporter Guild | DEVPORT-64-001, SDKREL-64-002 | Implement Export Center job `devportal --offline` bundling portal HTML, specs, SDK artifacts, changelogs, and verification manifest. | Job executes in staging; manifest contains checksums + DSSE signatures; docs updated. |
|
||||
| DVOFF-64-002 | TODO | DevPortal Offline Guild, AirGap Controller Guild | DVOFF-64-001 | Provide verification CLI (`stella devportal verify bundle.tgz`) ensuring integrity before import. | CLI command validates signatures; integration test covers corrupted bundle; runbook updated. |
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates bundle construction, manifest signing, and artefact persistence for the devportal offline export.
|
||||
/// </summary>
|
||||
public sealed class DevPortalOfflineJob
|
||||
{
|
||||
private static readonly JsonSerializerOptions SignatureSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly DevPortalOfflineBundleBuilder _builder;
|
||||
private readonly IDevPortalOfflineObjectStore _objectStore;
|
||||
private readonly IDevPortalOfflineManifestSigner _manifestSigner;
|
||||
private readonly ILogger<DevPortalOfflineJob> _logger;
|
||||
|
||||
public DevPortalOfflineJob(
|
||||
DevPortalOfflineBundleBuilder builder,
|
||||
IDevPortalOfflineObjectStore objectStore,
|
||||
IDevPortalOfflineManifestSigner manifestSigner,
|
||||
ILogger<DevPortalOfflineJob> logger)
|
||||
{
|
||||
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DevPortalOfflineJobOutcome> ExecuteAsync(
|
||||
DevPortalOfflineJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var bundleResult = _builder.Build(request.BundleRequest, cancellationToken);
|
||||
_logger.LogInformation("DevPortal offline bundle constructed with {EntryCount} entries.", bundleResult.Manifest.Entries.Count);
|
||||
|
||||
var signature = await _manifestSigner.SignAsync(
|
||||
request.BundleRequest.BundleId,
|
||||
bundleResult.ManifestJson,
|
||||
bundleResult.RootHash,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var storagePrefix = BuildStoragePrefix(request.StoragePrefix, request.BundleRequest.BundleId);
|
||||
var bundleFileName = SanitizeFileName(request.BundleFileName);
|
||||
|
||||
var manifestKey = $"{storagePrefix}/manifest.json";
|
||||
var signatureKey = $"{storagePrefix}/manifest.dsse.json";
|
||||
var checksumsKey = $"{storagePrefix}/checksums.txt";
|
||||
var bundleKey = $"{storagePrefix}/{bundleFileName}";
|
||||
|
||||
var manifestMetadata = await StoreTextAsync(
|
||||
bundleResult.ManifestJson,
|
||||
manifestKey,
|
||||
MediaTypes.Json,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var signatureJson = JsonSerializer.Serialize(signature, SignatureSerializerOptions);
|
||||
var signatureMetadata = await StoreTextAsync(
|
||||
signatureJson,
|
||||
signatureKey,
|
||||
MediaTypes.Json,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var checksumsMetadata = await StoreTextAsync(
|
||||
bundleResult.Checksums,
|
||||
checksumsKey,
|
||||
MediaTypes.Text,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
DevPortalOfflineStorageMetadata bundleMetadata;
|
||||
using (bundleResult.BundleStream)
|
||||
{
|
||||
bundleResult.BundleStream.Position = 0;
|
||||
bundleMetadata = await _objectStore.StoreAsync(
|
||||
bundleResult.BundleStream,
|
||||
new DevPortalOfflineObjectStoreOptions(bundleKey, MediaTypes.GZip),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DevPortal offline bundle stored at {BundleKey} ({SizeBytes} bytes).",
|
||||
bundleMetadata.StorageKey,
|
||||
bundleMetadata.SizeBytes);
|
||||
|
||||
return new DevPortalOfflineJobOutcome(
|
||||
bundleResult.Manifest,
|
||||
bundleResult.RootHash,
|
||||
signature,
|
||||
manifestMetadata,
|
||||
signatureMetadata,
|
||||
checksumsMetadata,
|
||||
bundleMetadata);
|
||||
}
|
||||
|
||||
private Task<DevPortalOfflineStorageMetadata> StoreTextAsync(
|
||||
string content,
|
||||
string storageKey,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var stream = new MemoryStream(bytes, writable: false);
|
||||
return StoreAsync(stream, storageKey, contentType, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<DevPortalOfflineStorageMetadata> StoreAsync(
|
||||
Stream stream,
|
||||
string storageKey,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _objectStore.StoreAsync(
|
||||
stream,
|
||||
new DevPortalOfflineObjectStoreOptions(storageKey, contentType),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildStoragePrefix(string? prefix, Guid bundleId)
|
||||
{
|
||||
var trimmed = string.IsNullOrWhiteSpace(prefix)
|
||||
? string.Empty
|
||||
: prefix.Trim().Trim('/').Replace('\\', '/');
|
||||
|
||||
return string.IsNullOrEmpty(trimmed)
|
||||
? bundleId.ToString("D")
|
||||
: $"{trimmed}/{bundleId:D}";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "devportal-offline-bundle.tgz";
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(value);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return "devportal-offline-bundle.tgz";
|
||||
}
|
||||
|
||||
if (fileName.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Bundle file name cannot contain path traversal sequences.");
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
private static class MediaTypes
|
||||
{
|
||||
public const string Json = "application/json";
|
||||
public const string Text = "text/plain";
|
||||
public const string GZip = "application/gzip";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the inputs required to execute the devportal offline export job.
|
||||
/// </summary>
|
||||
/// <param name="BundleRequest">Bundle builder request describing source directories and bundle metadata.</param>
|
||||
/// <param name="StoragePrefix">Relative storage prefix (without bundle identifier) where artefacts are persisted.</param>
|
||||
/// <param name="BundleFileName">File name used for the packaged archive in storage.</param>
|
||||
public sealed record DevPortalOfflineJobRequest(
|
||||
DevPortalOfflineBundleRequest BundleRequest,
|
||||
string StoragePrefix,
|
||||
string BundleFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Captures metadata produced after successfully executing the devportal offline export job.
|
||||
/// </summary>
|
||||
/// <param name="Manifest">The manifest describing bundled artefacts.</param>
|
||||
/// <param name="RootHash">SHA-256 hash of the manifest JSON payload.</param>
|
||||
/// <param name="Signature">DSSE envelope and metadata for the manifest.</param>
|
||||
/// <param name="ManifestStorage">Storage metadata for the manifest JSON artefact.</param>
|
||||
/// <param name="SignatureStorage">Storage metadata for the manifest signature document.</param>
|
||||
/// <param name="ChecksumsStorage">Storage metadata for the checksum file.</param>
|
||||
/// <param name="BundleStorage">Storage metadata for the bundled archive.</param>
|
||||
public sealed record DevPortalOfflineJobOutcome(
|
||||
DevPortalOfflineBundleManifest Manifest,
|
||||
string RootHash,
|
||||
DevPortalOfflineManifestSignatureDocument Signature,
|
||||
DevPortalOfflineStorageMetadata ManifestStorage,
|
||||
DevPortalOfflineStorageMetadata SignatureStorage,
|
||||
DevPortalOfflineStorageMetadata ChecksumsStorage,
|
||||
DevPortalOfflineStorageMetadata BundleStorage);
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Provides DSSE signing for devportal offline bundle manifests.
|
||||
/// </summary>
|
||||
public interface IDevPortalOfflineManifestSigner
|
||||
{
|
||||
Task<DevPortalOfflineManifestSignatureDocument> SignAsync(
|
||||
Guid bundleId,
|
||||
string manifestJson,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature document emitted for devportal manifest verification.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Identifier of the bundle being signed.</param>
|
||||
/// <param name="RootHash">SHA-256 hash of the manifest JSON.</param>
|
||||
/// <param name="SignedAtUtc">UTC timestamp when the signature was created.</param>
|
||||
/// <param name="Algorithm">Signing algorithm identifier (for example HMACSHA256).</param>
|
||||
/// <param name="KeyId">Identifier of the signing key.</param>
|
||||
/// <param name="Envelope">DSSE envelope containing payload and signatures.</param>
|
||||
public sealed record DevPortalOfflineManifestSignatureDocument(
|
||||
[property: JsonPropertyName("bundleId")] Guid BundleId,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("signedAt")] DateTimeOffset SignedAtUtc,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("keyId")] string KeyId,
|
||||
[property: JsonPropertyName("envelope")] DevPortalOfflineManifestDsseEnvelope Envelope);
|
||||
|
||||
/// <summary>
|
||||
/// Standard DSSE envelope carrying payload and signatures.
|
||||
/// </summary>
|
||||
/// <param name="PayloadType">Type of the payload (for example application/json).</param>
|
||||
/// <param name="Payload">Base64-encoded manifest payload.</param>
|
||||
/// <param name="Signatures">Collection of DSSE signatures.</param>
|
||||
public sealed record DevPortalOfflineManifestDsseEnvelope(
|
||||
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||
[property: JsonPropertyName("payload")] string Payload,
|
||||
[property: JsonPropertyName("signatures")] IReadOnlyList<DevPortalOfflineManifestDsseSignature> Signatures);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an individual DSSE signature entry.
|
||||
/// </summary>
|
||||
/// <param name="Signature">Base64-encoded signature.</param>
|
||||
/// <param name="KeyId">Identifier for the key used to sign.</param>
|
||||
public sealed record DevPortalOfflineManifestDsseSignature(
|
||||
[property: JsonPropertyName("sig")] string Signature,
|
||||
[property: JsonPropertyName("keyid")] string? KeyId);
|
||||
@@ -3,16 +3,15 @@
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
|
||||
var fixedNow = new DateTimeOffset(2025, 11, 4, 12, 30, 0, TimeSpan.Zero);
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(fixedNow));
|
||||
var result = builder.Build(request);
|
||||
var result = builder.Build(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(request.BundleId, result.Manifest.BundleId);
|
||||
Assert.Equal("devportal-offline/v1", result.Manifest.Version);
|
||||
@@ -65,12 +65,12 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
|
||||
var expectedPaths = new[]
|
||||
{
|
||||
"changelog/CHANGELOG.md",
|
||||
"portal/assets/app.js",
|
||||
"portal/index.html",
|
||||
"specs/openapi.yaml",
|
||||
"sdks/dotnet/stellaops.sdk.nupkg",
|
||||
"sdks/python/stellaops_sdk.whl",
|
||||
"changelog/CHANGELOG.md"
|
||||
"specs/openapi.yaml"
|
||||
};
|
||||
|
||||
Assert.Equal(expectedPaths, result.Manifest.Entries.Select(entry => entry.Path).ToArray());
|
||||
@@ -119,7 +119,10 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Dispose();
|
||||
if (Directory.Exists(tempRoot.FullName))
|
||||
{
|
||||
Directory.Delete(tempRoot.FullName, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +132,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => builder.Build(request));
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => builder.Build(request, TestContext.Current.CancellationToken));
|
||||
Assert.Contains("does not contain any files", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -145,7 +148,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html/>");
|
||||
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot));
|
||||
var result = builder.Build(new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Single(result.Manifest.Entries);
|
||||
Assert.True(result.Manifest.Sources.PortalIncluded);
|
||||
@@ -155,7 +158,10 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
tempRoot.Dispose();
|
||||
if (Directory.Exists(tempRoot.FullName))
|
||||
{
|
||||
Directory.Delete(tempRoot.FullName, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +172,7 @@ public sealed class DevPortalOfflineBundleBuilderTests
|
||||
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), missing);
|
||||
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request));
|
||||
Assert.Throws<DirectoryNotFoundException>(() => builder.Build(request, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static string CalculateFileHash(string path)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public class DevPortalOfflineJobTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_StoresArtefacts()
|
||||
{
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
|
||||
Directory.CreateDirectory(portalRoot);
|
||||
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html>offline</html>");
|
||||
|
||||
var specsRoot = Path.Combine(tempRoot.FullName, "specs");
|
||||
Directory.CreateDirectory(specsRoot);
|
||||
File.WriteAllText(Path.Combine(specsRoot, "api.json"), "{\"openapi\":\"3.1.0\"}");
|
||||
|
||||
var sdkRoot = Path.Combine(tempRoot.FullName, "sdk-dotnet");
|
||||
Directory.CreateDirectory(sdkRoot);
|
||||
File.WriteAllText(Path.Combine(sdkRoot, "stellaops.sdk.nupkg"), "binary");
|
||||
|
||||
var changelogRoot = Path.Combine(tempRoot.FullName, "changelog");
|
||||
Directory.CreateDirectory(changelogRoot);
|
||||
File.WriteAllText(Path.Combine(changelogRoot, "CHANGELOG.md"), "# 2025.11.0");
|
||||
|
||||
var bundleId = Guid.Parse("5e76bb6f-2925-41ea-8e72-1fd8a384dd1a");
|
||||
var request = new DevPortalOfflineBundleRequest(
|
||||
bundleId,
|
||||
portalRoot,
|
||||
specsRoot,
|
||||
new[] { new DevPortalSdkSource("dotnet", sdkRoot) },
|
||||
changelogRoot,
|
||||
new Dictionary<string, string> { ["releaseVersion"] = "2025.11.0" });
|
||||
|
||||
var fixedNow = new DateTimeOffset(2025, 11, 4, 18, 15, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(fixedNow);
|
||||
var builder = new DevPortalOfflineBundleBuilder(timeProvider);
|
||||
var objectStore = new InMemoryObjectStore(timeProvider);
|
||||
var signer = new TestManifestSigner(timeProvider);
|
||||
var job = new DevPortalOfflineJob(builder, objectStore, signer, NullLogger<DevPortalOfflineJob>.Instance);
|
||||
|
||||
var outcome = await job.ExecuteAsync(
|
||||
new DevPortalOfflineJobRequest(request, "exports/devportal", "bundle.tgz"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var expectedPrefix = $"exports/devportal/{bundleId:D}";
|
||||
Assert.Equal($"{expectedPrefix}/manifest.json", outcome.ManifestStorage.StorageKey);
|
||||
Assert.Equal($"{expectedPrefix}/manifest.dsse.json", outcome.SignatureStorage.StorageKey);
|
||||
Assert.Equal($"{expectedPrefix}/checksums.txt", outcome.ChecksumsStorage.StorageKey);
|
||||
Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey);
|
||||
|
||||
var manifestText = objectStore.GetText(outcome.ManifestStorage.StorageKey);
|
||||
using (var manifestDoc = JsonDocument.Parse(manifestText))
|
||||
{
|
||||
Assert.Equal("devportal-offline/v1", manifestDoc.RootElement.GetProperty("version").GetString());
|
||||
Assert.Equal("2025.11.0", manifestDoc.RootElement.GetProperty("metadata").GetProperty("releaseVersion").GetString());
|
||||
}
|
||||
|
||||
var signatureText = objectStore.GetText(outcome.SignatureStorage.StorageKey);
|
||||
Assert.Contains(bundleId.ToString("D"), signatureText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(outcome.RootHash, signatureText, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot.FullName))
|
||||
{
|
||||
Directory.Delete(tempRoot.FullName, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SanitizesBundleFileName()
|
||||
{
|
||||
var builder = new DevPortalOfflineBundleBuilder(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var objectStore = new InMemoryObjectStore(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var signer = new TestManifestSigner(new FixedTimeProvider(DateTimeOffset.UtcNow));
|
||||
var job = new DevPortalOfflineJob(builder, objectStore, signer, NullLogger<DevPortalOfflineJob>.Instance);
|
||||
|
||||
var tempRoot = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var portalRoot = Path.Combine(tempRoot.FullName, "portal");
|
||||
Directory.CreateDirectory(portalRoot);
|
||||
File.WriteAllText(Path.Combine(portalRoot, "index.html"), "<html/>");
|
||||
|
||||
var request = new DevPortalOfflineBundleRequest(Guid.NewGuid(), portalRoot);
|
||||
var outcome = await job.ExecuteAsync(
|
||||
new DevPortalOfflineJobRequest(request, "exports", "../bundle.tgz"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var expectedPrefix = $"exports/{request.BundleId:D}";
|
||||
Assert.Equal($"{expectedPrefix}/bundle.tgz", outcome.BundleStorage.StorageKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot.FullName))
|
||||
{
|
||||
Directory.Delete(tempRoot.FullName, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryObjectStore : IDevPortalOfflineObjectStore
|
||||
{
|
||||
private readonly Dictionary<string, InMemoryEntry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryObjectStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<DevPortalOfflineStorageMetadata> StoreAsync(
|
||||
Stream content,
|
||||
DevPortalOfflineObjectStoreOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
content.CopyTo(memory);
|
||||
var bytes = memory.ToArray();
|
||||
content.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
var metadata = new DevPortalOfflineStorageMetadata(
|
||||
options.StorageKey,
|
||||
options.ContentType,
|
||||
bytes.Length,
|
||||
sha,
|
||||
_timeProvider.GetUtcNow());
|
||||
|
||||
_entries[options.StorageKey] = new InMemoryEntry(options.ContentType, bytes);
|
||||
return Task.FromResult(metadata);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_entries.ContainsKey(storageKey));
|
||||
|
||||
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_entries.TryGetValue(storageKey, out var entry))
|
||||
{
|
||||
throw new FileNotFoundException(storageKey);
|
||||
}
|
||||
|
||||
Stream stream = new MemoryStream(entry.Content, writable: false);
|
||||
return Task.FromResult(stream);
|
||||
}
|
||||
|
||||
public string GetText(string storageKey)
|
||||
{
|
||||
if (!_entries.TryGetValue(storageKey, out var entry))
|
||||
{
|
||||
throw new FileNotFoundException(storageKey);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(entry.Content);
|
||||
}
|
||||
|
||||
private sealed record InMemoryEntry(string ContentType, byte[] Content);
|
||||
}
|
||||
|
||||
private sealed class TestManifestSigner : IDevPortalOfflineManifestSigner
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TestManifestSigner(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<DevPortalOfflineManifestSignatureDocument> SignAsync(
|
||||
Guid bundleId,
|
||||
string manifestJson,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||
return Task.FromResult(new DevPortalOfflineManifestSignatureDocument(
|
||||
bundleId,
|
||||
rootHash,
|
||||
_timeProvider.GetUtcNow(),
|
||||
"TEST-SHA256",
|
||||
"test-key",
|
||||
new DevPortalOfflineManifestDsseEnvelope(
|
||||
"application/json",
|
||||
payload,
|
||||
new[] { new DevPortalOfflineManifestDsseSignature("c2lnbmF0dXJl", "test-key") })));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public override long GetTimestamp() => TimeProvider.System.GetTimestamp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
using StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public class HmacDevPortalOfflineManifestSignerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignAsync_ComputesDeterministicSignature()
|
||||
{
|
||||
var options = new DevPortalOfflineManifestSigningOptions
|
||||
{
|
||||
KeyId = "devportal-test",
|
||||
Secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("shared-secret")),
|
||||
Algorithm = "HMACSHA256",
|
||||
PayloadType = "application/vnd.stella.devportal.manifest+json"
|
||||
};
|
||||
|
||||
var now = new DateTimeOffset(2025, 11, 4, 19, 0, 0, TimeSpan.Zero);
|
||||
var signer = new HmacDevPortalOfflineManifestSigner(
|
||||
new StaticOptionsMonitor<DevPortalOfflineManifestSigningOptions>(options),
|
||||
new FixedTimeProvider(now),
|
||||
NullLogger<HmacDevPortalOfflineManifestSigner>.Instance);
|
||||
|
||||
const string manifest = "{\"version\":\"v1\",\"entries\":[]}";
|
||||
var rootHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(manifest))).ToLowerInvariant();
|
||||
var bundleId = Guid.Parse("9ca2aafb-42b7-4df9-85f7-5a1d46c4e0ef");
|
||||
|
||||
var document = await signer.SignAsync(bundleId, manifest, rootHash, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(bundleId, document.BundleId);
|
||||
Assert.Equal(rootHash, document.RootHash);
|
||||
Assert.Equal(now, document.SignedAtUtc);
|
||||
Assert.Equal(options.Algorithm, document.Algorithm);
|
||||
Assert.Equal(options.KeyId, document.KeyId);
|
||||
Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes(manifest)), document.Envelope.Payload);
|
||||
Assert.Equal(options.PayloadType, document.Envelope.PayloadType);
|
||||
|
||||
var signature = Assert.Single(document.Envelope.Signatures);
|
||||
Assert.Equal(options.KeyId, signature.KeyId);
|
||||
|
||||
var expectedSignature = ComputeExpectedSignature(options, manifest);
|
||||
Assert.Equal(expectedSignature, signature.Signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ThrowsForUnsupportedAlgorithm()
|
||||
{
|
||||
var options = new DevPortalOfflineManifestSigningOptions
|
||||
{
|
||||
KeyId = "devportal-test",
|
||||
Secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("shared-secret")),
|
||||
Algorithm = "RSA",
|
||||
PayloadType = "application/json"
|
||||
};
|
||||
|
||||
var signer = new HmacDevPortalOfflineManifestSigner(
|
||||
new StaticOptionsMonitor<DevPortalOfflineManifestSigningOptions>(options),
|
||||
new FixedTimeProvider(DateTimeOffset.UtcNow),
|
||||
NullLogger<HmacDevPortalOfflineManifestSigner>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
signer.SignAsync(Guid.NewGuid(), "{}", "root", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static string ComputeExpectedSignature(DevPortalOfflineManifestSigningOptions options, string manifest)
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(manifest);
|
||||
var pae = BuildPreAuthEncoding(options.PayloadType, payloadBytes);
|
||||
|
||||
var secret = Convert.FromBase64String(options.Secret);
|
||||
using var hmac = new HMACSHA256(secret);
|
||||
var signature = hmac.ComputeHash(pae);
|
||||
return Convert.ToBase64String(signature);
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payloadBytes)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
const string prefix = "DSSEv1";
|
||||
|
||||
var buffer = new byte[prefix.Length + sizeof(ulong) + sizeof(ulong) + typeBytes.Length + sizeof(ulong) + payloadBytes.Length];
|
||||
var span = buffer.AsSpan();
|
||||
|
||||
var written = Encoding.UTF8.GetBytes(prefix, span);
|
||||
span = span[written..];
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(span, 2);
|
||||
span = span[sizeof(ulong)..];
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(span, (ulong)typeBytes.Length);
|
||||
span = span[sizeof(ulong)..];
|
||||
|
||||
typeBytes.CopyTo(span);
|
||||
span = span[typeBytes.Length..];
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(span, (ulong)payloadBytes.Length);
|
||||
span = span[sizeof(ulong)..];
|
||||
|
||||
payloadBytes.CopyTo(span);
|
||||
return buffer;
|
||||
}
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public override long GetTimestamp() => TimeProvider.System.GetTimestamp();
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.ExportCenter.Worker;
|
||||
|
||||
public sealed class DevPortalOfflineWorkerOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public Guid? BundleId { get; set; }
|
||||
public string? StoragePrefix { get; set; }
|
||||
public string? BundleFileName { get; set; }
|
||||
public string? PortalDirectory { get; set; }
|
||||
public string? SpecsDirectory { get; set; }
|
||||
public string? ChangelogDirectory { get; set; }
|
||||
public List<DevPortalOfflineWorkerSdkSource>? SdkSources { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DevPortalOfflineWorkerSdkSource(string Name, string Directory);
|
||||
@@ -1,7 +1,22 @@
|
||||
using StellaOps.ExportCenter.Worker;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
using StellaOps.ExportCenter.Infrastructure.DevPortalOffline;
|
||||
using StellaOps.ExportCenter.Worker;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.Configure<DevPortalOfflineWorkerOptions>(builder.Configuration.GetSection("DevPortalOffline"));
|
||||
builder.Services.Configure<DevPortalOfflineManifestSigningOptions>(builder.Configuration.GetSection("DevPortalOffline:Signing"));
|
||||
builder.Services.Configure<DevPortalOfflineStorageOptions>(builder.Configuration.GetSection("DevPortalOffline:Storage"));
|
||||
|
||||
builder.Services.AddSingleton<DevPortalOfflineBundleBuilder>();
|
||||
builder.Services.AddSingleton<IDevPortalOfflineManifestSigner, HmacDevPortalOfflineManifestSigner>();
|
||||
builder.Services.AddSingleton<IDevPortalOfflineObjectStore, FileSystemDevPortalOfflineObjectStore>();
|
||||
builder.Services.AddSingleton<DevPortalOfflineJob>();
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
|
||||
@@ -1,16 +1,87 @@
|
||||
namespace StellaOps.ExportCenter.Worker;
|
||||
|
||||
public class Worker(ILogger<Worker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||
}
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.Core.DevPortalOffline;
|
||||
|
||||
namespace StellaOps.ExportCenter.Worker;
|
||||
|
||||
public sealed class Worker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<Worker> _logger;
|
||||
private readonly DevPortalOfflineJob _job;
|
||||
private readonly IOptions<DevPortalOfflineWorkerOptions> _options;
|
||||
|
||||
public Worker(
|
||||
ILogger<Worker> logger,
|
||||
DevPortalOfflineJob job,
|
||||
IOptions<DevPortalOfflineWorkerOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_job = job ?? throw new ArgumentNullException(nameof(job));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var options = _options.Value ?? new DevPortalOfflineWorkerOptions();
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("DevPortal offline job disabled. Worker idling.");
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(options);
|
||||
var outcome = await _job.ExecuteAsync(request, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"DevPortal offline export completed. Bundle stored at {Key}. Manifest signature key {SignatureKey}.",
|
||||
outcome.BundleStorage.StorageKey,
|
||||
outcome.SignatureStorage.StorageKey);
|
||||
}
|
||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "DevPortal offline export job failed.");
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static DevPortalOfflineJobRequest BuildRequest(DevPortalOfflineWorkerOptions options)
|
||||
{
|
||||
var bundleId = options.BundleId ?? Guid.NewGuid();
|
||||
var metadata = options.Metadata is null || options.Metadata.Count == 0
|
||||
? null
|
||||
: new Dictionary<string, string>(options.Metadata, StringComparer.Ordinal);
|
||||
|
||||
var sdkSources = options.SdkSources is { Count: > 0 }
|
||||
? options.SdkSources
|
||||
.Select(source => new DevPortalSdkSource(source.Name, source.Directory))
|
||||
.ToArray()
|
||||
: Array.Empty<DevPortalSdkSource>();
|
||||
|
||||
var bundleRequest = new DevPortalOfflineBundleRequest(
|
||||
bundleId,
|
||||
options.PortalDirectory,
|
||||
options.SpecsDirectory,
|
||||
sdkSources,
|
||||
options.ChangelogDirectory,
|
||||
metadata);
|
||||
|
||||
var storagePrefix = options.StoragePrefix ?? "devportal/offline";
|
||||
var bundleFileName = string.IsNullOrWhiteSpace(options.BundleFileName)
|
||||
? "devportal-offline-bundle.tgz"
|
||||
: options.BundleFileName;
|
||||
|
||||
return new DevPortalOfflineJobRequest(
|
||||
bundleRequest,
|
||||
storagePrefix,
|
||||
bundleFileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"DevPortalOffline": {
|
||||
"Enabled": false,
|
||||
"StoragePrefix": "devportal/offline",
|
||||
"BundleFileName": "devportal-offline-bundle.tgz",
|
||||
"Metadata": {
|
||||
"releaseChannel": "stable"
|
||||
},
|
||||
"Storage": {
|
||||
"RootPath": "./out/devportal-offline"
|
||||
},
|
||||
"Signing": {
|
||||
"KeyId": "devportal-offline-local",
|
||||
"Secret": "ZGV2cG9ydGFsLW9mZmxpbmUtc2lnbmVyLXNlY3JldA==",
|
||||
"Algorithm": "HMACSHA256",
|
||||
"PayloadType": "application/vnd.stella.devportal.manifest+json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user