185 lines
5.9 KiB
C#
185 lines
5.9 KiB
C#
using System.Security.Cryptography;
|
|
using System.Xml.Linq;
|
|
|
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
|
|
|
/// <summary>
|
|
/// Parses Chocolatey/NuGet .nuspec files to extract package metadata.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// Parses a .nuspec file and extracts package metadata.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses basic package info from directory name pattern.
|
|
/// Format: packageid.version
|
|
/// </summary>
|
|
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<string> EnumerateInstalledFiles(string installDir)
|
|
{
|
|
var files = new List<string>();
|
|
|
|
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;
|
|
}
|
|
}
|