This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user