using System.Buffers; using System.Security.Cryptography; using System.Text.Json.Nodes; using w4c_workflows.Models.Nodes; namespace w4c_workflows.Services.Nodes.Binary; /// /// Content-addressed binary store on the local filesystem. A payload is written /// to a temp file while its SHA-256 is computed, then moved to /// <root>/<aa>/<sha256-hex> and gets a JSON sidecar with /// the display name, MIME type and size. Identical bytes always map to the same /// asset id, so a payload fetched repeatedly is stored once. /// /// Asset ids are validated strictly (sha256: + 64 hex chars) before any /// path is built, so a crafted id from run history cannot traverse outside the /// store root. /// public sealed class FileSystemBinaryStore : IBinaryStore { private const string IdPrefix = "sha256:"; private const int HashHexLength = 64; private const int CopyBufferSize = 81_920; /// Binary property name the HTTP node attaches a downloaded file under. public const string DefaultPropertyName = "data"; private readonly BinaryStoreOptions _options; private readonly string _rootPath; public FileSystemBinaryStore(BinaryStoreOptions options) { _options = options; _rootPath = string.IsNullOrWhiteSpace(options.RootPath) ? Path.Combine(AppContext.BaseDirectory, "binary-store") : Path.GetFullPath(options.RootPath); } /// public async Task SaveAsync( Stream content, string? fileName, string? mimeType, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(content); // Payloads are content-addressed, so a new directory holds only the temp // file until the real path is known; the root is created at first write. var root = _rootPath; Directory.CreateDirectory(root); var tempPath = Path.Combine(root, $"tmp-{Guid.NewGuid():N}"); long size; string hashHex; try { using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); await using (var target = new FileStream( tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, CopyBufferSize, useAsync: true)) { var buffer = ArrayPool.Shared.Rent(CopyBufferSize); try { size = 0; int read; while ((read = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0) { size += read; if (_options.MaxBytes > 0 && size > _options.MaxBytes) throw new InvalidOperationException( $"binary payload exceeds the {_options.MaxBytes}-byte store limit"); hasher.AppendData(buffer, 0, read); await target.WriteAsync(buffer.AsMemory(0, read), ct); } } finally { ArrayPool.Shared.Return(buffer); } } hashHex = Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant(); } catch { TryDelete(tempPath); throw; } var shard = Path.Combine(root, hashHex[..2]); Directory.CreateDirectory(shard); var payloadPath = Path.Combine(shard, hashHex); var metaPath = payloadPath + ".json"; try { if (File.Exists(payloadPath)) { // Same bytes already stored: keep the first metadata, drop the temp copy. TryDelete(tempPath); } else { File.Move(tempPath, payloadPath); } } catch (IOException) { // Lost a race with a concurrent store of the same content: the winner's // payload is equivalent, so discard ours. TryDelete(tempPath); } var attachment = new BinaryAttachment(BuildAssetId(hashHex), CleanName(fileName), CleanMime(mimeType), size); if (!File.Exists(metaPath)) await WriteMetadataAsync(metaPath, attachment, ct); return attachment; } /// public Task OpenAsync(string assetId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (!TryResolve(assetId, out var payloadPath) || !File.Exists(payloadPath)) return Task.FromResult(null); Stream stream = new FileStream( payloadPath, FileMode.Open, FileAccess.Read, FileShare.Read, CopyBufferSize, useAsync: true); return Task.FromResult(stream); } /// public async Task DescribeAsync(string assetId, CancellationToken ct = default) { if (!TryResolve(assetId, out var payloadPath)) return null; var metaPath = payloadPath + ".json"; if (!File.Exists(metaPath)) return File.Exists(payloadPath) ? new BinaryAttachment(assetId, SizeBytes: new FileInfo(payloadPath).Length) : null; try { var text = await File.ReadAllTextAsync(metaPath, ct); if (JsonNode.Parse(text) is not JsonObject meta) return null; return new BinaryAttachment( assetId, meta["fileName"]?.GetValue(), meta["mimeType"]?.GetValue(), meta["sizeBytes"]?.GetValue()); } catch (Exception) { // A corrupt sidecar must not fail the caller; fall back to the payload. return new BinaryAttachment(assetId, SizeBytes: new FileInfo(payloadPath).Length); } } /// Builds the canonical asset id for a computed SHA-256 hash. public static string BuildAssetId(string hashHex) => IdPrefix + hashHex; /// Validates an asset id and maps it to its payload path (no traversal). private bool TryResolve(string assetId, out string payloadPath) { payloadPath = string.Empty; if (string.IsNullOrWhiteSpace(assetId) || !assetId.StartsWith(IdPrefix, StringComparison.Ordinal)) return false; var hex = assetId[IdPrefix.Length..]; if (hex.Length != HashHexLength) return false; foreach (var character in hex) { var lower = char.ToLowerInvariant(character); if ((lower < '0' || lower > '9') && (lower < 'a' || lower > 'f')) return false; } hex = hex.ToLowerInvariant(); payloadPath = Path.Combine(_rootPath, hex[..2], hex); return true; } private static async Task WriteMetadataAsync(string metaPath, BinaryAttachment attachment, CancellationToken ct) { var meta = new JsonObject { ["fileName"] = attachment.FileName, ["mimeType"] = attachment.MimeType, ["sizeBytes"] = attachment.SizeBytes, }; var tempPath = metaPath + $".tmp-{Guid.NewGuid():N}"; try { await File.WriteAllTextAsync(tempPath, meta.ToJsonString(), ct); if (File.Exists(metaPath)) File.Delete(tempPath); else File.Move(tempPath, metaPath); } catch (IOException) { TryDelete(tempPath); } catch (OperationCanceledException) { TryDelete(tempPath); throw; } } private static string? CleanName(string? fileName) => string.IsNullOrWhiteSpace(fileName) ? null : fileName.Trim(); private static string? CleanMime(string? mimeType) => string.IsNullOrWhiteSpace(mimeType) ? null : mimeType.Trim(); private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (IOException) { // Best effort: a leftover temp file is harmless. } catch (UnauthorizedAccessException) { // Best effort: a leftover temp file is harmless. } } }