File: src\ReplacePackageParts.cs
Web Access
Project: Microsoft.DotNet.NuGetRepack.Tasks.csproj (Microsoft.DotNet.NuGetRepack.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.IO.Packaging;
using System.Linq;
using System.Xml.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using NuGet.Versioning;

namespace Microsoft.DotNet.Tools;

/// <summary>
/// Replaces content of files in specified package with new content and updates version of the package.
/// </summary>
[MSBuildMultiThreadableTask]
public sealed class ReplacePackageParts : Task, IMultiThreadableTask
{
    /// <summary>Injected by MSBuild so paths resolve against the project directory in multithreaded builds.</summary>
    public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

    /// <summary>
    /// Full path to the package to process.
    /// </summary>
    [Required]
    public string SourcePackage { get; set; }

    /// <summary>
    /// Directory to store the processed package to.
    /// </summary>
    [Required]
    public string DestinationFolder { get; set; }

    /// <summary>
    /// New version of the package.
    /// </summary>
    public string NewVersion { get; set; }

    /// <summary>
    /// Suffix of the new version of the package. New version is composed of the current version base and <see cref="NewVersionSuffix"/>.
    /// </summary>
    public string NewVersionSuffix { get; set; }

    /// <summary>
    /// Relative paths to files within the package.
    /// </summary>
    public string[] Parts { get; set; }

    /// <summary>
    /// Full paths to files whose content should replace the files in the package.
    /// Each item of <see cref="ReplacementFiles"/> corresponds to an item of <see cref="Parts"/> array.
    /// </summary>
    public string[] ReplacementFiles { get; set; }

    /// <summary>
    /// Full path the the processed package.
    /// </summary>
    [Output]
    public string NewPackage { get; private set; }

    public override bool Execute()
    {
        try
        {
            ExecuteImpl();
            return !Log.HasLoggedErrors;
        }
        finally
        {
        }
    }

    private Dictionary<string, string> GetPartReplacementMap()
    {
        int partCount = Parts?.Length ?? 0;

        if (partCount != (ReplacementFiles?.Length ?? 0))
        {
            Log.LogError($"{nameof(Parts)} and {nameof(ReplacementFiles)} lists must have the same length.");
            return null;
        }

        var map = new Dictionary<string, string>(partCount);

        for (int i = 0; i < partCount; i++)
        {
            var partUri = Parts[i].Replace('\\', '/');

            if (!partUri.StartsWith("/"))
            {
                partUri = "/" + partUri;
            }

            map[partUri] = ReplacementFiles[i];
        }

        return map;
    }

    private void ExecuteImpl()
    {
        var replacementMap = GetPartReplacementMap();
        if (replacementMap == null)
        {
            return;
        }

        string packageId = null;
        SemanticVersion packageVersion = null;
        // Directory.CreateTempSubdirectory atomically creates a uniquely named directory, so the
        // package copy below can never collide with a concurrently running task.
        DirectoryInfo tempDirectory = Directory.CreateTempSubdirectory();
        string tempPackagePath = Path.Combine(tempDirectory.FullName, "package.nupkg");
        AbsolutePath tempPackageAbsolutePath = TaskEnvironment.GetAbsolutePath(tempPackagePath);
        try
        {
            File.Copy(TaskEnvironment.GetAbsolutePath(SourcePackage), tempPackageAbsolutePath);

            using (var package = Package.Open(tempPackagePath, FileMode.Open, FileAccess.ReadWrite))
            {
                foreach (var part in package.GetParts())
                {
                    string relativePath = part.Uri.OriginalString;
                    if (NuGetUtils.IsNuSpec(relativePath))
                    {
                        if (packageId != null)
                        {
                            Log.LogError($"'{SourcePackage}' has multiple .nuspec files in the root");
                            return;
                        }

                        using (var nuspecStream = part.GetStream(FileMode.Open, FileAccess.ReadWrite))
                        {
                            string nuspecXmlns = NuGetUtils.DefaultNuspecXmlns;
                            var nuspecXml = XDocument.Load(nuspecStream);

                            if (nuspecXml.Root.HasAttributes)
                            {
                                var xmlNsAttribute = nuspecXml.Root.Attributes("xmlns").SingleOrDefault();
                                if (xmlNsAttribute != null)
                                {
                                    nuspecXmlns = xmlNsAttribute.Value;
                                }
                            }

                            var metadata = nuspecXml.Element(XName.Get("package", nuspecXmlns))?.Element(XName.Get("metadata", nuspecXmlns));
                            if (metadata == null)
                            {
                                Log.LogError($"'{SourcePackage}' has invalid nuspec: missing 'metadata' element");
                                return;
                            }

                            packageId = metadata.Element(XName.Get("id", nuspecXmlns))?.Value;
                            if (packageId == null)
                            {
                                Log.LogError($"'{SourcePackage}' has invalid nuspec: missing 'id' element");
                                return;
                            }

                            var versionElement = metadata.Element(XName.Get("version", nuspecXmlns));
                            var versionStr = versionElement?.Value;
                            if (versionStr == null)
                            {
                                Log.LogError($"'{SourcePackage}' has invalid nuspec: missing 'version' element");
                                return;
                            }

                            if (!SemanticVersion.TryParse(versionStr, out packageVersion))
                            {
                                Log.LogError($"Package NuSpec specifies an invalid package version: '{packageVersion}'");
                            }

                            packageVersion = GetNewVersion(packageVersion);
                            versionElement.SetValue(packageVersion);

                            nuspecStream.SetLength(0);
                            nuspecXml.Save(nuspecStream);
                        }
                    }
                    else if (replacementMap.TryGetValue(relativePath, out var replacementFilePath))
                    {
                        Stream replacementStream;
                        try
                        {
                            replacementStream = File.OpenRead(TaskEnvironment.GetAbsolutePath(replacementFilePath));
                        }
                        catch (Exception e)
                        {
                            Log.LogError($"Failed to open replacement file '{replacementFilePath}': {e.Message}");
                            continue;
                        }

                        using (replacementStream)
                        using (var partStream = part.GetStream(FileMode.Open, FileAccess.ReadWrite))
                        {
                            partStream.SetLength(0);
                            replacementStream.CopyTo(partStream);
                        }

                        Log.LogMessage(MessageImportance.Low, $"Part '{relativePath}' of package '{SourcePackage}' replaced with '{replacementFilePath}'.");
                        replacementMap.Remove(relativePath);
                    }
                }

                if (packageId == null)
                {
                    Log.LogError($"'{SourcePackage}' has no .nuspec file in the root");
                    return;
                }

                package.PackageProperties.Version = packageVersion.ToFullString();
            }

            if (replacementMap.Count > 0)
            {
                foreach (var partName in replacementMap.Keys.OrderBy(k => k))
                {
                    Log.LogWarning($"File '{partName}' not found in package '{SourcePackage}'");
                }
            }

            // remove signature if present (the signature part is not accessible thru Package API):
            using (var archive = new ZipArchive(File.Open(tempPackageAbsolutePath, FileMode.Open, FileAccess.ReadWrite), ZipArchiveMode.Update))
            {
                archive.Entries.FirstOrDefault(e => e.FullName == NuGetUtils.SignaturePartUri)?.Delete();
            }

            NewPackage = Path.Combine(DestinationFolder, packageId + "." + packageVersion + ".nupkg");

            Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(DestinationFolder));
            File.Copy(tempPackageAbsolutePath, TaskEnvironment.GetAbsolutePath(NewPackage), overwrite: true);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }

    private SemanticVersion GetNewVersion(SemanticVersion currentVersion)
    {
        if (NewVersion != null)
        {
            if (SemanticVersion.TryParse(NewVersion, out var newVersion))
            {
                return newVersion;
            }

            Log.LogError($"Invalid package version specified in {nameof(NewVersion)} parameter: '{NewVersion}'");
        }
        else if (NewVersionSuffix != null)
        {
            try
            {
                return new SemanticVersion(currentVersion.Major, currentVersion.Minor, currentVersion.Patch, NewVersionSuffix);
            }
            catch (Exception)
            {
                Log.LogError($"Invalid package version suffix specified in {nameof(NewVersionSuffix)} parameter: '{NewVersionSuffix}'");
            }
        }

        return currentVersion;
    }
}