using System.Security.Cryptography;
using System.Xml.Linq;
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
///
/// Parses Chocolatey/NuGet .nuspec files to extract package metadata.
///
internal sealed class NuspecParser
{
private static readonly XNamespace NuspecNs = "http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd";
private static readonly XNamespace NuspecNsOld = "http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd";
private static readonly XNamespace NuspecNsOld2 = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd";
///
/// Parses a .nuspec file and extracts package metadata.
///
public ChocolateyPackageMetadata? Parse(string nuspecPath, string installDir, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(nuspecPath) || !File.Exists(nuspecPath))
{
return null;
}
try
{
var doc = XDocument.Load(nuspecPath);
if (doc.Root is null)
{
return null;
}
cancellationToken.ThrowIfCancellationRequested();
// Find metadata element
var metadata = FindElement(doc.Root, "metadata");
if (metadata is null)
{
return null;
}
// Extract required fields
var id = GetElementValue(metadata, "id");
var version = GetElementValue(metadata, "version");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(version))
{
return null;
}
// Extract optional fields
var title = GetElementValue(metadata, "title");
var authors = GetElementValue(metadata, "authors");
var description = GetElementValue(metadata, "description");
var licenseUrl = GetElementValue(metadata, "licenseUrl");
var projectUrl = GetElementValue(metadata, "projectUrl");
// Look for install script and compute hash
var installScriptHash = ComputeInstallScriptHash(installDir);
// Enumerate installed files
var installedFiles = EnumerateInstalledFiles(installDir);
return new ChocolateyPackageMetadata(
Id: id,
Version: version,
Title: title,
Authors: authors,
Description: description,
LicenseUrl: licenseUrl,
ProjectUrl: projectUrl,
Checksum: null,
ChecksumType: null,
SourceFeed: null,
InstallDir: installDir,
InstallScriptHash: installScriptHash,
InstalledFiles: installedFiles);
}
catch (Exception ex) when (ex is System.Xml.XmlException or IOException or UnauthorizedAccessException)
{
return null;
}
}
///
/// Parses basic package info from directory name pattern.
/// Format: packageid.version
///
public static (string Id, string Version)? ParsePackageDirectory(string directoryName)
{
if (string.IsNullOrWhiteSpace(directoryName))
{
return null;
}
// Find the first dot followed by a digit (start of version)
// Iterating from left to right because package IDs can contain dots
// (e.g., "Microsoft.WindowsTerminal.1.18.0" -> id="Microsoft.WindowsTerminal", version="1.18.0")
var versionStartIndex = -1;
for (var i = 0; i < directoryName.Length - 1; i++)
{
if (directoryName[i] == '.' && char.IsDigit(directoryName[i + 1]))
{
versionStartIndex = i;
break;
}
}
if (versionStartIndex <= 0)
{
return null;
}
var id = directoryName[..versionStartIndex];
var version = directoryName[(versionStartIndex + 1)..];
return (id, version);
}
private static XElement? FindElement(XElement parent, string localName)
{
return parent.Element(NuspecNs + localName)
?? parent.Element(NuspecNsOld + localName)
?? parent.Element(NuspecNsOld2 + localName)
?? parent.Element(localName);
}
private static string? GetElementValue(XElement parent, string localName)
{
var element = FindElement(parent, localName);
return element?.Value?.Trim();
}
private static string? ComputeInstallScriptHash(string installDir)
{
// Look for chocolateyinstall.ps1 script
var scriptPath = Path.Combine(installDir, "tools", "chocolateyinstall.ps1");
if (!File.Exists(scriptPath))
{
scriptPath = Path.Combine(installDir, "chocolateyinstall.ps1");
}
if (!File.Exists(scriptPath))
{
return null;
}
try
{
using var stream = File.OpenRead(scriptPath);
var hash = SHA256.HashData(stream);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static List EnumerateInstalledFiles(string installDir)
{
var files = new List();
if (!Directory.Exists(installDir))
{
return files;
}
try
{
foreach (var file in Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(installDir, file);
files.Add(relativePath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Ignore access errors
}
return files;
}
}