File: CopyPreservingRelativeSymlinks.cs
Web Access
Project: src\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