| File: Pipelines\PipelineOutputSnapshotter.cs | Web Access |
| Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.IO.Hashing; using System.Text; using Microsoft.Extensions.FileSystemGlobbing; namespace Aspire.Hosting.Pipelines; /// <summary> /// Captures file snapshots without retaining file content. /// </summary> internal sealed class PipelineOutputSnapshotter { private readonly IPipelineOutputFileHasher? _hasher; /// <summary> /// Creates a snapshotter with an optional custom hasher. /// </summary> public PipelineOutputSnapshotter(IPipelineOutputFileHasher? hasher = null) { _hasher = hasher; } /// <summary> /// Captures all filesystem leaves in the output directory, treating a missing directory as empty. /// </summary> internal async Task<IReadOnlyDictionary<string, PipelineOutputFileSnapshot>> CaptureAsync(string outputDirectory, CancellationToken cancellationToken = default) { var directory = PipelineOutputRoot.NormalizeDirectoryPath(outputDirectory); var matcher = new Matcher(PipelineOutputRoot.PathComparison, preserveFilterOrder: true); matcher.AddInclude("**/*"); if (!ValidateOutputDirectory(directory, cancellationToken)) { return new Dictionary<string, PipelineOutputFileSnapshot>(PipelineOutputRoot.PathComparer); } return await CaptureAsync(directory, matcher, git: null, cancellationToken).ConfigureAwait(false); } /// <summary> /// Captures matched leaves using Git membership when available and the configured hashing policy. /// </summary> internal async Task<IReadOnlyDictionary<string, PipelineOutputFileSnapshot>> CaptureAsync(string directory, Matcher matcher, GitPipelineOutputReader? git, CancellationToken cancellationToken) { if (git is null) { return await CapturePathsAsync(GetFilesystemFiles(directory, matcher, cancellationToken), cancellationToken).ConfigureAwait(false); } var entries = await git.SelectFilesAsync(directory, matcher, cancellationToken).ConfigureAwait(false); if (_hasher is null) { return await GitPipelineOutputSnapshotter.CaptureAsync(git, entries, cancellationToken).ConfigureAwait(false); } // Custom hashing uses Git only for selection, not its hash or mode policy. return await CapturePathsAsync(entries.Select(entry => entry.File), cancellationToken).ConfigureAwait(false); } /// <summary> /// Selects filesystem leaves within the directory using the ordered matcher rules. /// </summary> internal static IEnumerable<FileInfo> GetFilesystemFiles(string directory, Matcher matcher, CancellationToken cancellationToken) => matcher.Execute(new PipelineOutputDirectory(new DirectoryInfo(directory), cancellationToken)).Files .Select(match => Path.GetFullPath(match.Path, directory)).Where(path => PipelineOutputRoot.IsWithinDirectory(directory, path)) .Select(path => new FileInfo(path)); /// <summary> /// Returns false for a missing directory, which generation may create. Rejects file roots as invalid /// destinations and link roots because capture does not traverse them. Inspection errors propagate /// rather than allowing unreadable output to appear empty. /// </summary> internal static bool ValidateOutputDirectory(string directory, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); FileAttributes attributes; try { attributes = File.GetAttributes(directory); } catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) { return false; } if (!attributes.IsDirectory() || attributes.IsJunctionOrSymlink()) { throw new IOException($"Pipeline output root must be a directory, not a file or link: {directory}"); } return true; } /// <summary> /// Captures each distinct leaf with bounded hashing concurrency and owned snapshot hashes. /// </summary> internal async Task<IReadOnlyDictionary<string, PipelineOutputFileSnapshot>> CapturePathsAsync(IEnumerable<FileInfo> files, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var snapshots = new Dictionary<string, PipelineOutputFileSnapshot>(PipelineOutputRoot.PathComparer); var uniqueFiles = files.DistinctBy(file => file.FullName, PipelineOutputRoot.PathComparer).ToArray(); var results = new PipelineOutputFileSnapshot[uniqueFiles.Length]; // Awaiting the bounded loop drains workers and their owned streams before failure escapes. await Parallel.ForEachAsync(Enumerable.Range(0, uniqueFiles.Length), cancellationToken, async (index, token) => { results[index] = await CaptureFileAsync(uniqueFiles[index], token).ConfigureAwait(false); }).ConfigureAwait(false); for (var index = 0; index < uniqueFiles.Length; index++) { snapshots.Add(uniqueFiles[index].FullName, results[index]); } return snapshots; } private async Task<PipelineOutputFileSnapshot> CaptureFileAsync(FileInfo file, CancellationToken cancellationToken) { var attributes = File.GetAttributes(file.FullName); var isLink = attributes.IsJunctionOrSymlink(); FileSystemInfo entry = attributes.IsDirectory() ? new DirectoryInfo(file.FullName) : file; ReadOnlyMemory<byte> hash = _hasher is not null ? await _hasher.HashAsync(entry, cancellationToken).ConfigureAwait(false) : await HashAsync(entry, isLink, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); return new PipelineOutputFileSnapshot(isLink, hash.ToArray()); } private static async Task<byte[]> HashAsync(FileSystemInfo file, bool isLink, CancellationToken cancellationToken) { if (isLink) { var target = file.LinkTarget ?? throw new IOException($"Cannot read pipeline output link target: {file.FullName}"); return XxHash3.Hash(Encoding.UTF8.GetBytes(target)); } using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); var hash = new XxHash3(); await hash.AppendAsync(stream, cancellationToken).ConfigureAwait(false); return hash.GetCurrentHash(); } }