semi implemented and features implemented save checkpoint

This commit is contained in:
master
2026-02-08 18:00:49 +02:00
parent 04360dff63
commit 1bf6bbf395
20895 changed files with 716795 additions and 64 deletions

View File

@@ -19,6 +19,7 @@ namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class HttpRekorClient : IRekorClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly string[] IndexLookupPaths = ["api/v2/index/retrieve", "api/v1/index/retrieve"];
private readonly HttpClient _httpClient;
private readonly ILogger<HttpRekorClient> _logger;
@@ -31,6 +32,16 @@ internal sealed class HttpRekorClient : IRekorClient
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var existing = await TryGetExistingEntryAsync(request, backend, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
_logger.LogInformation(
"Rekor hash pre-check hit for bundle {BundleSha}; reusing existing entry {Uuid}",
request.Meta.BundleSha256,
existing.Uuid);
return existing;
}
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
@@ -65,10 +76,252 @@ internal sealed class HttpRekorClient : IRekorClient
Index = index,
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default),
IntegratedTime = root.TryGetProperty("integratedTime", out var integratedTimeElement) && integratedTimeElement.TryGetInt64(out var integratedTime)
? integratedTime
: null
};
}
private async Task<RekorSubmissionResponse?> TryGetExistingEntryAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Meta.BundleSha256))
{
return null;
}
foreach (var path in IndexLookupPaths)
{
foreach (var hashCandidate in GetHashCandidates(request.Meta.BundleSha256))
{
var lookupUri = BuildUri(backend.Url, path);
try
{
using var lookupRequest = new HttpRequestMessage(HttpMethod.Post, lookupUri)
{
Content = JsonContent.Create(new { hash = hashCandidate }, options: SerializerOptions)
};
using var lookupResponse = await _httpClient.SendAsync(lookupRequest, cancellationToken).ConfigureAwait(false);
if (lookupResponse.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
{
break;
}
if (!lookupResponse.IsSuccessStatusCode)
{
_logger.LogDebug(
"Rekor hash pre-check returned {StatusCode} on {Path}",
lookupResponse.StatusCode,
path);
continue;
}
await using var stream = await lookupResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var existingUuid = ExtractFirstUuid(document.RootElement);
if (string.IsNullOrWhiteSpace(existingUuid))
{
continue;
}
return await GetExistingEntryAsync(existingUuid, backend, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(
ex,
"Rekor hash pre-check failed on {Path}; falling back to submit",
path);
}
}
}
return null;
}
private async Task<RekorSubmissionResponse> GetExistingEntryAsync(
string uuid,
RekorBackend backend,
CancellationToken cancellationToken)
{
var entryUri = BuildUri(backend.Url, $"api/v2/log/entries/{uuid}");
using var request = new HttpRequestMessage(HttpMethod.Get, entryUri);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return new RekorSubmissionResponse
{
Uuid = uuid,
LogUrl = backend.Url.ToString(),
Status = "included"
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
return new RekorSubmissionResponse
{
Uuid = TryGetString(root, "uuid") ?? uuid,
Index = TryGetLong(root, "logIndex") ?? TryGetLong(root, "index"),
LogUrl = TryGetString(root, "logURL") ?? backend.Url.ToString(),
Status = TryGetString(root, "status") ?? "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default),
IntegratedTime = TryGetLong(root, "integratedTime")
};
}
private static string[] GetHashCandidates(string bundleSha256)
{
var normalized = bundleSha256.Trim().ToLowerInvariant();
if (normalized.StartsWith("sha256:", StringComparison.Ordinal))
{
normalized = normalized["sha256:".Length..];
}
return [$"sha256:{normalized}", normalized];
}
private static string? ExtractFirstUuid(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.String:
{
var value = element.GetString();
return LooksLikeUuid(value) ? value : null;
}
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
{
var match = ExtractFirstUuid(item);
if (!string.IsNullOrWhiteSpace(match))
{
return match;
}
}
return null;
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.String &&
string.Equals(property.Name, "uuid", StringComparison.OrdinalIgnoreCase))
{
var direct = property.Value.GetString();
if (LooksLikeUuid(direct))
{
return direct;
}
}
if (LooksLikeUuid(property.Name))
{
return property.Name;
}
var nested = ExtractFirstUuid(property.Value);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
return null;
default:
return null;
}
}
private static bool LooksLikeUuid(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (Guid.TryParse(trimmed, out _))
{
return true;
}
return trimmed.Length == 64 && trimmed.All(static ch => Uri.IsHexDigit(ch));
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
}
if (property.Value.ValueKind == JsonValueKind.Object)
{
var nested = TryGetString(property.Value, propertyName);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
return null;
}
private static long? TryGetLong(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.TryGetInt64(out var value))
{
return value;
}
if (property.Value.ValueKind == JsonValueKind.Object)
{
var nested = TryGetLong(property.Value, propertyName);
if (nested.HasValue)
{
return nested;
}
}
}
return null;
}
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0055-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0055-A | DONE | Applied determinism, backend resolver, and Rekor client fixes 2026-01-08. |
| VAL-SMOKE-001 | DONE | Fixed continuation token behavior; unit tests pass. |
| SPRINT-20260208-060-IDEMP-001 | DONE | Added Rekor search-by-hash pre-check before submission with existing-entry reuse and deterministic fallback behavior (2026-02-08). |

View File

@@ -16,6 +16,7 @@ using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.X509;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.TestKit;
@@ -25,6 +26,40 @@ namespace StellaOps.Attestor.Infrastructure.Tests;
public sealed class HttpRekorClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SubmitAsync_HashPrecheckHit_ReusesExistingEntryWithoutNewSubmission()
{
var handler = new HashPrecheckHitHandler();
var client = CreateClient(handler);
var backend = CreateBackend();
var response = await client.SubmitAsync(CreateSubmissionRequest(), backend, CancellationToken.None);
response.Uuid.Should().Be("11111111-1111-1111-1111-111111111111");
response.Index.Should().Be(777);
response.Status.Should().Be("included");
handler.SubmitCalls.Should().Be(0);
handler.IndexLookupCalls.Should().BeGreaterThan(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SubmitAsync_HashPrecheckUnsupported_FallsBackToSubmission()
{
var handler = new HashPrecheckUnsupportedHandler();
var client = CreateClient(handler);
var backend = CreateBackend();
var response = await client.SubmitAsync(CreateSubmissionRequest(), backend, CancellationToken.None);
response.Uuid.Should().Be("new-uuid");
response.Index.Should().Be(42);
response.Status.Should().Be("included");
handler.SubmitCalls.Should().Be(1);
handler.IndexLookupCalls.Should().BeGreaterThan(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyInclusionAsync_MissingLogIndex_ReturnsFailure()
@@ -201,6 +236,40 @@ public sealed class HttpRekorClientTests
return new HttpRekorClient(httpClient, NullLogger<HttpRekorClient>.Instance);
}
private static AttestorSubmissionRequest CreateSubmissionRequest()
{
return new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test-key",
Signature = "sig"
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
BundleSha256 = new string('a', 64),
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('b', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
}
private static RekorBackend CreateBackend()
{
return new RekorBackend
@@ -308,6 +377,63 @@ public sealed class HttpRekorClientTests
}
}
private sealed class HashPrecheckHitHandler : HttpMessageHandler
{
public int IndexLookupCalls { get; private set; }
public int SubmitCalls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v2/index/retrieve", StringComparison.Ordinal))
{
IndexLookupCalls++;
return Task.FromResult(BuildResponse("""["11111111-1111-1111-1111-111111111111"]"""));
}
if (request.Method == HttpMethod.Get && path.EndsWith("/api/v2/log/entries/11111111-1111-1111-1111-111111111111", StringComparison.Ordinal))
{
return Task.FromResult(BuildResponse("""{"uuid":"11111111-1111-1111-1111-111111111111","logIndex":777,"status":"included","integratedTime":1735689600}"""));
}
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v2/log/entries", StringComparison.Ordinal))
{
SubmitCalls++;
return Task.FromResult(BuildResponse("""{"uuid":"should-not-submit","index":1,"status":"included"}"""));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
private sealed class HashPrecheckUnsupportedHandler : HttpMessageHandler
{
public int IndexLookupCalls { get; private set; }
public int SubmitCalls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Post &&
(path.EndsWith("/api/v2/index/retrieve", StringComparison.Ordinal) ||
path.EndsWith("/api/v1/index/retrieve", StringComparison.Ordinal)))
{
IndexLookupCalls++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
if (request.Method == HttpMethod.Post && path.EndsWith("/api/v2/log/entries", StringComparison.Ordinal))
{
SubmitCalls++;
return Task.FromResult(BuildResponse("""{"uuid":"new-uuid","index":42,"status":"included","integratedTime":1735689600}"""));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
private sealed class ProofOnlyHandler : HttpMessageHandler
{
private readonly string _proofJson;

View File

@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| VAL-SMOKE-001 | DONE | Removed xUnit v2 references and verified unit tests pass. |
| AUDIT-0208-T | DONE | Revalidated 2026-01-08 (raw string + xUnit1051 fixes). |
| AUDIT-0208-A | DONE | Applied fixes 2026-01-08 (raw string + xUnit1051 fixes). |
| SPRINT-20260208-060-IDEMP-001 | DONE | Added deterministic unit coverage for Rekor hash pre-check and submission fallback behavior; `HttpRekorClientTests` passing (2026-02-08). |