/*
TShock, a server mod for Terraria
Copyright (C) 2022 Janet Blackquill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
using System.Reflection;
using System.Runtime.InteropServices;
using NuGet.Common;
using NuGet.Configuration;
using NuGet.Frameworks;
using NuGet.Packaging;
using NuGet.Packaging.Core;
using NuGet.Packaging.Signing;
using NuGet.Protocol.Core.Types;
using NuGet.Resolver;
using NuGet.Versioning;
namespace TShockPluginManager
{
public class Nugetter
{
// this object can figure out the right framework folders to use from a set of packages
private FrameworkReducer FrameworkReducer;
// the package framework we want to install
private NuGetFramework NuGetFramework;
// nuget settings
private ISettings Settings;
// this is responsible for bookkeeping the folders that nuget touches
private NuGetPathContext PathContext;
// this is responsible for managing the package sources
private PackageSourceProvider PackageSourceProvider;
// this is responsible for managing the repositories of packages from all of the package sources
private SourceRepositoryProvider SourceRepositoryProvider;
// this can tell us the paths of local packages
private PackagePathResolver PackagePathResolver;
// this is possible for bookkeeping the extraction state of packages
private PackageExtractionContext PackageExtractionContext;
public Nugetter()
{
FrameworkReducer = new FrameworkReducer();
NuGetFramework = NuGetFramework.ParseFolder("net9.0");
Settings = NuGet.Configuration.Settings.LoadDefaultSettings(root: null);
PathContext = NuGetPathContext.Create(Settings);
PackageSourceProvider = new PackageSourceProvider(Settings);
SourceRepositoryProvider = new SourceRepositoryProvider(PackageSourceProvider, Repository.Provider.GetCoreV3());
PackagePathResolver = new PackagePathResolver(Path.GetFullPath("packages"));
PackageExtractionContext = new PackageExtractionContext(
PackageSaveMode.Defaultv3,
XmlDocFileSaveMode.Skip,
ClientPolicyContext.GetClientPolicy(Settings, NullLogger.Instance),
NullLogger.Instance);
}
async Task GetPackageDependencies(
PackageIdentity package,
NuGetFramework framework,
SourceCacheContext cacheContext,
ILogger logger,
IEnumerable repositories,
ISet availablePackages)
{
// if we've already gotten dependencies for this package, don't
if (availablePackages.Contains(package)) return;
foreach (var sourceRepository in repositories)
{
// make sure the source repository can actually tell us about dependencies
var dependencyInfoResource = await sourceRepository.GetResourceAsync();
// get the try and dependencies
if (dependencyInfoResource is null) continue;
var dependencyInfo = await dependencyInfoResource.ResolvePackage(
package, framework, cacheContext, logger, CancellationToken.None);
// oop, we don't have the ability to get dependency info from this repository, or
// it wasn't found. let's try the next source repository!
if (dependencyInfo is null) continue;
availablePackages.Add(dependencyInfo);
foreach (var dependency in dependencyInfo.Dependencies)
{
// make sure we get the dependencies of the dependencies of the dependencies ... as well
await GetPackageDependencies(
new PackageIdentity(dependency.Id, dependency.VersionRange.MinVersion),
framework, cacheContext, logger, repositories, availablePackages);
}
}
}
/// all the packages representing dependencies bundled with TShock.Server
public async Task> GetAllBuiltinDependencies()
{
// this is a convenient approximation of what dependencies will be included with TShock.Server
// and really only needs to be updated if new third-party dependencies are added
var knownBundles = new[] {
new PackageIdentity("GetText.NET", NuGetVersion.Parse("1.6.6")),
new PackageIdentity("OTAPI.Upcoming", NuGetVersion.Parse("3.1.8-alpha")),
new PackageIdentity("TSAPI", NuGetVersion.Parse("5.0.0-beta")),
new PackageIdentity("TShock", NuGetVersion.Parse("5.0.0-beta")),
};
return await GetAllDependenciesFor(knownBundles);
}
/// all the dependencies for the provided package identities
public async Task> GetAllDependenciesFor(IEnumerable targets)
{
using var cacheContext = new SourceCacheContext();
// get all of the possible packages in our dependency tree
var possiblePackages = new HashSet(PackageIdentityComparer.Default);
foreach (var target in targets)
{
await GetPackageDependencies(
target,
NuGetFramework,
cacheContext,
NullLogger.Instance,
SourceRepositoryProvider.GetRepositories(),
possiblePackages
);
}
var resolverContext = new PackageResolverContext(
// select minimum possible versions
DependencyBehavior.Lowest,
// these are the packages the user wanted
targets.Select(x => x.Id),
// we don't hard-require anything
Enumerable.Empty(),
// we don't have a lockfile
Enumerable.Empty(),
// we don't have fancy versioning
Enumerable.Empty(),
// these are the packages that we figured out are in the dependency tree from nuget
possiblePackages,
// all the package sources
SourceRepositoryProvider.GetRepositories().Select(s => s.PackageSource),
NullLogger.Instance
);
var resolver = new PackageResolver();
var packagesToInstall =
// get the resolved versioning info from the resolver
resolver.Resolve(resolverContext, CancellationToken.None)
// and use that to select the specific packages to install from the possible packages
.Select(p => possiblePackages.Single(x => PackageIdentityComparer.Default.Equals(x, p)));
return packagesToInstall;
}
/// whether or not subPath is a subpath of basePath
public static bool IsSubPathOf(string subPath, string basePath)
{
var rel = Path.GetRelativePath(basePath, subPath);
return rel != "."
&& rel != ".."
&& !rel.StartsWith("../")
&& !rel.StartsWith(@"..\")
&& !Path.IsPathRooted(rel);
}
/// items required for end-user running of a package
public IEnumerable ItemsToInstall(PackageReaderBase packageReader)
{
var libItems = packageReader.GetLibItems();
var libnearest = FrameworkReducer.GetNearest(NuGetFramework, libItems.Select(x => x.TargetFramework));
libItems = libItems.Where(x => x.TargetFramework.Equals(libnearest));
var frameworkItems = packageReader.GetFrameworkItems();
var fwnearest = FrameworkReducer.GetNearest(NuGetFramework, frameworkItems.Select(x => x.TargetFramework));
frameworkItems = frameworkItems.Where(x => x.TargetFramework.Equals(fwnearest));
return libItems.Concat(frameworkItems);
}
/// path to package folder and metadata reader
public async Task<(string, PackageReaderBase)> GetOrDownloadPackage(SourcePackageDependencyInfo pkg)
{
using var cacheContext = new SourceCacheContext();
PackageReaderBase packageReader;
string pkgPath;
// already installed?
if (PackagePathResolver.GetInstalledPath(pkg) is string path)
{
// we're gaming
packageReader = new PackageFolderReader(path);
pkgPath = path;
}
else
{
// gotta download it...
var downloadResource = await pkg.Source.GetResourceAsync(CancellationToken.None);
Console.WriteLine($"Downloading {pkg.Id}...");
var downloadResult = await downloadResource.GetDownloadResourceResultAsync(
pkg,
new PackageDownloadContext(cacheContext),
SettingsUtility.GetGlobalPackagesFolder(Settings),
NullLogger.Instance, CancellationToken.None);
packageReader = downloadResult.PackageReader;
Console.WriteLine($"Extracting {pkg.Id}...");
// and extract the package
await PackageExtractor.ExtractPackageAsync(
downloadResult.PackageSource,
downloadResult.PackageStream,
PackagePathResolver,
PackageExtractionContext,
CancellationToken.None);
if (PackagePathResolver.GetInstalledPath(pkg) is string loc)
{
pkgPath = loc;
}
else
{
pkgPath = null;
// die somehow
}
}
return (pkgPath, packageReader);
}
/// resolved packages to be installed for what the user requested
public async Task> GetPackagesToInstallFor(PackageIdentity[] userRequest)
{
using var cacheContext = new SourceCacheContext();
return (await GetAllDependenciesFor(userRequest)).OrderBy(v => v.Id);
}
/// installs a locally downloaded package
public void InstallPackage(SourcePackageDependencyInfo pkg, string pkgPath, PackageReaderBase packageReader)
{
// objects to help us detect if packages already come with the .NET distribution
string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll");
var paResolver = new PathAssemblyResolver(runtimeAssemblies);
using var mlc = new MetadataLoadContext(paResolver);
// packages can declare themselves as plugin via the TShockPlugin package type
var isPlugin = packageReader.NuspecReader.GetPackageTypes().Any(v => v.Name == "TShockPlugin");
Console.WriteLine($"Installing {pkg.Id}...");
foreach (var item in ItemsToInstall(packageReader))
{
var files = item.Items;
if (item.Items.Count() == 0)
continue;
// the common ancestor directory of all files in the package.
// if a package has the following files:
// - /home/orwell/packages/FooBar/hi.dll
// - /home/orwell/packages/FooBar/en-US/hi.resources.dll
// - /home/orwell/packages/FooBar/de-DE/hi.resources.dll
// - /home/orwell/packages/FooBar/ar-AR/hi.resources.dll
// this will be /home/orwell/packages/FooBar
var rootmostPath = files
.Select(x => Path.Join(pkgPath, x))
.Aggregate(Path.GetDirectoryName(Path.Join(pkgPath, files.First())), (acc, x) =>
IsSubPathOf(acc!, Path.GetDirectoryName(x)!) ?
Path.GetDirectoryName(x) :
acc);
foreach (var file in files)
{
// the absolute path of the package on the filesystem
var filePath = Path.Join(pkgPath, file);
// the path of the package relative to the package root
var packageRelativeFilePath = filePath.Substring(rootmostPath!.Length);
bool alreadyExists;
// if it's a .dll, we try to detect if we already have the assemblies
// (e.g. because we bundle them in TShock.Server or the .NET runtime comes)
// with them
if (file.EndsWith(".dll"))
{
var asms = AppDomain.CurrentDomain.GetAssemblies();
var asm = mlc.LoadFromAssemblyPath(filePath);
alreadyExists = asms.Any(a => a.GetName().Name == asm.GetName().Name);
}
else alreadyExists = false;
// if it already exists, skip. but only if it's not an explicitly requested plugin.
if (alreadyExists && !isPlugin)
continue;
var relativeFolder = Path.GetDirectoryName(packageRelativeFilePath);
var targetFolder = Path.Join(isPlugin ? "./ServerPlugins" : "./bin", relativeFolder);
if (File.Exists(filePath))
{
Directory.CreateDirectory(targetFolder);
File.Copy(filePath, Path.Join(targetFolder, Path.GetFileName(filePath)), true);
}
}
}
}
/// downloads and installs the given packages
public async Task DownloadAndInstall(PackageIdentity[] userRequest)
{
var packagesToInstall = await GetAllDependenciesFor(userRequest);
var builtins = await GetAllBuiltinDependencies();
foreach (var pkg in packagesToInstall)
{
var bundled = builtins!.Where(x => x.Id == pkg.Id).FirstOrDefault();
if (bundled != null)
continue;
(string pkgPath, PackageReaderBase packageReader) = await GetOrDownloadPackage(pkg);
InstallPackage(pkg, pkgPath, packageReader);
}
}
}
}