namespace MS.Microservice.Core.IO; /// Saves streams under generated names in a caller-selected local directory. public sealed class LocalFileStorage : IFileStorage { private readonly string _directory; /// A trusted storage directory, resolved when this instance is created. public LocalFileStorage(string directory) { ArgumentException.ThrowIfNullOrWhiteSpace(directory); _directory = Path.GetFullPath(directory); } /// /// Copies from the current position, leaving input open, and returns a generated name after publication. /// Failed or cancelled copies do not publish an output file. /// public async Task SaveAsync( Stream content, string extension, CancellationToken cancellationToken = default) { var key = StoredFileKey.Create(extension); await WriteNewAsync(key, content, cancellationToken).ConfigureAwait(false); return key; } public async Task WriteNewAsync(string key, Stream content, CancellationToken cancellationToken = default) { StoredFileKey.Validate(key); ArgumentNullException.ThrowIfNull(content); if (!content.CanRead) { throw new ArgumentException("The content stream must be readable.", nameof(content)); } cancellationToken.ThrowIfCancellationRequested(); var outputPath = Path.Combine(_directory, key); if (File.Exists(outputPath) || Directory.Exists(outputPath)) throw new IOException("The stored file already exists."); Directory.CreateDirectory(_directory); var id = Guid.NewGuid().ToString("N"); var temporaryPath = Path.Combine(_directory, "." + id + ".tmp"); // Open outside the cleanup scope: a failed CreateNew never grants ownership of that path. var output = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); try { await using (output) { await content.CopyToAsync(output, cancellationToken).ConfigureAwait(false); await output.FlushAsync(cancellationToken).ConfigureAwait(false); } cancellationToken.ThrowIfCancellationRequested(); File.Move(temporaryPath, outputPath, overwrite: false); } catch (Exception primary) { try { File.Delete(temporaryPath); } catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) { throw new AggregateException("File publication and staging cleanup both failed.", primary, cleanup); } throw; } } public Task OpenReadAsync(string key, CancellationToken cancellationToken = default) { StoredFileKey.Validate(key); cancellationToken.ThrowIfCancellationRequested(); try { Stream stream = new FileStream(Path.Combine(_directory, key), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); return Task.FromResult(stream); } catch (DirectoryNotFoundException error) { throw new FileNotFoundException("The stored file does not exist.", key, error); } } public Task GetMetadataAsync(string key, CancellationToken cancellationToken = default) { StoredFileKey.Validate(key); cancellationToken.ThrowIfCancellationRequested(); var file = new FileInfo(Path.Combine(_directory, key)); return Task.FromResult(file.Exists ? new StoredFileMetadata(key, file.Length, new(file.LastWriteTimeUtc)) : null); } public Task DeleteAsync(string key, CancellationToken cancellationToken = default) { StoredFileKey.Validate(key); cancellationToken.ThrowIfCancellationRequested(); try { File.Delete(Path.Combine(_directory, key)); } catch (DirectoryNotFoundException) { } return Task.CompletedTask; } }