src/WebJobs.Script/Description/DotNet/PackageManager.cs (186 lines of code) (raw):
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Azure.WebJobs.Script.Config;
using Microsoft.Azure.WebJobs.Script.Description.DotNet;
using Microsoft.Azure.WebJobs.Script.Diagnostics.Extensions;
using Microsoft.Extensions.Logging;
using NuGet.Frameworks;
using NuGet.LibraryModel;
using NuGet.ProjectModel;
using NuGet.Versioning;
using static Microsoft.Azure.WebJobs.Script.ScriptConstants;
namespace Microsoft.Azure.WebJobs.Script.Description
{
/// <summary>
/// Provides NuGet package management functionality.
/// </summary>
internal sealed class PackageManager
{
private readonly string _functionDirectory;
private readonly ILogger _logger;
public PackageManager(string workingDirectory, ILogger logger)
{
_functionDirectory = workingDirectory;
_logger = logger;
}
public Task<PackageRestoreResult> RestorePackagesAsync()
{
var tcs = new TaskCompletionSource<PackageRestoreResult>();
string projectPath = null;
string nugetHome = null;
string nugetFilePath = null;
string currentLockFileHash = null;
try
{
projectPath = Path.Combine(_functionDirectory, DotNetConstants.ProjectFileName);
nugetHome = GetNugetPackagesPath();
nugetFilePath = ResolveNuGetPath();
currentLockFileHash = GetCurrentLockFileHash(_functionDirectory);
// Copy the file to a temporary location, which is where we'll be performing our restore from:
string tempRestoreLocation = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
string restoreProjectPath = Path.Combine(tempRestoreLocation, Path.GetFileName(projectPath));
Directory.CreateDirectory(tempRestoreLocation);
File.Copy(projectPath, restoreProjectPath);
var startInfo = new ProcessStartInfo
{
FileName = nugetFilePath,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
UseShellExecute = false,
ErrorDialog = false,
WorkingDirectory = _functionDirectory,
Arguments = string.Format(CultureInfo.InvariantCulture, "restore \"{0}\" --packages \"{1}\"", restoreProjectPath, nugetHome)
};
startInfo.Environment.Add(EnvironmentSettingNames.DotnetSkipFirstTimeExperience, "true");
startInfo.Environment.Add(EnvironmentSettingNames.DotnetAddGlobalToolsToPath, "false");
startInfo.Environment.Add(EnvironmentSettingNames.DotnetNoLogo, "true");
var process = new Process { StartInfo = startInfo };
process.ErrorDataReceived += ProcessDataReceived;
process.OutputDataReceived += ProcessDataReceived;
process.EnableRaisingEvents = true;
process.Exited += (s, e) =>
{
string lockFileLocation = Path.Combine(tempRestoreLocation, "obj", DotNetConstants.ProjectLockFileName);
if (process.ExitCode == 0 && File.Exists(lockFileLocation))
{
File.Copy(lockFileLocation, Path.Combine(_functionDirectory, DotNetConstants.ProjectLockFileName), true);
}
string newLockFileHash = GetCurrentLockFileHash(_functionDirectory);
var result = new PackageRestoreResult
{
IsInitialInstall = string.IsNullOrEmpty(currentLockFileHash),
ReferencesChanged = !string.Equals(currentLockFileHash, newLockFileHash),
};
tcs.SetResult(result);
process.Close();
};
_logger.PackageManagerStartingPackagesRestore();
process.Start();
process.BeginErrorReadLine();
process.BeginOutputReadLine();
}
catch (Exception exc)
{
_logger.PackageManagerRestoreFailed(exc, _functionDirectory, projectPath, nugetHome, nugetFilePath, currentLockFileHash);
tcs.SetException(exc);
}
return tcs.Task;
}
internal static string GetCurrentLockFileHash(string functionDirectory)
{
string lockFilePath = Path.Combine(functionDirectory, DotNetConstants.ProjectLockFileName);
if (!File.Exists(lockFilePath))
{
return string.Empty;
}
// CodeQL [SM02196] The hash here is used to create a unique identifier over non-sensitive data and there is no security impact. Changing the hashing algorithm of the file path hash would be a breaking change for applications.
using (var md5 = MD5.Create())
{
using (var stream = File.OpenRead(lockFilePath))
{
byte[] hash = md5.ComputeHash(stream);
return hash
.Aggregate(new StringBuilder(), (a, b) => a.Append(b.ToString("x2")))
.ToString();
}
}
}
public static string ResolveNuGetPath() => DotNetMuxer.MuxerPathOrDefault();
public static bool RequiresPackageRestore(string functionPath)
{
string projectFilePath = Path.Combine(functionPath, DotNetConstants.ProjectFileName);
if (!File.Exists(projectFilePath))
{
// If there's no project file, we can just return from here
// as there's nothing to restore
return false;
}
string lockFilePath = Path.Combine(functionPath, DotNetConstants.ProjectLockFileName);
if (!File.Exists(lockFilePath))
{
// If have a project.json and no lock file, we need to
// restore the packages, just return true and skip validation
return true;
}
// This mimics the logic used by Nuget to validate a lock file against a given project file.
// In order to determine whether we have a match, we:
// - Read the project frameworks and their dependencies,
// extracting the appropriate version range using the lock file format
// - Read the lock file dependency groups
// - Ensure that each project dependency matches a dependency in the lock file for the
// appropriate group matching the framework (including non-framework specific/project wide dependencies)
LockFile lockFile = null;
try
{
var reader = new LockFileFormat();
lockFile = reader.Read(lockFilePath);
}
catch (FileFormatException)
{
return true;
}
var projectDependencies = GetProjectDependencies(projectFilePath)
.Select(d => d.ToLockFileDependencyGroupString())
.OrderBy(d => d, StringComparer.OrdinalIgnoreCase)
.ToList();
var dependencyGroups = lockFile.ProjectFileDependencyGroups
.Where(d => string.Equals(d.FrameworkName, FrameworkConstants.CommonFrameworks.NetStandard20.DotNetFrameworkName, StringComparison.OrdinalIgnoreCase))
.Aggregate(new List<string>(), (a, d) =>
{
a.AddRange(d.Dependencies.Where(name => !name.StartsWith("NETStandard.Library", StringComparison.OrdinalIgnoreCase)));
return a;
});
return !projectDependencies.SequenceEqual(dependencyGroups.OrderBy(d => d, StringComparer.OrdinalIgnoreCase));
}
private static IList<LibraryRange> GetProjectDependencies(string projectFilePath)
{
using (var reader = XmlTextReader.Create(new StringReader(File.ReadAllText(projectFilePath))))
{
XDocument root = XDocument.Load(reader);
return root.Descendants()?
.Where(i => PackageReferenceElementName.Equals(i.Name.LocalName, StringComparison.Ordinal))
.Select(i => new LibraryRange
{
Name = i.Attribute(PackageReferenceIncludeElementName)?.Value,
VersionRange = VersionRange.Parse(i.Attribute(PackageReferenceVersionElementName)?.Value)
})
.ToList();
}
}
internal static string GetNugetPackagesPath()
{
string nugetHome = null;
string home = ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath);
if (!string.IsNullOrEmpty(home))
{
// We're hosted in Azure
// Set the NuGet path to %home%\data\Functions\packages\nuget
// (i.e. d:\home\data\Functions\packages\nuget)
nugetHome = Path.Combine(home, "data", "Functions", "packages", "nuget");
}
else
{
string userProfile = Environment.ExpandEnvironmentVariables("%userprofile%");
nugetHome = Path.Combine(userProfile, ".nuget", "packages");
}
return nugetHome;
}
private void ProcessDataReceived(object sender, DataReceivedEventArgs e)
{
string message = e.Data ?? string.Empty;
_logger.PackageManagerProcessDataReceived(message);
}
}
}