244 lines
8.3 KiB
C#
244 lines
8.3 KiB
C#
using System.Buffers;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json.Nodes;
|
|
using w4c_workflows.Models.Nodes;
|
|
|
|
namespace w4c_workflows.Services.Nodes.Binary;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c><root>/<aa>/<sha256-hex></c> 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 (<c>sha256:</c> + 64 hex chars) before any
|
|
/// path is built, so a crafted id from run history cannot traverse outside the
|
|
/// store root.
|
|
/// </summary>
|
|
public sealed class FileSystemBinaryStore : IBinaryStore
|
|
{
|
|
private const string IdPrefix = "sha256:";
|
|
private const int HashHexLength = 64;
|
|
private const int CopyBufferSize = 81_920;
|
|
|
|
/// <summary>Binary property name the HTTP node attaches a downloaded file under.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<BinaryAttachment> 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<byte>.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<byte>.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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream?> OpenAsync(string assetId, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (!TryResolve(assetId, out var payloadPath) || !File.Exists(payloadPath))
|
|
return Task.FromResult<Stream?>(null);
|
|
|
|
Stream stream = new FileStream(
|
|
payloadPath, FileMode.Open, FileAccess.Read, FileShare.Read, CopyBufferSize, useAsync: true);
|
|
return Task.FromResult<Stream?>(stream);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<BinaryAttachment?> 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<string>(),
|
|
meta["mimeType"]?.GetValue<string>(),
|
|
meta["sizeBytes"]?.GetValue<long>());
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// A corrupt sidecar must not fail the caller; fall back to the payload.
|
|
return new BinaryAttachment(assetId, SizeBytes: new FileInfo(payloadPath).Length);
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds the canonical asset id for a computed SHA-256 hash.</summary>
|
|
public static string BuildAssetId(string hashHex) => IdPrefix + hashHex;
|
|
|
|
/// <summary>Validates an asset id and maps it to its payload path (no traversal).</summary>
|
|
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.
|
|
}
|
|
}
|
|
}
|