/*
+------------------------------------------------------------------+
| Author: Ivan Murzak (https://github.com/IvanMurzak) |
| Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) |
| Copyright (c) 2025 Ivan Murzak |
| Licensed under the Apache License, Version 2.0. |
| See the LICENSE file in the project root for more information. |
+------------------------------------------------------------------+
*/
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Xml.Linq;
using UnityEngine;
namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver
{
///
/// Extracts DLLs from .nupkg files (which are zip archives).
/// Selects the best target framework match and skips irrelevant content.
/// Also parses the .nuspec for transitive dependency information.
///
/// Install layout: DLLs are written FLAT under the install directory
/// using the original {stem}.dll filename from the .nupkg's
/// lib/<tfm>/ folder — the package version does NOT appear
/// in the filename. The version is tracked exclusively in
/// 's per-package version field;
/// the resolver compares that against
/// to decide when a DLL needs to be replaced.
///
/// Consuming asmdef precompiledReferences reference the unversioned
/// filename (e.g. System.Text.Json.dll) and never need to be edited
/// when a configured package version bumps — the resolver re-extracts the
/// new version into the same filename and the manifest's version
/// field tracks the change.
///
static class NuGetExtractor
{
///
/// Plans the flat-layout DLL paths a package would produce without
/// extracting anything. Used by the long-path pre-flight to reject an
/// install before any disk write happens (see issue #733).
///
/// Returns the list of (zip-entry, planned-on-disk-path) pairs. The
/// list is empty for development-only dependencies and packages that
/// ship no compatible framework folder; both cases are valid no-op
/// installs that the caller treats as "nothing to do".
///
public static List PlanDllPaths(string nupkgPath, string installDirectory)
{
var planned = new List();
using var zip = ZipFile.OpenRead(nupkgPath);
var libEntries = CollectLibEntries(zip);
var bestFramework = SelectBestFramework(libEntries.Keys);
if (bestFramework == null || !libEntries.TryGetValue(bestFramework, out var frameworkEntries))
return planned;
foreach (var entry in frameworkEntries)
{
// Snapshot FullName / Name into the PlannedDll BEFORE the zip
// disposes — accessing them on the entry after Dispose throws.
// The on-disk filename is the .nupkg's original lib-folder
// entry name; version is tracked in the manifest, not here.
var fileName = entry.Name;
var fullName = entry.FullName;
var targetPath = Path.Combine(installDirectory, fileName);
planned.Add(new PlannedDll(fullName, fileName, targetPath));
}
return planned;
}
///
/// Extracts DLLs from a .nupkg file FLAT under the install directory
/// using the original {stem}.dll filename. Returns the list of
/// extracted filenames — relative, no directory prefix. Empty list
/// when the package has no DLLs in any compatible framework folder.
///
public static List ExtractDlls(string nupkgPath, string installDirectory)
{
var extracted = new List();
if (!Directory.Exists(installDirectory))
Directory.CreateDirectory(installDirectory);
var planned = PlanDllPaths(nupkgPath, installDirectory);
if (planned.Count == 0)
return extracted;
using var zip = ZipFile.OpenRead(nupkgPath);
// Re-resolve entries by full name from the freshly opened archive.
// Holding ZipArchiveEntry references across two ZipFile.OpenRead
// calls is unsafe (the underlying stream is owned by the first
// archive's Dispose). PlanDllPaths captured FullName up front; we
// just look those names up in this archive.
var entriesByName = zip.Entries.ToDictionary(e => e.FullName, StringComparer.OrdinalIgnoreCase);
foreach (var p in planned)
{
if (!entriesByName.TryGetValue(p.EntryFullName, out var entry))
continue;
entry.ExtractToFile(p.TargetPath, overwrite: true);
extracted.Add(p.FileName);
}
return extracted;
}
///
/// Returns true if the .nupkg is marked as a development-only dependency
/// (<developmentDependency>true</developmentDependency> in its .nuspec).
/// These packages ship analyzers, source generators, or build-time assets under
/// analyzers/, build/, tools/ etc. — never runtime DLLs under
/// lib/<tfm>/. They are legitimately empty from the resolver's point of view
/// and should not trigger "No compatible framework" / "No DLLs extracted" warnings.
/// Returns false on any parse error so callers fall back to the normal extraction path.
///
internal static bool IsDevelopmentDependency(string nupkgPath)
{
try
{
using var zip = ZipFile.OpenRead(nupkgPath);
// .nuspec is always at the archive root (exactly one per package).
// Normalize backslashes to forward slashes so archives that use '\' as the
// path separator are matched the same way as ExtractDlls() treats them.
var nuspecEntry = zip.Entries.FirstOrDefault(e =>
{
var fullName = e.FullName.Replace('\\', '/');
return fullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) &&
!fullName.Contains('/');
});
if (nuspecEntry == null)
return false;
using var stream = nuspecEntry.Open();
var doc = XDocument.Load(stream);
var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
var devDep = doc.Root?
.Element(ns + "metadata")?
.Element(ns + "developmentDependency")?
.Value;
return string.Equals(devDep?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
}
catch
{
// Corrupt / unreadable nupkg — treat as "not a dev dep" so the normal
// extraction path runs and surfaces the real error.
return false;
}
}
///
/// Reads the .nuspec from a .nupkg and returns the transitive dependencies
/// for the best matching target framework group.
///
public static List GetDependencies(string nupkgPath)
{
var dependencies = new List();
using (var zip = ZipFile.OpenRead(nupkgPath))
{
// Find the .nuspec file (there should be exactly one at the root)
var nuspecEntry = zip.Entries.FirstOrDefault(e =>
e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) &&
!e.FullName.Contains('/'));
if (nuspecEntry == null)
return dependencies;
using (var stream = nuspecEntry.Open())
{
var doc = XDocument.Load(stream);
var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
// Find dependency groups
var metadata = doc.Root?.Element(ns + "metadata");
var dependenciesElement = metadata?.Element(ns + "dependencies");
if (dependenciesElement == null)
return dependencies;
// Try to find the best framework-specific dependency group
var groups = dependenciesElement.Elements(ns + "group").ToList();
if (groups.Count > 0)
{
var bestGroup = SelectBestDependencyGroup(groups, ns);
if (bestGroup != null)
AddDependenciesFromElements(bestGroup.Elements(ns + "dependency"), dependencies);
}
else
{
AddDependenciesFromElements(dependenciesElement.Elements(ns + "dependency"), dependencies);
}
}
}
return dependencies;
}
static Dictionary> CollectLibEntries(ZipArchive zip)
{
var libEntries = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in zip.Entries)
{
var entryName = entry.FullName.Replace('\\', '/');
if (!entryName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase))
continue;
// Skip directories, non-DLL files, and files we don't need
if (string.IsNullOrEmpty(entry.Name) || ShouldSkip(entryName))
continue;
var parts = entryName.Split('/');
if (parts.Length < 3)
continue;
var framework = parts[1];
if (!libEntries.TryGetValue(framework, out var entries))
{
entries = new List();
libEntries[framework] = entries;
}
entries.Add(entry);
}
return libEntries;
}
///
/// Selects the best target framework from available options using the priority list.
///
static string? SelectBestFramework(IEnumerable availableFrameworks)
{
// Materialize once to preserve a stable ordering for deterministic fallback.
var availableList = availableFrameworks.ToList();
var available = new HashSet(availableList, StringComparer.OrdinalIgnoreCase);
foreach (var preferred in NuGetConfig.TargetFrameworkPriority)
{
if (available.Contains(preferred))
return preferred;
}
// Deterministic fallback: lexicographically smallest framework (case-insensitive)
// to keep installs stable across machines/runs regardless of HashSet enumeration order.
return availableList
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
}
///
/// Selects the best dependency group based on target framework priority.
///
static XElement? SelectBestDependencyGroup(List groups, XNamespace ns)
{
// Pre-compute normalized TFMs to avoid repeated NormalizeTfm calls in the inner loop.
var normalizedGroups = new List<(string tfm, XElement group)>(groups.Count);
foreach (var group in groups)
{
var tfm = group.Attribute("targetFramework")?.Value ?? "";
normalizedGroups.Add((NormalizeTfm(tfm), group));
}
foreach (var preferred in NuGetConfig.TargetFrameworkPriority)
{
foreach (var (tfm, group) in normalizedGroups)
{
if (string.Equals(tfm, preferred, StringComparison.OrdinalIgnoreCase))
return group;
}
}
// Fallback: group with no targetFramework attribute (universal dependencies)
return groups.FirstOrDefault(g => string.IsNullOrEmpty(g.Attribute("targetFramework")?.Value))
?? groups.FirstOrDefault();
}
///
/// Normalizes target framework monikers from .nuspec format to short format.
/// e.g., ".NETStandard2.1" → "netstandard2.1", ".NETFramework4.7.2" → "net472"
///
static string NormalizeTfm(string tfm)
{
if (string.IsNullOrEmpty(tfm))
return "";
// .NETStandard,Version=v2.1 or .NETStandard2.1
if (tfm.StartsWith(".NETStandard", StringComparison.OrdinalIgnoreCase))
{
var version = tfm.Replace(".NETStandard", "").Replace(",Version=v", "").Replace(".", "");
if (version.Length == 2) // "21" → "2.1"
version = version[0] + "." + version[1];
return "netstandard" + version;
}
// .NETFramework,Version=v4.7.2 or .NETFramework4.7.2
if (tfm.StartsWith(".NETFramework", StringComparison.OrdinalIgnoreCase))
{
var version = tfm.Replace(".NETFramework", "").Replace(",Version=v", "").Replace(".", "");
return "net" + version;
}
return tfm.ToLowerInvariant();
}
static void AddDependenciesFromElements(IEnumerable elements, List dependencies)
{
foreach (var dep in elements)
{
var id = dep.Attribute("id")?.Value;
var version = dep.Attribute("version")?.Value;
if (id != null && version != null)
dependencies.Add(new NuGetPackage(id, CleanVersionRange(version)));
}
}
///
/// Cleans NuGet version range syntax to a simple version string.
/// e.g., "[1.0.0, )" → "1.0.0", "(, 2.0.0]" → "2.0.0", "1.0.0" → "1.0.0"
///
static string CleanVersionRange(string version)
{
if (string.IsNullOrEmpty(version))
return version;
// Remove brackets and parentheses
version = version.Trim('[', ']', '(', ')', ' ');
// If it's a range like "1.0.0, 2.0.0", take the first (lower bound)
var commaIndex = version.IndexOf(',');
if (commaIndex >= 0)
{
var lower = version.Substring(0, commaIndex).Trim();
if (!string.IsNullOrEmpty(lower))
return lower;
// No lower bound, use upper
return version.Substring(commaIndex + 1).Trim();
}
return version;
}
///
/// Returns true if this zip entry should be skipped during extraction.
///
static bool ShouldSkip(string entryPath)
{
// Skip non-DLL files (we only need assemblies)
if (!entryPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
return true;
// Skip localization satellite assemblies (lib/{framework}/{lang-code}/*.dll)
var parts = entryPath.Split('/');
if (parts.Length >= 4)
{
var possibleLangCode = parts[2];
if (possibleLangCode.Length == 2 ||
(possibleLangCode.Length >= 4 && possibleLangCode[2] == '-'))
return true;
}
return false;
}
}
///
/// One DLL the extractor is about to write under the install directory in
/// the flat-layout style. Returned by .
///
/// The struct deliberately stores strings rather than the live
/// reference:
/// disposes its zip before returning, and accessing entry properties on
/// a disposed archive throws.
///
readonly struct PlannedDll
{
///
/// Full archive-relative path of the source entry (e.g.
/// lib/netstandard2.0/System.Text.Json.dll). Used by
/// to re-resolve the entry
/// in a freshly opened zip.
///
public string EntryFullName { get; }
///
/// The DLL filename relative to the install directory
/// (e.g. System.Text.Json.dll).
///
public string FileName { get; }
/// The full on-disk path of the planned write.
public string TargetPath { get; }
public PlannedDll(string entryFullName, string fileName, string targetPath)
{
EntryFullName = entryFullName;
FileName = fileName;
TargetPath = targetPath;
}
}
}