| File: CopyPreservingRelativeSymlinks.cs | Web Access |
| Project: src\sdk\src\Tasks\sdk-tasks\sdk-tasks.csproj (sdk-tasks) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #if !NETFRAMEWORK using System; using System.Collections.Generic; using System.IO; using Microsoft.Build.Framework; namespace Microsoft.DotNet.Build.Tasks; /// <summary> /// Copies files while preserving relative symbolic links. Unlike the standard MSBuild Copy task, /// this task recreates symbolic links at the destination with their original relative targets /// instead of resolving and copying the target file contents. /// </summary> public sealed class CopyPreservingRelativeSymlinks : Task { /// <summary> /// The source files to copy. /// </summary> [Required] public ITaskItem[] SourceFiles { get; set; } = []; /// <summary> /// The destination files (must match SourceFiles count). /// </summary> [Required] public ITaskItem[] DestinationFiles { get; set; } = []; /// <summary> /// The files that were successfully copied. /// </summary> [Output] public ITaskItem[] CopiedFiles { get; private set; } = []; public override bool Execute() { if (SourceFiles.Length == 0) { return true; } if (SourceFiles.Length != DestinationFiles.Length) { Log.LogError($"SourceFiles count ({SourceFiles.Length}) must match DestinationFiles count ({DestinationFiles.Length})."); return false; } // Build a set of normalized source paths for symlink target validation var sourcePathSet = new HashSet<string>(StringComparer.Ordinal); foreach (var item in SourceFiles) { sourcePathSet.Add(Path.GetFullPath(item.ItemSpec)); } var copiedFiles = new List<ITaskItem>(); bool hasErrors = false; for (int i = 0; i < SourceFiles.Length; i++) { var sourcePath = SourceFiles[i].ItemSpec; var destPath = DestinationFiles[i].ItemSpec; try { CopyFile(sourcePath, destPath, sourcePathSet); copiedFiles.Add(new TaskItem(destPath)); } catch (Exception ex) { Log.LogError($"Failed to copy '{sourcePath}' to '{destPath}': {ex.Message}"); hasErrors = true; } } CopiedFiles = copiedFiles.ToArray(); Log.LogMessage(MessageImportance.Normal, $"Copied {copiedFiles.Count} files."); return !hasErrors; } private void CopyFile(string sourcePath, string destPath, HashSet<string> sourcePathSet) { var sourceInfo = new FileInfo(sourcePath); if (!sourceInfo.Exists) { throw new FileNotFoundException($"Source file does not exist: '{sourcePath}'"); } // Create destination directory if needed var destDir = Path.GetDirectoryName(destPath); if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) { Directory.CreateDirectory(destDir); } // Remove existing file/link at destination if (File.Exists(destPath)) { File.Delete(destPath); } // Check if source is a symbolic link if (sourceInfo.LinkTarget != null) { // Validate that the symlink target resolves to a file within the copy scope var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))!; var resolvedTarget = Path.GetFullPath(Path.Combine(sourceDir, sourceInfo.LinkTarget)); if (!sourcePathSet.Contains(resolvedTarget)) { throw new InvalidOperationException( $"Symbolic link target '{sourceInfo.LinkTarget}' resolves to '{resolvedTarget}' which is outside the copy scope."); } // Recreate the symbolic link with the same relative target File.CreateSymbolicLink(destPath, sourceInfo.LinkTarget); Log.LogMessage(MessageImportance.Low, $"Created symlink: '{destPath}' -> '{sourceInfo.LinkTarget}'"); } else { File.Copy(sourcePath, destPath); Log.LogMessage(MessageImportance.Low, $"Copied: '{sourcePath}' -> '{destPath}'"); } } } #endif