sprints enhancements

This commit is contained in:
StellaOps Bot
2025-12-25 19:52:30 +02:00
parent ef6ac36323
commit b8b2d83f4a
138 changed files with 25133 additions and 594 deletions

View File

@@ -0,0 +1,257 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Provcache;
/// <summary>
/// File-based lazy evidence chunk fetcher for sneakernet mode.
/// Fetches chunks from a local directory (e.g., USB drive, NFS mount).
/// </summary>
public sealed class FileChunkFetcher : ILazyEvidenceFetcher
{
private readonly string _basePath;
private readonly ILogger<FileChunkFetcher> _logger;
private readonly JsonSerializerOptions _jsonOptions;
/// <inheritdoc />
public string FetcherType => "file";
/// <summary>
/// Creates a file chunk fetcher with the specified base directory.
/// </summary>
/// <param name="basePath">The base directory containing evidence files.</param>
/// <param name="logger">Logger instance.</param>
public FileChunkFetcher(string basePath, ILogger<FileChunkFetcher> logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(basePath);
_basePath = Path.GetFullPath(basePath);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
_logger.LogDebug("FileChunkFetcher initialized with base path: {BasePath}", _basePath);
}
/// <inheritdoc />
public async Task<FetchedChunk?> FetchChunkAsync(
string proofRoot,
int chunkIndex,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentOutOfRangeException.ThrowIfNegative(chunkIndex);
var chunkPath = GetChunkPath(proofRoot, chunkIndex);
_logger.LogDebug("Looking for chunk at {Path}", chunkPath);
if (!File.Exists(chunkPath))
{
_logger.LogDebug("Chunk file not found: {Path}", chunkPath);
return null;
}
try
{
await using var stream = File.OpenRead(chunkPath);
var chunk = await JsonSerializer.DeserializeAsync<FetchedChunk>(stream, _jsonOptions, cancellationToken);
_logger.LogDebug("Successfully loaded chunk {Index}, {Bytes} bytes", chunkIndex, chunk?.Data.Length ?? 0);
return chunk;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error reading chunk file {Path}", chunkPath);
throw;
}
}
/// <inheritdoc />
public async IAsyncEnumerable<FetchedChunk> FetchChunksAsync(
string proofRoot,
IEnumerable<int> chunkIndices,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentNullException.ThrowIfNull(chunkIndices);
var indices = chunkIndices.ToList();
_logger.LogInformation("Fetching {Count} chunks from file system for proof root {ProofRoot}", indices.Count, proofRoot);
foreach (var index in indices)
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = await FetchChunkAsync(proofRoot, index, cancellationToken);
if (chunk is not null)
{
yield return chunk;
}
}
}
/// <inheritdoc />
public async IAsyncEnumerable<FetchedChunk> FetchRemainingChunksAsync(
string proofRoot,
ChunkManifest manifest,
IReadOnlySet<int> existingIndices,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(existingIndices);
var missingIndices = Enumerable.Range(0, manifest.TotalChunks)
.Where(i => !existingIndices.Contains(i))
.ToList();
_logger.LogInformation(
"Fetching {MissingCount} remaining chunks from files (have {ExistingCount}/{TotalCount})",
missingIndices.Count, existingIndices.Count, manifest.TotalChunks);
await foreach (var chunk in FetchChunksAsync(proofRoot, missingIndices, cancellationToken))
{
yield return chunk;
}
}
/// <inheritdoc />
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
var isAvailable = Directory.Exists(_basePath);
_logger.LogDebug("File fetcher availability check: {IsAvailable}", isAvailable);
return Task.FromResult(isAvailable);
}
/// <inheritdoc />
public async Task<ChunkManifest?> FetchManifestAsync(
string proofRoot,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
var manifestPath = GetManifestPath(proofRoot);
_logger.LogDebug("Looking for manifest at {Path}", manifestPath);
if (!File.Exists(manifestPath))
{
_logger.LogDebug("Manifest file not found: {Path}", manifestPath);
return null;
}
try
{
await using var stream = File.OpenRead(manifestPath);
return await JsonSerializer.DeserializeAsync<ChunkManifest>(stream, _jsonOptions, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error reading manifest file {Path}", manifestPath);
throw;
}
}
/// <summary>
/// Gets the file path for a chunk.
/// </summary>
private string GetChunkPath(string proofRoot, int chunkIndex)
{
// Sanitize proof root for use in file paths
var safeProofRoot = SanitizeForPath(proofRoot);
return Path.Combine(_basePath, safeProofRoot, $"chunk_{chunkIndex:D4}.json");
}
/// <summary>
/// Gets the file path for a manifest.
/// </summary>
private string GetManifestPath(string proofRoot)
{
var safeProofRoot = SanitizeForPath(proofRoot);
return Path.Combine(_basePath, safeProofRoot, "manifest.json");
}
/// <summary>
/// Sanitizes a proof root for use in file paths.
/// </summary>
private static string SanitizeForPath(string input)
{
// Use hash prefix to ensure consistent directory naming
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input))).ToLowerInvariant();
// Return first 16 chars of hash for reasonable directory names
return hash[..16];
}
/// <summary>
/// Exports chunks to files for sneakernet transfer.
/// </summary>
/// <param name="proofRoot">The proof root.</param>
/// <param name="manifest">The chunk manifest.</param>
/// <param name="chunks">The chunks to export.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ExportToFilesAsync(
string proofRoot,
ChunkManifest manifest,
IEnumerable<FetchedChunk> chunks,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(chunks);
var safeProofRoot = SanitizeForPath(proofRoot);
var proofDir = Path.Combine(_basePath, safeProofRoot);
Directory.CreateDirectory(proofDir);
_logger.LogInformation("Exporting to {Directory}", proofDir);
// Write manifest
var manifestPath = GetManifestPath(proofRoot);
await using (var manifestStream = File.Create(manifestPath))
{
await JsonSerializer.SerializeAsync(manifestStream, manifest, _jsonOptions, cancellationToken);
}
_logger.LogDebug("Wrote manifest to {Path}", manifestPath);
// Write chunks
var count = 0;
foreach (var chunk in chunks)
{
cancellationToken.ThrowIfCancellationRequested();
var chunkPath = GetChunkPath(proofRoot, chunk.Index);
await using var chunkStream = File.Create(chunkPath);
await JsonSerializer.SerializeAsync(chunkStream, chunk, _jsonOptions, cancellationToken);
count++;
}
_logger.LogInformation("Exported {Count} chunks to {Directory}", count, proofDir);
}
/// <summary>
/// Exports EvidenceChunks to files (converts to FetchedChunk format).
/// </summary>
/// <param name="proofRoot">The proof root.</param>
/// <param name="manifest">The chunk manifest.</param>
/// <param name="chunks">The evidence chunks to export.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task ExportEvidenceChunksToFilesAsync(
string proofRoot,
ChunkManifest manifest,
IEnumerable<EvidenceChunk> chunks,
CancellationToken cancellationToken = default)
{
var fetchedChunks = chunks.Select(c => new FetchedChunk
{
Index = c.ChunkIndex,
Data = c.Blob,
Hash = c.ChunkHash
});
return ExportToFilesAsync(proofRoot, manifest, fetchedChunks, cancellationToken);
}
}

View File

@@ -0,0 +1,194 @@
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Provcache;
/// <summary>
/// HTTP-based lazy evidence chunk fetcher for connected mode.
/// Fetches chunks from a remote Stella API endpoint.
/// </summary>
public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
{
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly ILogger<HttpChunkFetcher> _logger;
private readonly JsonSerializerOptions _jsonOptions;
/// <inheritdoc />
public string FetcherType => "http";
/// <summary>
/// Creates an HTTP chunk fetcher with the specified base URL.
/// </summary>
/// <param name="baseUrl">The base URL of the Stella API.</param>
/// <param name="logger">Logger instance.</param>
public HttpChunkFetcher(Uri baseUrl, ILogger<HttpChunkFetcher> logger)
: this(CreateClient(baseUrl), ownsClient: true, logger)
{
}
/// <summary>
/// Creates an HTTP chunk fetcher with an existing HTTP client.
/// </summary>
/// <param name="httpClient">The HTTP client to use.</param>
/// <param name="ownsClient">Whether this fetcher owns the client lifecycle.</param>
/// <param name="logger">Logger instance.</param>
public HttpChunkFetcher(HttpClient httpClient, bool ownsClient, ILogger<HttpChunkFetcher> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_ownsClient = ownsClient;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
}
private static HttpClient CreateClient(Uri baseUrl)
{
var client = new HttpClient { BaseAddress = baseUrl };
client.DefaultRequestHeaders.Add("Accept", "application/json");
return client;
}
/// <inheritdoc />
public async Task<FetchedChunk?> FetchChunkAsync(
string proofRoot,
int chunkIndex,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentOutOfRangeException.ThrowIfNegative(chunkIndex);
var url = $"api/v1/evidence/{Uri.EscapeDataString(proofRoot)}/chunks/{chunkIndex}";
_logger.LogDebug("Fetching chunk {Index} from {Url}", chunkIndex, url);
try
{
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Chunk {Index} not found at remote", chunkIndex);
return null;
}
response.EnsureSuccessStatusCode();
var chunk = await response.Content.ReadFromJsonAsync<FetchedChunk>(_jsonOptions, cancellationToken);
_logger.LogDebug("Successfully fetched chunk {Index}, {Bytes} bytes", chunkIndex, chunk?.Data.Length ?? 0);
return chunk;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "HTTP error fetching chunk {Index}", chunkIndex);
throw;
}
}
/// <inheritdoc />
public async IAsyncEnumerable<FetchedChunk> FetchChunksAsync(
string proofRoot,
IEnumerable<int> chunkIndices,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentNullException.ThrowIfNull(chunkIndices);
var indices = chunkIndices.ToList();
_logger.LogInformation("Fetching {Count} chunks for proof root {ProofRoot}", indices.Count, proofRoot);
foreach (var index in indices)
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = await FetchChunkAsync(proofRoot, index, cancellationToken);
if (chunk is not null)
{
yield return chunk;
}
}
}
/// <inheritdoc />
public async IAsyncEnumerable<FetchedChunk> FetchRemainingChunksAsync(
string proofRoot,
ChunkManifest manifest,
IReadOnlySet<int> existingIndices,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(existingIndices);
var missingIndices = Enumerable.Range(0, manifest.TotalChunks)
.Where(i => !existingIndices.Contains(i))
.ToList();
_logger.LogInformation(
"Fetching {MissingCount} remaining chunks (have {ExistingCount}/{TotalCount})",
missingIndices.Count, existingIndices.Count, manifest.TotalChunks);
await foreach (var chunk in FetchChunksAsync(proofRoot, missingIndices, cancellationToken))
{
yield return chunk;
}
}
/// <inheritdoc />
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync("api/v1/health", cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Health check failed");
return false;
}
}
/// <inheritdoc />
public async Task<ChunkManifest?> FetchManifestAsync(
string proofRoot,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
var url = $"api/v1/evidence/{Uri.EscapeDataString(proofRoot)}/manifest";
_logger.LogDebug("Fetching manifest from {Url}", url);
try
{
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Manifest not found for proof root {ProofRoot}", proofRoot);
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ChunkManifest>(_jsonOptions, cancellationToken);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "HTTP error fetching manifest for {ProofRoot}", proofRoot);
throw;
}
}
/// <inheritdoc />
public void Dispose()
{
if (_ownsClient)
{
_httpClient.Dispose();
}
}
}

View File

@@ -0,0 +1,131 @@
namespace StellaOps.Provcache;
/// <summary>
/// Interface for lazy evidence chunk fetching from various sources.
/// Enables on-demand evidence retrieval for air-gapped auditors.
/// </summary>
public interface ILazyEvidenceFetcher
{
/// <summary>
/// Gets the fetcher type (e.g., "http", "file").
/// </summary>
string FetcherType { get; }
/// <summary>
/// Fetches a single chunk by index.
/// </summary>
/// <param name="proofRoot">The proof root identifying the evidence.</param>
/// <param name="chunkIndex">The chunk index to fetch.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The fetched chunk or null if not found.</returns>
Task<FetchedChunk?> FetchChunkAsync(
string proofRoot,
int chunkIndex,
CancellationToken cancellationToken = default);
/// <summary>
/// Fetches multiple chunks by index.
/// </summary>
/// <param name="proofRoot">The proof root identifying the evidence.</param>
/// <param name="chunkIndices">The chunk indices to fetch.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of fetched chunks.</returns>
IAsyncEnumerable<FetchedChunk> FetchChunksAsync(
string proofRoot,
IEnumerable<int> chunkIndices,
CancellationToken cancellationToken = default);
/// <summary>
/// Fetches all remaining chunks for a proof root.
/// </summary>
/// <param name="proofRoot">The proof root identifying the evidence.</param>
/// <param name="manifest">The chunk manifest for reference.</param>
/// <param name="existingIndices">Indices of chunks already present locally.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of fetched chunks.</returns>
IAsyncEnumerable<FetchedChunk> FetchRemainingChunksAsync(
string proofRoot,
ChunkManifest manifest,
IReadOnlySet<int> existingIndices,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if the source is available for fetching.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the source is available.</returns>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the manifest from the source.
/// </summary>
/// <param name="proofRoot">The proof root to get manifest for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The chunk manifest or null if not available.</returns>
Task<ChunkManifest?> FetchManifestAsync(
string proofRoot,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Simplified chunk representation for lazy fetch interface.
/// Contains only the index and data for transport.
/// </summary>
public sealed record FetchedChunk
{
/// <summary>
/// Zero-based chunk index.
/// </summary>
public required int Index { get; init; }
/// <summary>
/// The chunk data.
/// </summary>
public required byte[] Data { get; init; }
/// <summary>
/// SHA256 hash of the data for verification.
/// </summary>
public required string Hash { get; init; }
}
/// <summary>
/// Result of a lazy fetch operation.
/// </summary>
public sealed record LazyFetchResult
{
/// <summary>
/// Whether the fetch was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Number of chunks fetched.
/// </summary>
public required int ChunksFetched { get; init; }
/// <summary>
/// Total bytes fetched.
/// </summary>
public required long BytesFetched { get; init; }
/// <summary>
/// Number of chunks that failed verification.
/// </summary>
public required int ChunksFailedVerification { get; init; }
/// <summary>
/// Indices of failed chunks.
/// </summary>
public IReadOnlyList<int> FailedIndices { get; init; } = [];
/// <summary>
/// Any errors encountered.
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>
/// Time taken for the fetch operation.
/// </summary>
public TimeSpan Duration { get; init; }
}

View File

@@ -0,0 +1,296 @@
using System.Diagnostics;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace StellaOps.Provcache;
/// <summary>
/// Orchestrates lazy evidence fetching with verification.
/// Coordinates between fetchers and the local evidence store.
/// </summary>
public sealed class LazyFetchOrchestrator
{
private readonly IEvidenceChunkRepository _repository;
private readonly ILogger<LazyFetchOrchestrator> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a lazy fetch orchestrator.
/// </summary>
/// <param name="repository">The chunk repository for local storage.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="timeProvider">Optional time provider.</param>
public LazyFetchOrchestrator(
IEvidenceChunkRepository repository,
ILogger<LazyFetchOrchestrator> logger,
TimeProvider? timeProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Fetches remaining chunks for a proof root and stores them locally.
/// </summary>
/// <param name="proofRoot">The proof root.</param>
/// <param name="fetcher">The fetcher to use.</param>
/// <param name="options">Fetch options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The fetch result.</returns>
public async Task<LazyFetchResult> FetchAndStoreAsync(
string proofRoot,
ILazyEvidenceFetcher fetcher,
LazyFetchOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
ArgumentNullException.ThrowIfNull(fetcher);
options ??= new LazyFetchOptions();
var stopwatch = Stopwatch.StartNew();
var errors = new List<string>();
var failedIndices = new List<int>();
var chunksFetched = 0;
long bytesFetched = 0;
var chunksFailedVerification = 0;
_logger.LogInformation(
"Starting lazy fetch for {ProofRoot} using {FetcherType} fetcher",
proofRoot, fetcher.FetcherType);
try
{
// Check fetcher availability
if (!await fetcher.IsAvailableAsync(cancellationToken))
{
_logger.LogWarning("Fetcher {FetcherType} is not available", fetcher.FetcherType);
return new LazyFetchResult
{
Success = false,
ChunksFetched = 0,
BytesFetched = 0,
ChunksFailedVerification = 0,
Errors = [$"Fetcher {fetcher.FetcherType} is not available"],
Duration = stopwatch.Elapsed
};
}
// Get local manifest
var localManifest = await _repository.GetManifestAsync(proofRoot, cancellationToken);
if (localManifest is null)
{
// Try to fetch manifest from remote
localManifest = await fetcher.FetchManifestAsync(proofRoot, cancellationToken);
if (localManifest is null)
{
_logger.LogWarning("No manifest found for {ProofRoot}", proofRoot);
return new LazyFetchResult
{
Success = false,
ChunksFetched = 0,
BytesFetched = 0,
ChunksFailedVerification = 0,
Errors = [$"No manifest found for proof root {proofRoot}"],
Duration = stopwatch.Elapsed
};
}
}
// Get existing chunks
var existingChunks = (await _repository.GetChunksAsync(proofRoot, cancellationToken))
.Select(c => c.ChunkIndex)
.ToHashSet();
var totalChunks = localManifest.TotalChunks;
var missingCount = totalChunks - existingChunks.Count;
_logger.LogInformation(
"Have {Existing}/{Total} chunks, need to fetch {Missing}",
existingChunks.Count, totalChunks, missingCount);
if (missingCount == 0)
{
_logger.LogInformation("All chunks already present, nothing to fetch");
return new LazyFetchResult
{
Success = true,
ChunksFetched = 0,
BytesFetched = 0,
ChunksFailedVerification = 0,
Duration = stopwatch.Elapsed
};
}
// Fetch remaining chunks
var chunksToStore = new List<EvidenceChunk>();
var now = _timeProvider.GetUtcNow();
await foreach (var fetchedChunk in fetcher.FetchRemainingChunksAsync(
proofRoot, localManifest, existingChunks, cancellationToken))
{
// Verify chunk if enabled
if (options.VerifyOnFetch)
{
var isValid = VerifyChunk(fetchedChunk, localManifest);
if (!isValid)
{
chunksFailedVerification++;
failedIndices.Add(fetchedChunk.Index);
errors.Add($"Chunk {fetchedChunk.Index} failed verification");
if (options.FailOnVerificationError)
{
_logger.LogError("Chunk {Index} failed verification, aborting", fetchedChunk.Index);
break;
}
_logger.LogWarning("Chunk {Index} failed verification, skipping", fetchedChunk.Index);
continue;
}
}
// Convert FetchedChunk to EvidenceChunk for storage
var evidenceChunk = new EvidenceChunk
{
ChunkId = Guid.NewGuid(),
ProofRoot = proofRoot,
ChunkIndex = fetchedChunk.Index,
ChunkHash = fetchedChunk.Hash,
Blob = fetchedChunk.Data,
BlobSize = fetchedChunk.Data.Length,
ContentType = "application/octet-stream",
CreatedAt = now
};
chunksToStore.Add(evidenceChunk);
bytesFetched += fetchedChunk.Data.Length;
chunksFetched++;
// Batch store to reduce database round-trips
if (chunksToStore.Count >= options.BatchSize)
{
await _repository.StoreChunksAsync(proofRoot, chunksToStore, cancellationToken);
_logger.LogDebug("Stored batch of {Count} chunks", chunksToStore.Count);
chunksToStore.Clear();
}
// Check max chunks limit
if (options.MaxChunksToFetch > 0 && chunksFetched >= options.MaxChunksToFetch)
{
_logger.LogInformation("Reached max chunks limit ({Max})", options.MaxChunksToFetch);
break;
}
}
// Store any remaining chunks
if (chunksToStore.Count > 0)
{
await _repository.StoreChunksAsync(proofRoot, chunksToStore, cancellationToken);
_logger.LogDebug("Stored final batch of {Count} chunks", chunksToStore.Count);
}
stopwatch.Stop();
var success = chunksFailedVerification == 0 || !options.FailOnVerificationError;
_logger.LogInformation(
"Lazy fetch complete: {Fetched} chunks, {Bytes} bytes, {Failed} verification failures in {Duration}",
chunksFetched, bytesFetched, chunksFailedVerification, stopwatch.Elapsed);
return new LazyFetchResult
{
Success = success,
ChunksFetched = chunksFetched,
BytesFetched = bytesFetched,
ChunksFailedVerification = chunksFailedVerification,
FailedIndices = failedIndices,
Errors = errors,
Duration = stopwatch.Elapsed
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during lazy fetch for {ProofRoot}", proofRoot);
errors.Add(ex.Message);
return new LazyFetchResult
{
Success = false,
ChunksFetched = chunksFetched,
BytesFetched = bytesFetched,
ChunksFailedVerification = chunksFailedVerification,
FailedIndices = failedIndices,
Errors = errors,
Duration = stopwatch.Elapsed
};
}
}
/// <summary>
/// Verifies a chunk against the manifest.
/// </summary>
private bool VerifyChunk(FetchedChunk chunk, ChunkManifest manifest)
{
// Check index bounds
if (chunk.Index < 0 || chunk.Index >= manifest.TotalChunks)
{
_logger.LogWarning("Chunk index {Index} out of bounds (max {Max})", chunk.Index, manifest.TotalChunks - 1);
return false;
}
// Verify hash against manifest metadata
if (manifest.Chunks is not null && chunk.Index < manifest.Chunks.Count)
{
var expectedHash = manifest.Chunks[chunk.Index].Hash;
var actualHash = Convert.ToHexString(SHA256.HashData(chunk.Data)).ToLowerInvariant();
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Chunk {Index} hash mismatch: expected {Expected}, got {Actual}",
chunk.Index, expectedHash, actualHash);
return false;
}
}
// Also verify the chunk's own hash claim
var claimedHash = Convert.ToHexString(SHA256.HashData(chunk.Data)).ToLowerInvariant();
if (!string.Equals(claimedHash, chunk.Hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Chunk {Index} self-hash mismatch: claimed {Claimed}, actual {Actual}",
chunk.Index, chunk.Hash, claimedHash);
return false;
}
return true;
}
}
/// <summary>
/// Options for lazy fetch operations.
/// </summary>
public sealed class LazyFetchOptions
{
/// <summary>
/// Whether to verify chunks on fetch.
/// </summary>
public bool VerifyOnFetch { get; init; } = true;
/// <summary>
/// Whether to fail the entire operation on verification error.
/// </summary>
public bool FailOnVerificationError { get; init; } = false;
/// <summary>
/// Batch size for storing chunks.
/// </summary>
public int BatchSize { get; init; } = 100;
/// <summary>
/// Maximum number of chunks to fetch (0 = unlimited).
/// </summary>
public int MaxChunksToFetch { get; init; } = 0;
}