From 2f5d42daea4bf6ad82ecf3f49ec3cd862799d5d6 Mon Sep 17 00:00:00 2001 From: "aikido-autofix[bot]" <119856028+aikido-autofix[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:21:46 +0000 Subject: [PATCH] fix(security): autofix Path traversal attack possible --- src/ARCHive.Archive/ArchiveJobPlanner.cs | 908 +++++----- src/ARCHive.Copy/PausableFolderCopyRunner.cs | 1632 +++++++++--------- src/ARCHive.Core/JobPlanner.cs | 700 ++++---- 3 files changed, 1656 insertions(+), 1584 deletions(-) diff --git a/src/ARCHive.Archive/ArchiveJobPlanner.cs b/src/ARCHive.Archive/ArchiveJobPlanner.cs index ce7c7cf..ea936b6 100644 --- a/src/ARCHive.Archive/ArchiveJobPlanner.cs +++ b/src/ARCHive.Archive/ArchiveJobPlanner.cs @@ -1,449 +1,459 @@ -using ARCHive.Core; - -namespace ARCHive.Archive; - -public sealed class ArchiveJobPlanner -{ - public async Task> PlanCreateAsync( - string sourceInput, - string destinationInput, - ArchiveFormat format, - CompressionPreset compression, - DateTimeOffset createdAt, - CancellationToken cancellationToken = default) => - await PlanCreateAsync( - [sourceInput], - destinationInput, - format, - compression, - createdAt, - cancellationToken); - - public async Task> PlanCreateAsync( - IReadOnlyCollection sourceInputs, - string destinationInput, - ArchiveFormat format, - CompressionPreset compression, - DateTimeOffset createdAt, - CancellationToken cancellationToken = default) - { - var issues = new List(); - string destinationRoot; - try - { - destinationRoot = PathSafety.Normalize(destinationInput); - } - catch (Exception ex) when ( - ex is ArgumentException or NotSupportedException or - PathTooLongException) - { - issues.Add(Error( - "destination.invalid", - "The destination path is not valid.")); - return new ArchivePlanResult( - null, - issues, - null); - } - - if (sourceInputs.Count == 0) - { - issues.Add(Error("source.missing", "Choose at least one source.")); - } - - if (File.Exists(destinationRoot)) - { - issues.Add(Error( - "destination.is_file", - "Choose a destination folder, not a file.")); - } - else if (!Directory.Exists(destinationRoot)) - { - issues.Add(Error( - "destination.missing", - "The destination folder does not exist.")); - } - - if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) - { - return new ArchivePlanResult(null, issues, null); - } - - var normalizedSources = new List(sourceInputs.Count); - foreach (var sourceInput in sourceInputs) - { - try - { - normalizedSources.Add(PathSafety.Normalize(sourceInput)); - } - catch (Exception ex) when ( - ex is ArgumentException or NotSupportedException or - PathTooLongException) - { - issues.Add(Error( - "source.invalid", - "One of the selected source paths is not valid.")); - return new ArchivePlanResult( - null, - issues, - null); - } - } - - if (normalizedSources.Distinct( - StringComparer.OrdinalIgnoreCase).Count() != - normalizedSources.Count) - { - issues.Add(Error( - "source.duplicate", - "The same source was selected more than once.")); - return new ArchivePlanResult( - null, - issues, - null); - } - - foreach (var sourcePath in normalizedSources) - { - if (!File.Exists(sourcePath) && !Directory.Exists(sourcePath)) - { - issues.Add(Error( - "source.missing", - "One of the selected sources no longer exists.")); - } - else if (Directory.Exists(sourcePath) && - PathSafety.IsSamePath( - sourcePath, - Path.GetPathRoot(sourcePath) ?? sourcePath)) - { - issues.Add(Error( - "source.drive_root", - "Choose folders within a drive rather than the entire drive.")); - } - } - - if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) - { - return new ArchivePlanResult( - null, - issues, - null); - } - - for (var outer = 0; outer < normalizedSources.Count; outer++) - { - var folder = normalizedSources[outer]; - if (!Directory.Exists(folder)) - { - continue; - } - - if (PathSafety.IsSameOrDescendant(destinationRoot, folder)) - { - issues.Add(Error( - "destination.inside_source", - "The destination cannot be inside a selected source folder.")); - return new ArchivePlanResult( - null, - issues, - null); - } - - for (var inner = 0; inner < normalizedSources.Count; inner++) - { - if (inner != outer && - PathSafety.IsSameOrDescendant( - normalizedSources[inner], - folder)) - { - issues.Add(Error( - "source.overlap", - "Do not select both a folder and an item already inside it.")); - return new ArchivePlanResult( - null, - issues, - null); - } - } - } - - if (normalizedSources.Count > 1) - { - var duplicateName = normalizedSources - .Select(GetTopLevelName) - .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(group => group.Count() > 1); - if (duplicateName is not null) - { - issues.Add(Error( - "source.name_collision", - $"Two selected items are named \"{duplicateName.Key}\". Rename one or archive them separately.")); - return new ArchivePlanResult( - null, - issues, - null); - } - } - - var sourceSpecs = new List( - normalizedSources.Count); - try - { - foreach (var sourcePath in normalizedSources) - { - cancellationToken.ThrowIfCancellationRequested(); - var sourceIsDirectory = Directory.Exists(sourcePath); - var statistics = await Task.Run( - () => ScanSource( - sourcePath, - sourceIsDirectory, - cancellationToken), - cancellationToken); - sourceSpecs.Add(new ArchiveSourceSpec( - sourcePath, - sourceIsDirectory, - GetTopLevelName(sourcePath), - statistics.TotalBytes, - statistics.TotalFiles)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (UnauthorizedAccessException) - { - issues.Add(Error( - "source.unreadable", - "ARCHive cannot read part of the selected source.")); - return new ArchivePlanResult(null, issues, null); - } - catch (IOException ex) - { - issues.Add(Error( - "source.scan_failed", - $"ARCHive could not inspect the source: {ex.Message}")); - return new ArchivePlanResult(null, issues, null); - } - catch (OverflowException) - { - issues.Add(Error( - "source.too_large", - "The selected sources are too large to measure safely.")); - return new ArchivePlanResult(null, issues, null); - } - - long totalBytes; - long totalFiles; - try - { - totalBytes = sourceSpecs.Aggregate( - 0L, - (total, source) => checked(total + source.TotalBytes)); - totalFiles = sourceSpecs.Aggregate( - 0L, - (total, source) => checked(total + source.TotalFiles)); - } - catch (OverflowException) - { - issues.Add(Error( - "source.too_large", - "The selected sources are too large to measure safely.")); - return new ArchivePlanResult(null, issues, null); - } - - var outputPath = CreateAvailableArchivePath( - sourceSpecs[0].SourcePath, - destinationRoot, - format, - createdAt, - sourceSpecs.Count > 1); - var freeBytes = PathUtilities.TryGetAvailableFreeSpace(destinationRoot); - long conservativeRequired; - try - { - conservativeRequired = checked( - totalBytes + - Math.Max(1024 * 1024, totalBytes / 100)); - } - catch (OverflowException) - { - conservativeRequired = long.MaxValue; - } - - if (freeBytes.HasValue && conservativeRequired > freeBytes.Value) - { - issues.Add(Error( - "destination.insufficient_space", - "The destination may not have enough space for this archive.")); - } - - var spec = new ArchiveCreateSpec( - Guid.NewGuid(), - sourceSpecs[0].SourcePath, - destinationRoot, - outputPath, - sourceSpecs[0].IsDirectory, - format, - compression, - totalBytes, - totalFiles, - createdAt, - sourceSpecs); - - return new ArchivePlanResult(spec, issues, freeBytes); - } - - public Task> PlanExtractAsync( - string archiveInput, - string destinationInput, - DateTimeOffset createdAt, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var issues = new List(); - string archivePath; - string destinationRoot; - - try - { - archivePath = PathSafety.Normalize(archiveInput); - destinationRoot = PathSafety.Normalize(destinationInput); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - issues.Add(Error("path.invalid", "The selected path is not valid.")); - return Task.FromResult( - new ArchivePlanResult(null, issues, null)); - } - - if (!File.Exists(archivePath)) - { - issues.Add(Error("archive.missing", "The selected archive does not exist.")); - } - - if (File.Exists(destinationRoot)) - { - issues.Add(Error( - "destination.is_file", - "Choose a destination folder, not a file.")); - } - else if (!Directory.Exists(destinationRoot)) - { - issues.Add(Error( - "destination.missing", - "The destination folder does not exist.")); - } - - var extension = Path.GetExtension(archivePath); - if (!extension.Equals(".7z", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".zip", StringComparison.OrdinalIgnoreCase)) - { - issues.Add(Error( - "archive.unsupported", - "Version 1 currently accepts 7z and ZIP archives.")); - } - - if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) - { - return Task.FromResult( - new ArchivePlanResult(null, issues, null)); - } - - var baseName = Path.GetFileNameWithoutExtension(archivePath); - var outputPath = CreateAvailableDirectoryPath( - destinationRoot, - $"{baseName} - Extracted {createdAt.ToLocalTime():yyyy-MM-dd HHmm}"); - var freeBytes = PathUtilities.TryGetAvailableFreeSpace(destinationRoot); - var spec = new ArchiveExtractSpec( - Guid.NewGuid(), - archivePath, - destinationRoot, - outputPath, - createdAt); - - return Task.FromResult( - new ArchivePlanResult(spec, issues, freeBytes)); - } - - private static SourceStatistics ScanSource( - string sourcePath, - bool sourceIsDirectory, - CancellationToken cancellationToken) - { - if (!sourceIsDirectory) - { - var file = new FileInfo(sourcePath); - using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - return new SourceStatistics(file.Length, 1); - } - - long totalBytes = 0; - long totalFiles = 0; - var options = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = false, - ReturnSpecialDirectories = false, - AttributesToSkip = FileAttributes.ReparsePoint - }; - - foreach (var filePath in Directory.EnumerateFiles(sourcePath, "*", options)) - { - cancellationToken.ThrowIfCancellationRequested(); - totalBytes = checked(totalBytes + new FileInfo(filePath).Length); - totalFiles++; - } - - return new SourceStatistics(totalBytes, totalFiles); - } - - private static string CreateAvailableArchivePath( - string sourcePath, - string destinationRoot, - ArchiveFormat format, - DateTimeOffset createdAt, - bool isMultiSource) - { - var sourceName = isMultiSource - ? "ARCHive Collection" - : Directory.Exists(sourcePath) - ? new DirectoryInfo(sourcePath).Name - : Path.GetFileNameWithoutExtension(sourcePath); - var extension = format == ArchiveFormat.SevenZip ? ".7z" : ".zip"; - var baseName = $"{sourceName} - {createdAt.ToLocalTime():yyyy-MM-dd HHmm}"; - var candidate = Path.Combine(destinationRoot, baseName + extension); - var suffix = 2; - - while (File.Exists(candidate) || Directory.Exists(candidate)) - { - candidate = Path.Combine(destinationRoot, $"{baseName} ({suffix}){extension}"); - suffix++; - } - - return candidate; - } - - private static string GetTopLevelName(string sourcePath) => - PathUtilities.GetTopLevelName(sourcePath); - - private static string CreateAvailableDirectoryPath( - string destinationRoot, - string baseName) - { - var candidate = Path.Combine(destinationRoot, baseName); - var suffix = 2; - while (File.Exists(candidate) || Directory.Exists(candidate)) - { - candidate = Path.Combine(destinationRoot, $"{baseName} ({suffix})"); - suffix++; - } - - return candidate; - } - - private static ValidationIssue Error(string code, string message) => - new(ValidationSeverity.Error, code, message); - - private sealed record SourceStatistics(long TotalBytes, long TotalFiles); -} +using ARCHive.Core; + +namespace ARCHive.Archive; + +public sealed class ArchiveJobPlanner +{ + public async Task> PlanCreateAsync( + string sourceInput, + string destinationInput, + ArchiveFormat format, + CompressionPreset compression, + DateTimeOffset createdAt, + CancellationToken cancellationToken = default) => + await PlanCreateAsync( + [sourceInput], + destinationInput, + format, + compression, + createdAt, + cancellationToken); + + public async Task> PlanCreateAsync( + IReadOnlyCollection sourceInputs, + string destinationInput, + ArchiveFormat format, + CompressionPreset compression, + DateTimeOffset createdAt, + CancellationToken cancellationToken = default) + { + var issues = new List(); + string destinationRoot; + try + { + destinationRoot = PathSafety.Normalize(destinationInput); + } + catch (Exception ex) when ( + ex is ArgumentException or NotSupportedException or + PathTooLongException) + { + issues.Add(Error( + "destination.invalid", + "The destination path is not valid.")); + return new ArchivePlanResult( + null, + issues, + null); + } + + if (sourceInputs.Count == 0) + { + issues.Add(Error("source.missing", "Choose at least one source.")); + } + + if (File.Exists(destinationRoot)) + { + issues.Add(Error( + "destination.is_file", + "Choose a destination folder, not a file.")); + } + else if (!Directory.Exists(destinationRoot)) + { + issues.Add(Error( + "destination.missing", + "The destination folder does not exist.")); + } + + if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) + { + return new ArchivePlanResult(null, issues, null); + } + + var normalizedSources = new List(sourceInputs.Count); + foreach (var sourceInput in sourceInputs) + { + if (sourceInput == null || sourceInput.Contains("..")) + { + issues.Add(Error( + "source.invalid", + "One of the selected source paths is not valid.")); + return new ArchivePlanResult( + null, + issues, + null); + } + try + { + normalizedSources.Add(PathSafety.Normalize(sourceInput)); + } + catch (Exception ex) when ( + ex is ArgumentException or NotSupportedException or + PathTooLongException) + { + issues.Add(Error( + "source.invalid", + "One of the selected source paths is not valid.")); + return new ArchivePlanResult( + null, + issues, + null); + } + } + + if (normalizedSources.Distinct( + StringComparer.OrdinalIgnoreCase).Count() != + normalizedSources.Count) + { + issues.Add(Error( + "source.duplicate", + "The same source was selected more than once.")); + return new ArchivePlanResult( + null, + issues, + null); + } + + foreach (var sourcePath in normalizedSources) + { + if (!File.Exists(sourcePath) && !Directory.Exists(sourcePath)) + { + issues.Add(Error( + "source.missing", + "One of the selected sources no longer exists.")); + } + else if (Directory.Exists(sourcePath) && + PathSafety.IsSamePath( + sourcePath, + Path.GetPathRoot(sourcePath) ?? sourcePath)) + { + issues.Add(Error( + "source.drive_root", + "Choose folders within a drive rather than the entire drive.")); + } + } + + if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) + { + return new ArchivePlanResult( + null, + issues, + null); + } + + for (var outer = 0; outer < normalizedSources.Count; outer++) + { + var folder = normalizedSources[outer]; + if (!Directory.Exists(folder)) + { + continue; + } + + if (PathSafety.IsSameOrDescendant(destinationRoot, folder)) + { + issues.Add(Error( + "destination.inside_source", + "The destination cannot be inside a selected source folder.")); + return new ArchivePlanResult( + null, + issues, + null); + } + + for (var inner = 0; inner < normalizedSources.Count; inner++) + { + if (inner != outer && + PathSafety.IsSameOrDescendant( + normalizedSources[inner], + folder)) + { + issues.Add(Error( + "source.overlap", + "Do not select both a folder and an item already inside it.")); + return new ArchivePlanResult( + null, + issues, + null); + } + } + } + + if (normalizedSources.Count > 1) + { + var duplicateName = normalizedSources + .Select(GetTopLevelName) + .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateName is not null) + { + issues.Add(Error( + "source.name_collision", + $"Two selected items are named \"{duplicateName.Key}\". Rename one or archive them separately.")); + return new ArchivePlanResult( + null, + issues, + null); + } + } + + var sourceSpecs = new List( + normalizedSources.Count); + try + { + foreach (var sourcePath in normalizedSources) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourceIsDirectory = Directory.Exists(sourcePath); + var statistics = await Task.Run( + () => ScanSource( + sourcePath, + sourceIsDirectory, + cancellationToken), + cancellationToken); + sourceSpecs.Add(new ArchiveSourceSpec( + sourcePath, + sourceIsDirectory, + GetTopLevelName(sourcePath), + statistics.TotalBytes, + statistics.TotalFiles)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (UnauthorizedAccessException) + { + issues.Add(Error( + "source.unreadable", + "ARCHive cannot read part of the selected source.")); + return new ArchivePlanResult(null, issues, null); + } + catch (IOException ex) + { + issues.Add(Error( + "source.scan_failed", + $"ARCHive could not inspect the source: {ex.Message}")); + return new ArchivePlanResult(null, issues, null); + } + catch (OverflowException) + { + issues.Add(Error( + "source.too_large", + "The selected sources are too large to measure safely.")); + return new ArchivePlanResult(null, issues, null); + } + + long totalBytes; + long totalFiles; + try + { + totalBytes = sourceSpecs.Aggregate( + 0L, + (total, source) => checked(total + source.TotalBytes)); + totalFiles = sourceSpecs.Aggregate( + 0L, + (total, source) => checked(total + source.TotalFiles)); + } + catch (OverflowException) + { + issues.Add(Error( + "source.too_large", + "The selected sources are too large to measure safely.")); + return new ArchivePlanResult(null, issues, null); + } + + var outputPath = CreateAvailableArchivePath( + sourceSpecs[0].SourcePath, + destinationRoot, + format, + createdAt, + sourceSpecs.Count > 1); + var freeBytes = PathUtilities.TryGetAvailableFreeSpace(destinationRoot); + long conservativeRequired; + try + { + conservativeRequired = checked( + totalBytes + + Math.Max(1024 * 1024, totalBytes / 100)); + } + catch (OverflowException) + { + conservativeRequired = long.MaxValue; + } + + if (freeBytes.HasValue && conservativeRequired > freeBytes.Value) + { + issues.Add(Error( + "destination.insufficient_space", + "The destination may not have enough space for this archive.")); + } + + var spec = new ArchiveCreateSpec( + Guid.NewGuid(), + sourceSpecs[0].SourcePath, + destinationRoot, + outputPath, + sourceSpecs[0].IsDirectory, + format, + compression, + totalBytes, + totalFiles, + createdAt, + sourceSpecs); + + return new ArchivePlanResult(spec, issues, freeBytes); + } + + public Task> PlanExtractAsync( + string archiveInput, + string destinationInput, + DateTimeOffset createdAt, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var issues = new List(); + string archivePath; + string destinationRoot; + + try + { + archivePath = PathSafety.Normalize(archiveInput); + destinationRoot = PathSafety.Normalize(destinationInput); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + issues.Add(Error("path.invalid", "The selected path is not valid.")); + return Task.FromResult( + new ArchivePlanResult(null, issues, null)); + } + + if (!File.Exists(archivePath)) + { + issues.Add(Error("archive.missing", "The selected archive does not exist.")); + } + + if (File.Exists(destinationRoot)) + { + issues.Add(Error( + "destination.is_file", + "Choose a destination folder, not a file.")); + } + else if (!Directory.Exists(destinationRoot)) + { + issues.Add(Error( + "destination.missing", + "The destination folder does not exist.")); + } + + var extension = Path.GetExtension(archivePath); + if (!extension.Equals(".7z", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Error( + "archive.unsupported", + "Version 1 currently accepts 7z and ZIP archives.")); + } + + if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) + { + return Task.FromResult( + new ArchivePlanResult(null, issues, null)); + } + + var baseName = Path.GetFileNameWithoutExtension(archivePath); + var outputPath = CreateAvailableDirectoryPath( + destinationRoot, + $"{baseName} - Extracted {createdAt.ToLocalTime():yyyy-MM-dd HHmm}"); + var freeBytes = PathUtilities.TryGetAvailableFreeSpace(destinationRoot); + var spec = new ArchiveExtractSpec( + Guid.NewGuid(), + archivePath, + destinationRoot, + outputPath, + createdAt); + + return Task.FromResult( + new ArchivePlanResult(spec, issues, freeBytes)); + } + + private static SourceStatistics ScanSource( + string sourcePath, + bool sourceIsDirectory, + CancellationToken cancellationToken) + { + if (!sourceIsDirectory) + { + var file = new FileInfo(sourcePath); + using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return new SourceStatistics(file.Length, 1); + } + + long totalBytes = 0; + long totalFiles = 0; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + foreach (var filePath in Directory.EnumerateFiles(sourcePath, "*", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + totalBytes = checked(totalBytes + new FileInfo(filePath).Length); + totalFiles++; + } + + return new SourceStatistics(totalBytes, totalFiles); + } + + private static string CreateAvailableArchivePath( + string sourcePath, + string destinationRoot, + ArchiveFormat format, + DateTimeOffset createdAt, + bool isMultiSource) + { + var sourceName = isMultiSource + ? "ARCHive Collection" + : Directory.Exists(sourcePath) + ? new DirectoryInfo(sourcePath).Name + : Path.GetFileNameWithoutExtension(sourcePath); + var extension = format == ArchiveFormat.SevenZip ? ".7z" : ".zip"; + var baseName = $"{sourceName} - {createdAt.ToLocalTime():yyyy-MM-dd HHmm}"; + var candidate = Path.Combine(destinationRoot, baseName + extension); + var suffix = 2; + + while (File.Exists(candidate) || Directory.Exists(candidate)) + { + candidate = Path.Combine(destinationRoot, $"{baseName} ({suffix}){extension}"); + suffix++; + } + + return candidate; + } + + private static string GetTopLevelName(string sourcePath) => + PathUtilities.GetTopLevelName(sourcePath); + + private static string CreateAvailableDirectoryPath( + string destinationRoot, + string baseName) + { + var candidate = Path.Combine(destinationRoot, baseName); + var suffix = 2; + while (File.Exists(candidate) || Directory.Exists(candidate)) + { + candidate = Path.Combine(destinationRoot, $"{baseName} ({suffix})"); + suffix++; + } + + return candidate; + } + + private static ValidationIssue Error(string code, string message) => + new(ValidationSeverity.Error, code, message); + + private sealed record SourceStatistics(long TotalBytes, long TotalFiles); +} diff --git a/src/ARCHive.Copy/PausableFolderCopyRunner.cs b/src/ARCHive.Copy/PausableFolderCopyRunner.cs index 1f6ce9a..1e6d8af 100644 --- a/src/ARCHive.Copy/PausableFolderCopyRunner.cs +++ b/src/ARCHive.Copy/PausableFolderCopyRunner.cs @@ -1,787 +1,845 @@ -using System.Buffers; -using System.Diagnostics; -using ARCHive.Core; - -namespace ARCHive.Copy; - -internal sealed class PausableFolderCopyRunner(CopyPauseController pauseController) -{ - private const int MinBufferSize = 256 * 1024; - private const int DefaultBufferSize = 1024 * 1024; - private const int MaxBufferSize = 4 * 1024 * 1024; - private static readonly TimeSpan ProgressInterval = - TimeSpan.FromMilliseconds(250); - - public async Task RunAsync( - JobSpec job, - IProgress? progress, - CancellationToken cancellationToken) - { - var stopwatch = Stopwatch.StartNew(); - var details = new List(); - var active = new List>(); - using var operationCancellation = - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var operationToken = operationCancellation.Token; - long transferredBytes = 0; - long completedFiles = 0; - var lastProgress = TimeSpan.Zero; - var progressGate = new object(); - - try - { - pauseController.BeginSession(job.TotalFiles > 1); - var plan = await Task.Run( - () => BuildPlan(job, operationToken), - operationToken); - if (plan.TotalBytes != job.TotalBytes || - plan.Files.Count != job.TotalFiles) - { - return Failed( - job, - stopwatch, - "The source changed after preflight. Select it again before copying.", - "Pause coordinator refused a source whose file count or total size changed."); - } - - var concurrency = SelectConcurrency(job); - details.Add("Copy engine: cooperative pause coordinator"); - details.Add($"Concurrent files: {concurrency}"); - details.Add("Integrity: temporary file per active item; publish after completion"); - CreateDestinationDirectories(job, plan.Directories); - var manifest = new CopySessionManifest(plan.Files); - var nextFile = 0; - - void Report(string stage, string message, bool force = false) - { - lock (progressGate) - { - if (!force && - stopwatch.Elapsed - lastProgress < ProgressInterval) - { - return; - } - - lastProgress = stopwatch.Elapsed; - var bytes = Math.Min( - Interlocked.Read(ref transferredBytes), - job.TotalBytes); - var files = Math.Min( - Interlocked.Read(ref completedFiles), - job.TotalFiles); - var percent = job.TotalBytes == 0 - ? 100 - : Math.Min(99, bytes * 100d / job.TotalBytes); - progress?.Report(new JobProgress( - stage, - message, - bytes, - job.TotalBytes, - files, - job.TotalFiles, - percent, - false)); - } - } - - Report( - "Copying", - job.CopySources is { Count: > 1 } - ? "Copying selected items..." - : "Copying folder contents...", - force: true); - - while (nextFile < plan.Files.Count || active.Count > 0) - { - operationToken.ThrowIfCancellationRequested(); - - if (pauseController.IsPauseRequested) - { - if (active.Count > 0) - { - Report( - "Pausing", - "Finishing active files before pausing...", - force: true); - var outcomes = await Task.WhenAll(active); - active.Clear(); - RecordOutcomes(outcomes, manifest, ref completedFiles); - } - - Report( - "Paused", - "Paused safely between files. Completed files are preserved.", - force: true); - await pauseController.WaitForResumeAsync(operationToken); - manifest.ValidateCompletedSources(); - Report( - "Copying", - "Resuming with the next file...", - force: true); - continue; - } - - while (nextFile < plan.Files.Count && - active.Count < concurrency && - !pauseController.IsPauseRequested) - { - var entry = plan.Files[nextFile++]; - active.Add(CopyFileWithRetryAsync( - job, - entry, - bytes => - { - Interlocked.Add(ref transferredBytes, bytes); - Report( - pauseController.IsPauseRequested - ? "Pausing" - : "Copying", - pauseController.IsPauseRequested - ? "Finishing active files before pausing..." - : $"Copying {entry.RelativePath}"); - }, - operationToken)); - } - - if (active.Count == 0) - { - continue; - } - - var completedTask = await Task.WhenAny(active); - active.Remove(completedTask); - var outcome = await completedTask; - RecordOutcomes([outcome], manifest, ref completedFiles); - Report("Copying", $"Completed {outcome.RelativePath}", force: true); - } - - ApplyDirectoryMetadata(job, plan.Directories); - progress?.Report(new JobProgress( - "Verifying", - "Checking copied file counts and sizes...", - job.TotalBytes, - job.TotalBytes, - job.TotalFiles, - job.TotalFiles, - null, - true)); - - var verification = Verify(job); - details.Add($"Verification: {verification.Message}"); - stopwatch.Stop(); - if (!verification.Verified) - { - return new JobResult( - job.JobId, - JobStatus.Failed, - job.OutputPath, - 0, - 0, - stopwatch.Elapsed, - null, - verification.Message, - string.Join(Environment.NewLine, details)); - } - - progress?.Report(new JobProgress( - "Copying", - job.CopySources is { Count: > 1 } - ? "Selected items copied." - : "Folder copy complete.", - job.TotalBytes, - job.TotalBytes, - job.TotalFiles, - job.TotalFiles, - 100, - false)); - return new JobResult( - job.JobId, - JobStatus.Completed, - job.OutputPath, - job.TotalBytes, - job.TotalFiles, - stopwatch.Elapsed, - 0, - job.CopySources is { Count: > 1 } - ? "The selected items were copied and their file counts and sizes were verified." - : "The folder was copied and its file counts and sizes were verified.", - string.Join(Environment.NewLine, details)); - } - catch (OperationCanceledException) - { - operationCancellation.Cancel(); - await DrainAsync(active); - var cleanup = DeleteOwnedOutput(job); - stopwatch.Stop(); - details.Add(cleanup.Details); - return new JobResult( - job.JobId, - JobStatus.Cancelled, - job.OutputPath, - Math.Min(transferredBytes, job.TotalBytes), - completedFiles, - stopwatch.Elapsed, - null, - cleanup.Removed - ? "Copy cancelled. The entire dated output was removed. The source was not changed." - : "Copy cancelled. Incomplete output remains and must not be treated as completed. The source was not changed.", - string.Join(Environment.NewLine, details)); - } - catch (Exception ex) when ( - ex is IOException or UnauthorizedAccessException or - SourceChangedException or OverflowException) - { - operationCancellation.Cancel(); - await DrainAsync(active); - var cleanup = DeleteOwnedOutput(job); - stopwatch.Stop(); - details.Add(ex.ToString()); - details.Add(cleanup.Details); - return new JobResult( - job.JobId, - JobStatus.Failed, - job.OutputPath, - cleanup.Removed ? 0 : Math.Min(transferredBytes, job.TotalBytes), - cleanup.Removed ? 0 : completedFiles, - stopwatch.Elapsed, - null, - cleanup.Removed - ? $"Copy failed: {ex.Message}. The entire dated output was removed. The source was not changed." - : $"Copy failed: {ex.Message}. Incomplete output remains and must not be treated as completed. The source was not changed.", - string.Join(Environment.NewLine, details)); - } - finally - { - pauseController.EndSession(); - } - } - - private static FolderCopyPlan BuildPlan( - JobSpec job, - CancellationToken cancellationToken) - { - var options = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = false, - ReturnSpecialDirectories = false, - AttributesToSkip = FileAttributes.ReparsePoint - }; - - var files = new List(); - var directories = new List(); - long totalBytes = 0; - - var sources = job.CopySources is { Count: > 0 } - ? job.CopySources - : [ - new CopySourceSpec( - job.SourcePath, - job.SourceIsDirectory, - job.SourceIsDirectory - ? new DirectoryInfo(job.SourcePath).Name - : Path.GetFileName(job.SourcePath), - job.TotalBytes, - job.TotalFiles, - job.LargestFileBytes, - job.SourceLastWriteTimeUtc) - ]; - var preserveSingleFolderLayout = - sources.Count == 1 && sources[0].IsDirectory; - - foreach (var source in sources) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!source.IsDirectory) - { - var info = new FileInfo(source.SourcePath); - var entry = FileCopyEntry.From( - source.SourcePath, - source.OutputName); - files.Add(entry); - totalBytes = checked(totalBytes + info.Length); - continue; - } - - var rootRelative = preserveSingleFolderLayout - ? string.Empty - : source.OutputName; - directories.Add(DirectoryCopyEntry.From( - source.SourcePath, - rootRelative)); - - foreach (var directory in Directory.EnumerateDirectories( - source.SourcePath, - "*", - options)) - { - cancellationToken.ThrowIfCancellationRequested(); - directories.Add(DirectoryCopyEntry.From( - directory, - CombineRelative( - rootRelative, - Path.GetRelativePath( - source.SourcePath, - directory)))); - } - - foreach (var path in Directory.EnumerateFiles( - source.SourcePath, - "*", - options)) - { - cancellationToken.ThrowIfCancellationRequested(); - var entry = FileCopyEntry.From( - path, - CombineRelative( - rootRelative, - Path.GetRelativePath(source.SourcePath, path))); - files.Add(entry); - totalBytes = checked(totalBytes + entry.Length); - } - } - - return new FolderCopyPlan(files, directories, totalBytes); - } - - private static string CombineRelative(string parent, string child) => - string.IsNullOrEmpty(parent) - ? child - : Path.Combine(parent, child); - - private static void CreateDestinationDirectories( - JobSpec job, - IReadOnlyList directories) - { - foreach (var directory in directories.OrderBy(item => item.Depth)) - { - var destination = string.IsNullOrEmpty(directory.RelativePath) - ? job.OutputPath - : Path.Combine(job.OutputPath, directory.RelativePath); - Directory.CreateDirectory(destination); - } - } - - private static async Task CopyFileWithRetryAsync( - JobSpec job, - FileCopyEntry entry, - Action transferred, - CancellationToken cancellationToken) - { - Exception? lastError = null; - for (var attempt = 0; attempt < 3; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - long attemptBytes = 0; - try - { - return await CopyFileAsync( - job, - entry, - bytes => - { - attemptBytes += bytes; - transferred(bytes); - }, - cancellationToken); - } - catch (SourceChangedException) when (attempt < 2) - { - transferred(-attemptBytes); - lastError = new IOException( - $"The source changed while copying {entry.RelativePath}. Retrying in 2 seconds..."); - await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); - } - catch (IOException ex) when (attempt < 2) - { - transferred(-attemptBytes); - lastError = ex; - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); - } - } - - throw lastError ?? new IOException( - $"Windows could not copy {entry.RelativePath}."); - } - - private static async Task CopyFileAsync( - JobSpec job, - FileCopyEntry entry, - Action transferred, - CancellationToken cancellationToken) - { - entry.ValidateSource(); - var destination = Path.Combine(job.OutputPath, entry.RelativePath); - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - var temporary = $"{destination}.{job.JobId:N}.partial"; - - try - { - var bufferSize = SelectBufferSize(entry.Length); - await using var source = new FileStream( - entry.SourcePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - await using var target = new FileStream( - temporary, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - var readBuffer = ArrayPool.Shared.Rent(bufferSize); - var writeBuffer = ArrayPool.Shared.Rent(bufferSize); - try - { - var currentBuffer = readBuffer; - var otherBuffer = writeBuffer; - var bytesRead = await source.ReadAsync( - currentBuffer.AsMemory(0, bufferSize), cancellationToken); - while (bytesRead > 0) - { - var writeTask = target.WriteAsync( - currentBuffer.AsMemory(0, bytesRead), cancellationToken); - var readTask = source.ReadAsync( - otherBuffer.AsMemory(0, bufferSize), cancellationToken); - - await writeTask; - transferred(bytesRead); - - bytesRead = await readTask; - (currentBuffer, otherBuffer) = (otherBuffer, currentBuffer); - } - } - finally - { - ArrayPool.Shared.Return(readBuffer); - ArrayPool.Shared.Return(writeBuffer); - } - - await target.FlushAsync(cancellationToken); - await target.DisposeAsync(); - entry.ValidateSource(); - var destinationInfo = new FileInfo(temporary); - if (destinationInfo.Length != entry.Length) - { - throw new IOException( - $"The copied length did not match {entry.RelativePath}."); - } - - File.SetCreationTimeUtc(temporary, entry.CreationTimeUtc); - File.SetLastAccessTimeUtc(temporary, entry.LastAccessTimeUtc); - File.SetLastWriteTimeUtc(temporary, entry.LastWriteTimeUtc); - File.SetAttributes(temporary, entry.Attributes); - File.Move(temporary, destination); - return new FileCopyOutcome( - entry.RelativePath, - entry.SourcePath, - entry.Length, - entry.LastWriteTimeUtc); - } - catch - { - TryDeleteFile(temporary); - throw; - } - } - - private static int SelectBufferSize(long fileSize) => - fileSize <= 16 * 1024 * 1024 - ? MinBufferSize - : fileSize <= 256 * 1024 * 1024 - ? DefaultBufferSize - : MaxBufferSize; - - private static void RecordOutcomes( - IEnumerable outcomes, - CopySessionManifest manifest, - ref long completedFiles) - { - foreach (var outcome in outcomes) - { - manifest.Record(outcome); - completedFiles++; - } - } - - private static void ApplyDirectoryMetadata( - JobSpec job, - IReadOnlyList directories) - { - foreach (var directory in directories.OrderByDescending(item => item.Depth)) - { - var destination = string.IsNullOrEmpty(directory.RelativePath) - ? job.OutputPath - : Path.Combine(job.OutputPath, directory.RelativePath); - Directory.SetCreationTimeUtc(destination, directory.CreationTimeUtc); - Directory.SetLastAccessTimeUtc(destination, directory.LastAccessTimeUtc); - Directory.SetLastWriteTimeUtc(destination, directory.LastWriteTimeUtc); - File.SetAttributes(destination, directory.Attributes); - } - } - - private static VerificationResult Verify(JobSpec job) - { - var measured = Measure(job.OutputPath); - return measured.Files == job.TotalFiles && - measured.Bytes == job.TotalBytes - ? new VerificationResult(true, "Folder structure verified.") - : new VerificationResult( - false, - "The destination file count or total size did not match the planned copy."); - } - - private static (long Bytes, long Files) Measure(string root) - { - long bytes = 0; - long files = 0; - var options = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = false, - ReturnSpecialDirectories = false, - AttributesToSkip = FileAttributes.ReparsePoint - }; - - foreach (var path in Directory.EnumerateFiles(root, "*", options)) - { - bytes = checked(bytes + new FileInfo(path).Length); - files++; - } - - return (bytes, files); - } - - private static int SelectConcurrency(JobSpec job) - { - try - { - return DriveClassifier.RecommendedConcurrency( - job.DestinationRoot, job.LargestFileBytes); - } - catch - { - return 2; - } - } - - private static async Task DrainAsync( - IReadOnlyCollection> active) - { - try - { - await Task.WhenAll(active); - } - catch - { - // The authoritative job result is produced by the caller. - } - } - - private static CleanupResult DeleteOwnedOutput(JobSpec job) - { - try - { - if (!Directory.Exists(job.OutputPath)) - { - return new CleanupResult( - true, - "Cleanup: no job output remained."); - } - - if (PathSafety.IsSamePath(job.OutputPath, job.DestinationRoot) || - !PathSafety.IsSameOrDescendant( - job.OutputPath, - job.DestinationRoot)) - { - return new CleanupResult( - false, - "Cleanup refused because the output was not an application-owned child of the destination."); - } - - NormalizeAttributes(job.OutputPath); - Directory.Delete(job.OutputPath, recursive: true); - return new CleanupResult( - true, - "Cleanup: removed the entire application-owned dated output."); - } - catch (Exception ex) when ( - ex is IOException or UnauthorizedAccessException or ArgumentException) - { - return new CleanupResult( - false, - $"Cleanup could not remove the dated output: {ex.Message}"); - } - } - - private static void NormalizeAttributes(string root) - { - foreach (var file in Directory.EnumerateFiles( - root, - "*", - SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - - foreach (var directory in Directory.EnumerateDirectories( - root, - "*", - SearchOption.AllDirectories)) - { - File.SetAttributes(directory, FileAttributes.Directory); - } - } - - private static void TryDeleteFile(string path) - { - try - { - if (File.Exists(path)) - { - File.SetAttributes(path, FileAttributes.Normal); - File.Delete(path); - } - } - catch - { - // Whole-job cleanup remains the final cancellation safeguard. - } - } - - private static JobResult Failed( - JobSpec job, - Stopwatch stopwatch, - string summary, - string details) - { - stopwatch.Stop(); - return new JobResult( - job.JobId, - JobStatus.Failed, - job.OutputPath, - 0, - 0, - stopwatch.Elapsed, - null, - summary, - details); - } - - private sealed record FolderCopyPlan( - IReadOnlyList Files, - IReadOnlyList Directories, - long TotalBytes); - - private sealed record FileCopyEntry( - string SourcePath, - string RelativePath, - long Length, - DateTime CreationTimeUtc, - DateTime LastAccessTimeUtc, - DateTime LastWriteTimeUtc, - FileAttributes Attributes) - { - public static FileCopyEntry From( - string sourcePath, - string relativePath) - { - var info = new FileInfo(sourcePath); - return new FileCopyEntry( - sourcePath, - relativePath, - info.Length, - info.CreationTimeUtc, - info.LastAccessTimeUtc, - info.LastWriteTimeUtc, - info.Attributes); - } - - public void ValidateSource() - { - var current = new FileInfo(SourcePath); - if (!current.Exists || - current.Length != Length || - current.LastWriteTimeUtc != LastWriteTimeUtc) - { - throw new SourceChangedException( - $"The source changed during Pause or copy: {RelativePath}"); - } - } - } - - private sealed record DirectoryCopyEntry( - string RelativePath, - DateTime CreationTimeUtc, - DateTime LastAccessTimeUtc, - DateTime LastWriteTimeUtc, - FileAttributes Attributes, - int Depth) - { - public static DirectoryCopyEntry From( - string source, - string relativePath) - { - var info = new DirectoryInfo(source); - var depth = string.IsNullOrEmpty(relativePath) - ? 0 - : relativePath.Count(character => - character is '\\' or '/') + 1; - return new DirectoryCopyEntry( - relativePath, - info.CreationTimeUtc, - info.LastAccessTimeUtc, - info.LastWriteTimeUtc, - info.Attributes, - depth); - } - } - - private sealed record FileCopyOutcome( - string RelativePath, - string SourcePath, - long Length, - DateTime LastWriteTimeUtc); - - private sealed class CopySessionManifest( - IReadOnlyCollection plannedFiles) - { - private readonly Dictionary _completed = - new(StringComparer.OrdinalIgnoreCase); - - public int PlannedFiles { get; } = plannedFiles.Count; - - public void Record(FileCopyOutcome outcome) => - _completed[outcome.RelativePath] = outcome; - - public void ValidateCompletedSources() - { - foreach (var item in _completed.Values) - { - var info = new FileInfo(item.SourcePath); - if (!info.Exists || - info.Length != item.Length || - info.LastWriteTimeUtc != item.LastWriteTimeUtc) - { - throw new SourceChangedException( - $"A completed source file changed while paused: {item.RelativePath}"); - } - } - } - } - - private sealed class SourceChangedException(string message) - : IOException(message); - - private readonly record struct VerificationResult( - bool Verified, - string Message); - - private readonly record struct CleanupResult( - bool Removed, - string Details); -} +using System; +using System.Buffers; +using System.Diagnostics; +using ARCHive.Core; + +namespace ARCHive.Copy; + +internal sealed class PausableFolderCopyRunner(CopyPauseController pauseController) +{ + private const int MinBufferSize = 256 * 1024; + private const int DefaultBufferSize = 1024 * 1024; + private const int MaxBufferSize = 4 * 1024 * 1024; + private static readonly TimeSpan ProgressInterval = + TimeSpan.FromMilliseconds(250); + + public async Task RunAsync( + JobSpec job, + IProgress? progress, + CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var details = new List(); + var active = new List>(); + using var operationCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var operationToken = operationCancellation.Token; + long transferredBytes = 0; + long completedFiles = 0; + var lastProgress = TimeSpan.Zero; + var progressGate = new object(); + + try + { + pauseController.BeginSession(job.TotalFiles > 1); + var plan = await Task.Run( + () => BuildPlan(job, operationToken), + operationToken); + if (plan.TotalBytes != job.TotalBytes || + plan.Files.Count != job.TotalFiles) + { + return Failed( + job, + stopwatch, + "The source changed after preflight. Select it again before copying.", + "Pause coordinator refused a source whose file count or total size changed."); + } + + var concurrency = SelectConcurrency(job); + details.Add("Copy engine: cooperative pause coordinator"); + details.Add($"Concurrent files: {concurrency}"); + details.Add("Integrity: temporary file per active item; publish after completion"); + CreateDestinationDirectories(job, plan.Directories); + var manifest = new CopySessionManifest(plan.Files); + var nextFile = 0; + + void Report(string stage, string message, bool force = false) + { + lock (progressGate) + { + if (!force && + stopwatch.Elapsed - lastProgress < ProgressInterval) + { + return; + } + + lastProgress = stopwatch.Elapsed; + var bytes = Math.Min( + Interlocked.Read(ref transferredBytes), + job.TotalBytes); + var files = Math.Min( + Interlocked.Read(ref completedFiles), + job.TotalFiles); + var percent = job.TotalBytes == 0 + ? 100 + : Math.Min(99, bytes * 100d / job.TotalBytes); + progress?.Report(new JobProgress( + stage, + message, + bytes, + job.TotalBytes, + files, + job.TotalFiles, + percent, + false)); + } + } + + Report( + "Copying", + job.CopySources is { Count: > 1 } + ? "Copying selected items..." + : "Copying folder contents...", + force: true); + + while (nextFile < plan.Files.Count || active.Count > 0) + { + operationToken.ThrowIfCancellationRequested(); + + if (pauseController.IsPauseRequested) + { + if (active.Count > 0) + { + Report( + "Pausing", + "Finishing active files before pausing...", + force: true); + var outcomes = await Task.WhenAll(active); + active.Clear(); + RecordOutcomes(outcomes, manifest, ref completedFiles); + } + + Report( + "Paused", + "Paused safely between files. Completed files are preserved.", + force: true); + await pauseController.WaitForResumeAsync(operationToken); + manifest.ValidateCompletedSources(); + Report( + "Copying", + "Resuming with the next file...", + force: true); + continue; + } + + while (nextFile < plan.Files.Count && + active.Count < concurrency && + !pauseController.IsPauseRequested) + { + var entry = plan.Files[nextFile++]; + active.Add(CopyFileWithRetryAsync( + job, + entry, + bytes => + { + Interlocked.Add(ref transferredBytes, bytes); + Report( + pauseController.IsPauseRequested + ? "Pausing" + : "Copying", + pauseController.IsPauseRequested + ? "Finishing active files before pausing..." + : $"Copying {entry.RelativePath}"); + }, + operationToken)); + } + + if (active.Count == 0) + { + continue; + } + + var completedTask = await Task.WhenAny(active); + active.Remove(completedTask); + var outcome = await completedTask; + RecordOutcomes([outcome], manifest, ref completedFiles); + Report("Copying", $"Completed {outcome.RelativePath}", force: true); + } + + ApplyDirectoryMetadata(job, plan.Directories); + progress?.Report(new JobProgress( + "Verifying", + "Checking copied file counts and sizes...", + job.TotalBytes, + job.TotalBytes, + job.TotalFiles, + job.TotalFiles, + null, + true)); + + var verification = Verify(job); + details.Add($"Verification: {verification.Message}"); + stopwatch.Stop(); + if (!verification.Verified) + { + return new JobResult( + job.JobId, + JobStatus.Failed, + job.OutputPath, + 0, + 0, + stopwatch.Elapsed, + null, + verification.Message, + string.Join(Environment.NewLine, details)); + } + + progress?.Report(new JobProgress( + "Copying", + job.CopySources is { Count: > 1 } + ? "Selected items copied." + : "Folder copy complete.", + job.TotalBytes, + job.TotalBytes, + job.TotalFiles, + job.TotalFiles, + 100, + false)); + return new JobResult( + job.JobId, + JobStatus.Completed, + job.OutputPath, + job.TotalBytes, + job.TotalFiles, + stopwatch.Elapsed, + 0, + job.CopySources is { Count: > 1 } + ? "The selected items were copied and their file counts and sizes were verified." + : "The folder was copied and its file counts and sizes were verified.", + string.Join(Environment.NewLine, details)); + } + catch (OperationCanceledException) + { + operationCancellation.Cancel(); + await DrainAsync(active); + var cleanup = DeleteOwnedOutput(job); + stopwatch.Stop(); + details.Add(cleanup.Details); + return new JobResult( + job.JobId, + JobStatus.Cancelled, + job.OutputPath, + Math.Min(transferredBytes, job.TotalBytes), + completedFiles, + stopwatch.Elapsed, + null, + cleanup.Removed + ? "Copy cancelled. The entire dated output was removed. The source was not changed." + : "Copy cancelled. Incomplete output remains and must not be treated as completed. The source was not changed.", + string.Join(Environment.NewLine, details)); + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or + SourceChangedException or OverflowException) + { + operationCancellation.Cancel(); + await DrainAsync(active); + var cleanup = DeleteOwnedOutput(job); + stopwatch.Stop(); + details.Add(ex.ToString()); + details.Add(cleanup.Details); + return new JobResult( + job.JobId, + JobStatus.Failed, + job.OutputPath, + cleanup.Removed ? 0 : Math.Min(transferredBytes, job.TotalBytes), + cleanup.Removed ? 0 : completedFiles, + stopwatch.Elapsed, + null, + cleanup.Removed + ? $"Copy failed: {ex.Message}. The entire dated output was removed. The source was not changed." + : $"Copy failed: {ex.Message}. Incomplete output remains and must not be treated as completed. The source was not changed.", + string.Join(Environment.NewLine, details)); + } + finally + { + pauseController.EndSession(); + } + } + + private static FolderCopyPlan BuildPlan( + JobSpec job, + CancellationToken cancellationToken) + { + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + var files = new List(); + var directories = new List(); + long totalBytes = 0; + + var sources = job.CopySources is { Count: > 0 } + ? job.CopySources + : [ + new CopySourceSpec( + job.SourcePath, + job.SourceIsDirectory, + job.SourceIsDirectory + ? new DirectoryInfo(job.SourcePath).Name + : Path.GetFileName(job.SourcePath), + job.TotalBytes, + job.TotalFiles, + job.LargestFileBytes, + job.SourceLastWriteTimeUtc) + ]; + var preserveSingleFolderLayout = + sources.Count == 1 && sources[0].IsDirectory; + + foreach (var source in sources) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!source.IsDirectory) + { + if (source.SourcePath == null || source.SourcePath.Contains("..")) + { + throw new ArgumentException("Invalid file path"); + } + var info = new FileInfo(source.SourcePath); + var entry = FileCopyEntry.From( + source.SourcePath, + source.OutputName); + files.Add(entry); + totalBytes = checked(totalBytes + info.Length); + continue; + } + + var rootRelative = preserveSingleFolderLayout + ? string.Empty + : source.OutputName; + directories.Add(DirectoryCopyEntry.From( + source.SourcePath, + rootRelative)); + + var sourceBase = Path.GetFullPath(source.SourcePath); + foreach (var directory in Directory.EnumerateDirectories( + source.SourcePath, + "*", + options)) + { + cancellationToken.ThrowIfCancellationRequested(); + var directoryFull = Path.GetFullPath(directory); + if (!directoryFull.StartsWith(sourceBase + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && directoryFull != sourceBase) + { + throw new ArgumentException("Invalid file path"); + } + directories.Add(DirectoryCopyEntry.From( + directoryFull, + CombineRelative( + rootRelative, + Path.GetRelativePath( + source.SourcePath, + directoryFull)))); + } + + foreach (var path in Directory.EnumerateFiles( + source.SourcePath, + "*", + options)) + { + cancellationToken.ThrowIfCancellationRequested(); + var pathFull = Path.GetFullPath(path); + if (!pathFull.StartsWith(sourceBase + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && pathFull != sourceBase) + { + throw new ArgumentException("Invalid file path"); + } + var entry = FileCopyEntry.From( + pathFull, + CombineRelative( + rootRelative, + Path.GetRelativePath(source.SourcePath, pathFull))); + files.Add(entry); + totalBytes = checked(totalBytes + entry.Length); + } + } + + return new FolderCopyPlan(files, directories, totalBytes); + } + + private static string CombineRelative(string parent, string child) => + string.IsNullOrEmpty(parent) + ? child + : Path.Combine(parent, child); + + private static void CreateDestinationDirectories( + JobSpec job, + IReadOnlyList directories) + { + var outputBase = Path.GetFullPath(job.OutputPath); + foreach (var directory in directories.OrderBy(item => item.Depth)) + { + var destination = string.IsNullOrEmpty(directory.RelativePath) + ? job.OutputPath + : Path.Combine(job.OutputPath, directory.RelativePath); + var destinationFull = Path.GetFullPath(destination); + if (!destinationFull.StartsWith(outputBase + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && destinationFull != outputBase) + { + throw new ArgumentException("Invalid file path"); + } + Directory.CreateDirectory(destinationFull); + } + } + + private static async Task CopyFileWithRetryAsync( + JobSpec job, + FileCopyEntry entry, + Action transferred, + CancellationToken cancellationToken) + { + Exception? lastError = null; + for (var attempt = 0; attempt < 3; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + long attemptBytes = 0; + try + { + return await CopyFileAsync( + job, + entry, + bytes => + { + attemptBytes += bytes; + transferred(bytes); + }, + cancellationToken); + } + catch (SourceChangedException) when (attempt < 2) + { + transferred(-attemptBytes); + lastError = new IOException( + $"The source changed while copying {entry.RelativePath}. Retrying in 2 seconds..."); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + } + catch (IOException ex) when (attempt < 2) + { + transferred(-attemptBytes); + lastError = ex; + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + + throw lastError ?? new IOException( + $"Windows could not copy {entry.RelativePath}."); + } + + private static async Task CopyFileAsync( + JobSpec job, + FileCopyEntry entry, + Action transferred, + CancellationToken cancellationToken) + { + entry.ValidateSource(); + if (entry.SourcePath == null || entry.SourcePath.Contains("..")) + { + throw new ArgumentException("Invalid file path"); + } + var outputBase = Path.GetFullPath(job.OutputPath); + var destination = Path.Combine(job.OutputPath, entry.RelativePath); + var destinationFull = Path.GetFullPath(destination); + if (!destinationFull.StartsWith(outputBase + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && destinationFull != outputBase) + { + throw new ArgumentException("Invalid file path"); + } + Directory.CreateDirectory(Path.GetDirectoryName(destinationFull)!); + var temporary = $"{destinationFull}.{job.JobId:N}.partial"; + + try + { + var bufferSize = SelectBufferSize(entry.Length); + await using var source = new FileStream( + entry.SourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var target = new FileStream( + temporary, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var readBuffer = ArrayPool.Shared.Rent(bufferSize); + var writeBuffer = ArrayPool.Shared.Rent(bufferSize); + try + { + var currentBuffer = readBuffer; + var otherBuffer = writeBuffer; + var bytesRead = await source.ReadAsync( + currentBuffer.AsMemory(0, bufferSize), cancellationToken); + while (bytesRead > 0) + { + var writeTask = target.WriteAsync( + currentBuffer.AsMemory(0, bytesRead), cancellationToken); + var readTask = source.ReadAsync( + otherBuffer.AsMemory(0, bufferSize), cancellationToken); + + await writeTask; + transferred(bytesRead); + + bytesRead = await readTask; + (currentBuffer, otherBuffer) = (otherBuffer, currentBuffer); + } + } + finally + { + ArrayPool.Shared.Return(readBuffer); + ArrayPool.Shared.Return(writeBuffer); + } + + await target.FlushAsync(cancellationToken); + await target.DisposeAsync(); + entry.ValidateSource(); + var destinationInfo = new FileInfo(temporary); + if (destinationInfo.Length != entry.Length) + { + throw new IOException( + $"The copied length did not match {entry.RelativePath}."); + } + + File.SetCreationTimeUtc(temporary, entry.CreationTimeUtc); + File.SetLastAccessTimeUtc(temporary, entry.LastAccessTimeUtc); + File.SetLastWriteTimeUtc(temporary, entry.LastWriteTimeUtc); + File.SetAttributes(temporary, entry.Attributes); + File.Move(temporary, destinationFull); + return new FileCopyOutcome( + entry.RelativePath, + entry.SourcePath, + entry.Length, + entry.LastWriteTimeUtc); + } + catch + { + TryDeleteFile(temporary); + throw; + } + } + + private static int SelectBufferSize(long fileSize) => + fileSize <= 16 * 1024 * 1024 + ? MinBufferSize + : fileSize <= 256 * 1024 * 1024 + ? DefaultBufferSize + : MaxBufferSize; + + private static void RecordOutcomes( + IEnumerable outcomes, + CopySessionManifest manifest, + ref long completedFiles) + { + foreach (var outcome in outcomes) + { + manifest.Record(outcome); + completedFiles++; + } + } + + private static void ApplyDirectoryMetadata( + JobSpec job, + IReadOnlyList directories) + { + var outputBase = Path.GetFullPath(job.OutputPath); + foreach (var directory in directories.OrderByDescending(item => item.Depth)) + { + var destination = string.IsNullOrEmpty(directory.RelativePath) + ? job.OutputPath + : Path.Combine(job.OutputPath, directory.RelativePath); + var destinationFull = Path.GetFullPath(destination); + if (!destinationFull.StartsWith(outputBase + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && destinationFull != outputBase) + { + throw new ArgumentException("Invalid file path"); + } + Directory.SetCreationTimeUtc(destinationFull, directory.CreationTimeUtc); + Directory.SetLastAccessTimeUtc(destinationFull, directory.LastAccessTimeUtc); + Directory.SetLastWriteTimeUtc(destinationFull, directory.LastWriteTimeUtc); + File.SetAttributes(destinationFull, directory.Attributes); + } + } + + private static VerificationResult Verify(JobSpec job) + { + var measured = Measure(job.OutputPath); + return measured.Files == job.TotalFiles && + measured.Bytes == job.TotalBytes + ? new VerificationResult(true, "Folder structure verified.") + : new VerificationResult( + false, + "The destination file count or total size did not match the planned copy."); + } + + private static (long Bytes, long Files) Measure(string root) + { + long bytes = 0; + long files = 0; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + var rootFull = Path.GetFullPath(root); + foreach (var path in Directory.EnumerateFiles(root, "*", options)) + { + var pathFull = Path.GetFullPath(path); + if (!pathFull.StartsWith(rootFull + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && pathFull != rootFull) + { + throw new ArgumentException("Invalid file path"); + } + bytes = checked(bytes + new FileInfo(pathFull).Length); + files++; + } + + return (bytes, files); + } + + private static int SelectConcurrency(JobSpec job) + { + try + { + return DriveClassifier.RecommendedConcurrency( + job.DestinationRoot, job.LargestFileBytes); + } + catch + { + return 2; + } + } + + private static async Task DrainAsync( + IReadOnlyCollection> active) + { + try + { + await Task.WhenAll(active); + } + catch + { + // The authoritative job result is produced by the caller. + } + } + + private static CleanupResult DeleteOwnedOutput(JobSpec job) + { + try + { + if (!Directory.Exists(job.OutputPath)) + { + return new CleanupResult( + true, + "Cleanup: no job output remained."); + } + + if (PathSafety.IsSamePath(job.OutputPath, job.DestinationRoot) || + !PathSafety.IsSameOrDescendant( + job.OutputPath, + job.DestinationRoot)) + { + return new CleanupResult( + false, + "Cleanup refused because the output was not an application-owned child of the destination."); + } + + NormalizeAttributes(job.OutputPath); + Directory.Delete(job.OutputPath, recursive: true); + return new CleanupResult( + true, + "Cleanup: removed the entire application-owned dated output."); + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return new CleanupResult( + false, + $"Cleanup could not remove the dated output: {ex.Message}"); + } + } + + private static void NormalizeAttributes(string root) + { + foreach (var file in Directory.EnumerateFiles( + root, + "*", + SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + foreach (var directory in Directory.EnumerateDirectories( + root, + "*", + SearchOption.AllDirectories)) + { + File.SetAttributes(directory, FileAttributes.Directory); + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (path == null || path.Contains("..")) + { + throw new ArgumentException("Invalid file path"); + } + if (File.Exists(path)) + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + } + catch + { + // Whole-job cleanup remains the final cancellation safeguard. + } + } + + private static JobResult Failed( + JobSpec job, + Stopwatch stopwatch, + string summary, + string details) + { + stopwatch.Stop(); + return new JobResult( + job.JobId, + JobStatus.Failed, + job.OutputPath, + 0, + 0, + stopwatch.Elapsed, + null, + summary, + details); + } + + private sealed record FolderCopyPlan( + IReadOnlyList Files, + IReadOnlyList Directories, + long TotalBytes); + + private sealed record FileCopyEntry( + string SourcePath, + string RelativePath, + long Length, + DateTime CreationTimeUtc, + DateTime LastAccessTimeUtc, + DateTime LastWriteTimeUtc, + FileAttributes Attributes) + { + public static FileCopyEntry From( + string sourcePath, + string relativePath) + { + if (sourcePath == null || sourcePath.Contains("..")) + { + throw new ArgumentException("Invalid file path"); + } + var info = new FileInfo(sourcePath); + return new FileCopyEntry( + sourcePath, + relativePath, + info.Length, + info.CreationTimeUtc, + info.LastAccessTimeUtc, + info.LastWriteTimeUtc, + info.Attributes); + } + + public void ValidateSource() + { + var current = new FileInfo(SourcePath); + if (!current.Exists || + current.Length != Length || + current.LastWriteTimeUtc != LastWriteTimeUtc) + { + throw new SourceChangedException( + $"The source changed during Pause or copy: {RelativePath}"); + } + } + } + + private sealed record DirectoryCopyEntry( + string RelativePath, + DateTime CreationTimeUtc, + DateTime LastAccessTimeUtc, + DateTime LastWriteTimeUtc, + FileAttributes Attributes, + int Depth) + { + public static DirectoryCopyEntry From( + string source, + string relativePath) + { + var info = new DirectoryInfo(source); + var depth = string.IsNullOrEmpty(relativePath) + ? 0 + : relativePath.Count(character => + character is '\\' or '/') + 1; + return new DirectoryCopyEntry( + relativePath, + info.CreationTimeUtc, + info.LastAccessTimeUtc, + info.LastWriteTimeUtc, + info.Attributes, + depth); + } + } + + private sealed record FileCopyOutcome( + string RelativePath, + string SourcePath, + long Length, + DateTime LastWriteTimeUtc); + + private sealed class CopySessionManifest( + IReadOnlyCollection plannedFiles) + { + private readonly Dictionary _completed = + new(StringComparer.OrdinalIgnoreCase); + + public int PlannedFiles { get; } = plannedFiles.Count; + + public void Record(FileCopyOutcome outcome) => + _completed[outcome.RelativePath] = outcome; + + public void ValidateCompletedSources() + { + foreach (var item in _completed.Values) + { + var info = new FileInfo(item.SourcePath); + if (!info.Exists || + info.Length != item.Length || + info.LastWriteTimeUtc != item.LastWriteTimeUtc) + { + throw new SourceChangedException( + $"A completed source file changed while paused: {item.RelativePath}"); + } + } + } + } + + private sealed class SourceChangedException(string message) + : IOException(message); + + private readonly record struct VerificationResult( + bool Verified, + string Message); + + private readonly record struct CleanupResult( + bool Removed, + string Details); +} diff --git a/src/ARCHive.Core/JobPlanner.cs b/src/ARCHive.Core/JobPlanner.cs index 7e55e2f..1f314e4 100644 --- a/src/ARCHive.Core/JobPlanner.cs +++ b/src/ARCHive.Core/JobPlanner.cs @@ -1,348 +1,352 @@ -namespace ARCHive.Core; - -public sealed class JobPlanner -{ - private const long Fat32MaximumFileBytes = 4L * 1024 * 1024 * 1024 - 1; - - public async Task PlanCopyAsync( - string sourceInput, - string destinationInput, - DateTimeOffset createdAt, - CancellationToken cancellationToken = default) => - await PlanCopyAsync( - [sourceInput], - destinationInput, - createdAt, - cancellationToken); - - public async Task PlanCopyAsync( - IReadOnlyCollection sourceInputs, - string destinationInput, - DateTimeOffset createdAt, - CancellationToken cancellationToken = default) - { - var issues = new List(); - string destinationRoot; - - try - { - destinationRoot = PathSafety.Normalize(destinationInput); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - issues.Add(Error("destination.invalid", "The destination path is not valid.")); - return new PreflightResult(null, issues, null); - } - - if (sourceInputs.Count == 0) - { - issues.Add(Error("source.missing", "Choose at least one source.")); - return new PreflightResult(null, issues, null); - } - - if (File.Exists(destinationRoot)) - { - issues.Add(Error( - "destination.is_file", - "Choose a destination folder, not a file.")); - } - else if (!Directory.Exists(destinationRoot)) - { - issues.Add(Error( - "destination.missing", - "The destination folder does not exist.")); - } - - if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) - { - return new PreflightResult(null, issues, null); - } - - var normalizedSources = new List(sourceInputs.Count); - foreach (var sourceInput in sourceInputs) - { - try - { - normalizedSources.Add(PathSafety.Normalize(sourceInput)); - } - catch (Exception ex) when ( - ex is ArgumentException or NotSupportedException or - PathTooLongException) - { - issues.Add(Error( - "source.invalid", - "One of the selected source paths is not valid.")); - return new PreflightResult(null, issues, null); - } - } - - if (normalizedSources.Distinct( - StringComparer.OrdinalIgnoreCase).Count() != - normalizedSources.Count) - { - issues.Add(Error( - "source.duplicate", - "The same source was selected more than once.")); - return new PreflightResult(null, issues, null); - } - - foreach (var sourcePath in normalizedSources) - { - if (!File.Exists(sourcePath) && !Directory.Exists(sourcePath)) - { - issues.Add(Error( - "source.missing", - "One of the selected sources no longer exists.")); - } - } - - if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) - { - return new PreflightResult(null, issues, null); - } - - for (var outer = 0; outer < normalizedSources.Count; outer++) - { - var folder = normalizedSources[outer]; - if (!Directory.Exists(folder)) - { - continue; - } - - if (PathSafety.IsSameOrDescendant(destinationRoot, folder)) - { - issues.Add(Error( - "destination.inside_source", - "The destination cannot be inside a selected source folder.")); - return new PreflightResult(null, issues, null); - } - - for (var inner = 0; inner < normalizedSources.Count; inner++) - { - if (inner != outer && - PathSafety.IsSameOrDescendant( - normalizedSources[inner], - folder)) - { - issues.Add(Error( - "source.overlap", - "Do not select both a folder and an item already inside it.")); - return new PreflightResult(null, issues, null); - } - } - } - - if (normalizedSources.Count > 1) - { - var duplicateName = normalizedSources - .Select(GetTopLevelName) - .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(group => group.Count() > 1); - if (duplicateName is not null) - { - issues.Add(Error( - "source.name_collision", - $"Two selected items are named \"{duplicateName.Key}\". Rename one or copy them separately.")); - return new PreflightResult(null, issues, null); - } - } - - var sourceSpecs = new List(normalizedSources.Count); - try - { - foreach (var sourcePath in normalizedSources) - { - cancellationToken.ThrowIfCancellationRequested(); - var sourceIsDirectory = Directory.Exists(sourcePath); - var statistics = await Task.Run( - () => ScanSource( - sourcePath, - sourceIsDirectory, - cancellationToken), - cancellationToken); - sourceSpecs.Add(new CopySourceSpec( - sourcePath, - sourceIsDirectory, - GetTopLevelName(sourcePath), - statistics.TotalBytes, - statistics.TotalFiles, - statistics.LargestFileBytes, - sourceIsDirectory - ? null - : File.GetLastWriteTimeUtc(sourcePath))); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (UnauthorizedAccessException) - { - issues.Add(Error( - "source.unreadable", - "ARCHive cannot read part of the selected source.")); - return new PreflightResult(null, issues, null); - } - catch (IOException ex) - { - issues.Add(Error( - "source.scan_failed", - $"ARCHive could not inspect the source: {ex.Message}")); - return new PreflightResult(null, issues, null); - } - - long totalBytes; - long totalFiles; - try - { - totalBytes = sourceSpecs.Aggregate( - 0L, - (total, source) => checked(total + source.TotalBytes)); - totalFiles = sourceSpecs.Aggregate( - 0L, - (total, source) => checked(total + source.TotalFiles)); - } - catch (OverflowException) - { - issues.Add(Error( - "source.too_large", - "The selected sources are too large to measure safely.")); - return new PreflightResult(null, issues, null); - } - - var largestFileBytes = sourceSpecs.Count == 0 - ? 0 - : sourceSpecs.Max(source => source.LargestFileBytes); - var isMultiSource = sourceSpecs.Count > 1; - var firstSource = sourceSpecs[0]; - var outputPath = CreateAvailableOutputPath( - firstSource.SourcePath, - destinationRoot, - firstSource.IsDirectory, - createdAt, - isMultiSource); - - long? freeBytes = PathUtilities.TryGetAvailableFreeSpace(destinationRoot); - if (freeBytes.HasValue && totalBytes > freeBytes.Value) - { - issues.Add(Error( - "destination.insufficient_space", - "The destination does not have enough available space.")); - } - - var destinationFormat = PathUtilities.TryGetDriveFormat(destinationRoot); - if (string.Equals(destinationFormat, "FAT32", StringComparison.OrdinalIgnoreCase) && - largestFileBytes > Fat32MaximumFileBytes) - { - issues.Add(Error( - "destination.fat32_limit", - "The source contains a file larger than the FAT32 file-size limit.")); - } - - var spec = new JobSpec( - Guid.NewGuid(), - JobAction.Copy, - firstSource.SourcePath, - destinationRoot, - outputPath, - firstSource.IsDirectory || isMultiSource, - totalBytes, - totalFiles, - createdAt, - largestFileBytes, - firstSource.LastWriteTimeUtc, - sourceSpecs); - - return new PreflightResult(spec, issues, freeBytes); - } - - private static SourceStatistics ScanSource( - string sourcePath, - bool sourceIsDirectory, - CancellationToken cancellationToken) - { - if (!sourceIsDirectory) - { - var file = new FileInfo(sourcePath); - using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - return new SourceStatistics(file.Length, 1, file.Length); - } - - long totalBytes = 0; - long totalFiles = 0; - long largestFileBytes = 0; - - var options = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = false, - ReturnSpecialDirectories = false, - AttributesToSkip = FileAttributes.ReparsePoint - }; - - foreach (var filePath in Directory.EnumerateFiles(sourcePath, "*", options)) - { - cancellationToken.ThrowIfCancellationRequested(); - var file = new FileInfo(filePath); - totalBytes = checked(totalBytes + file.Length); - totalFiles++; - largestFileBytes = Math.Max(largestFileBytes, file.Length); - } - - return new SourceStatistics(totalBytes, totalFiles, largestFileBytes); - } - - private static string CreateAvailableOutputPath( - string sourcePath, - string destinationRoot, - bool sourceIsDirectory, - DateTimeOffset createdAt, - bool isMultiSource) - { - var dateSuffix = createdAt.ToLocalTime().ToString("yyyy-MM-dd HHmm"); - string baseName; - string extension; - - if (isMultiSource) - { - baseName = "ARCHive Copy"; - extension = string.Empty; - } - else if (sourceIsDirectory) - { - baseName = new DirectoryInfo(sourcePath).Name; - extension = string.Empty; - } - else - { - baseName = Path.GetFileNameWithoutExtension(sourcePath); - extension = Path.GetExtension(sourcePath); - } - - var candidate = Path.Combine( - destinationRoot, - $"{baseName} - {dateSuffix}{extension}"); - - var suffix = 2; - while (File.Exists(candidate) || Directory.Exists(candidate)) - { - candidate = Path.Combine( - destinationRoot, - $"{baseName} - {dateSuffix} ({suffix}){extension}"); - suffix++; - } - - return candidate; - } - - private static string GetTopLevelName(string sourcePath) => - PathUtilities.GetTopLevelName(sourcePath); - - private static ValidationIssue Error(string code, string message) => - new(ValidationSeverity.Error, code, message); - - private sealed record SourceStatistics( - long TotalBytes, - long TotalFiles, - long LargestFileBytes); -} +namespace ARCHive.Core; + +public sealed class JobPlanner +{ + private const long Fat32MaximumFileBytes = 4L * 1024 * 1024 * 1024 - 1; + + public async Task PlanCopyAsync( + string sourceInput, + string destinationInput, + DateTimeOffset createdAt, + CancellationToken cancellationToken = default) => + await PlanCopyAsync( + [sourceInput], + destinationInput, + createdAt, + cancellationToken); + + public async Task PlanCopyAsync( + IReadOnlyCollection sourceInputs, + string destinationInput, + DateTimeOffset createdAt, + CancellationToken cancellationToken = default) + { + var issues = new List(); + string destinationRoot; + + try + { + destinationRoot = PathSafety.Normalize(destinationInput); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + issues.Add(Error("destination.invalid", "The destination path is not valid.")); + return new PreflightResult(null, issues, null); + } + + if (sourceInputs.Count == 0) + { + issues.Add(Error("source.missing", "Choose at least one source.")); + return new PreflightResult(null, issues, null); + } + + if (File.Exists(destinationRoot)) + { + issues.Add(Error( + "destination.is_file", + "Choose a destination folder, not a file.")); + } + else if (!Directory.Exists(destinationRoot)) + { + issues.Add(Error( + "destination.missing", + "The destination folder does not exist.")); + } + + if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) + { + return new PreflightResult(null, issues, null); + } + + var normalizedSources = new List(sourceInputs.Count); + foreach (var sourceInput in sourceInputs) + { + if (sourceInput == null || sourceInput.Contains("..")) + { + throw new ArgumentException("Invalid file path"); + } + try + { + normalizedSources.Add(PathSafety.Normalize(sourceInput)); + } + catch (Exception ex) when ( + ex is ArgumentException or NotSupportedException or + PathTooLongException) + { + issues.Add(Error( + "source.invalid", + "One of the selected source paths is not valid.")); + return new PreflightResult(null, issues, null); + } + } + + if (normalizedSources.Distinct( + StringComparer.OrdinalIgnoreCase).Count() != + normalizedSources.Count) + { + issues.Add(Error( + "source.duplicate", + "The same source was selected more than once.")); + return new PreflightResult(null, issues, null); + } + + foreach (var sourcePath in normalizedSources) + { + if (!File.Exists(sourcePath) && !Directory.Exists(sourcePath)) + { + issues.Add(Error( + "source.missing", + "One of the selected sources no longer exists.")); + } + } + + if (issues.Any(issue => issue.Severity == ValidationSeverity.Error)) + { + return new PreflightResult(null, issues, null); + } + + for (var outer = 0; outer < normalizedSources.Count; outer++) + { + var folder = normalizedSources[outer]; + if (!Directory.Exists(folder)) + { + continue; + } + + if (PathSafety.IsSameOrDescendant(destinationRoot, folder)) + { + issues.Add(Error( + "destination.inside_source", + "The destination cannot be inside a selected source folder.")); + return new PreflightResult(null, issues, null); + } + + for (var inner = 0; inner < normalizedSources.Count; inner++) + { + if (inner != outer && + PathSafety.IsSameOrDescendant( + normalizedSources[inner], + folder)) + { + issues.Add(Error( + "source.overlap", + "Do not select both a folder and an item already inside it.")); + return new PreflightResult(null, issues, null); + } + } + } + + if (normalizedSources.Count > 1) + { + var duplicateName = normalizedSources + .Select(GetTopLevelName) + .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateName is not null) + { + issues.Add(Error( + "source.name_collision", + $"Two selected items are named \"{duplicateName.Key}\". Rename one or copy them separately.")); + return new PreflightResult(null, issues, null); + } + } + + var sourceSpecs = new List(normalizedSources.Count); + try + { + foreach (var sourcePath in normalizedSources) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourceIsDirectory = Directory.Exists(sourcePath); + var statistics = await Task.Run( + () => ScanSource( + sourcePath, + sourceIsDirectory, + cancellationToken), + cancellationToken); + sourceSpecs.Add(new CopySourceSpec( + sourcePath, + sourceIsDirectory, + GetTopLevelName(sourcePath), + statistics.TotalBytes, + statistics.TotalFiles, + statistics.LargestFileBytes, + sourceIsDirectory + ? null + : File.GetLastWriteTimeUtc(sourcePath))); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (UnauthorizedAccessException) + { + issues.Add(Error( + "source.unreadable", + "ARCHive cannot read part of the selected source.")); + return new PreflightResult(null, issues, null); + } + catch (IOException ex) + { + issues.Add(Error( + "source.scan_failed", + $"ARCHive could not inspect the source: {ex.Message}")); + return new PreflightResult(null, issues, null); + } + + long totalBytes; + long totalFiles; + try + { + totalBytes = sourceSpecs.Aggregate( + 0L, + (total, source) => checked(total + source.TotalBytes)); + totalFiles = sourceSpecs.Aggregate( + 0L, + (total, source) => checked(total + source.TotalFiles)); + } + catch (OverflowException) + { + issues.Add(Error( + "source.too_large", + "The selected sources are too large to measure safely.")); + return new PreflightResult(null, issues, null); + } + + var largestFileBytes = sourceSpecs.Count == 0 + ? 0 + : sourceSpecs.Max(source => source.LargestFileBytes); + var isMultiSource = sourceSpecs.Count > 1; + var firstSource = sourceSpecs[0]; + var outputPath = CreateAvailableOutputPath( + firstSource.SourcePath, + destinationRoot, + firstSource.IsDirectory, + createdAt, + isMultiSource); + + long? freeBytes = PathUtilities.TryGetAvailableFreeSpace(destinationRoot); + if (freeBytes.HasValue && totalBytes > freeBytes.Value) + { + issues.Add(Error( + "destination.insufficient_space", + "The destination does not have enough available space.")); + } + + var destinationFormat = PathUtilities.TryGetDriveFormat(destinationRoot); + if (string.Equals(destinationFormat, "FAT32", StringComparison.OrdinalIgnoreCase) && + largestFileBytes > Fat32MaximumFileBytes) + { + issues.Add(Error( + "destination.fat32_limit", + "The source contains a file larger than the FAT32 file-size limit.")); + } + + var spec = new JobSpec( + Guid.NewGuid(), + JobAction.Copy, + firstSource.SourcePath, + destinationRoot, + outputPath, + firstSource.IsDirectory || isMultiSource, + totalBytes, + totalFiles, + createdAt, + largestFileBytes, + firstSource.LastWriteTimeUtc, + sourceSpecs); + + return new PreflightResult(spec, issues, freeBytes); + } + + private static SourceStatistics ScanSource( + string sourcePath, + bool sourceIsDirectory, + CancellationToken cancellationToken) + { + if (!sourceIsDirectory) + { + var file = new FileInfo(sourcePath); + using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return new SourceStatistics(file.Length, 1, file.Length); + } + + long totalBytes = 0; + long totalFiles = 0; + long largestFileBytes = 0; + + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + foreach (var filePath in Directory.EnumerateFiles(sourcePath, "*", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + var file = new FileInfo(filePath); + totalBytes = checked(totalBytes + file.Length); + totalFiles++; + largestFileBytes = Math.Max(largestFileBytes, file.Length); + } + + return new SourceStatistics(totalBytes, totalFiles, largestFileBytes); + } + + private static string CreateAvailableOutputPath( + string sourcePath, + string destinationRoot, + bool sourceIsDirectory, + DateTimeOffset createdAt, + bool isMultiSource) + { + var dateSuffix = createdAt.ToLocalTime().ToString("yyyy-MM-dd HHmm"); + string baseName; + string extension; + + if (isMultiSource) + { + baseName = "ARCHive Copy"; + extension = string.Empty; + } + else if (sourceIsDirectory) + { + baseName = new DirectoryInfo(sourcePath).Name; + extension = string.Empty; + } + else + { + baseName = Path.GetFileNameWithoutExtension(sourcePath); + extension = Path.GetExtension(sourcePath); + } + + var candidate = Path.Combine( + destinationRoot, + $"{baseName} - {dateSuffix}{extension}"); + + var suffix = 2; + while (File.Exists(candidate) || Directory.Exists(candidate)) + { + candidate = Path.Combine( + destinationRoot, + $"{baseName} - {dateSuffix} ({suffix}){extension}"); + suffix++; + } + + return candidate; + } + + private static string GetTopLevelName(string sourcePath) => + PathUtilities.GetTopLevelName(sourcePath); + + private static ValidationIssue Error(string code, string message) => + new(ValidationSeverity.Error, code, message); + + private sealed record SourceStatistics( + long TotalBytes, + long TotalFiles, + long LargestFileBytes); +}