/* 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("net6.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); } } } }