// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml; using EpicGames.Core; using Microsoft.Extensions.Logging; using UnrealBuildBase; namespace AutomationTool.Tasks { /// /// Parameters for a NuGetLicenseCheck task /// public class NuGetLicenseCheckTaskParameters { /// /// Base directory for running the command /// [TaskParameter] public string BaseDir { get; set; } /// /// Specifies a list of packages to ignore for version checks, separated by semicolons. Optional version number may be specified with 'name@version' syntax. /// [TaskParameter(Optional = true)] public string IgnorePackages { get; set; } /// /// Directory containing allowed licenses /// [TaskParameter(Optional = true)] public DirectoryReference LicenseDir { get; set; } /// /// Path to a csv file to write with list of packages and their licenses /// [TaskParameter(Optional = true)] public FileReference CsvFile { get; set; } /// /// Override path to dotnet executable /// [TaskParameter(Optional = true)] public FileReference DotNetPath { get; set; } } /// /// Verifies which licenses are in use by nuget dependencies /// [TaskElement("NuGet-LicenseCheck", typeof(NuGetLicenseCheckTaskParameters))] public class NuGetLicenseCheckTask : SpawnTaskBase { class LicenseConfig { public List Urls { get; set; } = new List(); } readonly NuGetLicenseCheckTaskParameters _parameters; /// /// Construct a NuGetLicenseCheckTask task /// /// Parameters for the task public NuGetLicenseCheckTask(NuGetLicenseCheckTaskParameters parameters) { _parameters = parameters; } enum PackageState { None, IgnoredViaArgs, MissingPackageFolder, MissingPackageDescriptor, Valid, } class PackageInfo { public string _name; public string _version; public string _projectUrl; public LicenseInfo _license; public string _licenseSource; public PackageState _state; public FileReference _descriptor; } class LicenseInfo { public IoHash _hash; public string _text; public string _normalizedText; public string _extension; public bool _approved; public FileReference _file; } static LicenseInfo FindOrAddLicense(Dictionary licenses, string text, string extension) { string normalizedText = text; normalizedText = Regex.Replace(normalizedText, @"^\s+", "", RegexOptions.Multiline); normalizedText = Regex.Replace(normalizedText, @"\s+$", "", RegexOptions.Multiline); normalizedText = Regex.Replace(normalizedText, "^(?:MIT License|The MIT License \\(MIT\\))\n", "", RegexOptions.Multiline); normalizedText = Regex.Replace(normalizedText, "^Copyright \\(c\\)[^\n]*\\s*(?:All rights reserved\\.?\\s*)?", "", RegexOptions.Multiline); normalizedText = Regex.Replace(normalizedText, @"\s+", " "); normalizedText = normalizedText.Trim(); byte[] data = Encoding.UTF8.GetBytes(normalizedText); IoHash hash = IoHash.Compute(data); LicenseInfo licenseInfo; if (!licenses.TryGetValue(hash, out licenseInfo)) { licenseInfo = new LicenseInfo(); licenseInfo._hash = hash; licenseInfo._text = text; licenseInfo._normalizedText = normalizedText; licenseInfo._extension = extension; licenses.Add(hash, licenseInfo); } return licenseInfo; } /// /// ExecuteAsync the task. /// /// Information about the current job /// Set of build products produced by this node. /// Mapping from tag names to the set of files they include public override async Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet) { FileReference dotNetPath = _parameters.DotNetPath ?? Unreal.DotnetPath; IProcessResult nuGetOutput = await ExecuteAsync(dotNetPath.FullName, $"nuget locals global-packages --list", logOutput: false); if (nuGetOutput.ExitCode != 0) { throw new AutomationException("DotNet terminated with an exit code indicating an error ({0})", nuGetOutput.ExitCode); } List nuGetPackageDirs = new List(); foreach (string line in nuGetOutput.Output.Split('\n')) { int colonIdx = line.IndexOf(':', StringComparison.Ordinal); if (colonIdx != -1) { DirectoryReference nuGetPackageDir = new DirectoryReference(line.Substring(colonIdx + 1).Trim()); Logger.LogInformation("Using NuGet package directory: {Path}", nuGetPackageDir); nuGetPackageDirs.Add(nuGetPackageDir); } } const string UnknownPrefix = "Unknown-"; IProcessResult packageListOutput = await ExecuteAsync(dotNetPath.FullName, "list package --include-transitive", workingDir: _parameters.BaseDir, logOutput: false); if (packageListOutput.ExitCode != 0) { throw new AutomationException("DotNet terminated with an exit code indicating an error ({0})", packageListOutput.ExitCode); } Dictionary packages = new Dictionary(); foreach (string line in packageListOutput.Output.Split('\n')) { Match match = Regex.Match(line, @"^\s*>\s*([^\s]+)\s+(?:[^\s]+\s+)?([^\s]+)\s*$"); if (match.Success) { PackageInfo info = new PackageInfo(); info._name = match.Groups[1].Value; info._version = match.Groups[2].Value; packages.TryAdd($"{info._name}@{info._version}", info); } } DirectoryReference packageRootDir = DirectoryReference.Combine(DirectoryReference.GetSpecialFolder(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); if (!DirectoryReference.Exists(packageRootDir)) { throw new AutomationException("Missing NuGet package cache at {0}", packageRootDir); } HashSet licenseUrls = new HashSet(StringComparer.OrdinalIgnoreCase); Dictionary licenses = new Dictionary(); if (_parameters.LicenseDir != null) { Logger.LogInformation("Reading allowed licenses from {LicenseDir}", _parameters.LicenseDir); foreach (FileReference file in DirectoryReference.EnumerateFiles(_parameters.LicenseDir)) { if (!file.GetFileName().StartsWith(UnknownPrefix, StringComparison.OrdinalIgnoreCase)) { try { if (file.HasExtension(".json")) { byte[] data = await FileReference.ReadAllBytesAsync(file); LicenseConfig config = JsonSerializer.Deserialize(data, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip }); licenseUrls.UnionWith(config.Urls); } else if (file.HasExtension(".txt") || file.HasExtension(".html") || file.HasExtension(".md")) { string text = await FileReference.ReadAllTextAsync(file); LicenseInfo license = FindOrAddLicense(licenses, text, file.GetFileNameWithoutExtension()); license._file = file; license._approved = true; } } catch (Exception ex) { throw new AutomationException(ex, $"Error parsing {file}: {ex.Message}"); } } } } HashSet ignorePackages = new HashSet(StringComparer.OrdinalIgnoreCase); if (_parameters.IgnorePackages != null) { ignorePackages.UnionWith(_parameters.IgnorePackages.Split(';')); } Dictionary licenseUrlToInfo = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (PackageInfo info in packages.Values) { if (ignorePackages.Contains(info._name) || ignorePackages.Contains($"{info._name}@{info._version}")) { info._state = PackageState.IgnoredViaArgs; continue; } DirectoryReference packageDir = nuGetPackageDirs.Select(x => DirectoryReference.Combine(x, info._name.ToLowerInvariant(), info._version.ToLowerInvariant())).FirstOrDefault(x => DirectoryReference.Exists(x)); if (packageDir == null) { info._state = PackageState.MissingPackageFolder; continue; } info._descriptor = FileReference.Combine(packageDir, $"{info._name.ToLowerInvariant()}.nuspec"); if (!FileReference.Exists(info._descriptor)) { info._state = PackageState.MissingPackageDescriptor; continue; } using (Stream stream = FileReference.Open(info._descriptor, FileMode.Open, FileAccess.Read, FileShare.Read)) { using XmlTextReader xmlReader = new XmlTextReader(stream); xmlReader.Namespaces = false; XmlDocument xmlDocument = new XmlDocument(); xmlDocument.Load(xmlReader); XmlNode projectUrlNode = xmlDocument.SelectSingleNode("/package/metadata/projectUrl"); info._projectUrl = projectUrlNode?.InnerText; if (info._license == null) { XmlNode licenseNode = xmlDocument.SelectSingleNode("/package/metadata/license"); if (licenseNode?.Attributes["type"]?.InnerText?.Equals("file", StringComparison.Ordinal) ?? false) { FileReference licenseFile = FileReference.Combine(packageDir, licenseNode.InnerText); if (FileReference.Exists(licenseFile)) { string text = await FileReference.ReadAllTextAsync(licenseFile); info._license = FindOrAddLicense(licenses, text, licenseFile.GetExtension()); info._licenseSource = licenseFile.FullName; } } } if (info._license == null) { XmlNode licenseUrlNode = xmlDocument.SelectSingleNode("/package/metadata/licenseUrl"); string licenseUrl = licenseUrlNode?.InnerText; if (licenseUrl != null) { licenseUrl = Regex.Replace(licenseUrl, @"^https://github.com/(.*)/blob/(.*)$", @"https://raw.githubusercontent.com/$1/$2"); info._licenseSource = licenseUrl; if (!licenseUrlToInfo.TryGetValue(licenseUrl, out info._license)) { using (HttpClient client = new HttpClient()) { using HttpResponseMessage response = await client.GetAsync(licenseUrl); if (!response.IsSuccessStatusCode) { Logger.LogError("Unable to fetch license from {LicenseUrl}", licenseUrl); } else { string text = await response.Content.ReadAsStringAsync(); string type = (response.Content.Headers.ContentType?.MediaType == "text/html") ? ".html" : ".txt"; info._license = FindOrAddLicense(licenses, text, type); if (!info._license._approved) { info._license._approved = licenseUrls.Contains(licenseUrl); } licenseUrlToInfo.Add(licenseUrl, info._license); } } } } } } info._state = PackageState.Valid; } Logger.LogInformation("Referenced Packages:"); Logger.LogInformation(""); foreach (PackageInfo info in packages.Values.OrderBy(x => x._name).ThenBy(x => x._version)) { switch (info._state) { case PackageState.IgnoredViaArgs: Logger.LogInformation(" {Name,-60} {Version,-10} Explicitly ignored via task arguments", info._name, info._version); break; case PackageState.MissingPackageFolder: Logger.LogInformation(" {Name,-60} {Version,-10} NuGet package not found", info._name, info._version); break; case PackageState.MissingPackageDescriptor: Logger.LogWarning(" {Name,-60} {Version,-10} Missing package descriptor: {NuSpecFile}", info._name, info._version, info._descriptor); break; case PackageState.Valid: if (info._license == null) { Logger.LogError(" {Name,-60} {Version,-10} No license metadata found", info._name, info._version); } else if (!info._license._approved) { Logger.LogWarning(" {Name,-60} {Version,-10} {Hash}", info._name, info._version, info._license._hash); } else { Logger.LogInformation(" {Name,-60} {Version,-10} {Hash}", info._name, info._version, info._license._hash); } break; default: Logger.LogError(" {Name,-60} {Version,-10} Unhandled state: {State}", info._name, info._version, info._state); break; } } Dictionary> missingLicenses = new Dictionary>(); foreach (PackageInfo packageInfo in packages.Values) { if (packageInfo._license != null && !packageInfo._license._approved) { List licensePackages; if (!missingLicenses.TryGetValue(packageInfo._license, out licensePackages)) { licensePackages = new List(); missingLicenses.Add(packageInfo._license, licensePackages); } licensePackages.Add(packageInfo); } } if (missingLicenses.Count > 0) { DirectoryReference licenseDir = _parameters.LicenseDir ?? DirectoryReference.Combine(Unreal.RootDirectory, "Engine", "Saved", "Licenses"); DirectoryReference.CreateDirectory(licenseDir); Logger.LogInformation(""); Logger.LogInformation("Missing licenses:"); foreach ((LicenseInfo missingLicense, List missingLicensePackages) in missingLicenses.OrderBy(x => x.Key._hash)) { FileReference outputFile = FileReference.Combine(licenseDir, $"{UnknownPrefix}{missingLicense._hash}{missingLicense._extension}"); await FileReference.WriteAllTextAsync(outputFile, missingLicense._text); Logger.LogInformation(""); Logger.LogInformation(" {LicenseFile}", outputFile); foreach (PackageInfo licensePackage in missingLicensePackages) { Logger.LogInformation(" -> {Name} {Version} ({Source})", licensePackage._name, licensePackage._version, licensePackage._licenseSource); } } } if (_parameters.CsvFile != null) { Logger.LogInformation("Writing {File}", _parameters.CsvFile); DirectoryReference.CreateDirectory(_parameters.CsvFile.Directory); using (StreamWriter writer = new StreamWriter(_parameters.CsvFile.FullName)) { await writer.WriteLineAsync($"Package,Version,Project Url,License Url,License Hash,License File"); foreach (PackageInfo packageInfo in packages.Values) { string relativeLicensePath = ""; if (packageInfo._license?._file != null) { relativeLicensePath = packageInfo._license._file.MakeRelativeTo(_parameters.CsvFile.Directory); } string licenseUrl = ""; if (packageInfo._licenseSource != null && packageInfo._licenseSource.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { licenseUrl = packageInfo._licenseSource; } await writer.WriteLineAsync($"\"{packageInfo._name}\",\"{packageInfo._version}\",{packageInfo._projectUrl},{licenseUrl},{packageInfo._license?._hash},{relativeLicensePath}"); } } } } /// /// Output this task out to an XML writer. /// public override void Write(XmlWriter writer) { Write(writer, _parameters); } /// /// Find all the tags which are used as inputs to this task /// /// The tag names which are read by this task public override IEnumerable FindConsumedTagNames() { yield break; } /// /// Find all the tags which are modified by this task /// /// The tag names which are modified by this task public override IEnumerable FindProducedTagNames() { yield break; } } }