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

#nullable enable

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.Deployment.DotNet.Releases;
using Microsoft.DotNet.Build.Tasks.Workloads.Msi;
using NuGet.Packaging;
using NuGet.Packaging.Core;
using NuGet.Versioning;

namespace Microsoft.DotNet.Build.Tasks.Workloads;

/// <summary>
/// Serves as a base class for implementing different types of workload packages. The class captures some common
/// elements related to the underlying NuGet package.
/// </summary>
public abstract class WorkloadPackageBase : IWorkloadPackageMetadata
{
    /// <summary>
    /// The package authors.
    /// </summary>
    public string Authors
    {
        get;
    }

    /// <summary>
    /// The NuGet package copyright.
    /// </summary>
    public string Copyright
    {
        get;
    }

    /// <summary>
    /// The NuGet package description.
    /// </summary>
    public string Description
    {
        get;
    }

    /// <summary>
    /// Determines on the contents of the package is managed.
    /// </summary>
    public abstract PackageExtractionMethod ExtractionMethod
    {
        get;
    }

    /// <summary>
    /// The NuGet package identifier.
    /// </summary>
    public string Id => Identity.Id;

    /// <summary>
    /// The identity of the NuGet package.
    /// </summary>
    public PackageIdentity Identity
    {
        get;
    }

    /// <summary>
    /// Gets whether the package has been extracted.
    /// </summary>
    public bool HasBeenExtracted
    {
        get;
        private set;
    }

    public LicenseMetadata? LicenseData
    {
        get;
    }

    public string? LicenseUrl
    {
        get;
    }

    /// <summary>
    /// Gets the version to use for the generated MSI's ProductVersion property.
    /// </summary>
    public abstract Version MsiVersion
    {
        get;
    }

    public string PackagePath
    {
        get;
    }

    public string PackageFileName
    {
        get;
    }

    public string ShortName
    {
        get;
    }

    /// <summary>
    /// A string containing the major, minor and patch version of the package.
    /// </summary>
    public string ProductVersion => $"{PackageVersion.Major}.{PackageVersion.Minor}.{PackageVersion.Patch}";

    public string Title
    {
        get;
    }

    public string ProjectUrl
    {
        get;
    }

    /// <summary>
    /// The version of the NuGet package.
    /// </summary>
    public NuGetVersion PackageVersion => Identity.Version;

    public ITaskItem[]? ShortNames
    {
        get;
    }

    /// <summary>
    /// The SWIX identifier for the package in VS.
    /// </summary>
    public string SwixPackageId
    {
        get;
        protected set;
    }

    /// <summary>
    /// The SWIX identifier for a package group that references this SWIX package.
    /// </summary>
    public string SwixPackageGroupId
    {
        get;
        protected set;
    }

    /// <summary>
    /// Gets an instance of a <see cref="TaskLoggingHelper"/> class containing task logging methods.
    /// </summary>
    protected TaskLoggingHelper? Log
    {
        get;
    }

    /// <summary>
    /// A <see cref="System.Version"/> containing the major, minor, and patch version of the underlying NuGet package.
    /// </summary>
    public Version Version => Identity.Version.Version;

    /// <summary>
    /// The destination directory where the package will be extracted.
    /// </summary>
    public string DestinationDirectory
    {
        get;
    }

    /// <summary>
    /// Creates a new instance of a <see cref="WorkloadPackageBase"/> class.
    /// </summary>
    /// <param name="packagePath">The path of the NuGet package.</param>
    /// <param name="destinationBaseDirectory">The root directory where packages will be extracted.</param>
    /// <param name="shortNames">A set of items used to shorten the names and identifiers of setup packages.</param>
    /// <param name="log">A <see cref="TaskLoggingHelper"/> class containing task logging methods.</param>
    public WorkloadPackageBase(string packagePath, string destinationBaseDirectory, ITaskItem[]? shortNames = null, TaskLoggingHelper? log = null)
    {
        // Very important: If the underlying stream isn't closed, it will cause
        // sharing violations when the package content is being extracted later.
        using FileStream fs = new(packagePath, FileMode.Open);
        using PackageArchiveReader reader = new(fs);
        NuspecReader nuspec = reader.NuspecReader;

        Authors = nuspec.GetAuthors();
        Copyright = nuspec.GetCopyright();
        Description = nuspec.GetDescription();
        Identity = nuspec.GetIdentity();
        LicenseData = nuspec.GetLicenseMetadata();
        LicenseUrl = nuspec.GetLicenseUrl();
        ProjectUrl = nuspec.GetProjectUrl();
        Title = nuspec.GetTitle();

        PackagePath = packagePath;

        // Directory structures that are too deep can still cause failures
        // when harvesting packages. Especially when running tests under Arcade. Stripping
        // off the package identity can work around this. Below is an example of such a failure:
        // 
        // heat.exe : error HEAT5059 : The file 'D:\git\forks\dotnet\src\arcade\artifacts\bin\Microsoft.DotNet.Build.Tasks.Workloads.Tests\Debug\net11.0\TEST_OUTPUT\uq4xhvn3\pkg\Microsoft.NET.Runtime.Emscripten.2.0.23.Python.win-x64.6.0.4\tools\lib\site-packages\pythonwin\pywin\framework\editor\color\coloreditor.py' cannot be found.
        DestinationDirectory = Path.Combine(destinationBaseDirectory, Path.GetRandomFileName());
        ShortNames = shortNames;

        PackageFileName = Path.GetFileNameWithoutExtension(packagePath);
        ShortName = PackageFileName.Replace(shortNames);
        SwixPackageId = $"{Id.Replace(shortNames)}.{Identity.Version}";
        SwixPackageGroupId = $"{DefaultValues.PackageGroupPrefix}.{SwixPackageId}";
        Log = log;
    }

    /// <summary>
    /// Extracts the contents of the package.
    /// </summary>
    public void Extract()
    {
        Extract(Enumerable.Empty<string>());
    }

    /// <summary>
    /// Extract the contents of the package and optionally delete files that match
    /// the set of exclusions.
    /// </summary>
    /// <param name="exclusionPatterns">A set of regular expression patterns used to determine if a
    /// file should be excluded. Excluded files will be deleted after the package has been extracted.</param>
    public virtual void Extract(IEnumerable<string> exclusionPatterns)
    {
        if (HasBeenExtracted)
        {
            return;
        }

        Utils.DeleteDirectory(DestinationDirectory);
        Directory.CreateDirectory(DestinationDirectory);

        if (ExtractionMethod == PackageExtractionMethod.Copy)
        {
            File.Copy(PackagePath, Path.Combine(DestinationDirectory, Path.GetFileName(PackagePath)), overwrite: true);
            HasBeenExtracted = true;
        }
        else if (ExtractionMethod == PackageExtractionMethod.Unzip)
        {
            ZipFile.ExtractToDirectory(PackagePath, DestinationDirectory);

            // Remove unnecessary files and directories that we never want to ship. These are always present in a NuGet package.
            Utils.DeleteDirectory(Path.Combine(DestinationDirectory, "_rels"));
            Utils.DeleteDirectory(Path.Combine(DestinationDirectory, "package"));

            Utils.DeleteFile(Path.Combine(DestinationDirectory, ".signature.p7s"));
            Utils.DeleteFile(Path.Combine(DestinationDirectory, "[Content_Types].xml"));
            Utils.DeleteFile(Path.Combine(DestinationDirectory, $"{Id}.nuspec"));

            if (exclusionPatterns.Any())
            {
                foreach (string file in Directory.EnumerateFiles(DestinationDirectory, "*.*", SearchOption.AllDirectories))
                {
                    if (exclusionPatterns.Any(pattern => Regex.IsMatch(file, pattern)))
                    {
                        Log?.LogMessage(MessageImportance.Low, string.Format(Strings.WorkloadPackageDeleteExclusion, file));
                        File.Delete(file);
                    }
                }
            }

            HasBeenExtracted = true;
        }
    }

    /// <summary>
    /// Converts a string containing an SDK version to a semantic version that normalizes the patch level and 
    /// optionally includes the first two prerelease labels. For example, if the specified version is 6.0.105, then
    /// 6.0.100 would be returned. If the version is 6.0.301-preview.2.1234, the result would be 6.0.300-preview.1.
    /// </summary>
    /// <param name="sdkVersion">A string containing an SDK version.</param>
    /// <returns>An SDK feature band version.</returns>
    internal static ReleaseVersion GetSdkFeatureBandVersion(string sdkVersion)
    {
        ReleaseVersion version = new(sdkVersion);

        // Ignore CI and dev builds.
        if (string.IsNullOrEmpty(version.Prerelease) || version.Prerelease.Split('.').Any(s => string.Equals("ci", s) || string.Equals("dev", s)))
        {
            return new ReleaseVersion(version.Major, version.Minor, version.SdkFeatureBand);
        }

        string[] preleaseParts = version.Prerelease.Split('.');

        // Only the first two prerelease identifiers are used to support side-by-side previews.
        string prerelease = (preleaseParts.Length > 1) ?
            $"{preleaseParts[0]}.{preleaseParts[1]}" :
            preleaseParts[0];

        return new ReleaseVersion(version.Major, version.Minor, version.SdkFeatureBand, prerelease);
    }

    /// <summary>
    /// Extracts the SDK version from the package ID.
    /// </summary>
    /// <param name="packageId">The package ID from which to extract the SDK version.</param>
    /// <param name="separator">A string used to determine where the SDK version should start.</param>
    /// <returns>SDK version part of the package ID.</returns>
    /// <exception cref="FormatException" />
    internal static string GetSdkVersion(string packageId, string separator) =>
        !string.IsNullOrWhiteSpace(packageId) && packageId.IndexOf(separator) > -1 ?
            packageId.Substring(packageId.IndexOf(separator) + separator.Length) :
            throw new FormatException(string.Format(Strings.CannotExtractSdkVersionFromPackageId, packageId));

    /// <summary>
    /// Gets the MSI ProductVersion to use for the given packagage. The task item metadata is used first, before falling
    /// back to using the version parameter on the task. If neither exist an exception is thrown.
    /// </summary>
    /// <param name="package">The package item to convert into an MSI.</param>
    /// <param name="msiVersion">The default MSI version 1</param>
    /// <param name="taskName"></param>
    /// <param name="taskParameterName"></param>
    /// <param name="taskItemName"></param>
    /// <returns></returns>
    /// <exception cref="Exception"></exception>
    internal static Version GetMsiVersion(ITaskItem package, Version msiVersion, string taskName,
        string taskParameterName, string taskItemName)
    {
        if (!string.IsNullOrWhiteSpace(package.GetMetadata(Metadata.MsiVersion)))
        {
            // We prefer version metadata information on the package item.
            return new(package.GetMetadata(Metadata.MsiVersion));
        }
        else if (msiVersion != null)
        {
            // Fall back to the version provided by the task parameter.
            return msiVersion;
        }

        // While we could use the major.minor.patch part of the package, it won't work for upgradable MSIs (manifests) and
        // unlike packs, we want users to be explicit about the MSI versionsand
        // the user to be aware of this and explicitly tell us the value.
        throw new Exception(string.Format(Strings.NoInstallerVersion, taskName,
            taskParameterName, taskItemName, Metadata.MsiVersion));
    }
}

#nullable disable