Files
git.stella-ops.org/src/Platform/StellaOps.Platform.Analytics/Services/CasContentReader.cs
2026-02-01 21:37:40 +02:00

128 lines
3.9 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.Analytics.Options;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.Analytics.Services;
public interface ICasContentReader
{
Task<CasContent?> OpenReadAsync(string casUri, CancellationToken cancellationToken);
}
public sealed record CasContent(Stream Stream, long? Length);
public sealed class FileCasContentReader : ICasContentReader
{
private readonly AnalyticsCasOptions _options;
private readonly ILogger<FileCasContentReader> _logger;
public FileCasContentReader(
IOptions<AnalyticsIngestionOptions> options,
ILogger<FileCasContentReader> logger)
{
_options = options?.Value.Cas ?? new AnalyticsCasOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<CasContent?> OpenReadAsync(string casUri, CancellationToken cancellationToken)
{
if (!TryParseCasUri(casUri, out var reference))
{
_logger.LogWarning("Unsupported CAS URI '{CasUri}'.", casUri);
return Task.FromResult<CasContent?>(null);
}
if (string.IsNullOrWhiteSpace(_options.RootPath))
{
_logger.LogWarning("CAS root path not configured; skipping {CasUri}.", casUri);
return Task.FromResult<CasContent?>(null);
}
var root = Path.GetFullPath(_options.RootPath);
foreach (var candidate in ExpandKeyCandidates(reference.Key))
{
var keyPath = candidate.Replace('/', Path.DirectorySeparatorChar);
var resolved = Path.GetFullPath(Path.Combine(root, reference.Bucket, keyPath));
if (!resolved.StartsWith(root, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("CAS URI '{CasUri}' resolved outside root '{Root}'.", casUri, root);
return Task.FromResult<CasContent?>(null);
}
if (!File.Exists(resolved))
{
continue;
}
var stream = new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.Read);
var length = new FileInfo(resolved).Length;
return Task.FromResult<CasContent?>(new CasContent(stream, length));
}
_logger.LogWarning("CAS object not found at '{Key}' for '{CasUri}'.", reference.Key, casUri);
return Task.FromResult<CasContent?>(null);
}
private bool TryParseCasUri(string casUri, out CasReference reference)
{
reference = default!;
if (string.IsNullOrWhiteSpace(casUri))
{
return false;
}
if (!Uri.TryCreate(casUri, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Scheme, "cas", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var bucket = uri.Host;
var key = uri.AbsolutePath.TrimStart('/');
if (string.IsNullOrWhiteSpace(bucket))
{
if (string.IsNullOrWhiteSpace(_options.DefaultBucket))
{
return false;
}
bucket = _options.DefaultBucket!;
}
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
reference = new CasReference(casUri, bucket, key);
return true;
}
private static IEnumerable<string> ExpandKeyCandidates(string key)
{
yield return key;
var colonIndex = key.IndexOf(':');
if (colonIndex <= 0 || colonIndex >= key.Length - 1)
{
yield break;
}
var prefix = key[..colonIndex];
var suffix = key[(colonIndex + 1)..];
yield return $"{prefix}/{suffix}";
yield return suffix;
}
}
public sealed record CasReference(string Uri, string Bucket, string Key);