up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,17 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new DotNetLanguageAnalyzer();
}
}
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.DotNet";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new DotNetLanguageAnalyzer();
}
}

View File

@@ -1,37 +1,37 @@
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "dotnet";
public string DisplayName => ".NET Analyzer (preview)";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
if (packages.Count == 0)
{
return;
}
foreach (var package in packages)
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "nuget",
metadata: package.Metadata,
evidence: package.Evidence,
usedByEntrypoint: package.UsedByEntrypoint);
}
}
}
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet;
public sealed class DotNetLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "dotnet";
public string DisplayName => ".NET Analyzer (preview)";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var packages = await DotNetDependencyCollector.CollectAsync(context, cancellationToken).ConfigureAwait(false);
if (packages.Count == 0)
{
return;
}
foreach (var package in packages)
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "nuget",
metadata: package.Metadata,
evidence: package.Evidence,
usedByEntrypoint: package.UsedByEntrypoint);
}
}
}

View File

@@ -1,9 +1,9 @@
global using System;
global using System.Collections.Generic;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;
global using System;
global using System.Collections.Generic;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -1,172 +1,172 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetDepsFile
{
private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries)
{
RelativePath = relativePath;
Libraries = libraries;
}
public string RelativePath { get; }
public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; }
public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)
{
return null;
}
var libraries = ParseLibraries(root, cancellationToken);
if (libraries.Count == 0)
{
return null;
}
PopulateTargets(root, libraries, cancellationToken);
return new DotNetDepsFile(relativePath, libraries);
}
private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken)
{
var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal);
if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object)
{
return result;
}
foreach (var property in librariesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library))
{
result[property.Name] = library;
}
}
return result;
}
private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken)
{
if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var targetProperty in targetsElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
var (tfm, rid) = ParseTargetKey(targetProperty.Name);
if (targetProperty.Value.ValueKind is not JsonValueKind.Object)
{
continue;
}
foreach (var libraryProperty in targetProperty.Value.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (!libraries.TryGetValue(libraryProperty.Name, out var library))
{
continue;
}
if (!string.IsNullOrEmpty(tfm))
{
library.AddTargetFramework(tfm);
}
if (!string.IsNullOrEmpty(rid))
{
library.AddRuntimeIdentifier(rid);
}
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetDepsFile
{
private DotNetDepsFile(string relativePath, IReadOnlyDictionary<string, DotNetLibrary> libraries)
{
RelativePath = relativePath;
Libraries = libraries;
}
public string RelativePath { get; }
public IReadOnlyDictionary<string, DotNetLibrary> Libraries { get; }
public static DotNetDepsFile? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)
{
return null;
}
var libraries = ParseLibraries(root, cancellationToken);
if (libraries.Count == 0)
{
return null;
}
PopulateTargets(root, libraries, cancellationToken);
return new DotNetDepsFile(relativePath, libraries);
}
private static Dictionary<string, DotNetLibrary> ParseLibraries(JsonElement root, CancellationToken cancellationToken)
{
var result = new Dictionary<string, DotNetLibrary>(StringComparer.Ordinal);
if (!root.TryGetProperty("libraries", out var librariesElement) || librariesElement.ValueKind is not JsonValueKind.Object)
{
return result;
}
foreach (var property in librariesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (DotNetLibrary.TryCreate(property.Name, property.Value, out var library))
{
result[property.Name] = library;
}
}
return result;
}
private static void PopulateTargets(JsonElement root, IDictionary<string, DotNetLibrary> libraries, CancellationToken cancellationToken)
{
if (!root.TryGetProperty("targets", out var targetsElement) || targetsElement.ValueKind is not JsonValueKind.Object)
{
return;
}
foreach (var targetProperty in targetsElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
var (tfm, rid) = ParseTargetKey(targetProperty.Name);
if (targetProperty.Value.ValueKind is not JsonValueKind.Object)
{
continue;
}
foreach (var libraryProperty in targetProperty.Value.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (!libraries.TryGetValue(libraryProperty.Name, out var library))
{
continue;
}
if (!string.IsNullOrEmpty(tfm))
{
library.AddTargetFramework(tfm);
}
if (!string.IsNullOrEmpty(rid))
{
library.AddRuntimeIdentifier(rid);
}
library.MergeTargetMetadata(libraryProperty.Value, tfm, rid);
}
}
}
private static (string tfm, string? rid) ParseTargetKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return (string.Empty, null);
}
var separatorIndex = value.IndexOf('/');
if (separatorIndex < 0)
{
return (value.Trim(), null);
}
var tfm = value[..separatorIndex].Trim();
var rid = value[(separatorIndex + 1)..].Trim();
return (tfm, string.IsNullOrEmpty(rid) ? null : rid);
}
}
internal sealed class DotNetLibrary
{
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
}
}
}
private static (string tfm, string? rid) ParseTargetKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return (string.Empty, null);
}
var separatorIndex = value.IndexOf('/');
if (separatorIndex < 0)
{
return (value.Trim(), null);
}
var tfm = value[..separatorIndex].Trim();
var rid = value[(separatorIndex + 1)..].Trim();
return (tfm, string.IsNullOrEmpty(rid) ? null : rid);
}
}
internal sealed class DotNetLibrary
{
private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal);
private readonly List<DotNetLibraryAsset> _runtimeAssets = new();
private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal);
private DotNetLibrary(
string key,
string id,
string version,
string type,
bool? serviceable,
string? sha512,
string? path,
string? hashPath)
{
Key = key;
Id = id;
Version = version;
Type = type;
Serviceable = serviceable;
Sha512 = NormalizeValue(sha512);
PackagePath = NormalizePath(path);
HashPath = NormalizePath(hashPath);
}
public string Key { get; }
public string Id { get; }
public string Version { get; }
public string Type { get; }
public bool? Serviceable { get; }
public string? Sha512 { get; }
public string? PackagePath { get; }
public string? HashPath { get; }
public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase);
private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal);
private DotNetLibrary(
string key,
string id,
string version,
string type,
bool? serviceable,
string? sha512,
string? path,
string? hashPath)
{
Key = key;
Id = id;
Version = version;
Type = type;
Serviceable = serviceable;
Sha512 = NormalizeValue(sha512);
PackagePath = NormalizePath(path);
HashPath = NormalizePath(hashPath);
}
public string Key { get; }
public string Id { get; }
public string Version { get; }
public string Type { get; }
public bool? Serviceable { get; }
public string? Sha512 { get; }
public string? PackagePath { get; }
public string? HashPath { get; }
public bool IsPackage => string.Equals(Type, "package", StringComparison.OrdinalIgnoreCase);
public IReadOnlyCollection<string> Dependencies => _dependencies;
public IReadOnlyCollection<string> TargetFrameworks => _targetFrameworks;
@@ -174,65 +174,65 @@ internal sealed class DotNetLibrary
public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers;
public IReadOnlyCollection<DotNetLibraryAsset> RuntimeAssets => _runtimeAssets;
public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library)
{
library = null;
if (!TrySplitNameAndVersion(key, out var id, out var version))
{
return false;
}
var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
? typeElement.GetString() ?? string.Empty
: string.Empty;
bool? serviceable = null;
if (element.TryGetProperty("serviceable", out var serviceableElement))
{
if (serviceableElement.ValueKind is JsonValueKind.True)
{
serviceable = true;
}
else if (serviceableElement.ValueKind is JsonValueKind.False)
{
serviceable = false;
}
}
var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String
? sha512Element.GetString()
: null;
var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
? pathElement.GetString()
: null;
var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String
? hashElement.GetString()
: null;
library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath);
library.MergeLibraryMetadata(element);
return true;
}
public void AddTargetFramework(string tfm)
{
if (!string.IsNullOrWhiteSpace(tfm))
{
_targetFrameworks.Add(tfm);
}
}
public void AddRuntimeIdentifier(string rid)
{
if (!string.IsNullOrWhiteSpace(rid))
{
_runtimeIdentifiers.Add(rid);
}
}
public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library)
{
library = null;
if (!TrySplitNameAndVersion(key, out var id, out var version))
{
return false;
}
var type = element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String
? typeElement.GetString() ?? string.Empty
: string.Empty;
bool? serviceable = null;
if (element.TryGetProperty("serviceable", out var serviceableElement))
{
if (serviceableElement.ValueKind is JsonValueKind.True)
{
serviceable = true;
}
else if (serviceableElement.ValueKind is JsonValueKind.False)
{
serviceable = false;
}
}
var sha512 = element.TryGetProperty("sha512", out var sha512Element) && sha512Element.ValueKind == JsonValueKind.String
? sha512Element.GetString()
: null;
var path = element.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.String
? pathElement.GetString()
: null;
var hashPath = element.TryGetProperty("hashPath", out var hashElement) && hashElement.ValueKind == JsonValueKind.String
? hashElement.GetString()
: null;
library = new DotNetLibrary(key, id, version, type, serviceable, sha512, path, hashPath);
library.MergeLibraryMetadata(element);
return true;
}
public void AddTargetFramework(string tfm)
{
if (!string.IsNullOrWhiteSpace(tfm))
{
_targetFrameworks.Add(tfm);
}
}
public void AddRuntimeIdentifier(string rid)
{
if (!string.IsNullOrWhiteSpace(rid))
{
_runtimeIdentifiers.Add(rid);
}
}
public void MergeTargetMetadata(JsonElement element, string? tfm, string? rid)
{
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object)
@@ -245,7 +245,7 @@ internal sealed class DotNetLibrary
MergeRuntimeAssets(element, tfm, rid);
}
public void MergeLibraryMetadata(JsonElement element)
{
if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object)
@@ -296,66 +296,66 @@ internal sealed class DotNetLibrary
}
}
}
private void AddDependency(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
var dependencyId = name;
if (TrySplitNameAndVersion(name, out var parsedName, out _))
{
dependencyId = parsedName;
}
if (!string.IsNullOrWhiteSpace(dependencyId))
{
_dependencies.Add(dependencyId);
}
}
private static bool TrySplitNameAndVersion(string key, out string name, out string version)
{
name = string.Empty;
version = string.Empty;
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
var separatorIndex = key.LastIndexOf('/');
if (separatorIndex <= 0 || separatorIndex >= key.Length - 1)
{
return false;
}
name = key[..separatorIndex].Trim();
version = key[(separatorIndex + 1)..].Trim();
return name.Length > 0 && version.Length > 0;
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return path.Replace('\\', '/');
}
private static string? NormalizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private void AddDependency(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
var dependencyId = name;
if (TrySplitNameAndVersion(name, out var parsedName, out _))
{
dependencyId = parsedName;
}
if (!string.IsNullOrWhiteSpace(dependencyId))
{
_dependencies.Add(dependencyId);
}
}
private static bool TrySplitNameAndVersion(string key, out string name, out string version)
{
name = string.Empty;
version = string.Empty;
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
var separatorIndex = key.LastIndexOf('/');
if (separatorIndex <= 0 || separatorIndex >= key.Length - 1)
{
return false;
}
name = key[..separatorIndex].Trim();
version = key[(separatorIndex + 1)..].Trim();
return name.Length > 0 && version.Length > 0;
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return path.Replace('\\', '/');
}
private static string? NormalizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}
internal enum DotNetLibraryAssetKind

View File

@@ -1,332 +1,332 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security;
using System.Xml;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal static class DotNetFileMetadataCache
{
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<string>> Sha256Cache = new();
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<AssemblyName>> AssemblyCache = new();
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<FileVersionInfo>> VersionCache = new();
public static bool TryGetSha256(string path, out string? sha256)
=> TryGet(path, Sha256Cache, ComputeSha256, out sha256);
public static bool TryGetAssemblyName(string path, out AssemblyName? assemblyName)
=> TryGet(path, AssemblyCache, TryReadAssemblyName, out assemblyName);
public static bool TryGetFileVersionInfo(string path, out FileVersionInfo? versionInfo)
=> TryGet(path, VersionCache, TryReadFileVersionInfo, out versionInfo);
private static bool TryGet<T>(string path, ConcurrentDictionary<DotNetFileCacheKey, Optional<T>> cache, Func<string, T?> resolver, out T? value)
where T : class
{
value = null;
DotNetFileCacheKey key;
try
{
var info = new FileInfo(path);
if (!info.Exists)
{
return false;
}
key = new DotNetFileCacheKey(info.FullName, info.Length, info.LastWriteTimeUtc.Ticks);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
var optional = cache.GetOrAdd(key, static (cacheKey, state) => CreateOptional(cacheKey.Path, state.resolver), (resolver, path));
if (!optional.HasValue)
{
return false;
}
value = optional.Value;
return value is not null;
}
private static Optional<T> CreateOptional<T>(string path, Func<string, T?> resolver) where T : class
{
try
{
var value = resolver(path);
return Optional<T>.From(value);
}
catch (FileNotFoundException)
{
return Optional<T>.None;
}
catch (FileLoadException)
{
return Optional<T>.None;
}
catch (BadImageFormatException)
{
return Optional<T>.None;
}
catch (UnauthorizedAccessException)
{
return Optional<T>.None;
}
catch (SecurityException)
{
return Optional<T>.None;
}
catch (IOException)
{
return Optional<T>.None;
}
}
private static string? ComputeSha256(string path)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static AssemblyName? TryReadAssemblyName(string path)
{
try
{
return AssemblyName.GetAssemblyName(path);
}
catch (FileNotFoundException)
{
return null;
}
catch (FileLoadException)
{
return null;
}
catch (BadImageFormatException)
{
return null;
}
catch (IOException)
{
return null;
}
}
private static FileVersionInfo? TryReadFileVersionInfo(string path)
{
try
{
return FileVersionInfo.GetVersionInfo(path);
}
catch (FileNotFoundException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
}
internal static class DotNetLicenseCache
{
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<DotNetLicenseInfo>> Licenses = new();
public static bool TryGetLicenseInfo(string nuspecPath, out DotNetLicenseInfo? info)
{
info = null;
DotNetFileCacheKey key;
try
{
var fileInfo = new FileInfo(nuspecPath);
if (!fileInfo.Exists)
{
return false;
}
key = new DotNetFileCacheKey(fileInfo.FullName, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
var optional = Licenses.GetOrAdd(key, static (cacheKey, path) => CreateOptional(path), nuspecPath);
if (!optional.HasValue)
{
return false;
}
info = optional.Value;
return info is not null;
}
private static Optional<DotNetLicenseInfo> CreateOptional(string nuspecPath)
{
try
{
var info = Parse(nuspecPath);
return Optional<DotNetLicenseInfo>.From(info);
}
catch (IOException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (UnauthorizedAccessException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (SecurityException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (XmlException)
{
return Optional<DotNetLicenseInfo>.None;
}
}
private static DotNetLicenseInfo? Parse(string path)
{
using var stream = File.OpenRead(path);
using var reader = XmlReader.Create(stream, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
IgnoreComments = true,
IgnoreWhitespace = true,
});
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.LocalName, "license", StringComparison.OrdinalIgnoreCase))
{
var type = reader.GetAttribute("type");
var value = reader.ReadElementContentAsString()?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (string.Equals(type, "expression", StringComparison.OrdinalIgnoreCase))
{
expressions.Add(value);
}
else if (string.Equals(type, "file", StringComparison.OrdinalIgnoreCase))
{
files.Add(NormalizeLicensePath(value));
}
else
{
expressions.Add(value);
}
}
else if (string.Equals(reader.LocalName, "licenseUrl", StringComparison.OrdinalIgnoreCase))
{
var value = reader.ReadElementContentAsString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
urls.Add(value);
}
}
}
if (expressions.Count == 0 && files.Count == 0 && urls.Count == 0)
{
return null;
}
return new DotNetLicenseInfo(
expressions.ToArray(),
files.ToArray(),
urls.ToArray());
}
private static string NormalizeLicensePath(string value)
=> value.Replace('\\', '/').Trim();
}
internal sealed record DotNetLicenseInfo(
IReadOnlyList<string> Expressions,
IReadOnlyList<string> Files,
IReadOnlyList<string> Urls);
internal readonly record struct DotNetFileCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(DotNetFileCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
internal readonly struct Optional<T> where T : class
{
private Optional(bool hasValue, T? value)
{
HasValue = hasValue;
Value = value;
}
public bool HasValue { get; }
public T? Value { get; }
public static Optional<T> From(T? value)
=> value is null ? None : new Optional<T>(true, value);
public static Optional<T> None => default;
}
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security;
using System.Xml;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal static class DotNetFileMetadataCache
{
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<string>> Sha256Cache = new();
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<AssemblyName>> AssemblyCache = new();
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<FileVersionInfo>> VersionCache = new();
public static bool TryGetSha256(string path, out string? sha256)
=> TryGet(path, Sha256Cache, ComputeSha256, out sha256);
public static bool TryGetAssemblyName(string path, out AssemblyName? assemblyName)
=> TryGet(path, AssemblyCache, TryReadAssemblyName, out assemblyName);
public static bool TryGetFileVersionInfo(string path, out FileVersionInfo? versionInfo)
=> TryGet(path, VersionCache, TryReadFileVersionInfo, out versionInfo);
private static bool TryGet<T>(string path, ConcurrentDictionary<DotNetFileCacheKey, Optional<T>> cache, Func<string, T?> resolver, out T? value)
where T : class
{
value = null;
DotNetFileCacheKey key;
try
{
var info = new FileInfo(path);
if (!info.Exists)
{
return false;
}
key = new DotNetFileCacheKey(info.FullName, info.Length, info.LastWriteTimeUtc.Ticks);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
var optional = cache.GetOrAdd(key, static (cacheKey, state) => CreateOptional(cacheKey.Path, state.resolver), (resolver, path));
if (!optional.HasValue)
{
return false;
}
value = optional.Value;
return value is not null;
}
private static Optional<T> CreateOptional<T>(string path, Func<string, T?> resolver) where T : class
{
try
{
var value = resolver(path);
return Optional<T>.From(value);
}
catch (FileNotFoundException)
{
return Optional<T>.None;
}
catch (FileLoadException)
{
return Optional<T>.None;
}
catch (BadImageFormatException)
{
return Optional<T>.None;
}
catch (UnauthorizedAccessException)
{
return Optional<T>.None;
}
catch (SecurityException)
{
return Optional<T>.None;
}
catch (IOException)
{
return Optional<T>.None;
}
}
private static string? ComputeSha256(string path)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static AssemblyName? TryReadAssemblyName(string path)
{
try
{
return AssemblyName.GetAssemblyName(path);
}
catch (FileNotFoundException)
{
return null;
}
catch (FileLoadException)
{
return null;
}
catch (BadImageFormatException)
{
return null;
}
catch (IOException)
{
return null;
}
}
private static FileVersionInfo? TryReadFileVersionInfo(string path)
{
try
{
return FileVersionInfo.GetVersionInfo(path);
}
catch (FileNotFoundException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
}
internal static class DotNetLicenseCache
{
private static readonly ConcurrentDictionary<DotNetFileCacheKey, Optional<DotNetLicenseInfo>> Licenses = new();
public static bool TryGetLicenseInfo(string nuspecPath, out DotNetLicenseInfo? info)
{
info = null;
DotNetFileCacheKey key;
try
{
var fileInfo = new FileInfo(nuspecPath);
if (!fileInfo.Exists)
{
return false;
}
key = new DotNetFileCacheKey(fileInfo.FullName, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
var optional = Licenses.GetOrAdd(key, static (cacheKey, path) => CreateOptional(path), nuspecPath);
if (!optional.HasValue)
{
return false;
}
info = optional.Value;
return info is not null;
}
private static Optional<DotNetLicenseInfo> CreateOptional(string nuspecPath)
{
try
{
var info = Parse(nuspecPath);
return Optional<DotNetLicenseInfo>.From(info);
}
catch (IOException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (UnauthorizedAccessException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (SecurityException)
{
return Optional<DotNetLicenseInfo>.None;
}
catch (XmlException)
{
return Optional<DotNetLicenseInfo>.None;
}
}
private static DotNetLicenseInfo? Parse(string path)
{
using var stream = File.OpenRead(path);
using var reader = XmlReader.Create(stream, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
IgnoreComments = true,
IgnoreWhitespace = true,
});
var expressions = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.LocalName, "license", StringComparison.OrdinalIgnoreCase))
{
var type = reader.GetAttribute("type");
var value = reader.ReadElementContentAsString()?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (string.Equals(type, "expression", StringComparison.OrdinalIgnoreCase))
{
expressions.Add(value);
}
else if (string.Equals(type, "file", StringComparison.OrdinalIgnoreCase))
{
files.Add(NormalizeLicensePath(value));
}
else
{
expressions.Add(value);
}
}
else if (string.Equals(reader.LocalName, "licenseUrl", StringComparison.OrdinalIgnoreCase))
{
var value = reader.ReadElementContentAsString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
urls.Add(value);
}
}
}
if (expressions.Count == 0 && files.Count == 0 && urls.Count == 0)
{
return null;
}
return new DotNetLicenseInfo(
expressions.ToArray(),
files.ToArray(),
urls.ToArray());
}
private static string NormalizeLicensePath(string value)
=> value.Replace('\\', '/').Trim();
}
internal sealed record DotNetLicenseInfo(
IReadOnlyList<string> Expressions,
IReadOnlyList<string> Files,
IReadOnlyList<string> Urls);
internal readonly record struct DotNetFileCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(DotNetFileCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
internal readonly struct Optional<T> where T : class
{
private Optional(bool hasValue, T? value)
{
HasValue = hasValue;
Value = value;
}
public bool HasValue { get; }
public T? Value { get; }
public static Optional<T> From(T? value)
=> value is null ? None : new Optional<T>(true, value);
public static Optional<T> None => default;
}

View File

@@ -1,158 +1,158 @@
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetRuntimeConfig
{
private DotNetRuntimeConfig(
string relativePath,
IReadOnlyCollection<string> tfms,
IReadOnlyCollection<string> frameworks,
IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph)
{
RelativePath = relativePath;
Tfms = tfms;
Frameworks = frameworks;
RuntimeGraph = runtimeGraph;
}
public string RelativePath { get; }
public IReadOnlyCollection<string> Tfms { get; }
public IReadOnlyCollection<string> Frameworks { get; }
public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; }
public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object)
{
return null;
}
var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var runtimeGraph = new List<RuntimeGraphEntry>();
if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String)
{
AddIfPresent(tfms, tfmElement.GetString());
}
if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object)
{
var frameworkId = FormatFramework(frameworkElement);
AddIfPresent(frameworks, frameworkId);
}
if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in frameworksElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in includedElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) &&
runtimeGraphElement.ValueKind == JsonValueKind.Object &&
runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) &&
runtimesElement.ValueKind == JsonValueKind.Object)
{
foreach (var ridProperty in runtimesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(ridProperty.Name))
{
continue;
}
var fallbacks = new List<string>();
if (ridProperty.Value.ValueKind == JsonValueKind.Object &&
ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) &&
fallbacksElement.ValueKind == JsonValueKind.Array)
{
foreach (var fallback in fallbacksElement.EnumerateArray())
{
if (fallback.ValueKind == JsonValueKind.String)
{
var fallbackValue = fallback.GetString();
if (!string.IsNullOrWhiteSpace(fallbackValue))
{
fallbacks.Add(fallbackValue.Trim());
}
}
}
}
runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks));
}
}
return new DotNetRuntimeConfig(
relativePath,
tfms.ToArray(),
frameworks.ToArray(),
runtimeGraph);
}
private static void AddIfPresent(ISet<string> set, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
set.Add(value.Trim());
}
}
private static string? FormatFramework(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Object)
{
return null;
}
var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
? nameElement.GetString()
: null;
var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
? versionElement.GetString()
: null;
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
if (string.IsNullOrWhiteSpace(version))
{
return name.Trim();
}
return $"{name.Trim()}@{version.Trim()}";
}
internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks);
}
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
internal sealed class DotNetRuntimeConfig
{
private DotNetRuntimeConfig(
string relativePath,
IReadOnlyCollection<string> tfms,
IReadOnlyCollection<string> frameworks,
IReadOnlyCollection<RuntimeGraphEntry> runtimeGraph)
{
RelativePath = relativePath;
Tfms = tfms;
Frameworks = frameworks;
RuntimeGraph = runtimeGraph;
}
public string RelativePath { get; }
public IReadOnlyCollection<string> Tfms { get; }
public IReadOnlyCollection<string> Frameworks { get; }
public IReadOnlyCollection<RuntimeGraphEntry> RuntimeGraph { get; }
public static DotNetRuntimeConfig? Load(string absolutePath, string relativePath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = document.RootElement;
if (!root.TryGetProperty("runtimeOptions", out var runtimeOptions) || runtimeOptions.ValueKind is not JsonValueKind.Object)
{
return null;
}
var tfms = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var frameworks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var runtimeGraph = new List<RuntimeGraphEntry>();
if (runtimeOptions.TryGetProperty("tfm", out var tfmElement) && tfmElement.ValueKind == JsonValueKind.String)
{
AddIfPresent(tfms, tfmElement.GetString());
}
if (runtimeOptions.TryGetProperty("framework", out var frameworkElement) && frameworkElement.ValueKind == JsonValueKind.Object)
{
var frameworkId = FormatFramework(frameworkElement);
AddIfPresent(frameworks, frameworkId);
}
if (runtimeOptions.TryGetProperty("frameworks", out var frameworksElement) && frameworksElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in frameworksElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("includedFrameworks", out var includedElement) && includedElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in includedElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var frameworkId = FormatFramework(item);
AddIfPresent(frameworks, frameworkId);
}
}
if (runtimeOptions.TryGetProperty("runtimeGraph", out var runtimeGraphElement) &&
runtimeGraphElement.ValueKind == JsonValueKind.Object &&
runtimeGraphElement.TryGetProperty("runtimes", out var runtimesElement) &&
runtimesElement.ValueKind == JsonValueKind.Object)
{
foreach (var ridProperty in runtimesElement.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(ridProperty.Name))
{
continue;
}
var fallbacks = new List<string>();
if (ridProperty.Value.ValueKind == JsonValueKind.Object &&
ridProperty.Value.TryGetProperty("fallbacks", out var fallbacksElement) &&
fallbacksElement.ValueKind == JsonValueKind.Array)
{
foreach (var fallback in fallbacksElement.EnumerateArray())
{
if (fallback.ValueKind == JsonValueKind.String)
{
var fallbackValue = fallback.GetString();
if (!string.IsNullOrWhiteSpace(fallbackValue))
{
fallbacks.Add(fallbackValue.Trim());
}
}
}
}
runtimeGraph.Add(new RuntimeGraphEntry(ridProperty.Name.Trim(), fallbacks));
}
}
return new DotNetRuntimeConfig(
relativePath,
tfms.ToArray(),
frameworks.ToArray(),
runtimeGraph);
}
private static void AddIfPresent(ISet<string> set, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
set.Add(value.Trim());
}
}
private static string? FormatFramework(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Object)
{
return null;
}
var name = element.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String
? nameElement.GetString()
: null;
var version = element.TryGetProperty("version", out var versionElement) && versionElement.ValueKind == JsonValueKind.String
? versionElement.GetString()
: null;
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
if (string.IsNullOrWhiteSpace(version))
{
return name.Trim();
}
return $"{name.Trim()}@{version.Trim()}";
}
internal sealed record RuntimeGraphEntry(string Rid, IReadOnlyList<string> Fallbacks);
}

View File

@@ -1,8 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -1,17 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Go;
public sealed class GoAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Go";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new GoLanguageAnalyzer();
}
}
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Go;
public sealed class GoAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Go";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new GoLanguageAnalyzer();
}
}

View File

@@ -1,30 +1,30 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoAnalyzerMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.Analyzers.Lang.Go", "1.0.0");
private static readonly Counter<long> HeuristicCounter = Meter.CreateCounter<long>(
"scanner_analyzer_golang_heuristic_total",
unit: "components",
description: "Counts Go components emitted via heuristic fallbacks when build metadata is missing.");
public static void RecordHeuristic(GoStrippedBinaryIndicator indicator, bool hasVersionHint)
{
HeuristicCounter.Add(
1,
new KeyValuePair<string, object?>("indicator", NormalizeIndicator(indicator)),
new KeyValuePair<string, object?>("version_hint", hasVersionHint ? "present" : "absent"));
}
private static string NormalizeIndicator(GoStrippedBinaryIndicator indicator)
=> indicator switch
{
GoStrippedBinaryIndicator.BuildId => "build-id",
GoStrippedBinaryIndicator.GoRuntimeMarkers => "runtime-markers",
_ => "unknown",
};
}
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoAnalyzerMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.Analyzers.Lang.Go", "1.0.0");
private static readonly Counter<long> HeuristicCounter = Meter.CreateCounter<long>(
"scanner_analyzer_golang_heuristic_total",
unit: "components",
description: "Counts Go components emitted via heuristic fallbacks when build metadata is missing.");
public static void RecordHeuristic(GoStrippedBinaryIndicator indicator, bool hasVersionHint)
{
HeuristicCounter.Add(
1,
new KeyValuePair<string, object?>("indicator", NormalizeIndicator(indicator)),
new KeyValuePair<string, object?>("version_hint", hasVersionHint ? "present" : "absent"));
}
private static string NormalizeIndicator(GoStrippedBinaryIndicator indicator)
=> indicator switch
{
GoStrippedBinaryIndicator.BuildId => "build-id",
GoStrippedBinaryIndicator.GoRuntimeMarkers => "runtime-markers",
_ => "unknown",
};
}

View File

@@ -1,264 +1,264 @@
using System;
using System.Collections.Generic;
using System.Buffers;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBinaryScanner
{
private static readonly ReadOnlyMemory<byte> BuildInfoMagic = new byte[]
{
0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (byte)':'
};
private static readonly ReadOnlyMemory<byte> BuildIdMarker = Encoding.ASCII.GetBytes("Go build ID:");
private static readonly ReadOnlyMemory<byte> GoPclnTabMarker = Encoding.ASCII.GetBytes(".gopclntab");
private static readonly ReadOnlyMemory<byte> GoVersionPrefix = Encoding.ASCII.GetBytes("go1.");
public static IEnumerable<string> EnumerateCandidateFiles(string rootPath)
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
MatchCasing = MatchCasing.CaseSensitive,
};
foreach (var path in Directory.EnumerateFiles(rootPath, "*", enumeration))
{
yield return path;
}
}
public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
FileInfo info;
try
{
info = new FileInfo(filePath);
if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var length = info.Length;
if (length <= 0)
{
return false;
}
var inspectLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(inspectLength);
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var totalRead = 0;
while (totalRead < inspectLength)
{
var read = stream.Read(buffer, totalRead, inspectLength - totalRead);
if (read <= 0)
{
break;
}
totalRead += read;
}
if (totalRead < 64)
{
return false;
}
var span = new ReadOnlySpan<byte>(buffer, 0, totalRead);
var offset = span.IndexOf(BuildInfoMagic.Span);
if (offset < 0)
{
return false;
}
var view = span[offset..];
return GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, inspectLength);
ArrayPool<byte>.Shared.Return(buffer);
}
}
public static bool TryClassifyStrippedBinary(string filePath, out GoStrippedBinaryClassification classification)
{
classification = default;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var length = fileInfo.Length;
if (length < 128)
{
return false;
}
const int WindowSize = 128 * 1024;
var readSize = (int)Math.Min(length, WindowSize);
var buffer = ArrayPool<byte>.Shared.Rent(readSize);
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var headRead = stream.Read(buffer, 0, readSize);
if (headRead <= 0)
{
return false;
}
var headSpan = new ReadOnlySpan<byte>(buffer, 0, headRead);
var hasBuildId = headSpan.IndexOf(BuildIdMarker.Span) >= 0;
var hasPcln = headSpan.IndexOf(GoPclnTabMarker.Span) >= 0;
var goVersion = ExtractGoVersion(headSpan);
if (length > headRead)
{
var tailSize = Math.Min(readSize, (int)length);
if (tailSize > 0)
{
stream.Seek(-tailSize, SeekOrigin.End);
var tailRead = stream.Read(buffer, 0, tailSize);
if (tailRead > 0)
{
var tailSpan = new ReadOnlySpan<byte>(buffer, 0, tailRead);
hasBuildId |= tailSpan.IndexOf(BuildIdMarker.Span) >= 0;
hasPcln |= tailSpan.IndexOf(GoPclnTabMarker.Span) >= 0;
goVersion ??= ExtractGoVersion(tailSpan);
}
}
}
if (hasBuildId)
{
classification = new GoStrippedBinaryClassification(
filePath,
GoStrippedBinaryIndicator.BuildId,
goVersion);
return true;
}
if (hasPcln && !string.IsNullOrEmpty(goVersion))
{
classification = new GoStrippedBinaryClassification(
filePath,
GoStrippedBinaryIndicator.GoRuntimeMarkers,
goVersion);
return true;
}
return false;
}
finally
{
Array.Clear(buffer, 0, readSize);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string? ExtractGoVersion(ReadOnlySpan<byte> data)
{
var prefix = GoVersionPrefix.Span;
var span = data;
while (!span.IsEmpty)
{
var index = span.IndexOf(prefix);
if (index < 0)
{
return null;
}
var absoluteIndex = data.Length - span.Length + index;
if (absoluteIndex > 0)
{
var previous = (char)data[absoluteIndex - 1];
if (char.IsLetterOrDigit(previous))
{
span = span[(index + 1)..];
continue;
}
}
var start = absoluteIndex;
var end = start + prefix.Length;
while (end < data.Length && IsVersionCharacter((char)data[end]))
{
end++;
}
if (end - start <= prefix.Length)
{
span = span[(index + 1)..];
continue;
}
var candidate = data[start..end];
return Encoding.ASCII.GetString(candidate);
}
return null;
}
private static bool IsVersionCharacter(char value)
=> (value >= '0' && value <= '9')
|| (value >= 'a' && value <= 'z')
|| (value >= 'A' && value <= 'Z')
|| value is '.' or '-' or '+' or '_';
}
using System;
using System.Collections.Generic;
using System.Buffers;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBinaryScanner
{
private static readonly ReadOnlyMemory<byte> BuildInfoMagic = new byte[]
{
0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (byte)':'
};
private static readonly ReadOnlyMemory<byte> BuildIdMarker = Encoding.ASCII.GetBytes("Go build ID:");
private static readonly ReadOnlyMemory<byte> GoPclnTabMarker = Encoding.ASCII.GetBytes(".gopclntab");
private static readonly ReadOnlyMemory<byte> GoVersionPrefix = Encoding.ASCII.GetBytes("go1.");
public static IEnumerable<string> EnumerateCandidateFiles(string rootPath)
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
MatchCasing = MatchCasing.CaseSensitive,
};
foreach (var path in Directory.EnumerateFiles(rootPath, "*", enumeration))
{
yield return path;
}
}
public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
FileInfo info;
try
{
info = new FileInfo(filePath);
if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var length = info.Length;
if (length <= 0)
{
return false;
}
var inspectLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(inspectLength);
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var totalRead = 0;
while (totalRead < inspectLength)
{
var read = stream.Read(buffer, totalRead, inspectLength - totalRead);
if (read <= 0)
{
break;
}
totalRead += read;
}
if (totalRead < 64)
{
return false;
}
var span = new ReadOnlySpan<byte>(buffer, 0, totalRead);
var offset = span.IndexOf(BuildInfoMagic.Span);
if (offset < 0)
{
return false;
}
var view = span[offset..];
return GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, inspectLength);
ArrayPool<byte>.Shared.Return(buffer);
}
}
public static bool TryClassifyStrippedBinary(string filePath, out GoStrippedBinaryClassification classification)
{
classification = default;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var length = fileInfo.Length;
if (length < 128)
{
return false;
}
const int WindowSize = 128 * 1024;
var readSize = (int)Math.Min(length, WindowSize);
var buffer = ArrayPool<byte>.Shared.Rent(readSize);
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var headRead = stream.Read(buffer, 0, readSize);
if (headRead <= 0)
{
return false;
}
var headSpan = new ReadOnlySpan<byte>(buffer, 0, headRead);
var hasBuildId = headSpan.IndexOf(BuildIdMarker.Span) >= 0;
var hasPcln = headSpan.IndexOf(GoPclnTabMarker.Span) >= 0;
var goVersion = ExtractGoVersion(headSpan);
if (length > headRead)
{
var tailSize = Math.Min(readSize, (int)length);
if (tailSize > 0)
{
stream.Seek(-tailSize, SeekOrigin.End);
var tailRead = stream.Read(buffer, 0, tailSize);
if (tailRead > 0)
{
var tailSpan = new ReadOnlySpan<byte>(buffer, 0, tailRead);
hasBuildId |= tailSpan.IndexOf(BuildIdMarker.Span) >= 0;
hasPcln |= tailSpan.IndexOf(GoPclnTabMarker.Span) >= 0;
goVersion ??= ExtractGoVersion(tailSpan);
}
}
}
if (hasBuildId)
{
classification = new GoStrippedBinaryClassification(
filePath,
GoStrippedBinaryIndicator.BuildId,
goVersion);
return true;
}
if (hasPcln && !string.IsNullOrEmpty(goVersion))
{
classification = new GoStrippedBinaryClassification(
filePath,
GoStrippedBinaryIndicator.GoRuntimeMarkers,
goVersion);
return true;
}
return false;
}
finally
{
Array.Clear(buffer, 0, readSize);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string? ExtractGoVersion(ReadOnlySpan<byte> data)
{
var prefix = GoVersionPrefix.Span;
var span = data;
while (!span.IsEmpty)
{
var index = span.IndexOf(prefix);
if (index < 0)
{
return null;
}
var absoluteIndex = data.Length - span.Length + index;
if (absoluteIndex > 0)
{
var previous = (char)data[absoluteIndex - 1];
if (char.IsLetterOrDigit(previous))
{
span = span[(index + 1)..];
continue;
}
}
var start = absoluteIndex;
var end = start + prefix.Length;
while (end < data.Length && IsVersionCharacter((char)data[end]))
{
end++;
}
if (end - start <= prefix.Length)
{
span = span[(index + 1)..];
continue;
}
var candidate = data[start..end];
return Encoding.ASCII.GetString(candidate);
}
return null;
}
private static bool IsVersionCharacter(char value)
=> (value >= '0' && value <= '9')
|| (value >= 'a' && value <= 'z')
|| (value >= 'A' && value <= 'Z')
|| value is '.' or '-' or '+' or '_';
}

View File

@@ -1,80 +1,80 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoBuildInfo
{
public GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
IEnumerable<GoModule> dependencies,
IEnumerable<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata = null)
: this(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies?
.Where(static module => module is not null)
.ToImmutableArray()
?? ImmutableArray<GoModule>.Empty,
settings?
.Where(static pair => pair.Key is not null)
.Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value))
.ToImmutableArray()
?? ImmutableArray<KeyValuePair<string, string?>>.Empty,
dwarfMetadata)
{
}
private GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
ImmutableArray<GoModule> dependencies,
ImmutableArray<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata)
{
GoVersion = goVersion ?? throw new ArgumentNullException(nameof(goVersion));
AbsoluteBinaryPath = absoluteBinaryPath ?? throw new ArgumentNullException(nameof(absoluteBinaryPath));
ModulePath = modulePath ?? throw new ArgumentNullException(nameof(modulePath));
MainModule = mainModule ?? throw new ArgumentNullException(nameof(mainModule));
Dependencies = dependencies;
Settings = settings;
DwarfMetadata = dwarfMetadata;
}
public string GoVersion { get; }
public string AbsoluteBinaryPath { get; }
public string ModulePath { get; }
public GoModule MainModule { get; }
public ImmutableArray<GoModule> Dependencies { get; }
public ImmutableArray<KeyValuePair<string, string?>> Settings { get; }
public GoDwarfMetadata? DwarfMetadata { get; }
public GoBuildInfo WithDwarf(GoDwarfMetadata metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
return new GoBuildInfo(
GoVersion,
AbsoluteBinaryPath,
ModulePath,
MainModule,
Dependencies,
Settings,
metadata);
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoBuildInfo
{
public GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
IEnumerable<GoModule> dependencies,
IEnumerable<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata = null)
: this(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies?
.Where(static module => module is not null)
.ToImmutableArray()
?? ImmutableArray<GoModule>.Empty,
settings?
.Where(static pair => pair.Key is not null)
.Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value))
.ToImmutableArray()
?? ImmutableArray<KeyValuePair<string, string?>>.Empty,
dwarfMetadata)
{
}
private GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
ImmutableArray<GoModule> dependencies,
ImmutableArray<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata)
{
GoVersion = goVersion ?? throw new ArgumentNullException(nameof(goVersion));
AbsoluteBinaryPath = absoluteBinaryPath ?? throw new ArgumentNullException(nameof(absoluteBinaryPath));
ModulePath = modulePath ?? throw new ArgumentNullException(nameof(modulePath));
MainModule = mainModule ?? throw new ArgumentNullException(nameof(mainModule));
Dependencies = dependencies;
Settings = settings;
DwarfMetadata = dwarfMetadata;
}
public string GoVersion { get; }
public string AbsoluteBinaryPath { get; }
public string ModulePath { get; }
public GoModule MainModule { get; }
public ImmutableArray<GoModule> Dependencies { get; }
public ImmutableArray<KeyValuePair<string, string?>> Settings { get; }
public GoDwarfMetadata? DwarfMetadata { get; }
public GoBuildInfo WithDwarf(GoDwarfMetadata metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
return new GoBuildInfo(
GoVersion,
AbsoluteBinaryPath,
ModulePath,
MainModule,
Dependencies,
Settings,
metadata);
}
}

View File

@@ -1,159 +1,159 @@
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoDecoder
{
private const string BuildInfoMagic = "\xff Go buildinf:";
private const int HeaderSize = 32;
private const byte VarintEncodingFlag = 0x02;
public static bool TryDecode(ReadOnlySpan<byte> data, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
if (data.Length < HeaderSize)
{
return false;
}
if (!IsMagicMatch(data))
{
return false;
}
var pointerSize = data[14];
var flags = data[15];
if (pointerSize != 4 && pointerSize != 8)
{
return false;
}
if ((flags & VarintEncodingFlag) == 0)
{
// Older Go toolchains encode pointers to strings instead of inline data.
// The Sprint 10 scope targets Go 1.18+, which always sets the varint flag.
return false;
}
var payload = data.Slice(HeaderSize);
if (!TryReadVarString(payload, out var version, out var consumed))
{
return false;
}
payload = payload.Slice(consumed);
if (!TryReadVarString(payload, out var modules, out _))
{
return false;
}
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
modules = StripSentinel(modules);
goVersion = version;
moduleData = modules;
return !string.IsNullOrWhiteSpace(moduleData);
}
private static bool IsMagicMatch(ReadOnlySpan<byte> data)
{
if (data.Length < BuildInfoMagic.Length)
{
return false;
}
for (var i = 0; i < BuildInfoMagic.Length; i++)
{
if (data[i] != BuildInfoMagic[i])
{
return false;
}
}
return true;
}
private static bool TryReadVarString(ReadOnlySpan<byte> data, out string result, out int consumed)
{
result = string.Empty;
consumed = 0;
if (!TryReadUVarint(data, out var length, out var lengthBytes))
{
return false;
}
if (length > int.MaxValue)
{
return false;
}
var stringLength = (int)length;
var totalRequired = lengthBytes + stringLength;
if (stringLength <= 0 || totalRequired > data.Length)
{
return false;
}
var slice = data.Slice(lengthBytes, stringLength);
result = Encoding.UTF8.GetString(slice);
consumed = totalRequired;
return true;
}
private static bool TryReadUVarint(ReadOnlySpan<byte> data, out ulong value, out int bytesRead)
{
value = 0;
bytesRead = 0;
ulong x = 0;
var shift = 0;
for (var i = 0; i < data.Length; i++)
{
var b = data[i];
if (b < 0x80)
{
if (i > 9 || i == 9 && b > 1)
{
return false;
}
value = x | (ulong)b << shift;
bytesRead = i + 1;
return true;
}
x |= (ulong)(b & 0x7F) << shift;
shift += 7;
}
return false;
}
private static string StripSentinel(string value)
{
if (string.IsNullOrEmpty(value) || value.Length < 33)
{
return value;
}
var sentinelIndex = value.Length - 17;
if (value[sentinelIndex] != '\n')
{
return value;
}
return value[16..^16];
}
}
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoDecoder
{
private const string BuildInfoMagic = "\xff Go buildinf:";
private const int HeaderSize = 32;
private const byte VarintEncodingFlag = 0x02;
public static bool TryDecode(ReadOnlySpan<byte> data, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
if (data.Length < HeaderSize)
{
return false;
}
if (!IsMagicMatch(data))
{
return false;
}
var pointerSize = data[14];
var flags = data[15];
if (pointerSize != 4 && pointerSize != 8)
{
return false;
}
if ((flags & VarintEncodingFlag) == 0)
{
// Older Go toolchains encode pointers to strings instead of inline data.
// The Sprint 10 scope targets Go 1.18+, which always sets the varint flag.
return false;
}
var payload = data.Slice(HeaderSize);
if (!TryReadVarString(payload, out var version, out var consumed))
{
return false;
}
payload = payload.Slice(consumed);
if (!TryReadVarString(payload, out var modules, out _))
{
return false;
}
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
modules = StripSentinel(modules);
goVersion = version;
moduleData = modules;
return !string.IsNullOrWhiteSpace(moduleData);
}
private static bool IsMagicMatch(ReadOnlySpan<byte> data)
{
if (data.Length < BuildInfoMagic.Length)
{
return false;
}
for (var i = 0; i < BuildInfoMagic.Length; i++)
{
if (data[i] != BuildInfoMagic[i])
{
return false;
}
}
return true;
}
private static bool TryReadVarString(ReadOnlySpan<byte> data, out string result, out int consumed)
{
result = string.Empty;
consumed = 0;
if (!TryReadUVarint(data, out var length, out var lengthBytes))
{
return false;
}
if (length > int.MaxValue)
{
return false;
}
var stringLength = (int)length;
var totalRequired = lengthBytes + stringLength;
if (stringLength <= 0 || totalRequired > data.Length)
{
return false;
}
var slice = data.Slice(lengthBytes, stringLength);
result = Encoding.UTF8.GetString(slice);
consumed = totalRequired;
return true;
}
private static bool TryReadUVarint(ReadOnlySpan<byte> data, out ulong value, out int bytesRead)
{
value = 0;
bytesRead = 0;
ulong x = 0;
var shift = 0;
for (var i = 0; i < data.Length; i++)
{
var b = data[i];
if (b < 0x80)
{
if (i > 9 || i == 9 && b > 1)
{
return false;
}
value = x | (ulong)b << shift;
bytesRead = i + 1;
return true;
}
x |= (ulong)(b & 0x7F) << shift;
shift += 7;
}
return false;
}
private static string StripSentinel(string value)
{
if (string.IsNullOrEmpty(value) || value.Length < 33)
{
return value;
}
var sentinelIndex = value.Length - 17;
if (value[sentinelIndex] != '\n')
{
return value;
}
return value[16..^16];
}
}

View File

@@ -1,234 +1,234 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoParser
{
private const string PathPrefix = "path\t";
private const string ModulePrefix = "mod\t";
private const string DependencyPrefix = "dep\t";
private const string ReplacementPrefix = "=>\t";
private const string BuildPrefix = "build\t";
public static bool TryParse(string goVersion, string absoluteBinaryPath, string rawModuleData, out GoBuildInfo? info)
{
info = null;
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(rawModuleData))
{
return false;
}
string? modulePath = null;
GoModule? mainModule = null;
var dependencies = new List<GoModule>();
var settings = new SortedDictionary<string, string?>(StringComparer.Ordinal);
GoModule? lastModule = null;
using var reader = new StringReader(rawModuleData);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith(PathPrefix, StringComparison.Ordinal))
{
modulePath = line[PathPrefix.Length..].Trim();
continue;
}
if (line.StartsWith(ModulePrefix, StringComparison.Ordinal))
{
mainModule = ParseModule(line.AsSpan(ModulePrefix.Length), isMain: true);
lastModule = mainModule;
continue;
}
if (line.StartsWith(DependencyPrefix, StringComparison.Ordinal))
{
var dependency = ParseModule(line.AsSpan(DependencyPrefix.Length), isMain: false);
if (dependency is not null)
{
dependencies.Add(dependency);
lastModule = dependency;
}
continue;
}
if (line.StartsWith(ReplacementPrefix, StringComparison.Ordinal))
{
if (lastModule is null)
{
continue;
}
var replacement = ParseReplacement(line.AsSpan(ReplacementPrefix.Length));
if (replacement is not null)
{
lastModule.SetReplacement(replacement);
}
continue;
}
if (line.StartsWith(BuildPrefix, StringComparison.Ordinal))
{
var pair = ParseBuildSetting(line.AsSpan(BuildPrefix.Length));
if (!string.IsNullOrEmpty(pair.Key))
{
settings[pair.Key] = pair.Value;
}
}
}
if (mainModule is null)
{
return false;
}
if (string.IsNullOrEmpty(modulePath))
{
modulePath = mainModule.Path;
}
info = new GoBuildInfo(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies,
settings);
return true;
}
private static GoModule? ParseModule(ReadOnlySpan<char> span, bool isMain)
{
var fields = SplitFields(span, expected: 4);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModule(path, version, sum, isMain);
}
private static GoModuleReplacement? ParseReplacement(ReadOnlySpan<char> span)
{
var fields = SplitFields(span, expected: 3);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModuleReplacement(path, version, sum);
}
private static KeyValuePair<string, string?> ParseBuildSetting(ReadOnlySpan<char> span)
{
span = span.Trim();
if (span.IsEmpty)
{
return default;
}
var separatorIndex = span.IndexOf('=');
if (separatorIndex <= 0)
{
return default;
}
var rawKey = span[..separatorIndex].Trim();
var rawValue = span[(separatorIndex + 1)..].Trim();
var key = Unquote(rawKey.ToString());
if (string.IsNullOrWhiteSpace(key))
{
return default;
}
var value = Unquote(rawValue.ToString());
return new KeyValuePair<string, string?>(key, value);
}
private static List<string> SplitFields(ReadOnlySpan<char> span, int expected)
{
var fields = new List<string>(expected);
var builder = new StringBuilder();
for (var i = 0; i < span.Length; i++)
{
var current = span[i];
if (current == '\t')
{
fields.Add(builder.ToString());
builder.Clear();
continue;
}
builder.Append(current);
}
fields.Add(builder.ToString());
return fields;
}
private static string Unquote(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
value = value.Trim();
if (value.Length < 2)
{
return value;
}
if (value[0] == '"' && value[^1] == '"')
{
try
{
return JsonSerializer.Deserialize<string>(value) ?? value;
}
catch (JsonException)
{
return value;
}
}
if (value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
return value;
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoParser
{
private const string PathPrefix = "path\t";
private const string ModulePrefix = "mod\t";
private const string DependencyPrefix = "dep\t";
private const string ReplacementPrefix = "=>\t";
private const string BuildPrefix = "build\t";
public static bool TryParse(string goVersion, string absoluteBinaryPath, string rawModuleData, out GoBuildInfo? info)
{
info = null;
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(rawModuleData))
{
return false;
}
string? modulePath = null;
GoModule? mainModule = null;
var dependencies = new List<GoModule>();
var settings = new SortedDictionary<string, string?>(StringComparer.Ordinal);
GoModule? lastModule = null;
using var reader = new StringReader(rawModuleData);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith(PathPrefix, StringComparison.Ordinal))
{
modulePath = line[PathPrefix.Length..].Trim();
continue;
}
if (line.StartsWith(ModulePrefix, StringComparison.Ordinal))
{
mainModule = ParseModule(line.AsSpan(ModulePrefix.Length), isMain: true);
lastModule = mainModule;
continue;
}
if (line.StartsWith(DependencyPrefix, StringComparison.Ordinal))
{
var dependency = ParseModule(line.AsSpan(DependencyPrefix.Length), isMain: false);
if (dependency is not null)
{
dependencies.Add(dependency);
lastModule = dependency;
}
continue;
}
if (line.StartsWith(ReplacementPrefix, StringComparison.Ordinal))
{
if (lastModule is null)
{
continue;
}
var replacement = ParseReplacement(line.AsSpan(ReplacementPrefix.Length));
if (replacement is not null)
{
lastModule.SetReplacement(replacement);
}
continue;
}
if (line.StartsWith(BuildPrefix, StringComparison.Ordinal))
{
var pair = ParseBuildSetting(line.AsSpan(BuildPrefix.Length));
if (!string.IsNullOrEmpty(pair.Key))
{
settings[pair.Key] = pair.Value;
}
}
}
if (mainModule is null)
{
return false;
}
if (string.IsNullOrEmpty(modulePath))
{
modulePath = mainModule.Path;
}
info = new GoBuildInfo(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies,
settings);
return true;
}
private static GoModule? ParseModule(ReadOnlySpan<char> span, bool isMain)
{
var fields = SplitFields(span, expected: 4);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModule(path, version, sum, isMain);
}
private static GoModuleReplacement? ParseReplacement(ReadOnlySpan<char> span)
{
var fields = SplitFields(span, expected: 3);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModuleReplacement(path, version, sum);
}
private static KeyValuePair<string, string?> ParseBuildSetting(ReadOnlySpan<char> span)
{
span = span.Trim();
if (span.IsEmpty)
{
return default;
}
var separatorIndex = span.IndexOf('=');
if (separatorIndex <= 0)
{
return default;
}
var rawKey = span[..separatorIndex].Trim();
var rawValue = span[(separatorIndex + 1)..].Trim();
var key = Unquote(rawKey.ToString());
if (string.IsNullOrWhiteSpace(key))
{
return default;
}
var value = Unquote(rawValue.ToString());
return new KeyValuePair<string, string?>(key, value);
}
private static List<string> SplitFields(ReadOnlySpan<char> span, int expected)
{
var fields = new List<string>(expected);
var builder = new StringBuilder();
for (var i = 0; i < span.Length; i++)
{
var current = span[i];
if (current == '\t')
{
fields.Add(builder.ToString());
builder.Clear();
continue;
}
builder.Append(current);
}
fields.Add(builder.ToString());
return fields;
}
private static string Unquote(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
value = value.Trim();
if (value.Length < 2)
{
return value;
}
if (value[0] == '"' && value[^1] == '"')
{
try
{
return JsonSerializer.Deserialize<string>(value) ?? value;
}
catch (JsonException)
{
return value;
}
}
if (value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
return value;
}
}

View File

@@ -1,82 +1,82 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoProvider
{
private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new();
public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info)
{
info = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(absolutePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath);
return info is not null;
}
private static GoBuildInfo? CreateBuildInfo(string absolutePath)
{
if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData))
{
return null;
}
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(moduleData))
{
return null;
}
if (!GoBuildInfoParser.TryParse(goVersion!, absolutePath, moduleData!, out var buildInfo) || buildInfo is null)
{
return null;
}
if (GoDwarfReader.TryRead(absolutePath, out var dwarf) && dwarf is not null)
{
buildInfo = buildInfo.WithDwarf(dwarf);
}
return buildInfo;
}
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(GoBinaryCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
}
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoProvider
{
private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new();
public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info)
{
info = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(absolutePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath);
return info is not null;
}
private static GoBuildInfo? CreateBuildInfo(string absolutePath)
{
if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData))
{
return null;
}
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(moduleData))
{
return null;
}
if (!GoBuildInfoParser.TryParse(goVersion!, absolutePath, moduleData!, out var buildInfo) || buildInfo is null)
{
return null;
}
if (GoDwarfReader.TryRead(absolutePath, out var dwarf) && dwarf is not null)
{
buildInfo = buildInfo.WithDwarf(dwarf);
}
return buildInfo;
}
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(GoBinaryCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
}

View File

@@ -1,33 +1,33 @@
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoDwarfMetadata
{
public GoDwarfMetadata(string? vcsSystem, string? revision, bool? modified, string? timestampUtc)
{
VcsSystem = Normalize(vcsSystem);
Revision = Normalize(revision);
Modified = modified;
TimestampUtc = Normalize(timestampUtc);
}
public string? VcsSystem { get; }
public string? Revision { get; }
public bool? Modified { get; }
public string? TimestampUtc { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoDwarfMetadata
{
public GoDwarfMetadata(string? vcsSystem, string? revision, bool? modified, string? timestampUtc)
{
VcsSystem = Normalize(vcsSystem);
Revision = Normalize(revision);
Modified = modified;
TimestampUtc = Normalize(timestampUtc);
}
public string? VcsSystem { get; }
public string? Revision { get; }
public bool? Modified { get; }
public string? TimestampUtc { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}

View File

@@ -1,120 +1,120 @@
using System;
using System.Buffers;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoDwarfReader
{
private static readonly byte[] VcsSystemToken = Encoding.UTF8.GetBytes("vcs=");
private static readonly byte[] VcsRevisionToken = Encoding.UTF8.GetBytes("vcs.revision=");
private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified=");
private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time=");
public static bool TryRead(string path, out GoDwarfMetadata? metadata)
{
metadata = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(path);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024)
{
return false;
}
var length = fileInfo.Length;
var readLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(readLength);
var bytesRead = 0;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
bytesRead = stream.Read(buffer, 0, readLength);
if (bytesRead <= 0)
{
return false;
}
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
var revision = ExtractValue(data, VcsRevisionToken);
var modifiedText = ExtractValue(data, VcsModifiedToken);
var timestamp = ExtractValue(data, VcsTimeToken);
var system = ExtractValue(data, VcsSystemToken);
bool? modified = null;
if (!string.IsNullOrWhiteSpace(modifiedText))
{
if (bool.TryParse(modifiedText, out var parsed))
{
modified = parsed;
}
}
if (string.IsNullOrWhiteSpace(revision) && string.IsNullOrWhiteSpace(system) && modified is null && string.IsNullOrWhiteSpace(timestamp))
{
return false;
}
metadata = new GoDwarfMetadata(system, revision, modified, timestamp);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, bytesRead);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token)
{
var index = data.IndexOf(token);
if (index < 0)
{
return null;
}
var start = index + token.Length;
var end = start;
while (end < data.Length)
{
var current = data[end];
if (current == 0 || current == (byte)'\n' || current == (byte)'\r')
{
break;
}
end++;
}
if (end <= start)
{
return null;
}
return Encoding.UTF8.GetString(data.Slice(start, end - start));
}
}
using System;
using System.Buffers;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoDwarfReader
{
private static readonly byte[] VcsSystemToken = Encoding.UTF8.GetBytes("vcs=");
private static readonly byte[] VcsRevisionToken = Encoding.UTF8.GetBytes("vcs.revision=");
private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified=");
private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time=");
public static bool TryRead(string path, out GoDwarfMetadata? metadata)
{
metadata = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(path);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024)
{
return false;
}
var length = fileInfo.Length;
var readLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(readLength);
var bytesRead = 0;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
bytesRead = stream.Read(buffer, 0, readLength);
if (bytesRead <= 0)
{
return false;
}
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
var revision = ExtractValue(data, VcsRevisionToken);
var modifiedText = ExtractValue(data, VcsModifiedToken);
var timestamp = ExtractValue(data, VcsTimeToken);
var system = ExtractValue(data, VcsSystemToken);
bool? modified = null;
if (!string.IsNullOrWhiteSpace(modifiedText))
{
if (bool.TryParse(modifiedText, out var parsed))
{
modified = parsed;
}
}
if (string.IsNullOrWhiteSpace(revision) && string.IsNullOrWhiteSpace(system) && modified is null && string.IsNullOrWhiteSpace(timestamp))
{
return false;
}
metadata = new GoDwarfMetadata(system, revision, modified, timestamp);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, bytesRead);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token)
{
var index = data.IndexOf(token);
if (index < 0)
{
return null;
}
var start = index + token.Length;
var end = start;
while (end < data.Length)
{
var current = data[end];
if (current == 0 || current == (byte)'\n' || current == (byte)'\r')
{
break;
}
end++;
}
if (end <= start)
{
return null;
}
return Encoding.UTF8.GetString(data.Slice(start, end - start));
}
}

View File

@@ -1,67 +1,67 @@
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoModule
{
public GoModule(string path, string? version, string? sum, bool isMain)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
IsMain = isMain;
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
public GoModuleReplacement? Replacement { get; private set; }
public bool IsMain { get; }
public void SetReplacement(GoModuleReplacement replacement)
{
Replacement = replacement ?? throw new ArgumentNullException(nameof(replacement));
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
internal sealed class GoModuleReplacement
{
public GoModuleReplacement(string path, string? version, string? sum)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoModule
{
public GoModule(string path, string? version, string? sum, bool isMain)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
IsMain = isMain;
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
public GoModuleReplacement? Replacement { get; private set; }
public bool IsMain { get; }
public void SetReplacement(GoModuleReplacement replacement)
{
Replacement = replacement ?? throw new ArgumentNullException(nameof(replacement));
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
internal sealed class GoModuleReplacement
{
public GoModuleReplacement(string path, string? version, string? sum)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}

View File

@@ -1,13 +1,13 @@
global using System;
global using System.Collections.Generic;
global using System.Globalization;
global using System.IO;
global using System.IO.Compression;
global using System.Linq;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;
global using System;
global using System.Collections.Generic;
global using System.Globalization;
global using System.IO;
global using System.IO.Compression;
global using System.Linq;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -1,62 +1,62 @@
using System.IO.Compression;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal enum JavaClassLocationKind
{
ArchiveEntry,
EmbeddedArchiveEntry,
}
internal sealed record JavaClassLocation(
JavaClassLocationKind Kind,
JavaArchive Archive,
JavaArchiveEntry Entry,
string? NestedClassPath)
{
public static JavaClassLocation ForArchive(JavaArchive archive, JavaArchiveEntry entry)
=> new(JavaClassLocationKind.ArchiveEntry, archive, entry, NestedClassPath: null);
public static JavaClassLocation ForEmbedded(JavaArchive archive, JavaArchiveEntry entry, string nestedClassPath)
=> new(JavaClassLocationKind.EmbeddedArchiveEntry, archive, entry, nestedClassPath);
public Stream OpenClassStream(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Kind switch
{
JavaClassLocationKind.ArchiveEntry => Archive.OpenEntry(Entry),
JavaClassLocationKind.EmbeddedArchiveEntry => OpenEmbeddedEntryStream(cancellationToken),
_ => throw new InvalidOperationException($"Unsupported class location kind '{Kind}'."),
};
}
private Stream OpenEmbeddedEntryStream(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using var embeddedStream = Archive.OpenEntry(Entry);
using var buffer = new MemoryStream();
embeddedStream.CopyTo(buffer);
buffer.Position = 0;
using var nestedArchive = new ZipArchive(buffer, ZipArchiveMode.Read, leaveOpen: true);
if (NestedClassPath is null)
{
throw new InvalidOperationException($"Nested class path not specified for embedded entry '{Entry.OriginalPath}'.");
}
var classEntry = nestedArchive.GetEntry(NestedClassPath);
if (classEntry is null)
{
throw new FileNotFoundException($"Class '{NestedClassPath}' not found inside embedded archive entry '{Entry.OriginalPath}'.");
}
using var classStream = classEntry.Open();
var output = new MemoryStream();
classStream.CopyTo(output);
output.Position = 0;
return output;
}
}
using System.IO.Compression;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal enum JavaClassLocationKind
{
ArchiveEntry,
EmbeddedArchiveEntry,
}
internal sealed record JavaClassLocation(
JavaClassLocationKind Kind,
JavaArchive Archive,
JavaArchiveEntry Entry,
string? NestedClassPath)
{
public static JavaClassLocation ForArchive(JavaArchive archive, JavaArchiveEntry entry)
=> new(JavaClassLocationKind.ArchiveEntry, archive, entry, NestedClassPath: null);
public static JavaClassLocation ForEmbedded(JavaArchive archive, JavaArchiveEntry entry, string nestedClassPath)
=> new(JavaClassLocationKind.EmbeddedArchiveEntry, archive, entry, nestedClassPath);
public Stream OpenClassStream(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Kind switch
{
JavaClassLocationKind.ArchiveEntry => Archive.OpenEntry(Entry),
JavaClassLocationKind.EmbeddedArchiveEntry => OpenEmbeddedEntryStream(cancellationToken),
_ => throw new InvalidOperationException($"Unsupported class location kind '{Kind}'."),
};
}
private Stream OpenEmbeddedEntryStream(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using var embeddedStream = Archive.OpenEntry(Entry);
using var buffer = new MemoryStream();
embeddedStream.CopyTo(buffer);
buffer.Position = 0;
using var nestedArchive = new ZipArchive(buffer, ZipArchiveMode.Read, leaveOpen: true);
if (NestedClassPath is null)
{
throw new InvalidOperationException($"Nested class path not specified for embedded entry '{Entry.OriginalPath}'.");
}
var classEntry = nestedArchive.GetEntry(NestedClassPath);
if (classEntry is null)
{
throw new FileNotFoundException($"Class '{NestedClassPath}' not found inside embedded archive entry '{Entry.OriginalPath}'.");
}
using var classStream = classEntry.Open();
var output = new MemoryStream();
classStream.CopyTo(output);
output.Position = 0;
return output;
}
}

View File

@@ -1,102 +1,102 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal sealed class JavaClassPathAnalysis
{
public JavaClassPathAnalysis(
IEnumerable<JavaClassPathSegment> segments,
IEnumerable<JavaModuleDescriptor> modules,
IEnumerable<JavaClassDuplicate> duplicateClasses,
IEnumerable<JavaSplitPackage> splitPackages)
{
Segments = segments
.Where(static segment => segment is not null)
.OrderBy(static segment => segment.Order)
.ThenBy(static segment => segment.Identifier, StringComparer.Ordinal)
.ToImmutableArray();
Modules = modules
.Where(static module => module is not null)
.OrderBy(static module => module.Name, StringComparer.Ordinal)
.ThenBy(static module => module.Source, StringComparer.Ordinal)
.ToImmutableArray();
DuplicateClasses = duplicateClasses
.Where(static duplicate => duplicate is not null)
.OrderBy(static duplicate => duplicate.ClassName, StringComparer.Ordinal)
.ToImmutableArray();
SplitPackages = splitPackages
.Where(static split => split is not null)
.OrderBy(static split => split.PackageName, StringComparer.Ordinal)
.ToImmutableArray();
}
public ImmutableArray<JavaClassPathSegment> Segments { get; }
public ImmutableArray<JavaModuleDescriptor> Modules { get; }
public ImmutableArray<JavaClassDuplicate> DuplicateClasses { get; }
public ImmutableArray<JavaSplitPackage> SplitPackages { get; }
}
internal sealed class JavaClassPathSegment
{
public JavaClassPathSegment(
string identifier,
string displayPath,
JavaClassPathSegmentKind kind,
JavaPackagingKind packaging,
int order,
JavaModuleDescriptor? module,
ImmutableSortedSet<string> classes,
ImmutableDictionary<string, JavaPackageFingerprint> packages,
ImmutableDictionary<string, ImmutableArray<string>> serviceDefinitions,
ImmutableDictionary<string, JavaClassLocation> classLocations)
{
Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier));
DisplayPath = displayPath ?? throw new ArgumentNullException(nameof(displayPath));
Kind = kind;
Packaging = packaging;
Order = order;
Module = module;
Classes = classes;
Packages = packages ?? ImmutableDictionary<string, JavaPackageFingerprint>.Empty;
ServiceDefinitions = serviceDefinitions ?? ImmutableDictionary<string, ImmutableArray<string>>.Empty;
ClassLocations = classLocations ?? ImmutableDictionary<string, JavaClassLocation>.Empty;
}
public string Identifier { get; }
public string DisplayPath { get; }
public JavaClassPathSegmentKind Kind { get; }
public JavaPackagingKind Packaging { get; }
public int Order { get; }
public JavaModuleDescriptor? Module { get; }
public ImmutableSortedSet<string> Classes { get; }
public ImmutableDictionary<string, JavaPackageFingerprint> Packages { get; }
public ImmutableDictionary<string, ImmutableArray<string>> ServiceDefinitions { get; }
public ImmutableDictionary<string, JavaClassLocation> ClassLocations { get; }
}
internal enum JavaClassPathSegmentKind
{
Archive,
Directory,
}
internal sealed record JavaPackageFingerprint(string PackageName, int ClassCount, string Fingerprint);
internal sealed record JavaClassDuplicate(string ClassName, ImmutableArray<string> SegmentIdentifiers);
internal sealed record JavaSplitPackage(string PackageName, ImmutableArray<string> SegmentIdentifiers);
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal sealed class JavaClassPathAnalysis
{
public JavaClassPathAnalysis(
IEnumerable<JavaClassPathSegment> segments,
IEnumerable<JavaModuleDescriptor> modules,
IEnumerable<JavaClassDuplicate> duplicateClasses,
IEnumerable<JavaSplitPackage> splitPackages)
{
Segments = segments
.Where(static segment => segment is not null)
.OrderBy(static segment => segment.Order)
.ThenBy(static segment => segment.Identifier, StringComparer.Ordinal)
.ToImmutableArray();
Modules = modules
.Where(static module => module is not null)
.OrderBy(static module => module.Name, StringComparer.Ordinal)
.ThenBy(static module => module.Source, StringComparer.Ordinal)
.ToImmutableArray();
DuplicateClasses = duplicateClasses
.Where(static duplicate => duplicate is not null)
.OrderBy(static duplicate => duplicate.ClassName, StringComparer.Ordinal)
.ToImmutableArray();
SplitPackages = splitPackages
.Where(static split => split is not null)
.OrderBy(static split => split.PackageName, StringComparer.Ordinal)
.ToImmutableArray();
}
public ImmutableArray<JavaClassPathSegment> Segments { get; }
public ImmutableArray<JavaModuleDescriptor> Modules { get; }
public ImmutableArray<JavaClassDuplicate> DuplicateClasses { get; }
public ImmutableArray<JavaSplitPackage> SplitPackages { get; }
}
internal sealed class JavaClassPathSegment
{
public JavaClassPathSegment(
string identifier,
string displayPath,
JavaClassPathSegmentKind kind,
JavaPackagingKind packaging,
int order,
JavaModuleDescriptor? module,
ImmutableSortedSet<string> classes,
ImmutableDictionary<string, JavaPackageFingerprint> packages,
ImmutableDictionary<string, ImmutableArray<string>> serviceDefinitions,
ImmutableDictionary<string, JavaClassLocation> classLocations)
{
Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier));
DisplayPath = displayPath ?? throw new ArgumentNullException(nameof(displayPath));
Kind = kind;
Packaging = packaging;
Order = order;
Module = module;
Classes = classes;
Packages = packages ?? ImmutableDictionary<string, JavaPackageFingerprint>.Empty;
ServiceDefinitions = serviceDefinitions ?? ImmutableDictionary<string, ImmutableArray<string>>.Empty;
ClassLocations = classLocations ?? ImmutableDictionary<string, JavaClassLocation>.Empty;
}
public string Identifier { get; }
public string DisplayPath { get; }
public JavaClassPathSegmentKind Kind { get; }
public JavaPackagingKind Packaging { get; }
public int Order { get; }
public JavaModuleDescriptor? Module { get; }
public ImmutableSortedSet<string> Classes { get; }
public ImmutableDictionary<string, JavaPackageFingerprint> Packages { get; }
public ImmutableDictionary<string, ImmutableArray<string>> ServiceDefinitions { get; }
public ImmutableDictionary<string, JavaClassLocation> ClassLocations { get; }
}
internal enum JavaClassPathSegmentKind
{
Archive,
Directory,
}
internal sealed record JavaPackageFingerprint(string PackageName, int ClassCount, string Fingerprint);
internal sealed record JavaClassDuplicate(string ClassName, ImmutableArray<string> SegmentIdentifiers);
internal sealed record JavaSplitPackage(string PackageName, ImmutableArray<string> SegmentIdentifiers);

View File

@@ -1,22 +1,22 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal sealed record JavaModuleDescriptor(
string Name,
string? Version,
ushort Flags,
ImmutableArray<JavaModuleRequires> Requires,
ImmutableArray<JavaModuleExports> Exports,
ImmutableArray<JavaModuleOpens> Opens,
ImmutableArray<string> Uses,
ImmutableArray<JavaModuleProvides> Provides,
string Source);
internal sealed record JavaModuleRequires(string Name, ushort Flags, string? Version);
internal sealed record JavaModuleExports(string Package, ushort Flags, ImmutableArray<string> Targets);
internal sealed record JavaModuleOpens(string Package, ushort Flags, ImmutableArray<string> Targets);
internal sealed record JavaModuleProvides(string Service, ImmutableArray<string> Implementations);
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal sealed record JavaModuleDescriptor(
string Name,
string? Version,
ushort Flags,
ImmutableArray<JavaModuleRequires> Requires,
ImmutableArray<JavaModuleExports> Exports,
ImmutableArray<JavaModuleOpens> Opens,
ImmutableArray<string> Uses,
ImmutableArray<JavaModuleProvides> Provides,
string Source);
internal sealed record JavaModuleRequires(string Name, ushort Flags, string? Version);
internal sealed record JavaModuleExports(string Package, ushort Flags, ImmutableArray<string> Targets);
internal sealed record JavaModuleOpens(string Package, ushort Flags, ImmutableArray<string> Targets);
internal sealed record JavaModuleProvides(string Service, ImmutableArray<string> Implementations);

View File

@@ -1,367 +1,367 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal static class JavaModuleInfoParser
{
public static JavaModuleDescriptor? TryParse(Stream stream, string sourceIdentifier, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
if (!TryReadMagic(reader, out var magic) || magic != 0xCAFEBABE)
{
return null;
}
// Skip minor/major version.
_ = ReadUInt16(reader);
_ = ReadUInt16(reader);
var constantPool = ReadConstantPool(reader);
cancellationToken.ThrowIfCancellationRequested();
// access_flags, this_class, super_class
_ = ReadUInt16(reader);
_ = ReadUInt16(reader);
_ = ReadUInt16(reader);
// interfaces
var interfacesCount = ReadUInt16(reader);
SkipBytes(reader, interfacesCount * 2);
// fields
var fieldsCount = ReadUInt16(reader);
SkipMembers(reader, fieldsCount);
// methods
var methodsCount = ReadUInt16(reader);
SkipMembers(reader, methodsCount);
var attributesCount = ReadUInt16(reader);
JavaModuleDescriptor? descriptor = null;
for (var i = 0; i < attributesCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var nameIndex = ReadUInt16(reader);
var length = ReadUInt32(reader);
var attributeName = GetUtf8(constantPool, nameIndex);
if (string.Equals(attributeName, "Module", StringComparison.Ordinal))
{
descriptor = ParseModuleAttribute(reader, constantPool, sourceIdentifier);
}
else
{
SkipBytes(reader, (int)length);
}
}
return descriptor;
}
private static JavaModuleDescriptor ParseModuleAttribute(BinaryReader reader, ConstantPoolEntry[] constantPool, string sourceIdentifier)
{
var moduleNameIndex = ReadUInt16(reader);
var moduleFlags = ReadUInt16(reader);
var moduleVersionIndex = ReadUInt16(reader);
var moduleName = GetModuleName(constantPool, moduleNameIndex);
var moduleVersion = moduleVersionIndex != 0 ? GetUtf8(constantPool, moduleVersionIndex) : null;
var requiresCount = ReadUInt16(reader);
var requiresBuilder = ImmutableArray.CreateBuilder<JavaModuleRequires>(requiresCount);
for (var i = 0; i < requiresCount; i++)
{
var requiresIndex = ReadUInt16(reader);
var requiresFlags = ReadUInt16(reader);
var requiresVersionIndex = ReadUInt16(reader);
var requiresName = GetModuleName(constantPool, requiresIndex);
var requiresVersion = requiresVersionIndex != 0 ? GetUtf8(constantPool, requiresVersionIndex) : null;
requiresBuilder.Add(new JavaModuleRequires(requiresName, requiresFlags, requiresVersion));
}
var exportsCount = ReadUInt16(reader);
var exportsBuilder = ImmutableArray.CreateBuilder<JavaModuleExports>(exportsCount);
for (var i = 0; i < exportsCount; i++)
{
var exportsIndex = ReadUInt16(reader);
var exportsFlags = ReadUInt16(reader);
var exportsToCount = ReadUInt16(reader);
var targetsBuilder = ImmutableArray.CreateBuilder<string>(exportsToCount);
for (var j = 0; j < exportsToCount; j++)
{
var targetIndex = ReadUInt16(reader);
targetsBuilder.Add(GetModuleName(constantPool, targetIndex));
}
var packageName = GetPackageName(constantPool, exportsIndex);
exportsBuilder.Add(new JavaModuleExports(packageName, exportsFlags, targetsBuilder.ToImmutable()));
}
var opensCount = ReadUInt16(reader);
var opensBuilder = ImmutableArray.CreateBuilder<JavaModuleOpens>(opensCount);
for (var i = 0; i < opensCount; i++)
{
var opensIndex = ReadUInt16(reader);
var opensFlags = ReadUInt16(reader);
var opensToCount = ReadUInt16(reader);
var targetsBuilder = ImmutableArray.CreateBuilder<string>(opensToCount);
for (var j = 0; j < opensToCount; j++)
{
var targetIndex = ReadUInt16(reader);
targetsBuilder.Add(GetModuleName(constantPool, targetIndex));
}
var packageName = GetPackageName(constantPool, opensIndex);
opensBuilder.Add(new JavaModuleOpens(packageName, opensFlags, targetsBuilder.ToImmutable()));
}
var usesCount = ReadUInt16(reader);
var usesBuilder = ImmutableArray.CreateBuilder<string>(usesCount);
for (var i = 0; i < usesCount; i++)
{
var classIndex = ReadUInt16(reader);
usesBuilder.Add(GetClassName(constantPool, classIndex));
}
var providesCount = ReadUInt16(reader);
var providesBuilder = ImmutableArray.CreateBuilder<JavaModuleProvides>(providesCount);
for (var i = 0; i < providesCount; i++)
{
var serviceIndex = ReadUInt16(reader);
var providesWithCount = ReadUInt16(reader);
var implementationsBuilder = ImmutableArray.CreateBuilder<string>(providesWithCount);
for (var j = 0; j < providesWithCount; j++)
{
var implIndex = ReadUInt16(reader);
implementationsBuilder.Add(GetClassName(constantPool, implIndex));
}
var serviceName = GetClassName(constantPool, serviceIndex);
providesBuilder.Add(new JavaModuleProvides(serviceName, implementationsBuilder.ToImmutable()));
}
return new JavaModuleDescriptor(
moduleName,
moduleVersion,
moduleFlags,
requiresBuilder.ToImmutable(),
exportsBuilder.ToImmutable(),
opensBuilder.ToImmutable(),
usesBuilder.ToImmutable(),
providesBuilder.ToImmutable(),
sourceIdentifier);
}
private static ConstantPoolEntry[] ReadConstantPool(BinaryReader reader)
{
var count = ReadUInt16(reader);
var pool = new ConstantPoolEntry[count];
var index = 1;
while (index < count)
{
var tag = reader.ReadByte();
switch (tag)
{
case 1: // Utf8
{
var length = ReadUInt16(reader);
var bytes = reader.ReadBytes(length);
var value = Encoding.UTF8.GetString(bytes);
pool[index] = new Utf8Entry(value);
break;
}
case 7: // Class
{
var nameIndex = ReadUInt16(reader);
pool[index] = new ClassEntry(nameIndex);
break;
}
case 8: // String
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(2, SeekOrigin.Current);
break;
case 3: // Integer
case 4: // Float
case 9: // Fieldref
case 10: // Methodref
case 11: // InterfaceMethodref
case 12: // NameAndType
case 17: // Dynamic
case 18: // InvokeDynamic
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(4, SeekOrigin.Current);
break;
case 5: // Long
case 6: // Double
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(8, SeekOrigin.Current);
index++;
break;
case 15: // MethodHandle
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(3, SeekOrigin.Current);
break;
case 16: // MethodType
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(2, SeekOrigin.Current);
break;
case 19: // Module
{
var nameIndex = ReadUInt16(reader);
pool[index] = new ModuleEntry(nameIndex);
break;
}
case 20: // Package
{
var nameIndex = ReadUInt16(reader);
pool[index] = new PackageEntry(nameIndex);
break;
}
default:
throw new InvalidDataException($"Unsupported constant pool tag {tag}.");
}
index++;
}
return pool;
}
private static string GetUtf8(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is Utf8Entry utf8)
{
return utf8.Value;
}
return string.Empty;
}
private static string GetModuleName(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is ModuleEntry module)
{
var utf8 = GetUtf8(pool, module.NameIndex);
return NormalizeBinaryName(utf8);
}
return string.Empty;
}
private static string GetPackageName(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is PackageEntry package)
{
var utf8 = GetUtf8(pool, package.NameIndex);
return NormalizeBinaryName(utf8);
}
return string.Empty;
}
private static string GetClassName(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is ClassEntry classEntry)
{
var utf8 = GetUtf8(pool, classEntry.NameIndex);
return NormalizeBinaryName(utf8);
}
return string.Empty;
}
private static string NormalizeBinaryName(string value)
=> string.IsNullOrEmpty(value) ? string.Empty : value.Replace('/', '.');
private static bool TryReadMagic(BinaryReader reader, out uint magic)
{
if (reader.BaseStream.Length - reader.BaseStream.Position < 4)
{
magic = 0;
return false;
}
magic = ReadUInt32(reader);
return true;
}
private static void SkipMembers(BinaryReader reader, int count)
{
for (var i = 0; i < count; i++)
{
// access_flags, name_index, descriptor_index
reader.BaseStream.Seek(6, SeekOrigin.Current);
var attributesCount = ReadUInt16(reader);
SkipAttributes(reader, attributesCount);
}
}
private static void SkipAttributes(BinaryReader reader, int count)
{
for (var i = 0; i < count; i++)
{
reader.BaseStream.Seek(2, SeekOrigin.Current); // name_index
var length = ReadUInt32(reader);
SkipBytes(reader, (int)length);
}
}
private static void SkipBytes(BinaryReader reader, int count)
{
if (count <= 0)
{
return;
}
reader.BaseStream.Seek(count, SeekOrigin.Current);
}
private static ushort ReadUInt16(BinaryReader reader)
=> BinaryPrimitives.ReadUInt16BigEndian(reader.ReadBytes(sizeof(ushort)));
private static uint ReadUInt32(BinaryReader reader)
=> BinaryPrimitives.ReadUInt32BigEndian(reader.ReadBytes(sizeof(uint)));
private abstract record ConstantPoolEntry(byte Tag);
private sealed record Utf8Entry(string Value) : ConstantPoolEntry(1);
private sealed record ClassEntry(ushort NameIndex) : ConstantPoolEntry(7);
private sealed record ModuleEntry(ushort NameIndex) : ConstantPoolEntry(19);
private sealed record PackageEntry(ushort NameIndex) : ConstantPoolEntry(20);
private sealed record SimpleEntry(byte Tag) : ConstantPoolEntry(Tag);
}
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal static class JavaModuleInfoParser
{
public static JavaModuleDescriptor? TryParse(Stream stream, string sourceIdentifier, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
if (!TryReadMagic(reader, out var magic) || magic != 0xCAFEBABE)
{
return null;
}
// Skip minor/major version.
_ = ReadUInt16(reader);
_ = ReadUInt16(reader);
var constantPool = ReadConstantPool(reader);
cancellationToken.ThrowIfCancellationRequested();
// access_flags, this_class, super_class
_ = ReadUInt16(reader);
_ = ReadUInt16(reader);
_ = ReadUInt16(reader);
// interfaces
var interfacesCount = ReadUInt16(reader);
SkipBytes(reader, interfacesCount * 2);
// fields
var fieldsCount = ReadUInt16(reader);
SkipMembers(reader, fieldsCount);
// methods
var methodsCount = ReadUInt16(reader);
SkipMembers(reader, methodsCount);
var attributesCount = ReadUInt16(reader);
JavaModuleDescriptor? descriptor = null;
for (var i = 0; i < attributesCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var nameIndex = ReadUInt16(reader);
var length = ReadUInt32(reader);
var attributeName = GetUtf8(constantPool, nameIndex);
if (string.Equals(attributeName, "Module", StringComparison.Ordinal))
{
descriptor = ParseModuleAttribute(reader, constantPool, sourceIdentifier);
}
else
{
SkipBytes(reader, (int)length);
}
}
return descriptor;
}
private static JavaModuleDescriptor ParseModuleAttribute(BinaryReader reader, ConstantPoolEntry[] constantPool, string sourceIdentifier)
{
var moduleNameIndex = ReadUInt16(reader);
var moduleFlags = ReadUInt16(reader);
var moduleVersionIndex = ReadUInt16(reader);
var moduleName = GetModuleName(constantPool, moduleNameIndex);
var moduleVersion = moduleVersionIndex != 0 ? GetUtf8(constantPool, moduleVersionIndex) : null;
var requiresCount = ReadUInt16(reader);
var requiresBuilder = ImmutableArray.CreateBuilder<JavaModuleRequires>(requiresCount);
for (var i = 0; i < requiresCount; i++)
{
var requiresIndex = ReadUInt16(reader);
var requiresFlags = ReadUInt16(reader);
var requiresVersionIndex = ReadUInt16(reader);
var requiresName = GetModuleName(constantPool, requiresIndex);
var requiresVersion = requiresVersionIndex != 0 ? GetUtf8(constantPool, requiresVersionIndex) : null;
requiresBuilder.Add(new JavaModuleRequires(requiresName, requiresFlags, requiresVersion));
}
var exportsCount = ReadUInt16(reader);
var exportsBuilder = ImmutableArray.CreateBuilder<JavaModuleExports>(exportsCount);
for (var i = 0; i < exportsCount; i++)
{
var exportsIndex = ReadUInt16(reader);
var exportsFlags = ReadUInt16(reader);
var exportsToCount = ReadUInt16(reader);
var targetsBuilder = ImmutableArray.CreateBuilder<string>(exportsToCount);
for (var j = 0; j < exportsToCount; j++)
{
var targetIndex = ReadUInt16(reader);
targetsBuilder.Add(GetModuleName(constantPool, targetIndex));
}
var packageName = GetPackageName(constantPool, exportsIndex);
exportsBuilder.Add(new JavaModuleExports(packageName, exportsFlags, targetsBuilder.ToImmutable()));
}
var opensCount = ReadUInt16(reader);
var opensBuilder = ImmutableArray.CreateBuilder<JavaModuleOpens>(opensCount);
for (var i = 0; i < opensCount; i++)
{
var opensIndex = ReadUInt16(reader);
var opensFlags = ReadUInt16(reader);
var opensToCount = ReadUInt16(reader);
var targetsBuilder = ImmutableArray.CreateBuilder<string>(opensToCount);
for (var j = 0; j < opensToCount; j++)
{
var targetIndex = ReadUInt16(reader);
targetsBuilder.Add(GetModuleName(constantPool, targetIndex));
}
var packageName = GetPackageName(constantPool, opensIndex);
opensBuilder.Add(new JavaModuleOpens(packageName, opensFlags, targetsBuilder.ToImmutable()));
}
var usesCount = ReadUInt16(reader);
var usesBuilder = ImmutableArray.CreateBuilder<string>(usesCount);
for (var i = 0; i < usesCount; i++)
{
var classIndex = ReadUInt16(reader);
usesBuilder.Add(GetClassName(constantPool, classIndex));
}
var providesCount = ReadUInt16(reader);
var providesBuilder = ImmutableArray.CreateBuilder<JavaModuleProvides>(providesCount);
for (var i = 0; i < providesCount; i++)
{
var serviceIndex = ReadUInt16(reader);
var providesWithCount = ReadUInt16(reader);
var implementationsBuilder = ImmutableArray.CreateBuilder<string>(providesWithCount);
for (var j = 0; j < providesWithCount; j++)
{
var implIndex = ReadUInt16(reader);
implementationsBuilder.Add(GetClassName(constantPool, implIndex));
}
var serviceName = GetClassName(constantPool, serviceIndex);
providesBuilder.Add(new JavaModuleProvides(serviceName, implementationsBuilder.ToImmutable()));
}
return new JavaModuleDescriptor(
moduleName,
moduleVersion,
moduleFlags,
requiresBuilder.ToImmutable(),
exportsBuilder.ToImmutable(),
opensBuilder.ToImmutable(),
usesBuilder.ToImmutable(),
providesBuilder.ToImmutable(),
sourceIdentifier);
}
private static ConstantPoolEntry[] ReadConstantPool(BinaryReader reader)
{
var count = ReadUInt16(reader);
var pool = new ConstantPoolEntry[count];
var index = 1;
while (index < count)
{
var tag = reader.ReadByte();
switch (tag)
{
case 1: // Utf8
{
var length = ReadUInt16(reader);
var bytes = reader.ReadBytes(length);
var value = Encoding.UTF8.GetString(bytes);
pool[index] = new Utf8Entry(value);
break;
}
case 7: // Class
{
var nameIndex = ReadUInt16(reader);
pool[index] = new ClassEntry(nameIndex);
break;
}
case 8: // String
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(2, SeekOrigin.Current);
break;
case 3: // Integer
case 4: // Float
case 9: // Fieldref
case 10: // Methodref
case 11: // InterfaceMethodref
case 12: // NameAndType
case 17: // Dynamic
case 18: // InvokeDynamic
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(4, SeekOrigin.Current);
break;
case 5: // Long
case 6: // Double
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(8, SeekOrigin.Current);
index++;
break;
case 15: // MethodHandle
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(3, SeekOrigin.Current);
break;
case 16: // MethodType
pool[index] = new SimpleEntry(tag);
reader.BaseStream.Seek(2, SeekOrigin.Current);
break;
case 19: // Module
{
var nameIndex = ReadUInt16(reader);
pool[index] = new ModuleEntry(nameIndex);
break;
}
case 20: // Package
{
var nameIndex = ReadUInt16(reader);
pool[index] = new PackageEntry(nameIndex);
break;
}
default:
throw new InvalidDataException($"Unsupported constant pool tag {tag}.");
}
index++;
}
return pool;
}
private static string GetUtf8(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is Utf8Entry utf8)
{
return utf8.Value;
}
return string.Empty;
}
private static string GetModuleName(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is ModuleEntry module)
{
var utf8 = GetUtf8(pool, module.NameIndex);
return NormalizeBinaryName(utf8);
}
return string.Empty;
}
private static string GetPackageName(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is PackageEntry package)
{
var utf8 = GetUtf8(pool, package.NameIndex);
return NormalizeBinaryName(utf8);
}
return string.Empty;
}
private static string GetClassName(ConstantPoolEntry[] pool, int index)
{
if (index <= 0 || index >= pool.Length)
{
return string.Empty;
}
if (pool[index] is ClassEntry classEntry)
{
var utf8 = GetUtf8(pool, classEntry.NameIndex);
return NormalizeBinaryName(utf8);
}
return string.Empty;
}
private static string NormalizeBinaryName(string value)
=> string.IsNullOrEmpty(value) ? string.Empty : value.Replace('/', '.');
private static bool TryReadMagic(BinaryReader reader, out uint magic)
{
if (reader.BaseStream.Length - reader.BaseStream.Position < 4)
{
magic = 0;
return false;
}
magic = ReadUInt32(reader);
return true;
}
private static void SkipMembers(BinaryReader reader, int count)
{
for (var i = 0; i < count; i++)
{
// access_flags, name_index, descriptor_index
reader.BaseStream.Seek(6, SeekOrigin.Current);
var attributesCount = ReadUInt16(reader);
SkipAttributes(reader, attributesCount);
}
}
private static void SkipAttributes(BinaryReader reader, int count)
{
for (var i = 0; i < count; i++)
{
reader.BaseStream.Seek(2, SeekOrigin.Current); // name_index
var length = ReadUInt32(reader);
SkipBytes(reader, (int)length);
}
}
private static void SkipBytes(BinaryReader reader, int count)
{
if (count <= 0)
{
return;
}
reader.BaseStream.Seek(count, SeekOrigin.Current);
}
private static ushort ReadUInt16(BinaryReader reader)
=> BinaryPrimitives.ReadUInt16BigEndian(reader.ReadBytes(sizeof(ushort)));
private static uint ReadUInt32(BinaryReader reader)
=> BinaryPrimitives.ReadUInt32BigEndian(reader.ReadBytes(sizeof(uint)));
private abstract record ConstantPoolEntry(byte Tag);
private sealed record Utf8Entry(string Value) : ConstantPoolEntry(1);
private sealed record ClassEntry(ushort NameIndex) : ConstantPoolEntry(7);
private sealed record ModuleEntry(ushort NameIndex) : ConstantPoolEntry(19);
private sealed record PackageEntry(ushort NameIndex) : ConstantPoolEntry(20);
private sealed record SimpleEntry(byte Tag) : ConstantPoolEntry(Tag);
}

View File

@@ -1,264 +1,264 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed class JavaArchive
{
private readonly ImmutableDictionary<string, JavaArchiveEntry> _entryMap;
private JavaArchive(
string absolutePath,
string relativePath,
JavaPackagingKind packaging,
ImmutableArray<string> layeredDirectories,
bool isMultiRelease,
bool hasModuleInfo,
ImmutableArray<JavaArchiveEntry> entries)
{
AbsolutePath = absolutePath ?? throw new ArgumentNullException(nameof(absolutePath));
RelativePath = relativePath ?? throw new ArgumentNullException(nameof(relativePath));
Packaging = packaging;
LayeredDirectories = layeredDirectories;
IsMultiRelease = isMultiRelease;
HasModuleInfo = hasModuleInfo;
Entries = entries;
_entryMap = entries.ToImmutableDictionary(static entry => entry.EffectivePath, static entry => entry, StringComparer.Ordinal);
}
public string AbsolutePath { get; }
public string RelativePath { get; }
public JavaPackagingKind Packaging { get; }
public ImmutableArray<string> LayeredDirectories { get; }
public bool IsMultiRelease { get; }
public bool HasModuleInfo { get; }
public ImmutableArray<JavaArchiveEntry> Entries { get; }
public static JavaArchive Load(string absolutePath, string relativePath)
{
ArgumentException.ThrowIfNullOrEmpty(absolutePath);
ArgumentException.ThrowIfNullOrEmpty(relativePath);
using var fileStream = new FileStream(absolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var zip = new ZipArchive(fileStream, ZipArchiveMode.Read, leaveOpen: false);
var layeredDirectories = new HashSet<string>(StringComparer.Ordinal);
var candidates = new Dictionary<string, List<EntryCandidate>>(StringComparer.Ordinal);
var isMultiRelease = false;
var hasModuleInfo = false;
var hasBootInf = false;
var hasWebInf = false;
foreach (var entry in zip.Entries)
{
var normalized = JavaZipEntryUtilities.NormalizeEntryName(entry.FullName);
if (string.IsNullOrEmpty(normalized) || normalized.EndsWith('/'))
{
continue;
}
if (normalized.StartsWith("BOOT-INF/", StringComparison.OrdinalIgnoreCase))
{
hasBootInf = true;
layeredDirectories.Add("BOOT-INF");
}
if (normalized.StartsWith("WEB-INF/", StringComparison.OrdinalIgnoreCase))
{
hasWebInf = true;
layeredDirectories.Add("WEB-INF");
}
var version = 0;
var effectivePath = normalized;
if (JavaZipEntryUtilities.TryParseMultiReleasePath(normalized, out var candidatePath, out var candidateVersion))
{
effectivePath = candidatePath;
version = candidateVersion;
isMultiRelease = true;
}
if (string.IsNullOrEmpty(effectivePath))
{
continue;
}
if (string.Equals(effectivePath, "module-info.class", StringComparison.Ordinal))
{
hasModuleInfo = true;
}
var candidate = new EntryCandidate(
effectivePath,
entry.FullName,
version,
entry.Length,
entry.LastWriteTime.ToUniversalTime());
if (!candidates.TryGetValue(effectivePath, out var bucket))
{
bucket = new List<EntryCandidate>();
candidates[effectivePath] = bucket;
}
bucket.Add(candidate);
}
var entries = new List<JavaArchiveEntry>(candidates.Count);
foreach (var pair in candidates)
{
var selected = pair.Value
.OrderByDescending(static candidate => candidate.Version)
.ThenBy(static candidate => candidate.OriginalPath, StringComparer.Ordinal)
.First();
entries.Add(new JavaArchiveEntry(
pair.Key,
selected.OriginalPath,
selected.Version,
selected.Length,
selected.LastWriteTime));
}
entries.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.EffectivePath, right.EffectivePath));
var packaging = DeterminePackaging(absolutePath, hasBootInf, hasWebInf);
return new JavaArchive(
absolutePath,
relativePath,
packaging,
layeredDirectories
.OrderBy(static directory => directory, StringComparer.Ordinal)
.ToImmutableArray(),
isMultiRelease,
hasModuleInfo,
entries.ToImmutableArray());
}
public bool TryGetEntry(string effectivePath, out JavaArchiveEntry entry)
{
ArgumentNullException.ThrowIfNull(effectivePath);
return _entryMap.TryGetValue(effectivePath, out entry!);
}
public Stream OpenEntry(JavaArchiveEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
var fileStream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
ZipArchive? archive = null;
Stream? entryStream = null;
try
{
archive = new ZipArchive(fileStream, ZipArchiveMode.Read, leaveOpen: false);
var zipEntry = archive.GetEntry(entry.OriginalPath);
if (zipEntry is null)
{
throw new FileNotFoundException($"Entry '{entry.OriginalPath}' not found in archive '{AbsolutePath}'.");
}
entryStream = zipEntry.Open();
return new ZipEntryStream(fileStream, archive, entryStream);
}
catch
{
entryStream?.Dispose();
archive?.Dispose();
fileStream.Dispose();
throw;
}
}
private static JavaPackagingKind DeterminePackaging(string absolutePath, bool hasBootInf, bool hasWebInf)
{
var extension = Path.GetExtension(absolutePath);
return extension switch
{
".war" => JavaPackagingKind.War,
".ear" => JavaPackagingKind.Ear,
".jmod" => JavaPackagingKind.JMod,
".jimage" => JavaPackagingKind.JImage,
".jar" => hasBootInf ? JavaPackagingKind.SpringBootFatJar : JavaPackagingKind.Jar,
_ => JavaPackagingKind.Unknown,
};
}
private sealed record EntryCandidate(
string EffectivePath,
string OriginalPath,
int Version,
long Length,
DateTimeOffset LastWriteTime);
private sealed class ZipEntryStream : Stream
{
private readonly Stream _fileStream;
private readonly ZipArchive _archive;
private readonly Stream _entryStream;
public ZipEntryStream(Stream fileStream, ZipArchive archive, Stream entryStream)
{
_fileStream = fileStream;
_archive = archive;
_entryStream = entryStream;
}
public override bool CanRead => _entryStream.CanRead;
public override bool CanSeek => _entryStream.CanSeek;
public override bool CanWrite => _entryStream.CanWrite;
public override long Length => _entryStream.Length;
public override long Position
{
get => _entryStream.Position;
set => _entryStream.Position = value;
}
public override void Flush() => _entryStream.Flush();
public override int Read(byte[] buffer, int offset, int count)
=> _entryStream.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _entryStream.ReadAsync(buffer, cancellationToken);
public override long Seek(long offset, SeekOrigin origin)
=> _entryStream.Seek(offset, origin);
public override void SetLength(long value)
=> _entryStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count)
=> _entryStream.Write(buffer, offset, count);
public override ValueTask DisposeAsync()
{
_entryStream.Dispose();
_archive.Dispose();
_fileStream.Dispose();
return ValueTask.CompletedTask;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_entryStream.Dispose();
_archive.Dispose();
_fileStream.Dispose();
}
base.Dispose(disposing);
}
}
}
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed class JavaArchive
{
private readonly ImmutableDictionary<string, JavaArchiveEntry> _entryMap;
private JavaArchive(
string absolutePath,
string relativePath,
JavaPackagingKind packaging,
ImmutableArray<string> layeredDirectories,
bool isMultiRelease,
bool hasModuleInfo,
ImmutableArray<JavaArchiveEntry> entries)
{
AbsolutePath = absolutePath ?? throw new ArgumentNullException(nameof(absolutePath));
RelativePath = relativePath ?? throw new ArgumentNullException(nameof(relativePath));
Packaging = packaging;
LayeredDirectories = layeredDirectories;
IsMultiRelease = isMultiRelease;
HasModuleInfo = hasModuleInfo;
Entries = entries;
_entryMap = entries.ToImmutableDictionary(static entry => entry.EffectivePath, static entry => entry, StringComparer.Ordinal);
}
public string AbsolutePath { get; }
public string RelativePath { get; }
public JavaPackagingKind Packaging { get; }
public ImmutableArray<string> LayeredDirectories { get; }
public bool IsMultiRelease { get; }
public bool HasModuleInfo { get; }
public ImmutableArray<JavaArchiveEntry> Entries { get; }
public static JavaArchive Load(string absolutePath, string relativePath)
{
ArgumentException.ThrowIfNullOrEmpty(absolutePath);
ArgumentException.ThrowIfNullOrEmpty(relativePath);
using var fileStream = new FileStream(absolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var zip = new ZipArchive(fileStream, ZipArchiveMode.Read, leaveOpen: false);
var layeredDirectories = new HashSet<string>(StringComparer.Ordinal);
var candidates = new Dictionary<string, List<EntryCandidate>>(StringComparer.Ordinal);
var isMultiRelease = false;
var hasModuleInfo = false;
var hasBootInf = false;
var hasWebInf = false;
foreach (var entry in zip.Entries)
{
var normalized = JavaZipEntryUtilities.NormalizeEntryName(entry.FullName);
if (string.IsNullOrEmpty(normalized) || normalized.EndsWith('/'))
{
continue;
}
if (normalized.StartsWith("BOOT-INF/", StringComparison.OrdinalIgnoreCase))
{
hasBootInf = true;
layeredDirectories.Add("BOOT-INF");
}
if (normalized.StartsWith("WEB-INF/", StringComparison.OrdinalIgnoreCase))
{
hasWebInf = true;
layeredDirectories.Add("WEB-INF");
}
var version = 0;
var effectivePath = normalized;
if (JavaZipEntryUtilities.TryParseMultiReleasePath(normalized, out var candidatePath, out var candidateVersion))
{
effectivePath = candidatePath;
version = candidateVersion;
isMultiRelease = true;
}
if (string.IsNullOrEmpty(effectivePath))
{
continue;
}
if (string.Equals(effectivePath, "module-info.class", StringComparison.Ordinal))
{
hasModuleInfo = true;
}
var candidate = new EntryCandidate(
effectivePath,
entry.FullName,
version,
entry.Length,
entry.LastWriteTime.ToUniversalTime());
if (!candidates.TryGetValue(effectivePath, out var bucket))
{
bucket = new List<EntryCandidate>();
candidates[effectivePath] = bucket;
}
bucket.Add(candidate);
}
var entries = new List<JavaArchiveEntry>(candidates.Count);
foreach (var pair in candidates)
{
var selected = pair.Value
.OrderByDescending(static candidate => candidate.Version)
.ThenBy(static candidate => candidate.OriginalPath, StringComparer.Ordinal)
.First();
entries.Add(new JavaArchiveEntry(
pair.Key,
selected.OriginalPath,
selected.Version,
selected.Length,
selected.LastWriteTime));
}
entries.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.EffectivePath, right.EffectivePath));
var packaging = DeterminePackaging(absolutePath, hasBootInf, hasWebInf);
return new JavaArchive(
absolutePath,
relativePath,
packaging,
layeredDirectories
.OrderBy(static directory => directory, StringComparer.Ordinal)
.ToImmutableArray(),
isMultiRelease,
hasModuleInfo,
entries.ToImmutableArray());
}
public bool TryGetEntry(string effectivePath, out JavaArchiveEntry entry)
{
ArgumentNullException.ThrowIfNull(effectivePath);
return _entryMap.TryGetValue(effectivePath, out entry!);
}
public Stream OpenEntry(JavaArchiveEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
var fileStream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
ZipArchive? archive = null;
Stream? entryStream = null;
try
{
archive = new ZipArchive(fileStream, ZipArchiveMode.Read, leaveOpen: false);
var zipEntry = archive.GetEntry(entry.OriginalPath);
if (zipEntry is null)
{
throw new FileNotFoundException($"Entry '{entry.OriginalPath}' not found in archive '{AbsolutePath}'.");
}
entryStream = zipEntry.Open();
return new ZipEntryStream(fileStream, archive, entryStream);
}
catch
{
entryStream?.Dispose();
archive?.Dispose();
fileStream.Dispose();
throw;
}
}
private static JavaPackagingKind DeterminePackaging(string absolutePath, bool hasBootInf, bool hasWebInf)
{
var extension = Path.GetExtension(absolutePath);
return extension switch
{
".war" => JavaPackagingKind.War,
".ear" => JavaPackagingKind.Ear,
".jmod" => JavaPackagingKind.JMod,
".jimage" => JavaPackagingKind.JImage,
".jar" => hasBootInf ? JavaPackagingKind.SpringBootFatJar : JavaPackagingKind.Jar,
_ => JavaPackagingKind.Unknown,
};
}
private sealed record EntryCandidate(
string EffectivePath,
string OriginalPath,
int Version,
long Length,
DateTimeOffset LastWriteTime);
private sealed class ZipEntryStream : Stream
{
private readonly Stream _fileStream;
private readonly ZipArchive _archive;
private readonly Stream _entryStream;
public ZipEntryStream(Stream fileStream, ZipArchive archive, Stream entryStream)
{
_fileStream = fileStream;
_archive = archive;
_entryStream = entryStream;
}
public override bool CanRead => _entryStream.CanRead;
public override bool CanSeek => _entryStream.CanSeek;
public override bool CanWrite => _entryStream.CanWrite;
public override long Length => _entryStream.Length;
public override long Position
{
get => _entryStream.Position;
set => _entryStream.Position = value;
}
public override void Flush() => _entryStream.Flush();
public override int Read(byte[] buffer, int offset, int count)
=> _entryStream.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _entryStream.ReadAsync(buffer, cancellationToken);
public override long Seek(long offset, SeekOrigin origin)
=> _entryStream.Seek(offset, origin);
public override void SetLength(long value)
=> _entryStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count)
=> _entryStream.Write(buffer, offset, count);
public override ValueTask DisposeAsync()
{
_entryStream.Dispose();
_archive.Dispose();
_fileStream.Dispose();
return ValueTask.CompletedTask;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_entryStream.Dispose();
_archive.Dispose();
_fileStream.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -1,8 +1,8 @@
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed record JavaArchiveEntry(
string EffectivePath,
string OriginalPath,
int Version,
long Length,
DateTimeOffset LastWriteTime);
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed record JavaArchiveEntry(
string EffectivePath,
string OriginalPath,
int Version,
long Length,
DateTimeOffset LastWriteTime);

View File

@@ -1,12 +1,12 @@
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal enum JavaPackagingKind
{
Jar,
SpringBootFatJar,
War,
Ear,
JMod,
JImage,
Unknown,
}
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal enum JavaPackagingKind
{
Jar,
SpringBootFatJar,
War,
Ear,
JMod,
JImage,
Unknown,
}

View File

@@ -1,68 +1,68 @@
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal static class JavaReleaseFileParser
{
public static JavaReleaseMetadata Parse(string filePath)
{
ArgumentException.ThrowIfNullOrEmpty(filePath);
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? line;
while ((line = reader.ReadLine()) is not null)
{
line = line.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
if (key.Length == 0)
{
continue;
}
var value = line[(separatorIndex + 1)..].Trim();
map[key] = TrimQuotes(value);
}
map.TryGetValue("JAVA_VERSION", out var version);
if (string.IsNullOrWhiteSpace(version) && map.TryGetValue("JAVA_RUNTIME_VERSION", out var runtimeVersion))
{
version = runtimeVersion;
}
map.TryGetValue("IMPLEMENTOR", out var vendor);
if (string.IsNullOrWhiteSpace(vendor) && map.TryGetValue("IMPLEMENTOR_VERSION", out var implementorVersion))
{
vendor = implementorVersion;
}
return new JavaReleaseMetadata(
version?.Trim() ?? string.Empty,
vendor?.Trim() ?? string.Empty);
}
private static string TrimQuotes(string value)
{
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
return value[1..^1];
}
return value;
}
public sealed record JavaReleaseMetadata(string Version, string Vendor);
}
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal static class JavaReleaseFileParser
{
public static JavaReleaseMetadata Parse(string filePath)
{
ArgumentException.ThrowIfNullOrEmpty(filePath);
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? line;
while ((line = reader.ReadLine()) is not null)
{
line = line.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
if (key.Length == 0)
{
continue;
}
var value = line[(separatorIndex + 1)..].Trim();
map[key] = TrimQuotes(value);
}
map.TryGetValue("JAVA_VERSION", out var version);
if (string.IsNullOrWhiteSpace(version) && map.TryGetValue("JAVA_RUNTIME_VERSION", out var runtimeVersion))
{
version = runtimeVersion;
}
map.TryGetValue("IMPLEMENTOR", out var vendor);
if (string.IsNullOrWhiteSpace(vendor) && map.TryGetValue("IMPLEMENTOR_VERSION", out var implementorVersion))
{
vendor = implementorVersion;
}
return new JavaReleaseMetadata(
version?.Trim() ?? string.Empty,
vendor?.Trim() ?? string.Empty);
}
private static string TrimQuotes(string value)
{
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
return value[1..^1];
}
return value;
}
public sealed record JavaReleaseMetadata(string Version, string Vendor);
}

View File

@@ -1,7 +1,7 @@
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed record JavaRuntimeImage(
string AbsolutePath,
string RelativePath,
string JavaVersion,
string Vendor);
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed record JavaRuntimeImage(
string AbsolutePath,
string RelativePath,
string JavaVersion,
string Vendor);

View File

@@ -1,28 +1,28 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed class JavaWorkspace
{
public JavaWorkspace(
IEnumerable<JavaArchive> archives,
IEnumerable<JavaRuntimeImage> runtimeImages)
{
ArgumentNullException.ThrowIfNull(archives);
ArgumentNullException.ThrowIfNull(runtimeImages);
Archives = archives
.Where(static archive => archive is not null)
.OrderBy(static archive => archive.RelativePath, StringComparer.Ordinal)
.ToImmutableArray();
RuntimeImages = runtimeImages
.Where(static image => image is not null)
.OrderBy(static image => image.RelativePath, StringComparer.Ordinal)
.ToImmutableArray();
}
public ImmutableArray<JavaArchive> Archives { get; }
public ImmutableArray<JavaRuntimeImage> RuntimeImages { get; }
}
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal sealed class JavaWorkspace
{
public JavaWorkspace(
IEnumerable<JavaArchive> archives,
IEnumerable<JavaRuntimeImage> runtimeImages)
{
ArgumentNullException.ThrowIfNull(archives);
ArgumentNullException.ThrowIfNull(runtimeImages);
Archives = archives
.Where(static archive => archive is not null)
.OrderBy(static archive => archive.RelativePath, StringComparer.Ordinal)
.ToImmutableArray();
RuntimeImages = runtimeImages
.Where(static image => image is not null)
.OrderBy(static image => image.RelativePath, StringComparer.Ordinal)
.ToImmutableArray();
}
public ImmutableArray<JavaArchive> Archives { get; }
public ImmutableArray<JavaRuntimeImage> RuntimeImages { get; }
}

View File

@@ -1,101 +1,101 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal static class JavaWorkspaceNormalizer
{
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jar",
".war",
".ear",
".jmod",
".jimage",
};
private static readonly EnumerationOptions EnumerationOptions = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
ReturnSpecialDirectories = false,
};
public static JavaWorkspace Normalize(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var archives = new List<JavaArchive>();
var runtimeImages = new List<JavaRuntimeImage>();
foreach (var filePath in Directory.EnumerateFiles(context.RootPath, "*", EnumerationOptions))
{
cancellationToken.ThrowIfCancellationRequested();
if (!SupportedExtensions.Contains(Path.GetExtension(filePath)))
{
continue;
}
try
{
var relative = context.GetRelativePath(filePath);
var archive = JavaArchive.Load(filePath, relative);
archives.Add(archive);
}
catch (IOException)
{
// Corrupt archives should not abort the scan.
}
catch (InvalidDataException)
{
// Skip non-zip payloads despite the extension.
}
}
foreach (var directory in Directory.EnumerateDirectories(context.RootPath, "*", EnumerationOptions))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!LooksLikeRuntimeImage(directory))
{
continue;
}
var releasePath = Path.Combine(directory, "release");
if (!File.Exists(releasePath))
{
continue;
}
var metadata = JavaReleaseFileParser.Parse(releasePath);
runtimeImages.Add(new JavaRuntimeImage(
AbsolutePath: directory,
RelativePath: context.GetRelativePath(directory),
JavaVersion: metadata.Version,
Vendor: metadata.Vendor));
}
catch (IOException)
{
// Skip directories we cannot access.
}
}
return new JavaWorkspace(archives, runtimeImages);
}
private static bool LooksLikeRuntimeImage(string directory)
{
if (!Directory.Exists(directory))
{
return false;
}
var libModules = Path.Combine(directory, "lib", "modules");
var binJava = Path.Combine(directory, "bin", OperatingSystem.IsWindows() ? "java.exe" : "java");
return File.Exists(libModules) || File.Exists(binJava);
}
}
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal static class JavaWorkspaceNormalizer
{
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jar",
".war",
".ear",
".jmod",
".jimage",
};
private static readonly EnumerationOptions EnumerationOptions = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
ReturnSpecialDirectories = false,
};
public static JavaWorkspace Normalize(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var archives = new List<JavaArchive>();
var runtimeImages = new List<JavaRuntimeImage>();
foreach (var filePath in Directory.EnumerateFiles(context.RootPath, "*", EnumerationOptions))
{
cancellationToken.ThrowIfCancellationRequested();
if (!SupportedExtensions.Contains(Path.GetExtension(filePath)))
{
continue;
}
try
{
var relative = context.GetRelativePath(filePath);
var archive = JavaArchive.Load(filePath, relative);
archives.Add(archive);
}
catch (IOException)
{
// Corrupt archives should not abort the scan.
}
catch (InvalidDataException)
{
// Skip non-zip payloads despite the extension.
}
}
foreach (var directory in Directory.EnumerateDirectories(context.RootPath, "*", EnumerationOptions))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!LooksLikeRuntimeImage(directory))
{
continue;
}
var releasePath = Path.Combine(directory, "release");
if (!File.Exists(releasePath))
{
continue;
}
var metadata = JavaReleaseFileParser.Parse(releasePath);
runtimeImages.Add(new JavaRuntimeImage(
AbsolutePath: directory,
RelativePath: context.GetRelativePath(directory),
JavaVersion: metadata.Version,
Vendor: metadata.Vendor));
}
catch (IOException)
{
// Skip directories we cannot access.
}
}
return new JavaWorkspace(archives, runtimeImages);
}
private static bool LooksLikeRuntimeImage(string directory)
{
if (!Directory.Exists(directory))
{
return false;
}
var libModules = Path.Combine(directory, "lib", "modules");
var binJava = Path.Combine(directory, "bin", OperatingSystem.IsWindows() ? "java.exe" : "java");
return File.Exists(libModules) || File.Exists(binJava);
}
}

View File

@@ -1,52 +1,52 @@
using System.Globalization;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal static class JavaZipEntryUtilities
{
public static string NormalizeEntryName(string entryName)
{
var normalized = entryName.Replace('\\', '/');
return normalized.TrimStart('/');
}
public static bool TryParseMultiReleasePath(string normalizedPath, out string effectivePath, out int version)
{
const string Prefix = "META-INF/versions/";
if (!normalizedPath.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
{
effectivePath = normalizedPath;
version = 0;
return false;
}
var remainder = normalizedPath.AsSpan(Prefix.Length);
var separatorIndex = remainder.IndexOf('/');
if (separatorIndex <= 0)
{
effectivePath = normalizedPath;
version = 0;
return false;
}
var versionSpan = remainder[..separatorIndex];
if (!int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersion))
{
effectivePath = normalizedPath;
version = 0;
return false;
}
var relativeSpan = remainder[(separatorIndex + 1)..];
if (relativeSpan.IsEmpty)
{
effectivePath = normalizedPath;
version = 0;
return false;
}
effectivePath = relativeSpan.ToString();
version = parsedVersion;
return true;
}
}
using System.Globalization;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
internal static class JavaZipEntryUtilities
{
public static string NormalizeEntryName(string entryName)
{
var normalized = entryName.Replace('\\', '/');
return normalized.TrimStart('/');
}
public static bool TryParseMultiReleasePath(string normalizedPath, out string effectivePath, out int version)
{
const string Prefix = "META-INF/versions/";
if (!normalizedPath.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
{
effectivePath = normalizedPath;
version = 0;
return false;
}
var remainder = normalizedPath.AsSpan(Prefix.Length);
var separatorIndex = remainder.IndexOf('/');
if (separatorIndex <= 0)
{
effectivePath = normalizedPath;
version = 0;
return false;
}
var versionSpan = remainder[..separatorIndex];
if (!int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersion))
{
effectivePath = normalizedPath;
version = 0;
return false;
}
var relativeSpan = remainder[(separatorIndex + 1)..];
if (relativeSpan.IsEmpty)
{
effectivePath = normalizedPath;
version = 0;
return false;
}
effectivePath = relativeSpan.ToString();
version = parsedVersion;
return true;
}
}

View File

@@ -1,44 +1,44 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Reflection;
internal sealed record JavaReflectionAnalysis(
ImmutableArray<JavaReflectionEdge> Edges,
ImmutableArray<JavaReflectionWarning> Warnings)
{
public static readonly JavaReflectionAnalysis Empty = new(ImmutableArray<JavaReflectionEdge>.Empty, ImmutableArray<JavaReflectionWarning>.Empty);
}
internal sealed record JavaReflectionEdge(
string SourceClass,
string SegmentIdentifier,
string? TargetType,
JavaReflectionReason Reason,
JavaReflectionConfidence Confidence,
string MethodName,
string MethodDescriptor,
int InstructionOffset,
string? Details);
internal sealed record JavaReflectionWarning(
string SourceClass,
string SegmentIdentifier,
string WarningCode,
string Message,
string MethodName,
string MethodDescriptor);
internal enum JavaReflectionReason
{
ClassForName,
ClassLoaderLoadClass,
ServiceLoaderLoad,
ResourceLookup,
}
internal enum JavaReflectionConfidence
{
Low = 1,
Medium = 2,
High = 3,
}
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Reflection;
internal sealed record JavaReflectionAnalysis(
ImmutableArray<JavaReflectionEdge> Edges,
ImmutableArray<JavaReflectionWarning> Warnings)
{
public static readonly JavaReflectionAnalysis Empty = new(ImmutableArray<JavaReflectionEdge>.Empty, ImmutableArray<JavaReflectionWarning>.Empty);
}
internal sealed record JavaReflectionEdge(
string SourceClass,
string SegmentIdentifier,
string? TargetType,
JavaReflectionReason Reason,
JavaReflectionConfidence Confidence,
string MethodName,
string MethodDescriptor,
int InstructionOffset,
string? Details);
internal sealed record JavaReflectionWarning(
string SourceClass,
string SegmentIdentifier,
string WarningCode,
string Message,
string MethodName,
string MethodDescriptor);
internal enum JavaReflectionReason
{
ClassForName,
ClassLoaderLoadClass,
ServiceLoaderLoad,
ResourceLookup,
}
internal enum JavaReflectionConfidence
{
Low = 1,
Medium = 2,
High = 3,
}

View File

@@ -1,160 +1,160 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ServiceProviders;
internal static class JavaServiceProviderScanner
{
public static JavaServiceProviderAnalysis Scan(JavaClassPathAnalysis classPath, JavaSpiCatalog catalog, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(classPath);
ArgumentNullException.ThrowIfNull(catalog);
var services = new Dictionary<string, ServiceAccumulator>(StringComparer.Ordinal);
foreach (var segment in classPath.Segments.OrderBy(static s => s.Order))
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var kvp in segment.ServiceDefinitions)
{
cancellationToken.ThrowIfCancellationRequested();
if (kvp.Value.IsDefaultOrEmpty)
{
continue;
}
if (!services.TryGetValue(kvp.Key, out var accumulator))
{
accumulator = new ServiceAccumulator();
services[kvp.Key] = accumulator;
}
var providerIndex = 0;
foreach (var provider in kvp.Value)
{
var normalizedProvider = provider?.Trim();
if (string.IsNullOrEmpty(normalizedProvider))
{
providerIndex++;
continue;
}
accumulator.AddCandidate(new JavaServiceProviderCandidateRecord(
ProviderClass: normalizedProvider,
SegmentIdentifier: segment.Identifier,
SegmentOrder: segment.Order,
ProviderIndex: providerIndex++,
IsSelected: false));
}
}
}
var records = new List<JavaServiceProviderRecord>(services.Count);
foreach (var pair in services.OrderBy(static entry => entry.Key, StringComparer.Ordinal))
{
var descriptor = catalog.Resolve(pair.Key);
var accumulator = pair.Value;
var orderedCandidates = accumulator.GetOrderedCandidates();
if (orderedCandidates.Count == 0)
{
continue;
}
var selectedIndex = accumulator.DetermineSelection(orderedCandidates);
var warnings = accumulator.BuildWarnings();
var candidateArray = ImmutableArray.CreateRange(orderedCandidates.Select((candidate, index) =>
candidate with { IsSelected = index == selectedIndex }));
var warningsArray = warnings.Count == 0
? ImmutableArray<string>.Empty
: ImmutableArray.CreateRange(warnings);
records.Add(new JavaServiceProviderRecord(
ServiceId: pair.Key,
DisplayName: descriptor.DisplayName,
Category: descriptor.Category,
Candidates: candidateArray,
SelectedIndex: selectedIndex,
Warnings: warningsArray));
}
return new JavaServiceProviderAnalysis(records.ToImmutableArray());
}
private sealed class ServiceAccumulator
{
private readonly List<JavaServiceProviderCandidateRecord> _candidates = new();
private readonly Dictionary<string, HashSet<string>> _providerSources = new(StringComparer.Ordinal);
public void AddCandidate(JavaServiceProviderCandidateRecord candidate)
{
_candidates.Add(candidate);
if (!_providerSources.TryGetValue(candidate.ProviderClass, out var sources))
{
sources = new HashSet<string>(StringComparer.Ordinal);
_providerSources[candidate.ProviderClass] = sources;
}
sources.Add(candidate.SegmentIdentifier);
}
public IReadOnlyList<JavaServiceProviderCandidateRecord> GetOrderedCandidates()
=> _candidates
.OrderBy(static c => c.SegmentOrder)
.ThenBy(static c => c.ProviderIndex)
.ThenBy(static c => c.ProviderClass, StringComparer.Ordinal)
.ToList();
public int DetermineSelection(IReadOnlyList<JavaServiceProviderCandidateRecord> orderedCandidates)
=> orderedCandidates.Count == 0 ? -1 : 0;
public List<string> BuildWarnings()
{
var warnings = new List<string>();
foreach (var pair in _providerSources.OrderBy(static entry => entry.Key, StringComparer.Ordinal))
{
if (pair.Value.Count <= 1)
{
continue;
}
var locations = pair.Value
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
warnings.Add($"duplicate-provider: {pair.Key} ({string.Join(", ", locations)})");
}
return warnings;
}
}
}
internal sealed record JavaServiceProviderAnalysis(ImmutableArray<JavaServiceProviderRecord> Services)
{
public static readonly JavaServiceProviderAnalysis Empty = new(ImmutableArray<JavaServiceProviderRecord>.Empty);
}
internal sealed record JavaServiceProviderRecord(
string ServiceId,
string DisplayName,
string Category,
ImmutableArray<JavaServiceProviderCandidateRecord> Candidates,
int SelectedIndex,
ImmutableArray<string> Warnings);
internal sealed record JavaServiceProviderCandidateRecord(
string ProviderClass,
string SegmentIdentifier,
int SegmentOrder,
int ProviderIndex,
bool IsSelected);
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ServiceProviders;
internal static class JavaServiceProviderScanner
{
public static JavaServiceProviderAnalysis Scan(JavaClassPathAnalysis classPath, JavaSpiCatalog catalog, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(classPath);
ArgumentNullException.ThrowIfNull(catalog);
var services = new Dictionary<string, ServiceAccumulator>(StringComparer.Ordinal);
foreach (var segment in classPath.Segments.OrderBy(static s => s.Order))
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var kvp in segment.ServiceDefinitions)
{
cancellationToken.ThrowIfCancellationRequested();
if (kvp.Value.IsDefaultOrEmpty)
{
continue;
}
if (!services.TryGetValue(kvp.Key, out var accumulator))
{
accumulator = new ServiceAccumulator();
services[kvp.Key] = accumulator;
}
var providerIndex = 0;
foreach (var provider in kvp.Value)
{
var normalizedProvider = provider?.Trim();
if (string.IsNullOrEmpty(normalizedProvider))
{
providerIndex++;
continue;
}
accumulator.AddCandidate(new JavaServiceProviderCandidateRecord(
ProviderClass: normalizedProvider,
SegmentIdentifier: segment.Identifier,
SegmentOrder: segment.Order,
ProviderIndex: providerIndex++,
IsSelected: false));
}
}
}
var records = new List<JavaServiceProviderRecord>(services.Count);
foreach (var pair in services.OrderBy(static entry => entry.Key, StringComparer.Ordinal))
{
var descriptor = catalog.Resolve(pair.Key);
var accumulator = pair.Value;
var orderedCandidates = accumulator.GetOrderedCandidates();
if (orderedCandidates.Count == 0)
{
continue;
}
var selectedIndex = accumulator.DetermineSelection(orderedCandidates);
var warnings = accumulator.BuildWarnings();
var candidateArray = ImmutableArray.CreateRange(orderedCandidates.Select((candidate, index) =>
candidate with { IsSelected = index == selectedIndex }));
var warningsArray = warnings.Count == 0
? ImmutableArray<string>.Empty
: ImmutableArray.CreateRange(warnings);
records.Add(new JavaServiceProviderRecord(
ServiceId: pair.Key,
DisplayName: descriptor.DisplayName,
Category: descriptor.Category,
Candidates: candidateArray,
SelectedIndex: selectedIndex,
Warnings: warningsArray));
}
return new JavaServiceProviderAnalysis(records.ToImmutableArray());
}
private sealed class ServiceAccumulator
{
private readonly List<JavaServiceProviderCandidateRecord> _candidates = new();
private readonly Dictionary<string, HashSet<string>> _providerSources = new(StringComparer.Ordinal);
public void AddCandidate(JavaServiceProviderCandidateRecord candidate)
{
_candidates.Add(candidate);
if (!_providerSources.TryGetValue(candidate.ProviderClass, out var sources))
{
sources = new HashSet<string>(StringComparer.Ordinal);
_providerSources[candidate.ProviderClass] = sources;
}
sources.Add(candidate.SegmentIdentifier);
}
public IReadOnlyList<JavaServiceProviderCandidateRecord> GetOrderedCandidates()
=> _candidates
.OrderBy(static c => c.SegmentOrder)
.ThenBy(static c => c.ProviderIndex)
.ThenBy(static c => c.ProviderClass, StringComparer.Ordinal)
.ToList();
public int DetermineSelection(IReadOnlyList<JavaServiceProviderCandidateRecord> orderedCandidates)
=> orderedCandidates.Count == 0 ? -1 : 0;
public List<string> BuildWarnings()
{
var warnings = new List<string>();
foreach (var pair in _providerSources.OrderBy(static entry => entry.Key, StringComparer.Ordinal))
{
if (pair.Value.Count <= 1)
{
continue;
}
var locations = pair.Value
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
warnings.Add($"duplicate-provider: {pair.Key} ({string.Join(", ", locations)})");
}
return warnings;
}
}
}
internal sealed record JavaServiceProviderAnalysis(ImmutableArray<JavaServiceProviderRecord> Services)
{
public static readonly JavaServiceProviderAnalysis Empty = new(ImmutableArray<JavaServiceProviderRecord>.Empty);
}
internal sealed record JavaServiceProviderRecord(
string ServiceId,
string DisplayName,
string Category,
ImmutableArray<JavaServiceProviderCandidateRecord> Candidates,
int SelectedIndex,
ImmutableArray<string> Warnings);
internal sealed record JavaServiceProviderCandidateRecord(
string ProviderClass,
string SegmentIdentifier,
int SegmentOrder,
int ProviderIndex,
bool IsSelected);

View File

@@ -1,103 +1,103 @@
using System.Collections.Immutable;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ServiceProviders;
internal sealed class JavaSpiCatalog
{
private static readonly Lazy<JavaSpiCatalog> LazyDefault = new(LoadDefaultCore);
private readonly ImmutableDictionary<string, JavaSpiDescriptor> _descriptors;
private JavaSpiCatalog(ImmutableDictionary<string, JavaSpiDescriptor> descriptors)
{
_descriptors = descriptors;
}
public static JavaSpiCatalog Default => LazyDefault.Value;
public JavaSpiDescriptor Resolve(string serviceId)
{
if (string.IsNullOrWhiteSpace(serviceId))
{
return JavaSpiDescriptor.CreateUnknown(string.Empty);
}
var key = serviceId.Trim();
if (_descriptors.TryGetValue(key, out var descriptor))
{
return descriptor;
}
return JavaSpiDescriptor.CreateUnknown(key);
}
private static JavaSpiCatalog LoadDefaultCore()
{
var assembly = typeof(JavaSpiCatalog).GetTypeInfo().Assembly;
var resourceName = "StellaOps.Scanner.Analyzers.Lang.Java.Internal.ServiceProviders.java-spi-catalog.json";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded SPI catalog '{resourceName}' not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false);
var json = reader.ReadToEnd();
var items = JsonSerializer.Deserialize<List<JavaSpiDescriptor>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
}) ?? new List<JavaSpiDescriptor>();
var descriptors = items
.Select(Normalize)
.Where(static item => !string.IsNullOrWhiteSpace(item.ServiceId))
.ToImmutableDictionary(
static item => item.ServiceId,
static item => item,
StringComparer.Ordinal);
return new JavaSpiCatalog(descriptors);
}
private static JavaSpiDescriptor Normalize(JavaSpiDescriptor descriptor)
{
var serviceId = descriptor.ServiceId?.Trim() ?? string.Empty;
var category = string.IsNullOrWhiteSpace(descriptor.Category)
? "unknown"
: descriptor.Category.Trim().ToLowerInvariant();
var displayName = string.IsNullOrWhiteSpace(descriptor.DisplayName)
? serviceId
: descriptor.DisplayName.Trim();
return descriptor with
{
ServiceId = serviceId,
Category = category,
DisplayName = displayName,
};
}
}
internal sealed record class JavaSpiDescriptor
{
[JsonPropertyName("serviceId")]
public string ServiceId { get; init; } = string.Empty;
[JsonPropertyName("category")]
public string Category { get; init; } = "unknown";
[JsonPropertyName("displayName")]
public string DisplayName { get; init; } = string.Empty;
[JsonPropertyName("notes")]
public string? Notes { get; init; }
public static JavaSpiDescriptor CreateUnknown(string serviceId)
=> new()
{
ServiceId = serviceId,
Category = "unknown",
DisplayName = serviceId,
};
}
using System.Collections.Immutable;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ServiceProviders;
internal sealed class JavaSpiCatalog
{
private static readonly Lazy<JavaSpiCatalog> LazyDefault = new(LoadDefaultCore);
private readonly ImmutableDictionary<string, JavaSpiDescriptor> _descriptors;
private JavaSpiCatalog(ImmutableDictionary<string, JavaSpiDescriptor> descriptors)
{
_descriptors = descriptors;
}
public static JavaSpiCatalog Default => LazyDefault.Value;
public JavaSpiDescriptor Resolve(string serviceId)
{
if (string.IsNullOrWhiteSpace(serviceId))
{
return JavaSpiDescriptor.CreateUnknown(string.Empty);
}
var key = serviceId.Trim();
if (_descriptors.TryGetValue(key, out var descriptor))
{
return descriptor;
}
return JavaSpiDescriptor.CreateUnknown(key);
}
private static JavaSpiCatalog LoadDefaultCore()
{
var assembly = typeof(JavaSpiCatalog).GetTypeInfo().Assembly;
var resourceName = "StellaOps.Scanner.Analyzers.Lang.Java.Internal.ServiceProviders.java-spi-catalog.json";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded SPI catalog '{resourceName}' not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false);
var json = reader.ReadToEnd();
var items = JsonSerializer.Deserialize<List<JavaSpiDescriptor>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
}) ?? new List<JavaSpiDescriptor>();
var descriptors = items
.Select(Normalize)
.Where(static item => !string.IsNullOrWhiteSpace(item.ServiceId))
.ToImmutableDictionary(
static item => item.ServiceId,
static item => item,
StringComparer.Ordinal);
return new JavaSpiCatalog(descriptors);
}
private static JavaSpiDescriptor Normalize(JavaSpiDescriptor descriptor)
{
var serviceId = descriptor.ServiceId?.Trim() ?? string.Empty;
var category = string.IsNullOrWhiteSpace(descriptor.Category)
? "unknown"
: descriptor.Category.Trim().ToLowerInvariant();
var displayName = string.IsNullOrWhiteSpace(descriptor.DisplayName)
? serviceId
: descriptor.DisplayName.Trim();
return descriptor with
{
ServiceId = serviceId,
Category = category,
DisplayName = displayName,
};
}
}
internal sealed record class JavaSpiDescriptor
{
[JsonPropertyName("serviceId")]
public string ServiceId { get; init; } = string.Empty;
[JsonPropertyName("category")]
public string Category { get; init; } = "unknown";
[JsonPropertyName("displayName")]
public string DisplayName { get; init; } = string.Empty;
[JsonPropertyName("notes")]
public string? Notes { get; init; }
public static JavaSpiDescriptor CreateUnknown(string serviceId)
=> new()
{
ServiceId = serviceId,
Category = "unknown",
DisplayName = serviceId,
};
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Lang.Java.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Lang.Java.Tests")]

View File

@@ -1,31 +1,31 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal static class NodeAnalyzerMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.Analyzers.Lang.Node", "1.0.0");
private static readonly Counter<long> LifecycleScriptsCounter = Meter.CreateCounter<long>(
"scanner_analyzer_node_scripts_total",
unit: "scripts",
description: "Counts Node.js install lifecycle scripts discovered by the language analyzer.");
public static void RecordLifecycleScript(string scriptName)
{
var normalized = Normalize(scriptName);
LifecycleScriptsCounter.Add(
1,
new KeyValuePair<string, object?>("script", normalized));
}
private static string Normalize(string? scriptName)
{
if (string.IsNullOrWhiteSpace(scriptName))
{
return "unknown";
}
return scriptName.Trim().ToLowerInvariant();
}
}
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal static class NodeAnalyzerMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.Analyzers.Lang.Node", "1.0.0");
private static readonly Counter<long> LifecycleScriptsCounter = Meter.CreateCounter<long>(
"scanner_analyzer_node_scripts_total",
unit: "scripts",
description: "Counts Node.js install lifecycle scripts discovered by the language analyzer.");
public static void RecordLifecycleScript(string scriptName)
{
var normalized = Normalize(scriptName);
LifecycleScriptsCounter.Add(
1,
new KeyValuePair<string, object?>("script", normalized));
}
private static string Normalize(string? scriptName)
{
if (string.IsNullOrWhiteSpace(scriptName))
{
return "unknown";
}
return scriptName.Trim().ToLowerInvariant();
}
}

View File

@@ -1,37 +1,37 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal sealed record NodeLifecycleScript
{
public NodeLifecycleScript(string name, string command)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(command);
Name = name.Trim();
Command = command.Trim();
Sha256 = ComputeSha256(Command);
}
public string Name { get; }
public string Command { get; }
public string Sha256 { get; }
[SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "SHA256 is required for deterministic evidence hashing.")]
private static string ComputeSha256(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal sealed record NodeLifecycleScript
{
public NodeLifecycleScript(string name, string command)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(command);
Name = name.Trim();
Command = command.Trim();
Sha256 = ComputeSha256(Command);
}
public string Name { get; }
public string Command { get; }
public string Sha256 { get; }
[SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "SHA256 is required for deterministic evidence hashing.")]
private static string ComputeSha256(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,278 +1,278 @@
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal sealed class NodeWorkspaceIndex
{
private readonly string _rootPath;
private readonly HashSet<string> _workspacePaths;
private readonly Dictionary<string, string> _workspaceByName;
private NodeWorkspaceIndex(string rootPath, HashSet<string> workspacePaths, Dictionary<string, string> workspaceByName)
{
_rootPath = rootPath;
_workspacePaths = workspacePaths;
_workspaceByName = workspaceByName;
}
public static NodeWorkspaceIndex Create(string rootPath)
{
var normalizedRoot = Path.GetFullPath(rootPath);
var workspacePaths = new HashSet<string>(StringComparer.Ordinal);
var workspaceByName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var packageJsonPath = Path.Combine(normalizedRoot, "package.json");
if (!File.Exists(packageJsonPath))
{
return new NodeWorkspaceIndex(normalizedRoot, workspacePaths, workspaceByName);
}
try
{
using var stream = File.OpenRead(packageJsonPath);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
if (!root.TryGetProperty("workspaces", out var workspacesElement))
{
return new NodeWorkspaceIndex(normalizedRoot, workspacePaths, workspaceByName);
}
var patterns = ExtractPatterns(workspacesElement);
foreach (var pattern in patterns)
{
foreach (var workspacePath in ExpandPattern(normalizedRoot, pattern))
{
if (string.IsNullOrWhiteSpace(workspacePath))
{
continue;
}
workspacePaths.Add(workspacePath);
var packagePath = Path.Combine(normalizedRoot, workspacePath.Replace('/', Path.DirectorySeparatorChar), "package.json");
if (!File.Exists(packagePath))
{
continue;
}
try
{
using var workspaceStream = File.OpenRead(packagePath);
using var workspaceDoc = JsonDocument.Parse(workspaceStream);
if (workspaceDoc.RootElement.TryGetProperty("name", out var nameElement))
{
var name = nameElement.GetString();
if (!string.IsNullOrWhiteSpace(name))
{
workspaceByName[name] = workspacePath!;
}
}
}
catch (IOException)
{
// Ignore unreadable workspace package definitions.
}
catch (JsonException)
{
// Ignore malformed workspace package definitions.
}
}
}
}
catch (IOException)
{
// If the root package.json is unreadable we treat as no workspaces.
}
catch (JsonException)
{
// Malformed root package.json: treat as no workspaces.
}
return new NodeWorkspaceIndex(normalizedRoot, workspacePaths, workspaceByName);
}
public IEnumerable<string> GetMembers()
=> _workspacePaths.OrderBy(static path => path, StringComparer.Ordinal);
public bool TryGetMember(string relativePath, out string normalizedPath)
{
if (string.IsNullOrEmpty(relativePath))
{
normalizedPath = string.Empty;
return false;
}
var normalized = NormalizeRelative(relativePath);
if (_workspacePaths.Contains(normalized))
{
normalizedPath = normalized;
return true;
}
normalizedPath = string.Empty;
return false;
}
public bool TryGetWorkspacePathByName(string packageName, out string? relativePath)
=> _workspaceByName.TryGetValue(packageName, out relativePath);
public IReadOnlyList<string> ResolveWorkspaceTargets(string relativeDirectory, JsonElement? dependencies)
{
if (dependencies is null || dependencies.Value.ValueKind != JsonValueKind.Object)
{
return Array.Empty<string>();
}
var result = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in dependencies.Value.EnumerateObject())
{
var value = property.Value;
if (value.ValueKind != JsonValueKind.String)
{
continue;
}
var targetSpec = value.GetString();
if (string.IsNullOrWhiteSpace(targetSpec))
{
continue;
}
const string workspacePrefix = "workspace:";
if (!targetSpec.StartsWith(workspacePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var descriptor = targetSpec[workspacePrefix.Length..].Trim();
if (string.IsNullOrEmpty(descriptor) || descriptor is "*" or "^")
{
if (_workspaceByName.TryGetValue(property.Name, out var workspaceByName))
{
result.Add(workspaceByName);
}
continue;
}
if (TryResolveWorkspaceTarget(relativeDirectory, descriptor, out var resolved))
{
result.Add(resolved);
}
}
if (result.Count == 0)
{
return Array.Empty<string>();
}
return result.OrderBy(static x => x, StringComparer.Ordinal).ToArray();
}
public bool TryResolveWorkspaceTarget(string relativeDirectory, string descriptor, out string normalized)
{
normalized = string.Empty;
var baseDirectory = string.IsNullOrEmpty(relativeDirectory) ? string.Empty : relativeDirectory;
var baseAbsolute = Path.GetFullPath(Path.Combine(_rootPath, baseDirectory));
var candidate = Path.GetFullPath(Path.Combine(baseAbsolute, descriptor.Replace('/', Path.DirectorySeparatorChar)));
if (!IsUnderRoot(_rootPath, candidate))
{
return false;
}
var relative = NormalizeRelative(Path.GetRelativePath(_rootPath, candidate));
if (_workspacePaths.Contains(relative))
{
normalized = relative;
return true;
}
return false;
}
private static IEnumerable<string> ExtractPatterns(JsonElement workspacesElement)
{
if (workspacesElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in workspacesElement.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
yield return value.Trim();
}
}
}
}
else if (workspacesElement.ValueKind == JsonValueKind.Object)
{
if (workspacesElement.TryGetProperty("packages", out var packagesElement) && packagesElement.ValueKind == JsonValueKind.Array)
{
foreach (var pattern in ExtractPatterns(packagesElement))
{
yield return pattern;
}
}
}
}
private static IEnumerable<string> ExpandPattern(string rootPath, string pattern)
{
var cleanedPattern = pattern.Replace('\\', '/').Trim();
if (cleanedPattern.EndsWith("/*", StringComparison.Ordinal))
{
var baseSegment = cleanedPattern[..^2];
var baseAbsolute = CombineAndNormalize(rootPath, baseSegment);
if (baseAbsolute is null || !Directory.Exists(baseAbsolute))
{
yield break;
}
foreach (var directory in Directory.EnumerateDirectories(baseAbsolute))
{
var normalized = NormalizeRelative(Path.GetRelativePath(rootPath, directory));
yield return normalized;
}
}
else
{
var absolute = CombineAndNormalize(rootPath, cleanedPattern);
if (absolute is null || !Directory.Exists(absolute))
{
yield break;
}
var normalized = NormalizeRelative(Path.GetRelativePath(rootPath, absolute));
yield return normalized;
}
}
private static string? CombineAndNormalize(string rootPath, string relative)
{
var candidate = Path.GetFullPath(Path.Combine(rootPath, relative.Replace('/', Path.DirectorySeparatorChar)));
return IsUnderRoot(rootPath, candidate) ? candidate : null;
}
private static string NormalizeRelative(string relativePath)
{
if (string.IsNullOrEmpty(relativePath) || relativePath == ".")
{
return string.Empty;
}
var normalized = relativePath.Replace('\\', '/');
normalized = normalized.TrimStart('.', '/');
return normalized;
}
private static bool IsUnderRoot(string rootPath, string absolutePath)
{
if (OperatingSystem.IsWindows())
{
return absolutePath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase);
}
return absolutePath.StartsWith(rootPath, StringComparison.Ordinal);
}
}
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal sealed class NodeWorkspaceIndex
{
private readonly string _rootPath;
private readonly HashSet<string> _workspacePaths;
private readonly Dictionary<string, string> _workspaceByName;
private NodeWorkspaceIndex(string rootPath, HashSet<string> workspacePaths, Dictionary<string, string> workspaceByName)
{
_rootPath = rootPath;
_workspacePaths = workspacePaths;
_workspaceByName = workspaceByName;
}
public static NodeWorkspaceIndex Create(string rootPath)
{
var normalizedRoot = Path.GetFullPath(rootPath);
var workspacePaths = new HashSet<string>(StringComparer.Ordinal);
var workspaceByName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var packageJsonPath = Path.Combine(normalizedRoot, "package.json");
if (!File.Exists(packageJsonPath))
{
return new NodeWorkspaceIndex(normalizedRoot, workspacePaths, workspaceByName);
}
try
{
using var stream = File.OpenRead(packageJsonPath);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
if (!root.TryGetProperty("workspaces", out var workspacesElement))
{
return new NodeWorkspaceIndex(normalizedRoot, workspacePaths, workspaceByName);
}
var patterns = ExtractPatterns(workspacesElement);
foreach (var pattern in patterns)
{
foreach (var workspacePath in ExpandPattern(normalizedRoot, pattern))
{
if (string.IsNullOrWhiteSpace(workspacePath))
{
continue;
}
workspacePaths.Add(workspacePath);
var packagePath = Path.Combine(normalizedRoot, workspacePath.Replace('/', Path.DirectorySeparatorChar), "package.json");
if (!File.Exists(packagePath))
{
continue;
}
try
{
using var workspaceStream = File.OpenRead(packagePath);
using var workspaceDoc = JsonDocument.Parse(workspaceStream);
if (workspaceDoc.RootElement.TryGetProperty("name", out var nameElement))
{
var name = nameElement.GetString();
if (!string.IsNullOrWhiteSpace(name))
{
workspaceByName[name] = workspacePath!;
}
}
}
catch (IOException)
{
// Ignore unreadable workspace package definitions.
}
catch (JsonException)
{
// Ignore malformed workspace package definitions.
}
}
}
}
catch (IOException)
{
// If the root package.json is unreadable we treat as no workspaces.
}
catch (JsonException)
{
// Malformed root package.json: treat as no workspaces.
}
return new NodeWorkspaceIndex(normalizedRoot, workspacePaths, workspaceByName);
}
public IEnumerable<string> GetMembers()
=> _workspacePaths.OrderBy(static path => path, StringComparer.Ordinal);
public bool TryGetMember(string relativePath, out string normalizedPath)
{
if (string.IsNullOrEmpty(relativePath))
{
normalizedPath = string.Empty;
return false;
}
var normalized = NormalizeRelative(relativePath);
if (_workspacePaths.Contains(normalized))
{
normalizedPath = normalized;
return true;
}
normalizedPath = string.Empty;
return false;
}
public bool TryGetWorkspacePathByName(string packageName, out string? relativePath)
=> _workspaceByName.TryGetValue(packageName, out relativePath);
public IReadOnlyList<string> ResolveWorkspaceTargets(string relativeDirectory, JsonElement? dependencies)
{
if (dependencies is null || dependencies.Value.ValueKind != JsonValueKind.Object)
{
return Array.Empty<string>();
}
var result = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in dependencies.Value.EnumerateObject())
{
var value = property.Value;
if (value.ValueKind != JsonValueKind.String)
{
continue;
}
var targetSpec = value.GetString();
if (string.IsNullOrWhiteSpace(targetSpec))
{
continue;
}
const string workspacePrefix = "workspace:";
if (!targetSpec.StartsWith(workspacePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var descriptor = targetSpec[workspacePrefix.Length..].Trim();
if (string.IsNullOrEmpty(descriptor) || descriptor is "*" or "^")
{
if (_workspaceByName.TryGetValue(property.Name, out var workspaceByName))
{
result.Add(workspaceByName);
}
continue;
}
if (TryResolveWorkspaceTarget(relativeDirectory, descriptor, out var resolved))
{
result.Add(resolved);
}
}
if (result.Count == 0)
{
return Array.Empty<string>();
}
return result.OrderBy(static x => x, StringComparer.Ordinal).ToArray();
}
public bool TryResolveWorkspaceTarget(string relativeDirectory, string descriptor, out string normalized)
{
normalized = string.Empty;
var baseDirectory = string.IsNullOrEmpty(relativeDirectory) ? string.Empty : relativeDirectory;
var baseAbsolute = Path.GetFullPath(Path.Combine(_rootPath, baseDirectory));
var candidate = Path.GetFullPath(Path.Combine(baseAbsolute, descriptor.Replace('/', Path.DirectorySeparatorChar)));
if (!IsUnderRoot(_rootPath, candidate))
{
return false;
}
var relative = NormalizeRelative(Path.GetRelativePath(_rootPath, candidate));
if (_workspacePaths.Contains(relative))
{
normalized = relative;
return true;
}
return false;
}
private static IEnumerable<string> ExtractPatterns(JsonElement workspacesElement)
{
if (workspacesElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in workspacesElement.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
yield return value.Trim();
}
}
}
}
else if (workspacesElement.ValueKind == JsonValueKind.Object)
{
if (workspacesElement.TryGetProperty("packages", out var packagesElement) && packagesElement.ValueKind == JsonValueKind.Array)
{
foreach (var pattern in ExtractPatterns(packagesElement))
{
yield return pattern;
}
}
}
}
private static IEnumerable<string> ExpandPattern(string rootPath, string pattern)
{
var cleanedPattern = pattern.Replace('\\', '/').Trim();
if (cleanedPattern.EndsWith("/*", StringComparison.Ordinal))
{
var baseSegment = cleanedPattern[..^2];
var baseAbsolute = CombineAndNormalize(rootPath, baseSegment);
if (baseAbsolute is null || !Directory.Exists(baseAbsolute))
{
yield break;
}
foreach (var directory in Directory.EnumerateDirectories(baseAbsolute))
{
var normalized = NormalizeRelative(Path.GetRelativePath(rootPath, directory));
yield return normalized;
}
}
else
{
var absolute = CombineAndNormalize(rootPath, cleanedPattern);
if (absolute is null || !Directory.Exists(absolute))
{
yield break;
}
var normalized = NormalizeRelative(Path.GetRelativePath(rootPath, absolute));
yield return normalized;
}
}
private static string? CombineAndNormalize(string rootPath, string relative)
{
var candidate = Path.GetFullPath(Path.Combine(rootPath, relative.Replace('/', Path.DirectorySeparatorChar)));
return IsUnderRoot(rootPath, candidate) ? candidate : null;
}
private static string NormalizeRelative(string relativePath)
{
if (string.IsNullOrEmpty(relativePath) || relativePath == ".")
{
return string.Empty;
}
var normalized = relativePath.Replace('\\', '/');
normalized = normalized.TrimStart('.', '/');
return normalized;
}
private static bool IsUnderRoot(string rootPath, string absolutePath)
{
if (OperatingSystem.IsWindows())
{
return absolutePath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase);
}
return absolutePath.StartsWith(rootPath, StringComparison.Ordinal);
}
}

View File

@@ -1,18 +1,18 @@
using System;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Node;
public sealed class NodeAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Node";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new NodeLanguageAnalyzer();
}
}
using System;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Node;
public sealed class NodeAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Node";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new NodeLanguageAnalyzer();
}
}

View File

@@ -1,4 +1,4 @@
global using System;
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;

View File

@@ -1,17 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Python;
public sealed class PythonAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Python";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new PythonLanguageAnalyzer();
}
}
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Python;
public sealed class PythonAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Python";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new PythonLanguageAnalyzer();
}
}

View File

@@ -1,7 +1,7 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -1,243 +1,243 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustBinaryClassifier
{
private static readonly ReadOnlyMemory<byte> ElfMagic = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F' };
private static readonly ReadOnlyMemory<byte> SymbolPrefix = new byte[] { (byte)'_', (byte)'Z', (byte)'N' };
private const int ChunkSize = 64 * 1024;
private const int OverlapSize = 48;
private const long MaxBinarySize = 128L * 1024L * 1024L;
private static readonly HashSet<string> StandardCrates = new(StringComparer.Ordinal)
{
"core",
"alloc",
"std",
"panic_unwind",
"panic_abort",
};
private static readonly EnumerationOptions Enumeration = new()
{
MatchCasing = MatchCasing.CaseSensitive,
IgnoreInaccessible = true,
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
};
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<string>> CandidateCache = new();
public static IReadOnlyList<RustBinaryInfo> Scan(string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("Root path is required", nameof(rootPath));
}
var binaries = new List<RustBinaryInfo>();
foreach (var path in Directory.EnumerateFiles(rootPath, "*", Enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsEligibleBinary(path))
{
continue;
}
if (!RustFileCacheKey.TryCreate(path, out var key))
{
continue;
}
var candidates = CandidateCache.GetOrAdd(
key,
static (_, state) => ExtractCrateNames(state.Path, state.CancellationToken),
(Path: path, CancellationToken: cancellationToken));
binaries.Add(new RustBinaryInfo(path, candidates));
}
return binaries;
}
private static bool IsEligibleBinary(string path)
{
try
{
var info = new FileInfo(path);
if (!info.Exists || info.Length == 0 || info.Length > MaxBinarySize)
{
return false;
}
using var stream = info.OpenRead();
Span<byte> buffer = stackalloc byte[4];
var read = stream.Read(buffer);
if (read != 4)
{
return false;
}
return buffer.SequenceEqual(ElfMagic.Span);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static ImmutableArray<string> ExtractCrateNames(string path, CancellationToken cancellationToken)
{
var names = new HashSet<string>(StringComparer.Ordinal);
var buffer = ArrayPool<byte>.Shared.Rent(ChunkSize + OverlapSize);
var overlap = new byte[OverlapSize];
var overlapLength = 0;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
// Copy previous overlap to buffer prefix.
if (overlapLength > 0)
{
Array.Copy(overlap, 0, buffer, 0, overlapLength);
}
var read = stream.Read(buffer, overlapLength, ChunkSize);
if (read <= 0)
{
break;
}
var span = new ReadOnlySpan<byte>(buffer, 0, overlapLength + read);
ScanForSymbols(span, names);
overlapLength = Math.Min(OverlapSize, span.Length);
if (overlapLength > 0)
{
span[^overlapLength..].CopyTo(overlap);
}
}
}
catch (IOException)
{
return ImmutableArray<string>.Empty;
}
catch (UnauthorizedAccessException)
{
return ImmutableArray<string>.Empty;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
if (names.Count == 0)
{
return ImmutableArray<string>.Empty;
}
var ordered = names
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Select(static name => name.Trim())
.Where(static name => name.Length > 1)
.Where(name => !StandardCrates.Contains(name))
.Distinct(StringComparer.Ordinal)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToImmutableArray();
return ordered;
}
private static void ScanForSymbols(ReadOnlySpan<byte> span, HashSet<string> names)
{
var prefix = SymbolPrefix.Span;
var index = 0;
while (index < span.Length)
{
var slice = span[index..];
var offset = slice.IndexOf(prefix);
if (offset < 0)
{
break;
}
index += offset + prefix.Length;
if (index >= span.Length)
{
break;
}
var remaining = span[index..];
if (!TryParseCrate(remaining, out var crate, out var consumed))
{
index += 1;
continue;
}
if (!string.IsNullOrWhiteSpace(crate))
{
names.Add(crate);
}
index += Math.Max(consumed, 1);
}
}
private static bool TryParseCrate(ReadOnlySpan<byte> span, out string? crate, out int consumed)
{
crate = null;
consumed = 0;
var i = 0;
var length = 0;
while (i < span.Length && span[i] is >= (byte)'0' and <= (byte)'9')
{
length = (length * 10) + (span[i] - (byte)'0');
i++;
if (length > 256)
{
return false;
}
}
if (i == 0 || length <= 0 || i + length > span.Length)
{
return false;
}
crate = Encoding.ASCII.GetString(span.Slice(i, length));
consumed = i + length;
return true;
}
}
internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates)
{
public string ComputeSha256()
{
if (RustFileHashCache.TryGetSha256(AbsolutePath, out var sha256) && !string.IsNullOrEmpty(sha256))
{
return sha256;
}
return string.Empty;
}
}
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustBinaryClassifier
{
private static readonly ReadOnlyMemory<byte> ElfMagic = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F' };
private static readonly ReadOnlyMemory<byte> SymbolPrefix = new byte[] { (byte)'_', (byte)'Z', (byte)'N' };
private const int ChunkSize = 64 * 1024;
private const int OverlapSize = 48;
private const long MaxBinarySize = 128L * 1024L * 1024L;
private static readonly HashSet<string> StandardCrates = new(StringComparer.Ordinal)
{
"core",
"alloc",
"std",
"panic_unwind",
"panic_abort",
};
private static readonly EnumerationOptions Enumeration = new()
{
MatchCasing = MatchCasing.CaseSensitive,
IgnoreInaccessible = true,
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
};
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<string>> CandidateCache = new();
public static IReadOnlyList<RustBinaryInfo> Scan(string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("Root path is required", nameof(rootPath));
}
var binaries = new List<RustBinaryInfo>();
foreach (var path in Directory.EnumerateFiles(rootPath, "*", Enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsEligibleBinary(path))
{
continue;
}
if (!RustFileCacheKey.TryCreate(path, out var key))
{
continue;
}
var candidates = CandidateCache.GetOrAdd(
key,
static (_, state) => ExtractCrateNames(state.Path, state.CancellationToken),
(Path: path, CancellationToken: cancellationToken));
binaries.Add(new RustBinaryInfo(path, candidates));
}
return binaries;
}
private static bool IsEligibleBinary(string path)
{
try
{
var info = new FileInfo(path);
if (!info.Exists || info.Length == 0 || info.Length > MaxBinarySize)
{
return false;
}
using var stream = info.OpenRead();
Span<byte> buffer = stackalloc byte[4];
var read = stream.Read(buffer);
if (read != 4)
{
return false;
}
return buffer.SequenceEqual(ElfMagic.Span);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static ImmutableArray<string> ExtractCrateNames(string path, CancellationToken cancellationToken)
{
var names = new HashSet<string>(StringComparer.Ordinal);
var buffer = ArrayPool<byte>.Shared.Rent(ChunkSize + OverlapSize);
var overlap = new byte[OverlapSize];
var overlapLength = 0;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
// Copy previous overlap to buffer prefix.
if (overlapLength > 0)
{
Array.Copy(overlap, 0, buffer, 0, overlapLength);
}
var read = stream.Read(buffer, overlapLength, ChunkSize);
if (read <= 0)
{
break;
}
var span = new ReadOnlySpan<byte>(buffer, 0, overlapLength + read);
ScanForSymbols(span, names);
overlapLength = Math.Min(OverlapSize, span.Length);
if (overlapLength > 0)
{
span[^overlapLength..].CopyTo(overlap);
}
}
}
catch (IOException)
{
return ImmutableArray<string>.Empty;
}
catch (UnauthorizedAccessException)
{
return ImmutableArray<string>.Empty;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
if (names.Count == 0)
{
return ImmutableArray<string>.Empty;
}
var ordered = names
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Select(static name => name.Trim())
.Where(static name => name.Length > 1)
.Where(name => !StandardCrates.Contains(name))
.Distinct(StringComparer.Ordinal)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToImmutableArray();
return ordered;
}
private static void ScanForSymbols(ReadOnlySpan<byte> span, HashSet<string> names)
{
var prefix = SymbolPrefix.Span;
var index = 0;
while (index < span.Length)
{
var slice = span[index..];
var offset = slice.IndexOf(prefix);
if (offset < 0)
{
break;
}
index += offset + prefix.Length;
if (index >= span.Length)
{
break;
}
var remaining = span[index..];
if (!TryParseCrate(remaining, out var crate, out var consumed))
{
index += 1;
continue;
}
if (!string.IsNullOrWhiteSpace(crate))
{
names.Add(crate);
}
index += Math.Max(consumed, 1);
}
}
private static bool TryParseCrate(ReadOnlySpan<byte> span, out string? crate, out int consumed)
{
crate = null;
consumed = 0;
var i = 0;
var length = 0;
while (i < span.Length && span[i] is >= (byte)'0' and <= (byte)'9')
{
length = (length * 10) + (span[i] - (byte)'0');
i++;
if (length > 256)
{
return false;
}
}
if (i == 0 || length <= 0 || i + length > span.Length)
{
return false;
}
crate = Encoding.ASCII.GetString(span.Slice(i, length));
consumed = i + length;
return true;
}
}
internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates)
{
public string ComputeSha256()
{
if (RustFileHashCache.TryGetSha256(AbsolutePath, out var sha256) && !string.IsNullOrEmpty(sha256))
{
return sha256;
}
return string.Empty;
}
}

View File

@@ -1,312 +1,312 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustCargoLockParser
{
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<RustCargoPackage>> Cache = new();
public static IReadOnlyList<RustCargoPackage> Parse(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Lock path is required", nameof(path));
}
if (!RustFileCacheKey.TryCreate(path, out var key))
{
return Array.Empty<RustCargoPackage>();
}
var packages = Cache.GetOrAdd(
key,
static (_, state) => ParseInternal(state.Path, state.CancellationToken),
(Path: path, CancellationToken: cancellationToken));
return packages.IsDefaultOrEmpty ? Array.Empty<RustCargoPackage>() : packages;
}
private static ImmutableArray<RustCargoPackage> ParseInternal(string path, CancellationToken cancellationToken)
{
var resultBuilder = ImmutableArray.CreateBuilder<RustCargoPackage>();
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
RustCargoPackageBuilder? packageBuilder = null;
string? currentArrayKey = null;
var arrayValues = new List<string>();
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = reader.ReadLine();
if (line is null)
{
break;
}
var trimmed = TrimComments(line.AsSpan());
if (trimmed.Length == 0)
{
continue;
}
if (IsPackageHeader(trimmed))
{
FlushCurrent(packageBuilder, resultBuilder);
packageBuilder = new RustCargoPackageBuilder();
currentArrayKey = null;
arrayValues.Clear();
continue;
}
if (packageBuilder is null)
{
continue;
}
if (currentArrayKey is not null)
{
if (trimmed[0] == ']')
{
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustCargoLockParser
{
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<RustCargoPackage>> Cache = new();
public static IReadOnlyList<RustCargoPackage> Parse(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Lock path is required", nameof(path));
}
if (!RustFileCacheKey.TryCreate(path, out var key))
{
return Array.Empty<RustCargoPackage>();
}
var packages = Cache.GetOrAdd(
key,
static (_, state) => ParseInternal(state.Path, state.CancellationToken),
(Path: path, CancellationToken: cancellationToken));
return packages.IsDefaultOrEmpty ? Array.Empty<RustCargoPackage>() : packages;
}
private static ImmutableArray<RustCargoPackage> ParseInternal(string path, CancellationToken cancellationToken)
{
var resultBuilder = ImmutableArray.CreateBuilder<RustCargoPackage>();
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
RustCargoPackageBuilder? packageBuilder = null;
string? currentArrayKey = null;
var arrayValues = new List<string>();
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = reader.ReadLine();
if (line is null)
{
break;
}
var trimmed = TrimComments(line.AsSpan());
if (trimmed.Length == 0)
{
continue;
}
if (IsPackageHeader(trimmed))
{
FlushCurrent(packageBuilder, resultBuilder);
packageBuilder = new RustCargoPackageBuilder();
currentArrayKey = null;
arrayValues.Clear();
continue;
}
if (packageBuilder is null)
{
continue;
}
if (currentArrayKey is not null)
{
if (trimmed[0] == ']')
{
packageBuilder.SetArray(currentArrayKey, arrayValues);
currentArrayKey = null;
arrayValues.Clear();
continue;
}
var value = ExtractString(trimmed);
if (!string.IsNullOrEmpty(value))
{
arrayValues.Add(value);
}
continue;
}
if (trimmed[0] == '[')
{
// Entering a new table; finish any pending package and skip section.
currentArrayKey = null;
arrayValues.Clear();
continue;
}
var value = ExtractString(trimmed);
if (!string.IsNullOrEmpty(value))
{
arrayValues.Add(value);
}
continue;
}
if (trimmed[0] == '[')
{
// Entering a new table; finish any pending package and skip section.
FlushCurrent(packageBuilder, resultBuilder);
packageBuilder = null;
continue;
}
var equalsIndex = trimmed.IndexOf('=');
if (equalsIndex < 0)
{
continue;
}
var key = trimmed[..equalsIndex].Trim();
var valuePart = trimmed[(equalsIndex + 1)..].Trim();
if (valuePart.Length == 0)
{
continue;
}
if (valuePart[0] == '[')
{
currentArrayKey = key.ToString();
arrayValues.Clear();
if (valuePart.Length > 1 && valuePart[^1] == ']')
{
var inline = valuePart[1..^1].Trim();
if (inline.Length > 0)
{
foreach (var token in SplitInlineArray(inline.ToString()))
{
var parsedValue = ExtractString(token.AsSpan());
if (!string.IsNullOrEmpty(parsedValue))
{
arrayValues.Add(parsedValue);
}
}
}
packageBuilder.SetArray(currentArrayKey, arrayValues);
currentArrayKey = null;
arrayValues.Clear();
}
continue;
}
var parsed = ExtractString(valuePart);
if (parsed is not null)
{
packageBuilder.SetField(key, parsed);
}
}
if (currentArrayKey is not null && arrayValues.Count > 0)
{
packageBuilder?.SetArray(currentArrayKey, arrayValues);
}
FlushCurrent(packageBuilder, resultBuilder);
return resultBuilder.ToImmutable();
}
private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line)
{
var index = line.IndexOf('#');
if (index >= 0)
{
line = line[..index];
}
return line.Trim();
}
private static bool IsPackageHeader(ReadOnlySpan<char> value)
=> value.SequenceEqual("[[package]]".AsSpan());
private static IEnumerable<string> SplitInlineArray(string value)
{
var start = 0;
var inString = false;
for (var i = 0; i < value.Length; i++)
{
var current = value[i];
if (current == '"')
{
inString = !inString;
}
if (current == ',' && !inString)
{
var item = value.AsSpan(start, i - start).Trim();
if (item.Length > 0)
{
yield return item.ToString();
}
start = i + 1;
}
}
if (start < value.Length)
{
var item = value.AsSpan(start).Trim();
if (item.Length > 0)
{
yield return item.ToString();
}
}
}
private static string? ExtractString(ReadOnlySpan<char> value)
{
if (value.Length == 0)
{
return null;
}
if (value[0] == '"' && value[^1] == '"')
{
var inner = value[1..^1];
return inner.ToString();
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed.ToString();
}
private static void FlushCurrent(RustCargoPackageBuilder? packageBuilder, ImmutableArray<RustCargoPackage>.Builder packages)
{
if (packageBuilder is null || !packageBuilder.HasData)
{
return;
}
if (packageBuilder.TryBuild(out var package))
{
packages.Add(package);
}
}
private sealed class RustCargoPackageBuilder
{
private readonly SortedSet<string> _dependencies = new(StringComparer.Ordinal);
private string? _name;
private string? _version;
private string? _source;
private string? _checksum;
public bool HasData => !string.IsNullOrWhiteSpace(_name);
public void SetField(ReadOnlySpan<char> key, string value)
{
if (key.SequenceEqual("name".AsSpan()))
{
_name ??= value.Trim();
}
else if (key.SequenceEqual("version".AsSpan()))
{
_version ??= value.Trim();
}
else if (key.SequenceEqual("source".AsSpan()))
{
_source ??= value.Trim();
}
else if (key.SequenceEqual("checksum".AsSpan()))
{
_checksum ??= value.Trim();
}
}
public void SetArray(string key, IEnumerable<string> values)
{
if (!string.Equals(key, "dependencies", StringComparison.Ordinal))
{
return;
}
foreach (var entry in values)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var normalized = entry.Trim();
if (normalized.Length > 0)
{
_dependencies.Add(normalized);
}
}
}
public bool TryBuild(out RustCargoPackage package)
{
if (string.IsNullOrWhiteSpace(_name))
{
package = null!;
return false;
}
package = new RustCargoPackage(
_name!,
_version ?? string.Empty,
_source,
_checksum,
_dependencies.ToArray());
return true;
}
}
}
internal sealed record RustCargoPackage(
string Name,
string Version,
string? Source,
string? Checksum,
IReadOnlyList<string> Dependencies);
continue;
}
var equalsIndex = trimmed.IndexOf('=');
if (equalsIndex < 0)
{
continue;
}
var key = trimmed[..equalsIndex].Trim();
var valuePart = trimmed[(equalsIndex + 1)..].Trim();
if (valuePart.Length == 0)
{
continue;
}
if (valuePart[0] == '[')
{
currentArrayKey = key.ToString();
arrayValues.Clear();
if (valuePart.Length > 1 && valuePart[^1] == ']')
{
var inline = valuePart[1..^1].Trim();
if (inline.Length > 0)
{
foreach (var token in SplitInlineArray(inline.ToString()))
{
var parsedValue = ExtractString(token.AsSpan());
if (!string.IsNullOrEmpty(parsedValue))
{
arrayValues.Add(parsedValue);
}
}
}
packageBuilder.SetArray(currentArrayKey, arrayValues);
currentArrayKey = null;
arrayValues.Clear();
}
continue;
}
var parsed = ExtractString(valuePart);
if (parsed is not null)
{
packageBuilder.SetField(key, parsed);
}
}
if (currentArrayKey is not null && arrayValues.Count > 0)
{
packageBuilder?.SetArray(currentArrayKey, arrayValues);
}
FlushCurrent(packageBuilder, resultBuilder);
return resultBuilder.ToImmutable();
}
private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line)
{
var index = line.IndexOf('#');
if (index >= 0)
{
line = line[..index];
}
return line.Trim();
}
private static bool IsPackageHeader(ReadOnlySpan<char> value)
=> value.SequenceEqual("[[package]]".AsSpan());
private static IEnumerable<string> SplitInlineArray(string value)
{
var start = 0;
var inString = false;
for (var i = 0; i < value.Length; i++)
{
var current = value[i];
if (current == '"')
{
inString = !inString;
}
if (current == ',' && !inString)
{
var item = value.AsSpan(start, i - start).Trim();
if (item.Length > 0)
{
yield return item.ToString();
}
start = i + 1;
}
}
if (start < value.Length)
{
var item = value.AsSpan(start).Trim();
if (item.Length > 0)
{
yield return item.ToString();
}
}
}
private static string? ExtractString(ReadOnlySpan<char> value)
{
if (value.Length == 0)
{
return null;
}
if (value[0] == '"' && value[^1] == '"')
{
var inner = value[1..^1];
return inner.ToString();
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed.ToString();
}
private static void FlushCurrent(RustCargoPackageBuilder? packageBuilder, ImmutableArray<RustCargoPackage>.Builder packages)
{
if (packageBuilder is null || !packageBuilder.HasData)
{
return;
}
if (packageBuilder.TryBuild(out var package))
{
packages.Add(package);
}
}
private sealed class RustCargoPackageBuilder
{
private readonly SortedSet<string> _dependencies = new(StringComparer.Ordinal);
private string? _name;
private string? _version;
private string? _source;
private string? _checksum;
public bool HasData => !string.IsNullOrWhiteSpace(_name);
public void SetField(ReadOnlySpan<char> key, string value)
{
if (key.SequenceEqual("name".AsSpan()))
{
_name ??= value.Trim();
}
else if (key.SequenceEqual("version".AsSpan()))
{
_version ??= value.Trim();
}
else if (key.SequenceEqual("source".AsSpan()))
{
_source ??= value.Trim();
}
else if (key.SequenceEqual("checksum".AsSpan()))
{
_checksum ??= value.Trim();
}
}
public void SetArray(string key, IEnumerable<string> values)
{
if (!string.Equals(key, "dependencies", StringComparison.Ordinal))
{
return;
}
foreach (var entry in values)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var normalized = entry.Trim();
if (normalized.Length > 0)
{
_dependencies.Add(normalized);
}
}
}
public bool TryBuild(out RustCargoPackage package)
{
if (string.IsNullOrWhiteSpace(_name))
{
package = null!;
return false;
}
package = new RustCargoPackage(
_name!,
_version ?? string.Empty,
_source,
_checksum,
_dependencies.ToArray());
return true;
}
}
}
internal sealed record RustCargoPackage(
string Name,
string Version,
string? Source,
string? Checksum,
IReadOnlyList<string> Dependencies);

View File

@@ -1,74 +1,74 @@
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal readonly struct RustFileCacheKey : IEquatable<RustFileCacheKey>
{
private readonly string _normalizedPath;
private readonly long _length;
private readonly long _lastWriteTicks;
private RustFileCacheKey(string normalizedPath, long length, long lastWriteTicks)
{
_normalizedPath = normalizedPath;
_length = length;
_lastWriteTicks = lastWriteTicks;
}
public static bool TryCreate(string path, out RustFileCacheKey key)
{
key = default;
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
var info = new FileInfo(path);
if (!info.Exists)
{
return false;
}
var normalizedPath = OperatingSystem.IsWindows()
? info.FullName.ToLowerInvariant()
: info.FullName;
key = new RustFileCacheKey(normalizedPath, info.Length, info.LastWriteTimeUtc.Ticks);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
}
public bool Equals(RustFileCacheKey other)
=> _length == other._length
&& _lastWriteTicks == other._lastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override bool Equals(object? obj)
=> obj is RustFileCacheKey other && Equals(other);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, _length, _lastWriteTicks);
}
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal readonly struct RustFileCacheKey : IEquatable<RustFileCacheKey>
{
private readonly string _normalizedPath;
private readonly long _length;
private readonly long _lastWriteTicks;
private RustFileCacheKey(string normalizedPath, long length, long lastWriteTicks)
{
_normalizedPath = normalizedPath;
_length = length;
_lastWriteTicks = lastWriteTicks;
}
public static bool TryCreate(string path, out RustFileCacheKey key)
{
key = default;
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
var info = new FileInfo(path);
if (!info.Exists)
{
return false;
}
var normalizedPath = OperatingSystem.IsWindows()
? info.FullName.ToLowerInvariant()
: info.FullName;
key = new RustFileCacheKey(normalizedPath, info.Length, info.LastWriteTimeUtc.Ticks);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
}
public bool Equals(RustFileCacheKey other)
=> _length == other._length
&& _lastWriteTicks == other._lastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override bool Equals(object? obj)
=> obj is RustFileCacheKey other && Equals(other);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, _length, _lastWriteTicks);
}

View File

@@ -1,45 +1,45 @@
using System.Collections.Concurrent;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustFileHashCache
{
private static readonly ConcurrentDictionary<RustFileCacheKey, string> Sha256Cache = new();
public static bool TryGetSha256(string path, out string? sha256)
{
sha256 = null;
if (!RustFileCacheKey.TryCreate(path, out var key))
{
return false;
}
try
{
sha256 = Sha256Cache.GetOrAdd(key, static (_, state) => ComputeSha256(state), path);
return !string.IsNullOrEmpty(sha256);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
}
private static string ComputeSha256(string path)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
using System.Collections.Concurrent;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustFileHashCache
{
private static readonly ConcurrentDictionary<RustFileCacheKey, string> Sha256Cache = new();
public static bool TryGetSha256(string path, out string? sha256)
{
sha256 = null;
if (!RustFileCacheKey.TryCreate(path, out var key))
{
return false;
}
try
{
sha256 = Sha256Cache.GetOrAdd(key, static (_, state) => ComputeSha256(state), path);
return !string.IsNullOrEmpty(sha256);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
}
private static string ComputeSha256(string path)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,186 +1,186 @@
using System.Collections.Concurrent;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustFingerprintScanner
{
private static readonly EnumerationOptions Enumeration = new()
{
MatchCasing = MatchCasing.CaseSensitive,
IgnoreInaccessible = true,
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
};
private static readonly string FingerprintSegment = $"{Path.DirectorySeparatorChar}.fingerprint{Path.DirectorySeparatorChar}";
private static readonly ConcurrentDictionary<RustFileCacheKey, RustFingerprintRecord?> Cache = new();
public static IReadOnlyList<RustFingerprintRecord> Scan(string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("Root path is required", nameof(rootPath));
}
var results = new List<RustFingerprintRecord>();
foreach (var path in Directory.EnumerateFiles(rootPath, "*.json", Enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
if (!path.Contains(FingerprintSegment, StringComparison.Ordinal))
{
continue;
}
if (!RustFileCacheKey.TryCreate(path, out var key))
{
continue;
}
var record = Cache.GetOrAdd(
key,
static (_, state) => ParseFingerprint(state),
path);
if (record is not null)
{
results.Add(record);
}
}
return results;
}
private static RustFingerprintRecord? ParseFingerprint(string path)
{
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
var pkgId = TryGetString(root, "pkgid")
?? TryGetString(root, "package_id")
?? TryGetString(root, "packageId");
var (name, version, source) = ParseIdentity(pkgId, path);
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var profile = TryGetString(root, "profile");
var targetKind = TryGetKind(root);
return new RustFingerprintRecord(
Name: name!,
Version: version,
Source: source,
TargetKind: targetKind,
Profile: profile,
AbsolutePath: path);
}
catch (JsonException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
private static (string? Name, string? Version, string? Source) ParseIdentity(string? pkgId, string filePath)
{
if (!string.IsNullOrWhiteSpace(pkgId))
{
var span = pkgId.AsSpan().Trim();
var firstSpace = span.IndexOf(' ');
if (firstSpace > 0 && firstSpace < span.Length - 1)
{
var name = span[..firstSpace].ToString();
var remaining = span[(firstSpace + 1)..].Trim();
var secondSpace = remaining.IndexOf(' ');
if (secondSpace < 0)
{
return (name, remaining.ToString(), null);
}
var version = remaining[..secondSpace].ToString();
var potentialSource = remaining[(secondSpace + 1)..].Trim();
if (potentialSource.Length > 1 && potentialSource[0] == '(' && potentialSource[^1] == ')')
{
potentialSource = potentialSource[1..^1].Trim();
}
var source = potentialSource.Length == 0 ? null : potentialSource.ToString();
return (name, version, source);
}
}
var directory = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(directory))
{
return (null, null, null);
}
var crateDirectory = Path.GetFileName(directory);
if (string.IsNullOrWhiteSpace(crateDirectory))
{
return (null, null, null);
}
var dashIndex = crateDirectory.LastIndexOf('-');
if (dashIndex <= 0)
{
return (crateDirectory, null, null);
}
var maybeName = crateDirectory[..dashIndex];
return (maybeName, null, null);
}
private static string? TryGetKind(JsonElement root)
{
if (root.TryGetProperty("target_kind", out var array) && array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0)
{
var first = array[0];
if (first.ValueKind == JsonValueKind.String)
{
return first.GetString();
}
}
if (root.TryGetProperty("target", out var target) && target.ValueKind == JsonValueKind.String)
{
return target.GetString();
}
return null;
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String)
{
return value.GetString();
}
return null;
}
}
internal sealed record RustFingerprintRecord(
string Name,
string? Version,
string? Source,
string? TargetKind,
string? Profile,
string AbsolutePath);
using System.Collections.Concurrent;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustFingerprintScanner
{
private static readonly EnumerationOptions Enumeration = new()
{
MatchCasing = MatchCasing.CaseSensitive,
IgnoreInaccessible = true,
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
};
private static readonly string FingerprintSegment = $"{Path.DirectorySeparatorChar}.fingerprint{Path.DirectorySeparatorChar}";
private static readonly ConcurrentDictionary<RustFileCacheKey, RustFingerprintRecord?> Cache = new();
public static IReadOnlyList<RustFingerprintRecord> Scan(string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("Root path is required", nameof(rootPath));
}
var results = new List<RustFingerprintRecord>();
foreach (var path in Directory.EnumerateFiles(rootPath, "*.json", Enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
if (!path.Contains(FingerprintSegment, StringComparison.Ordinal))
{
continue;
}
if (!RustFileCacheKey.TryCreate(path, out var key))
{
continue;
}
var record = Cache.GetOrAdd(
key,
static (_, state) => ParseFingerprint(state),
path);
if (record is not null)
{
results.Add(record);
}
}
return results;
}
private static RustFingerprintRecord? ParseFingerprint(string path)
{
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
var pkgId = TryGetString(root, "pkgid")
?? TryGetString(root, "package_id")
?? TryGetString(root, "packageId");
var (name, version, source) = ParseIdentity(pkgId, path);
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var profile = TryGetString(root, "profile");
var targetKind = TryGetKind(root);
return new RustFingerprintRecord(
Name: name!,
Version: version,
Source: source,
TargetKind: targetKind,
Profile: profile,
AbsolutePath: path);
}
catch (JsonException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
private static (string? Name, string? Version, string? Source) ParseIdentity(string? pkgId, string filePath)
{
if (!string.IsNullOrWhiteSpace(pkgId))
{
var span = pkgId.AsSpan().Trim();
var firstSpace = span.IndexOf(' ');
if (firstSpace > 0 && firstSpace < span.Length - 1)
{
var name = span[..firstSpace].ToString();
var remaining = span[(firstSpace + 1)..].Trim();
var secondSpace = remaining.IndexOf(' ');
if (secondSpace < 0)
{
return (name, remaining.ToString(), null);
}
var version = remaining[..secondSpace].ToString();
var potentialSource = remaining[(secondSpace + 1)..].Trim();
if (potentialSource.Length > 1 && potentialSource[0] == '(' && potentialSource[^1] == ')')
{
potentialSource = potentialSource[1..^1].Trim();
}
var source = potentialSource.Length == 0 ? null : potentialSource.ToString();
return (name, version, source);
}
}
var directory = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(directory))
{
return (null, null, null);
}
var crateDirectory = Path.GetFileName(directory);
if (string.IsNullOrWhiteSpace(crateDirectory))
{
return (null, null, null);
}
var dashIndex = crateDirectory.LastIndexOf('-');
if (dashIndex <= 0)
{
return (crateDirectory, null, null);
}
var maybeName = crateDirectory[..dashIndex];
return (maybeName, null, null);
}
private static string? TryGetKind(JsonElement root)
{
if (root.TryGetProperty("target_kind", out var array) && array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0)
{
var first = array[0];
if (first.ValueKind == JsonValueKind.String)
{
return first.GetString();
}
}
if (root.TryGetProperty("target", out var target) && target.ValueKind == JsonValueKind.String)
{
return target.GetString();
}
return null;
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String)
{
return value.GetString();
}
return null;
}
}
internal sealed record RustFingerprintRecord(
string Name,
string? Version,
string? Source,
string? TargetKind,
string? Profile,
string AbsolutePath);

View File

@@ -1,298 +1,298 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustLicenseScanner
{
private static readonly ConcurrentDictionary<string, RustLicenseIndex> IndexCache = new(StringComparer.Ordinal);
public static RustLicenseIndex GetOrCreate(string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
{
return RustLicenseIndex.Empty;
}
var normalizedRoot = NormalizeRoot(rootPath);
return IndexCache.GetOrAdd(
normalizedRoot,
static (_, state) => BuildIndex(state.RootPath, state.CancellationToken),
(RootPath: rootPath, CancellationToken: cancellationToken));
}
private static RustLicenseIndex BuildIndex(string rootPath, CancellationToken cancellationToken)
{
var byName = new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal);
var enumeration = new EnumerationOptions
{
MatchCasing = MatchCasing.CaseSensitive,
IgnoreInaccessible = true,
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
};
foreach (var cargoTomlPath in Directory.EnumerateFiles(rootPath, "Cargo.toml", enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
if (IsUnderTargetDirectory(cargoTomlPath))
{
continue;
}
if (!TryParseCargoToml(rootPath, cargoTomlPath, out var info))
{
continue;
}
var normalizedName = RustCrateBuilder.NormalizeName(info.Name);
if (!byName.TryGetValue(normalizedName, out var entries))
{
entries = new List<RustLicenseInfo>();
byName[normalizedName] = entries;
}
entries.Add(info);
}
foreach (var entry in byName.Values)
{
entry.Sort(static (left, right) =>
{
var versionCompare = string.Compare(left.Version, right.Version, StringComparison.OrdinalIgnoreCase);
if (versionCompare != 0)
{
return versionCompare;
}
return string.Compare(left.CargoTomlRelativePath, right.CargoTomlRelativePath, StringComparison.Ordinal);
});
}
return new RustLicenseIndex(byName);
}
private static bool TryParseCargoToml(string rootPath, string cargoTomlPath, out RustLicenseInfo info)
{
info = default!;
try
{
using var stream = new FileStream(cargoTomlPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, leaveOpen: false);
string? name = null;
string? version = null;
string? licenseExpression = null;
string? licenseFile = null;
var inPackageSection = false;
while (reader.ReadLine() is { } line)
{
line = StripComment(line).Trim();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("[", StringComparison.Ordinal))
{
inPackageSection = string.Equals(line, "[package]", StringComparison.OrdinalIgnoreCase);
if (!inPackageSection && line.StartsWith("[dependency", StringComparison.OrdinalIgnoreCase))
{
// Exiting package section.
break;
}
continue;
}
if (!inPackageSection)
{
continue;
}
if (TryParseStringAssignment(line, "name", out var parsedName))
{
name ??= parsedName;
continue;
}
if (TryParseStringAssignment(line, "version", out var parsedVersion))
{
version ??= parsedVersion;
continue;
}
if (TryParseStringAssignment(line, "license", out var parsedLicense))
{
licenseExpression ??= parsedLicense;
continue;
}
if (TryParseStringAssignment(line, "license-file", out var parsedLicenseFile))
{
licenseFile ??= parsedLicenseFile;
continue;
}
}
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var expressions = ImmutableArray<string>.Empty;
if (!string.IsNullOrWhiteSpace(licenseExpression))
{
expressions = ImmutableArray.Create(licenseExpression!);
}
var files = ImmutableArray<RustLicenseFileReference>.Empty;
if (!string.IsNullOrWhiteSpace(licenseFile))
{
var directory = Path.GetDirectoryName(cargoTomlPath) ?? string.Empty;
var absolute = Path.GetFullPath(Path.Combine(directory, licenseFile!));
if (File.Exists(absolute))
{
var relative = NormalizeRelativePath(rootPath, absolute);
if (RustFileHashCache.TryGetSha256(absolute, out var sha256))
{
files = ImmutableArray.Create(new RustLicenseFileReference(relative, sha256));
}
else
{
files = ImmutableArray.Create(new RustLicenseFileReference(relative, null));
}
}
}
var cargoRelative = NormalizeRelativePath(rootPath, cargoTomlPath);
info = new RustLicenseInfo(
name!.Trim(),
string.IsNullOrWhiteSpace(version) ? null : version!.Trim(),
expressions,
files,
cargoRelative);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
}
private static string NormalizeRoot(string rootPath)
{
var full = Path.GetFullPath(rootPath);
return OperatingSystem.IsWindows()
? full.ToLowerInvariant()
: full;
}
private static bool TryParseStringAssignment(string line, string key, out string? value)
{
value = null;
if (!line.StartsWith(key, StringComparison.Ordinal))
{
return false;
}
var remaining = line[key.Length..].TrimStart();
if (remaining.Length == 0 || remaining[0] != '=')
{
return false;
}
remaining = remaining[1..].TrimStart();
if (remaining.Length < 2 || remaining[0] != '"' || remaining[^1] != '"')
{
return false;
}
value = remaining[1..^1];
return true;
}
private static string StripComment(string line)
{
var index = line.IndexOf('#');
return index < 0 ? line : line[..index];
}
private static bool IsUnderTargetDirectory(string path)
{
var segment = $"{Path.DirectorySeparatorChar}target{Path.DirectorySeparatorChar}";
return path.Contains(segment, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
private static string NormalizeRelativePath(string rootPath, string absolutePath)
{
var relative = Path.GetRelativePath(rootPath, absolutePath);
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
{
return ".";
}
return relative.Replace('\\', '/');
}
}
internal sealed class RustLicenseIndex
{
private readonly Dictionary<string, List<RustLicenseInfo>> _byName;
public static readonly RustLicenseIndex Empty = new(new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal));
public RustLicenseIndex(Dictionary<string, List<RustLicenseInfo>> byName)
{
_byName = byName ?? throw new ArgumentNullException(nameof(byName));
}
public RustLicenseInfo? Find(string crateName, string? version)
{
if (string.IsNullOrWhiteSpace(crateName))
{
return null;
}
var normalized = RustCrateBuilder.NormalizeName(crateName);
if (!_byName.TryGetValue(normalized, out var list) || list.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(version))
{
var match = list.FirstOrDefault(entry => string.Equals(entry.Version, version, StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
return match;
}
}
return list[0];
}
}
internal sealed record RustLicenseInfo(
string Name,
string? Version,
ImmutableArray<string> Expressions,
ImmutableArray<RustLicenseFileReference> Files,
string CargoTomlRelativePath);
internal sealed record RustLicenseFileReference(string RelativePath, string? Sha256);
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
internal static class RustLicenseScanner
{
private static readonly ConcurrentDictionary<string, RustLicenseIndex> IndexCache = new(StringComparer.Ordinal);
public static RustLicenseIndex GetOrCreate(string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
{
return RustLicenseIndex.Empty;
}
var normalizedRoot = NormalizeRoot(rootPath);
return IndexCache.GetOrAdd(
normalizedRoot,
static (_, state) => BuildIndex(state.RootPath, state.CancellationToken),
(RootPath: rootPath, CancellationToken: cancellationToken));
}
private static RustLicenseIndex BuildIndex(string rootPath, CancellationToken cancellationToken)
{
var byName = new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal);
var enumeration = new EnumerationOptions
{
MatchCasing = MatchCasing.CaseSensitive,
IgnoreInaccessible = true,
RecurseSubdirectories = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
};
foreach (var cargoTomlPath in Directory.EnumerateFiles(rootPath, "Cargo.toml", enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
if (IsUnderTargetDirectory(cargoTomlPath))
{
continue;
}
if (!TryParseCargoToml(rootPath, cargoTomlPath, out var info))
{
continue;
}
var normalizedName = RustCrateBuilder.NormalizeName(info.Name);
if (!byName.TryGetValue(normalizedName, out var entries))
{
entries = new List<RustLicenseInfo>();
byName[normalizedName] = entries;
}
entries.Add(info);
}
foreach (var entry in byName.Values)
{
entry.Sort(static (left, right) =>
{
var versionCompare = string.Compare(left.Version, right.Version, StringComparison.OrdinalIgnoreCase);
if (versionCompare != 0)
{
return versionCompare;
}
return string.Compare(left.CargoTomlRelativePath, right.CargoTomlRelativePath, StringComparison.Ordinal);
});
}
return new RustLicenseIndex(byName);
}
private static bool TryParseCargoToml(string rootPath, string cargoTomlPath, out RustLicenseInfo info)
{
info = default!;
try
{
using var stream = new FileStream(cargoTomlPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream, leaveOpen: false);
string? name = null;
string? version = null;
string? licenseExpression = null;
string? licenseFile = null;
var inPackageSection = false;
while (reader.ReadLine() is { } line)
{
line = StripComment(line).Trim();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("[", StringComparison.Ordinal))
{
inPackageSection = string.Equals(line, "[package]", StringComparison.OrdinalIgnoreCase);
if (!inPackageSection && line.StartsWith("[dependency", StringComparison.OrdinalIgnoreCase))
{
// Exiting package section.
break;
}
continue;
}
if (!inPackageSection)
{
continue;
}
if (TryParseStringAssignment(line, "name", out var parsedName))
{
name ??= parsedName;
continue;
}
if (TryParseStringAssignment(line, "version", out var parsedVersion))
{
version ??= parsedVersion;
continue;
}
if (TryParseStringAssignment(line, "license", out var parsedLicense))
{
licenseExpression ??= parsedLicense;
continue;
}
if (TryParseStringAssignment(line, "license-file", out var parsedLicenseFile))
{
licenseFile ??= parsedLicenseFile;
continue;
}
}
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var expressions = ImmutableArray<string>.Empty;
if (!string.IsNullOrWhiteSpace(licenseExpression))
{
expressions = ImmutableArray.Create(licenseExpression!);
}
var files = ImmutableArray<RustLicenseFileReference>.Empty;
if (!string.IsNullOrWhiteSpace(licenseFile))
{
var directory = Path.GetDirectoryName(cargoTomlPath) ?? string.Empty;
var absolute = Path.GetFullPath(Path.Combine(directory, licenseFile!));
if (File.Exists(absolute))
{
var relative = NormalizeRelativePath(rootPath, absolute);
if (RustFileHashCache.TryGetSha256(absolute, out var sha256))
{
files = ImmutableArray.Create(new RustLicenseFileReference(relative, sha256));
}
else
{
files = ImmutableArray.Create(new RustLicenseFileReference(relative, null));
}
}
}
var cargoRelative = NormalizeRelativePath(rootPath, cargoTomlPath);
info = new RustLicenseInfo(
name!.Trim(),
string.IsNullOrWhiteSpace(version) ? null : version!.Trim(),
expressions,
files,
cargoRelative);
return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (SecurityException)
{
return false;
}
}
private static string NormalizeRoot(string rootPath)
{
var full = Path.GetFullPath(rootPath);
return OperatingSystem.IsWindows()
? full.ToLowerInvariant()
: full;
}
private static bool TryParseStringAssignment(string line, string key, out string? value)
{
value = null;
if (!line.StartsWith(key, StringComparison.Ordinal))
{
return false;
}
var remaining = line[key.Length..].TrimStart();
if (remaining.Length == 0 || remaining[0] != '=')
{
return false;
}
remaining = remaining[1..].TrimStart();
if (remaining.Length < 2 || remaining[0] != '"' || remaining[^1] != '"')
{
return false;
}
value = remaining[1..^1];
return true;
}
private static string StripComment(string line)
{
var index = line.IndexOf('#');
return index < 0 ? line : line[..index];
}
private static bool IsUnderTargetDirectory(string path)
{
var segment = $"{Path.DirectorySeparatorChar}target{Path.DirectorySeparatorChar}";
return path.Contains(segment, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
private static string NormalizeRelativePath(string rootPath, string absolutePath)
{
var relative = Path.GetRelativePath(rootPath, absolutePath);
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
{
return ".";
}
return relative.Replace('\\', '/');
}
}
internal sealed class RustLicenseIndex
{
private readonly Dictionary<string, List<RustLicenseInfo>> _byName;
public static readonly RustLicenseIndex Empty = new(new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal));
public RustLicenseIndex(Dictionary<string, List<RustLicenseInfo>> byName)
{
_byName = byName ?? throw new ArgumentNullException(nameof(byName));
}
public RustLicenseInfo? Find(string crateName, string? version)
{
if (string.IsNullOrWhiteSpace(crateName))
{
return null;
}
var normalized = RustCrateBuilder.NormalizeName(crateName);
if (!_byName.TryGetValue(normalized, out var list) || list.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(version))
{
var match = list.FirstOrDefault(entry => string.Equals(entry.Version, version, StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
return match;
}
}
return list[0];
}
}
internal sealed record RustLicenseInfo(
string Name,
string? Version,
ImmutableArray<string> Expressions,
ImmutableArray<RustLicenseFileReference> Files,
string CargoTomlRelativePath);
internal sealed record RustLicenseFileReference(string RelativePath, string? Sha256);

View File

@@ -1,17 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Rust;
public sealed class RustAnalyzerPlugin : ILanguageAnalyzerPlugin
{
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Rust;
public sealed class RustAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Rust";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new RustLanguageAnalyzer();
}
}
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new RustLanguageAnalyzer();
}
}

View File

@@ -2,9 +2,9 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Rust;
namespace StellaOps.Scanner.Analyzers.Lang.Rust;
public sealed class RustLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "rust";

View File

@@ -1,24 +1,24 @@
namespace StellaOps.Scanner.Analyzers.Lang;
/// <summary>
/// Contract implemented by language ecosystem analyzers. Analyzers must be deterministic,
/// cancellation-aware, and refrain from mutating shared state.
/// </summary>
public interface ILanguageAnalyzer
{
/// <summary>
/// Stable identifier (e.g., <c>java</c>, <c>node</c>).
/// </summary>
string Id { get; }
/// <summary>
/// Human-readable display name for diagnostics.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Executes the analyzer against the resolved filesystem.
/// </summary>
ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken);
}
namespace StellaOps.Scanner.Analyzers.Lang;
/// <summary>
/// Contract implemented by language ecosystem analyzers. Analyzers must be deterministic,
/// cancellation-aware, and refrain from mutating shared state.
/// </summary>
public interface ILanguageAnalyzer
{
/// <summary>
/// Stable identifier (e.g., <c>java</c>, <c>node</c>).
/// </summary>
string Id { get; }
/// <summary>
/// Human-readable display name for diagnostics.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Executes the analyzer against the resolved filesystem.
/// </summary>
ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken);
}

View File

@@ -1,16 +1,16 @@
namespace StellaOps.Scanner.Analyzers.Lang.Internal;
internal static class LanguageAnalyzerJson
{
public static JsonSerializerOptions CreateDefault(bool indent = false)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = indent,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
}
namespace StellaOps.Scanner.Analyzers.Lang.Internal;
internal static class LanguageAnalyzerJson
{
public static JsonSerializerOptions CreateDefault(bool indent = false)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = indent,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
}

View File

@@ -19,13 +19,13 @@ public sealed class LanguageAnalyzerContext
{
throw new ArgumentException("Root path is required", nameof(rootPath));
}
RootPath = Path.GetFullPath(rootPath);
if (!Directory.Exists(RootPath))
{
throw new DirectoryNotFoundException($"Root path '{RootPath}' does not exist.");
}
RootPath = Path.GetFullPath(rootPath);
if (!Directory.Exists(RootPath))
{
throw new DirectoryNotFoundException($"Root path '{RootPath}' does not exist.");
}
TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
UsageHints = usageHints ?? LanguageUsageHints.Empty;
Services = services;
@@ -50,35 +50,35 @@ public sealed class LanguageAnalyzerContext
if (Services is null)
{
service = null;
return false;
}
service = Services.GetService(typeof(T)) as T;
return service is not null;
}
public string ResolvePath(ReadOnlySpan<char> relative)
{
if (relative.IsEmpty)
{
return RootPath;
}
var relativeString = new string(relative);
return false;
}
service = Services.GetService(typeof(T)) as T;
return service is not null;
}
public string ResolvePath(ReadOnlySpan<char> relative)
{
if (relative.IsEmpty)
{
return RootPath;
}
var relativeString = new string(relative);
var combined = Path.Combine(RootPath, relativeString);
return Path.GetFullPath(combined);
}
public string GetRelativePath(string absolutePath)
{
if (string.IsNullOrWhiteSpace(absolutePath))
{
return string.Empty;
}
var relative = Path.GetRelativePath(RootPath, absolutePath);
return OperatingSystem.IsWindows()
? relative.Replace('\\', '/')
{
if (string.IsNullOrWhiteSpace(absolutePath))
{
return string.Empty;
}
var relative = Path.GetRelativePath(RootPath, absolutePath);
return OperatingSystem.IsWindows()
? relative.Replace('\\', '/')
: relative;
}

View File

@@ -1,59 +1,59 @@
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageAnalyzerEngine
{
private readonly IReadOnlyList<ILanguageAnalyzer> _analyzers;
public LanguageAnalyzerEngine(IEnumerable<ILanguageAnalyzer> analyzers)
{
if (analyzers is null)
{
throw new ArgumentNullException(nameof(analyzers));
}
_analyzers = analyzers
.Where(static analyzer => analyzer is not null)
.Distinct(new AnalyzerIdComparer())
.OrderBy(static analyzer => analyzer.Id, StringComparer.Ordinal)
.ToArray();
}
public IReadOnlyList<ILanguageAnalyzer> Analyzers => _analyzers;
public async ValueTask<LanguageAnalyzerResult> AnalyzeAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var builder = new LanguageAnalyzerResultBuilder();
var writer = new LanguageComponentWriter(builder);
foreach (var analyzer in _analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
await analyzer.AnalyzeAsync(context, writer, cancellationToken).ConfigureAwait(false);
}
return builder.Build();
}
private sealed class AnalyzerIdComparer : IEqualityComparer<ILanguageAnalyzer>
{
public bool Equals(ILanguageAnalyzer? x, ILanguageAnalyzer? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.Id, y.Id, StringComparison.Ordinal);
}
public int GetHashCode(ILanguageAnalyzer obj)
=> obj?.Id is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Id);
}
}
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageAnalyzerEngine
{
private readonly IReadOnlyList<ILanguageAnalyzer> _analyzers;
public LanguageAnalyzerEngine(IEnumerable<ILanguageAnalyzer> analyzers)
{
if (analyzers is null)
{
throw new ArgumentNullException(nameof(analyzers));
}
_analyzers = analyzers
.Where(static analyzer => analyzer is not null)
.Distinct(new AnalyzerIdComparer())
.OrderBy(static analyzer => analyzer.Id, StringComparer.Ordinal)
.ToArray();
}
public IReadOnlyList<ILanguageAnalyzer> Analyzers => _analyzers;
public async ValueTask<LanguageAnalyzerResult> AnalyzeAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var builder = new LanguageAnalyzerResultBuilder();
var writer = new LanguageComponentWriter(builder);
foreach (var analyzer in _analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
await analyzer.AnalyzeAsync(context, writer, cancellationToken).ConfigureAwait(false);
}
return builder.Build();
}
private sealed class AnalyzerIdComparer : IEqualityComparer<ILanguageAnalyzer>
{
public bool Equals(ILanguageAnalyzer? x, ILanguageAnalyzer? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.Id, y.Id, StringComparison.Ordinal);
}
public int GetHashCode(ILanguageAnalyzer obj)
=> obj?.Id is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Id);
}
}

View File

@@ -1,27 +1,27 @@
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageAnalyzerResult
{
private readonly ImmutableArray<LanguageComponentRecord> _components;
internal LanguageAnalyzerResult(IEnumerable<LanguageComponentRecord> components)
{
_components = components
.OrderBy(static record => record.ComponentKey, StringComparer.Ordinal)
.ThenBy(static record => record.AnalyzerId, StringComparer.Ordinal)
.ToImmutableArray();
}
public IReadOnlyList<LanguageComponentRecord> Components => _components;
public ImmutableArray<ComponentRecord> ToComponentRecords(string analyzerId, string? layerDigest = null)
=> LanguageComponentMapper.ToComponentRecords(analyzerId, _components, layerDigest);
public LayerComponentFragment ToLayerFragment(string analyzerId, string? layerDigest = null)
=> LanguageComponentMapper.ToLayerFragment(analyzerId, _components, layerDigest);
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageAnalyzerResult
{
private readonly ImmutableArray<LanguageComponentRecord> _components;
internal LanguageAnalyzerResult(IEnumerable<LanguageComponentRecord> components)
{
_components = components
.OrderBy(static record => record.ComponentKey, StringComparer.Ordinal)
.ThenBy(static record => record.AnalyzerId, StringComparer.Ordinal)
.ToImmutableArray();
}
public IReadOnlyList<LanguageComponentRecord> Components => _components;
public ImmutableArray<ComponentRecord> ToComponentRecords(string analyzerId, string? layerDigest = null)
=> LanguageComponentMapper.ToComponentRecords(analyzerId, _components, layerDigest);
public LayerComponentFragment ToLayerFragment(string analyzerId, string? layerDigest = null)
=> LanguageComponentMapper.ToLayerFragment(analyzerId, _components, layerDigest);
public IReadOnlyList<LanguageComponentSnapshot> ToSnapshots()
=> _components.Select(static component => component.ToSnapshot()).ToImmutableArray();
@@ -41,82 +41,82 @@ public sealed class LanguageAnalyzerResult
var snapshots = ToSnapshots();
var options = Internal.LanguageAnalyzerJson.CreateDefault(indent);
return JsonSerializer.Serialize(snapshots, options);
}
}
internal sealed class LanguageAnalyzerResultBuilder
{
private readonly Dictionary<string, LanguageComponentRecord> _records = new(StringComparer.Ordinal);
private readonly object _sync = new();
public void Add(LanguageComponentRecord record)
{
ArgumentNullException.ThrowIfNull(record);
lock (_sync)
{
if (_records.TryGetValue(record.ComponentKey, out var existing))
{
existing.Merge(record);
return;
}
_records[record.ComponentKey] = record;
}
}
public void AddRange(IEnumerable<LanguageComponentRecord> records)
{
foreach (var record in records ?? Array.Empty<LanguageComponentRecord>())
{
Add(record);
}
}
public LanguageAnalyzerResult Build()
{
lock (_sync)
{
return new LanguageAnalyzerResult(_records.Values.ToArray());
}
}
}
public sealed class LanguageComponentWriter
{
private readonly LanguageAnalyzerResultBuilder _builder;
internal LanguageComponentWriter(LanguageAnalyzerResultBuilder builder)
{
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
}
public void Add(LanguageComponentRecord record)
=> _builder.Add(record);
public void AddRange(IEnumerable<LanguageComponentRecord> records)
=> _builder.AddRange(records);
public void AddFromPurl(
string analyzerId,
string purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
=> Add(LanguageComponentRecord.FromPurl(analyzerId, purl, name, version, type, metadata, evidence, usedByEntrypoint));
public void AddFromExplicitKey(
string analyzerId,
string componentKey,
string? purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
=> Add(LanguageComponentRecord.FromExplicitKey(analyzerId, componentKey, purl, name, version, type, metadata, evidence, usedByEntrypoint));
}
}
}
internal sealed class LanguageAnalyzerResultBuilder
{
private readonly Dictionary<string, LanguageComponentRecord> _records = new(StringComparer.Ordinal);
private readonly object _sync = new();
public void Add(LanguageComponentRecord record)
{
ArgumentNullException.ThrowIfNull(record);
lock (_sync)
{
if (_records.TryGetValue(record.ComponentKey, out var existing))
{
existing.Merge(record);
return;
}
_records[record.ComponentKey] = record;
}
}
public void AddRange(IEnumerable<LanguageComponentRecord> records)
{
foreach (var record in records ?? Array.Empty<LanguageComponentRecord>())
{
Add(record);
}
}
public LanguageAnalyzerResult Build()
{
lock (_sync)
{
return new LanguageAnalyzerResult(_records.Values.ToArray());
}
}
}
public sealed class LanguageComponentWriter
{
private readonly LanguageAnalyzerResultBuilder _builder;
internal LanguageComponentWriter(LanguageAnalyzerResultBuilder builder)
{
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
}
public void Add(LanguageComponentRecord record)
=> _builder.Add(record);
public void AddRange(IEnumerable<LanguageComponentRecord> records)
=> _builder.AddRange(records);
public void AddFromPurl(
string analyzerId,
string purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
=> Add(LanguageComponentRecord.FromPurl(analyzerId, purl, name, version, type, metadata, evidence, usedByEntrypoint));
public void AddFromExplicitKey(
string analyzerId,
string componentKey,
string? purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
=> Add(LanguageComponentRecord.FromExplicitKey(analyzerId, componentKey, purl, name, version, type, metadata, evidence, usedByEntrypoint));
}

View File

@@ -1,18 +1,18 @@
namespace StellaOps.Scanner.Analyzers.Lang;
public enum LanguageEvidenceKind
{
File,
Metadata,
Derived,
}
public sealed record LanguageComponentEvidence(
LanguageEvidenceKind Kind,
string Source,
string Locator,
string? Value,
string? Sha256)
{
public string ComparisonKey => string.Join('|', Kind, Source, Locator, Value, Sha256);
}
namespace StellaOps.Scanner.Analyzers.Lang;
public enum LanguageEvidenceKind
{
File,
Metadata,
Derived,
}
public sealed record LanguageComponentEvidence(
LanguageEvidenceKind Kind,
string Source,
string Locator,
string? Value,
string? Sha256)
{
public string ComparisonKey => string.Join('|', Kind, Source, Locator, Value, Sha256);
}

View File

@@ -1,223 +1,223 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.Lang;
/// <summary>
/// Helpers converting language analyzer component records into canonical scanner component models.
/// </summary>
public static class LanguageComponentMapper
{
private const string LayerHashPrefix = "stellaops:lang:";
private const string MetadataPrefix = "stellaops.lang";
/// <summary>
/// Computes a deterministic synthetic layer digest for the supplied analyzer identifier.
/// </summary>
public static string ComputeLayerDigest(string analyzerId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
var payload = $"{LayerHashPrefix}{analyzerId.Trim().ToLowerInvariant()}";
var bytes = Encoding.UTF8.GetBytes(payload);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Projects language component records into a deterministic set of component records.
/// </summary>
public static ImmutableArray<ComponentRecord> ToComponentRecords(
string analyzerId,
IEnumerable<LanguageComponentRecord> components,
string? layerDigest = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
ArgumentNullException.ThrowIfNull(components);
var effectiveLayer = string.IsNullOrWhiteSpace(layerDigest)
? ComputeLayerDigest(analyzerId)
: layerDigest!;
var builder = ImmutableArray.CreateBuilder<ComponentRecord>();
foreach (var record in components.OrderBy(static component => component.ComponentKey, StringComparer.Ordinal))
{
builder.Add(CreateComponentRecord(analyzerId, effectiveLayer, record));
}
return builder.ToImmutable();
}
/// <summary>
/// Creates a layer component fragment using the supplied component records.
/// </summary>
public static LayerComponentFragment ToLayerFragment(
string analyzerId,
IEnumerable<LanguageComponentRecord> components,
string? layerDigest = null)
{
var componentRecords = ToComponentRecords(analyzerId, components, layerDigest);
if (componentRecords.IsEmpty)
{
return LayerComponentFragment.Create(ComputeLayerDigest(analyzerId), componentRecords);
}
return LayerComponentFragment.Create(componentRecords[0].LayerDigest, componentRecords);
}
private static ComponentRecord CreateComponentRecord(
string analyzerId,
string layerDigest,
LanguageComponentRecord record)
{
ArgumentNullException.ThrowIfNull(record);
var identity = ComponentIdentity.Create(
key: ResolveIdentityKey(record),
name: record.Name,
version: record.Version,
purl: record.Purl,
componentType: record.Type);
var evidence = MapEvidence(record);
var metadata = BuildMetadata(analyzerId, record);
var usage = record.UsedByEntrypoint
? ComponentUsage.Create(usedByEntrypoint: true)
: ComponentUsage.Unused;
return new ComponentRecord
{
Identity = identity,
LayerDigest = layerDigest,
Evidence = evidence,
Dependencies = ImmutableArray<string>.Empty,
Metadata = metadata,
Usage = usage,
};
}
private static ImmutableArray<ComponentEvidence> MapEvidence(LanguageComponentRecord record)
{
var builder = ImmutableArray.CreateBuilder<ComponentEvidence>();
foreach (var item in record.Evidence)
{
if (item is null)
{
continue;
}
var kind = item.Kind switch
{
LanguageEvidenceKind.File => "file",
LanguageEvidenceKind.Metadata => "metadata",
LanguageEvidenceKind.Derived => "derived",
_ => "unknown",
};
var value = string.IsNullOrWhiteSpace(item.Locator) ? item.Source : item.Locator;
if (string.IsNullOrWhiteSpace(value))
{
value = kind;
}
builder.Add(new ComponentEvidence
{
Kind = kind,
Value = value,
Source = string.IsNullOrWhiteSpace(item.Source) ? null : item.Source,
});
}
return builder.Count == 0
? ImmutableArray<ComponentEvidence>.Empty
: builder.ToImmutable();
}
private static ComponentMetadata? BuildMetadata(string analyzerId, LanguageComponentRecord record)
{
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
[$"{MetadataPrefix}.analyzerId"] = analyzerId
};
var licenseList = new List<string>();
foreach (var pair in record.Metadata)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
if (!string.IsNullOrWhiteSpace(pair.Value))
{
var value = pair.Value.Trim();
properties[$"{MetadataPrefix}.meta.{pair.Key}"] = value;
if (IsLicenseKey(pair.Key) && value.Length > 0)
{
foreach (var candidate in value.Split(new[] { ',', ';' }, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (candidate.Length > 0)
{
licenseList.Add(candidate);
}
}
}
}
}
var evidenceIndex = 0;
foreach (var evidence in record.Evidence)
{
if (evidence is null)
{
continue;
}
var prefix = $"{MetadataPrefix}.evidence.{evidenceIndex}";
if (!string.IsNullOrWhiteSpace(evidence.Value))
{
properties[$"{prefix}.value"] = evidence.Value.Trim();
}
if (!string.IsNullOrWhiteSpace(evidence.Sha256))
{
properties[$"{prefix}.sha256"] = evidence.Sha256.Trim();
}
evidenceIndex++;
}
IReadOnlyList<string>? licenses = null;
if (licenseList.Count > 0)
{
licenses = licenseList
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static license => license, StringComparer.Ordinal)
.ToArray();
}
return new ComponentMetadata
{
Licenses = licenses,
Properties = properties.Count == 0 ? null : properties,
};
}
private static string ResolveIdentityKey(LanguageComponentRecord record)
{
var key = record.ComponentKey;
if (key.StartsWith("purl::", StringComparison.Ordinal))
{
return key[6..];
}
return key;
}
private static bool IsLicenseKey(string key)
=> key.Contains("license", StringComparison.OrdinalIgnoreCase);
}
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.Lang;
/// <summary>
/// Helpers converting language analyzer component records into canonical scanner component models.
/// </summary>
public static class LanguageComponentMapper
{
private const string LayerHashPrefix = "stellaops:lang:";
private const string MetadataPrefix = "stellaops.lang";
/// <summary>
/// Computes a deterministic synthetic layer digest for the supplied analyzer identifier.
/// </summary>
public static string ComputeLayerDigest(string analyzerId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
var payload = $"{LayerHashPrefix}{analyzerId.Trim().ToLowerInvariant()}";
var bytes = Encoding.UTF8.GetBytes(payload);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Projects language component records into a deterministic set of component records.
/// </summary>
public static ImmutableArray<ComponentRecord> ToComponentRecords(
string analyzerId,
IEnumerable<LanguageComponentRecord> components,
string? layerDigest = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
ArgumentNullException.ThrowIfNull(components);
var effectiveLayer = string.IsNullOrWhiteSpace(layerDigest)
? ComputeLayerDigest(analyzerId)
: layerDigest!;
var builder = ImmutableArray.CreateBuilder<ComponentRecord>();
foreach (var record in components.OrderBy(static component => component.ComponentKey, StringComparer.Ordinal))
{
builder.Add(CreateComponentRecord(analyzerId, effectiveLayer, record));
}
return builder.ToImmutable();
}
/// <summary>
/// Creates a layer component fragment using the supplied component records.
/// </summary>
public static LayerComponentFragment ToLayerFragment(
string analyzerId,
IEnumerable<LanguageComponentRecord> components,
string? layerDigest = null)
{
var componentRecords = ToComponentRecords(analyzerId, components, layerDigest);
if (componentRecords.IsEmpty)
{
return LayerComponentFragment.Create(ComputeLayerDigest(analyzerId), componentRecords);
}
return LayerComponentFragment.Create(componentRecords[0].LayerDigest, componentRecords);
}
private static ComponentRecord CreateComponentRecord(
string analyzerId,
string layerDigest,
LanguageComponentRecord record)
{
ArgumentNullException.ThrowIfNull(record);
var identity = ComponentIdentity.Create(
key: ResolveIdentityKey(record),
name: record.Name,
version: record.Version,
purl: record.Purl,
componentType: record.Type);
var evidence = MapEvidence(record);
var metadata = BuildMetadata(analyzerId, record);
var usage = record.UsedByEntrypoint
? ComponentUsage.Create(usedByEntrypoint: true)
: ComponentUsage.Unused;
return new ComponentRecord
{
Identity = identity,
LayerDigest = layerDigest,
Evidence = evidence,
Dependencies = ImmutableArray<string>.Empty,
Metadata = metadata,
Usage = usage,
};
}
private static ImmutableArray<ComponentEvidence> MapEvidence(LanguageComponentRecord record)
{
var builder = ImmutableArray.CreateBuilder<ComponentEvidence>();
foreach (var item in record.Evidence)
{
if (item is null)
{
continue;
}
var kind = item.Kind switch
{
LanguageEvidenceKind.File => "file",
LanguageEvidenceKind.Metadata => "metadata",
LanguageEvidenceKind.Derived => "derived",
_ => "unknown",
};
var value = string.IsNullOrWhiteSpace(item.Locator) ? item.Source : item.Locator;
if (string.IsNullOrWhiteSpace(value))
{
value = kind;
}
builder.Add(new ComponentEvidence
{
Kind = kind,
Value = value,
Source = string.IsNullOrWhiteSpace(item.Source) ? null : item.Source,
});
}
return builder.Count == 0
? ImmutableArray<ComponentEvidence>.Empty
: builder.ToImmutable();
}
private static ComponentMetadata? BuildMetadata(string analyzerId, LanguageComponentRecord record)
{
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
[$"{MetadataPrefix}.analyzerId"] = analyzerId
};
var licenseList = new List<string>();
foreach (var pair in record.Metadata)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
if (!string.IsNullOrWhiteSpace(pair.Value))
{
var value = pair.Value.Trim();
properties[$"{MetadataPrefix}.meta.{pair.Key}"] = value;
if (IsLicenseKey(pair.Key) && value.Length > 0)
{
foreach (var candidate in value.Split(new[] { ',', ';' }, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (candidate.Length > 0)
{
licenseList.Add(candidate);
}
}
}
}
}
var evidenceIndex = 0;
foreach (var evidence in record.Evidence)
{
if (evidence is null)
{
continue;
}
var prefix = $"{MetadataPrefix}.evidence.{evidenceIndex}";
if (!string.IsNullOrWhiteSpace(evidence.Value))
{
properties[$"{prefix}.value"] = evidence.Value.Trim();
}
if (!string.IsNullOrWhiteSpace(evidence.Sha256))
{
properties[$"{prefix}.sha256"] = evidence.Sha256.Trim();
}
evidenceIndex++;
}
IReadOnlyList<string>? licenses = null;
if (licenseList.Count > 0)
{
licenses = licenseList
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static license => license, StringComparer.Ordinal)
.ToArray();
}
return new ComponentMetadata
{
Licenses = licenses,
Properties = properties.Count == 0 ? null : properties,
};
}
private static string ResolveIdentityKey(LanguageComponentRecord record)
{
var key = record.ComponentKey;
if (key.StartsWith("purl::", StringComparison.Ordinal))
{
return key[6..];
}
return key;
}
private static bool IsLicenseKey(string key)
=> key.Contains("license", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -1,114 +1,114 @@
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageComponentRecord
{
private readonly SortedDictionary<string, string?> _metadata;
private readonly SortedDictionary<string, LanguageComponentEvidence> _evidence;
private LanguageComponentRecord(
string analyzerId,
string componentKey,
string? purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>> metadata,
IEnumerable<LanguageComponentEvidence> evidence,
bool usedByEntrypoint)
{
AnalyzerId = analyzerId ?? throw new ArgumentNullException(nameof(analyzerId));
ComponentKey = componentKey ?? throw new ArgumentNullException(nameof(componentKey));
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
Name = name ?? throw new ArgumentNullException(nameof(name));
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Type is required", nameof(type)) : type.Trim();
UsedByEntrypoint = usedByEntrypoint;
_metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal);
foreach (var entry in metadata ?? Array.Empty<KeyValuePair<string, string?>>())
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
continue;
}
_metadata[entry.Key.Trim()] = entry.Value;
}
_evidence = new SortedDictionary<string, LanguageComponentEvidence>(StringComparer.Ordinal);
foreach (var evidenceItem in evidence ?? Array.Empty<LanguageComponentEvidence>())
{
if (evidenceItem is null)
{
continue;
}
_evidence[evidenceItem.ComparisonKey] = evidenceItem;
}
}
public string AnalyzerId { get; }
public string ComponentKey { get; }
public string? Purl { get; }
public string Name { get; }
public string? Version { get; }
public string Type { get; }
public bool UsedByEntrypoint { get; private set; }
public IReadOnlyDictionary<string, string?> Metadata => _metadata;
public IReadOnlyCollection<LanguageComponentEvidence> Evidence => _evidence.Values;
public static LanguageComponentRecord FromPurl(
string analyzerId,
string purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
{
if (string.IsNullOrWhiteSpace(purl))
{
throw new ArgumentException("purl is required", nameof(purl));
}
var key = $"purl::{purl.Trim()}";
return new LanguageComponentRecord(
analyzerId,
key,
purl,
name,
version,
type,
metadata ?? Array.Empty<KeyValuePair<string, string?>>(),
evidence ?? Array.Empty<LanguageComponentEvidence>(),
usedByEntrypoint);
}
public static LanguageComponentRecord FromExplicitKey(
string analyzerId,
string componentKey,
string? purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
{
if (string.IsNullOrWhiteSpace(componentKey))
{
throw new ArgumentException("Component key is required", nameof(componentKey));
}
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageComponentRecord
{
private readonly SortedDictionary<string, string?> _metadata;
private readonly SortedDictionary<string, LanguageComponentEvidence> _evidence;
private LanguageComponentRecord(
string analyzerId,
string componentKey,
string? purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>> metadata,
IEnumerable<LanguageComponentEvidence> evidence,
bool usedByEntrypoint)
{
AnalyzerId = analyzerId ?? throw new ArgumentNullException(nameof(analyzerId));
ComponentKey = componentKey ?? throw new ArgumentNullException(nameof(componentKey));
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
Name = name ?? throw new ArgumentNullException(nameof(name));
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Type is required", nameof(type)) : type.Trim();
UsedByEntrypoint = usedByEntrypoint;
_metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal);
foreach (var entry in metadata ?? Array.Empty<KeyValuePair<string, string?>>())
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
continue;
}
_metadata[entry.Key.Trim()] = entry.Value;
}
_evidence = new SortedDictionary<string, LanguageComponentEvidence>(StringComparer.Ordinal);
foreach (var evidenceItem in evidence ?? Array.Empty<LanguageComponentEvidence>())
{
if (evidenceItem is null)
{
continue;
}
_evidence[evidenceItem.ComparisonKey] = evidenceItem;
}
}
public string AnalyzerId { get; }
public string ComponentKey { get; }
public string? Purl { get; }
public string Name { get; }
public string? Version { get; }
public string Type { get; }
public bool UsedByEntrypoint { get; private set; }
public IReadOnlyDictionary<string, string?> Metadata => _metadata;
public IReadOnlyCollection<LanguageComponentEvidence> Evidence => _evidence.Values;
public static LanguageComponentRecord FromPurl(
string analyzerId,
string purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
{
if (string.IsNullOrWhiteSpace(purl))
{
throw new ArgumentException("purl is required", nameof(purl));
}
var key = $"purl::{purl.Trim()}";
return new LanguageComponentRecord(
analyzerId,
key,
purl,
name,
version,
type,
metadata ?? Array.Empty<KeyValuePair<string, string?>>(),
evidence ?? Array.Empty<LanguageComponentEvidence>(),
usedByEntrypoint);
}
public static LanguageComponentRecord FromExplicitKey(
string analyzerId,
string componentKey,
string? purl,
string name,
string? version,
string type,
IEnumerable<KeyValuePair<string, string?>>? metadata = null,
IEnumerable<LanguageComponentEvidence>? evidence = null,
bool usedByEntrypoint = false)
{
if (string.IsNullOrWhiteSpace(componentKey))
{
throw new ArgumentException("Component key is required", nameof(componentKey));
}
return new LanguageComponentRecord(
analyzerId,
componentKey.Trim(),
@@ -174,94 +174,94 @@ public sealed class LanguageComponentRecord
ArgumentNullException.ThrowIfNull(other);
if (!ComponentKey.Equals(other.ComponentKey, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Cannot merge component '{ComponentKey}' with '{other.ComponentKey}'.");
}
UsedByEntrypoint |= other.UsedByEntrypoint;
foreach (var entry in other._metadata)
{
if (!_metadata.TryGetValue(entry.Key, out var existing) || string.IsNullOrEmpty(existing))
{
_metadata[entry.Key] = entry.Value;
}
}
foreach (var evidenceItem in other._evidence)
{
_evidence[evidenceItem.Key] = evidenceItem.Value;
}
}
public LanguageComponentSnapshot ToSnapshot()
{
return new LanguageComponentSnapshot
{
AnalyzerId = AnalyzerId,
ComponentKey = ComponentKey,
Purl = Purl,
Name = Name,
Version = Version,
Type = Type,
UsedByEntrypoint = UsedByEntrypoint,
Metadata = _metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal),
Evidence = _evidence.Values.Select(static item => new LanguageComponentEvidenceSnapshot
{
Kind = item.Kind,
Source = item.Source,
Locator = item.Locator,
Value = item.Value,
Sha256 = item.Sha256,
}).ToArray(),
};
}
}
public sealed class LanguageComponentSnapshot
{
[JsonPropertyName("analyzerId")]
public string AnalyzerId { get; set; } = string.Empty;
[JsonPropertyName("componentKey")]
public string ComponentKey { get; set; } = string.Empty;
[JsonPropertyName("purl")]
public string? Purl { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("usedByEntrypoint")]
public bool UsedByEntrypoint { get; set; }
[JsonPropertyName("metadata")]
public IDictionary<string, string?> Metadata { get; set; } = new Dictionary<string, string?>(StringComparer.Ordinal);
[JsonPropertyName("evidence")]
public IReadOnlyList<LanguageComponentEvidenceSnapshot> Evidence { get; set; } = Array.Empty<LanguageComponentEvidenceSnapshot>();
}
public sealed class LanguageComponentEvidenceSnapshot
{
[JsonPropertyName("kind")]
public LanguageEvidenceKind Kind { get; set; }
[JsonPropertyName("source")]
public string Source { get; set; } = string.Empty;
[JsonPropertyName("locator")]
public string Locator { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string? Value { get; set; }
[JsonPropertyName("sha256")]
public string? Sha256 { get; set; }
}
{
throw new InvalidOperationException($"Cannot merge component '{ComponentKey}' with '{other.ComponentKey}'.");
}
UsedByEntrypoint |= other.UsedByEntrypoint;
foreach (var entry in other._metadata)
{
if (!_metadata.TryGetValue(entry.Key, out var existing) || string.IsNullOrEmpty(existing))
{
_metadata[entry.Key] = entry.Value;
}
}
foreach (var evidenceItem in other._evidence)
{
_evidence[evidenceItem.Key] = evidenceItem.Value;
}
}
public LanguageComponentSnapshot ToSnapshot()
{
return new LanguageComponentSnapshot
{
AnalyzerId = AnalyzerId,
ComponentKey = ComponentKey,
Purl = Purl,
Name = Name,
Version = Version,
Type = Type,
UsedByEntrypoint = UsedByEntrypoint,
Metadata = _metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal),
Evidence = _evidence.Values.Select(static item => new LanguageComponentEvidenceSnapshot
{
Kind = item.Kind,
Source = item.Source,
Locator = item.Locator,
Value = item.Value,
Sha256 = item.Sha256,
}).ToArray(),
};
}
}
public sealed class LanguageComponentSnapshot
{
[JsonPropertyName("analyzerId")]
public string AnalyzerId { get; set; } = string.Empty;
[JsonPropertyName("componentKey")]
public string ComponentKey { get; set; } = string.Empty;
[JsonPropertyName("purl")]
public string? Purl { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("usedByEntrypoint")]
public bool UsedByEntrypoint { get; set; }
[JsonPropertyName("metadata")]
public IDictionary<string, string?> Metadata { get; set; } = new Dictionary<string, string?>(StringComparer.Ordinal);
[JsonPropertyName("evidence")]
public IReadOnlyList<LanguageComponentEvidenceSnapshot> Evidence { get; set; } = Array.Empty<LanguageComponentEvidenceSnapshot>();
}
public sealed class LanguageComponentEvidenceSnapshot
{
[JsonPropertyName("kind")]
public LanguageEvidenceKind Kind { get; set; }
[JsonPropertyName("source")]
public string Source { get; set; } = string.Empty;
[JsonPropertyName("locator")]
public string Locator { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string? Value { get; set; }
[JsonPropertyName("sha256")]
public string? Sha256 { get; set; }
}

View File

@@ -1,49 +1,49 @@
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageUsageHints
{
private static readonly StringComparer Comparer = OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
private readonly ImmutableHashSet<string> _usedPaths;
public static LanguageUsageHints Empty { get; } = new(Array.Empty<string>());
public LanguageUsageHints(IEnumerable<string> usedPaths)
{
if (usedPaths is null)
{
throw new ArgumentNullException(nameof(usedPaths));
}
_usedPaths = usedPaths
.Select(Normalize)
.Where(static path => path.Length > 0)
.ToImmutableHashSet(Comparer);
}
public bool IsPathUsed(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var normalized = Normalize(path);
return _usedPaths.Contains(normalized);
}
private static string Normalize(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var full = Path.GetFullPath(path);
return OperatingSystem.IsWindows()
? full.Replace('\\', '/').TrimEnd('/')
: full;
}
}
namespace StellaOps.Scanner.Analyzers.Lang;
public sealed class LanguageUsageHints
{
private static readonly StringComparer Comparer = OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
private readonly ImmutableHashSet<string> _usedPaths;
public static LanguageUsageHints Empty { get; } = new(Array.Empty<string>());
public LanguageUsageHints(IEnumerable<string> usedPaths)
{
if (usedPaths is null)
{
throw new ArgumentNullException(nameof(usedPaths));
}
_usedPaths = usedPaths
.Select(Normalize)
.Where(static path => path.Length > 0)
.ToImmutableHashSet(Comparer);
}
public bool IsPathUsed(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var normalized = Normalize(path);
return _usedPaths.Contains(normalized);
}
private static string Normalize(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var full = Path.GetFullPath(path);
return OperatingSystem.IsWindows()
? full.Replace('\\', '/').TrimEnd('/')
: full;
}
}

View File

@@ -1,12 +1,12 @@
global using System;
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Diagnostics.CodeAnalysis;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Threading;
global using System.Threading.Tasks;
global using System;
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Diagnostics.CodeAnalysis;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Threading;
global using System.Threading.Tasks;

View File

@@ -1,15 +1,15 @@
using System;
using StellaOps.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Plugin;
/// <summary>
/// Represents a restart-time plug-in that exposes a language analyzer.
/// </summary>
public interface ILanguageAnalyzerPlugin : IAvailabilityPlugin
{
/// <summary>
/// Creates the analyzer instance bound to the service provider.
/// </summary>
ILanguageAnalyzer CreateAnalyzer(IServiceProvider services);
}
using System;
using StellaOps.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Plugin;
/// <summary>
/// Represents a restart-time plug-in that exposes a language analyzer.
/// </summary>
public interface ILanguageAnalyzerPlugin : IAvailabilityPlugin
{
/// <summary>
/// Creates the analyzer instance bound to the service provider.
/// </summary>
ILanguageAnalyzer CreateAnalyzer(IServiceProvider services);
}

View File

@@ -1,147 +1,147 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.Core.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Plugin;
public interface ILanguageAnalyzerPluginCatalog
{
IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins { get; }
void LoadFromDirectory(string directory, bool seal = true);
IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services);
}
public sealed class LanguageAnalyzerPluginCatalog : ILanguageAnalyzerPluginCatalog
{
private readonly ILogger<LanguageAnalyzerPluginCatalog> _logger;
private readonly IPluginCatalogGuard _guard;
private readonly ConcurrentDictionary<string, Assembly> _assemblies = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<ILanguageAnalyzerPlugin> _plugins = Array.Empty<ILanguageAnalyzerPlugin>();
public LanguageAnalyzerPluginCatalog(IPluginCatalogGuard guard, ILogger<LanguageAnalyzerPluginCatalog> logger)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins => _plugins;
public void LoadFromDirectory(string directory, bool seal = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add("StellaOps.Scanner.Analyzers.*.dll");
var result = PluginHost.LoadPlugins(options, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning("No language analyzer plug-ins discovered under '{Directory}'.", fullDirectory);
}
foreach (var descriptor in result.Plugins)
{
try
{
_guard.EnsureRegistrationAllowed(descriptor.AssemblyPath);
_assemblies[descriptor.AssemblyPath] = descriptor.Assembly;
_logger.LogInformation(
"Registered language analyzer plug-in assembly '{Assembly}' from '{Path}'.",
descriptor.Assembly.FullName,
descriptor.AssemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register language analyzer plug-in '{Path}'.", descriptor.AssemblyPath);
}
}
RefreshPluginList();
if (seal)
{
_guard.Seal();
}
}
public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
if (_plugins.Count == 0)
{
_logger.LogWarning("No language analyzer plug-ins available; skipping language analysis.");
return Array.Empty<ILanguageAnalyzer>();
}
var analyzers = new List<ILanguageAnalyzer>(_plugins.Count);
foreach (var plugin in _plugins)
{
if (!IsPluginAvailable(plugin, services))
{
continue;
}
try
{
var analyzer = plugin.CreateAnalyzer(services);
if (analyzer is null)
{
continue;
}
analyzers.Add(analyzer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Language analyzer plug-in '{Plugin}' failed to create analyzer instance.", plugin.Name);
}
}
if (analyzers.Count == 0)
{
_logger.LogWarning("All language analyzer plug-ins were unavailable.");
return Array.Empty<ILanguageAnalyzer>();
}
analyzers.Sort(static (a, b) => string.CompareOrdinal(a.Id, b.Id));
return new ReadOnlyCollection<ILanguageAnalyzer>(analyzers);
}
private void RefreshPluginList()
{
var assemblies = _assemblies.Values.ToArray();
var plugins = PluginLoader.LoadPlugins<ILanguageAnalyzerPlugin>(assemblies);
_plugins = plugins is IReadOnlyList<ILanguageAnalyzerPlugin> list
? list
: new ReadOnlyCollection<ILanguageAnalyzerPlugin>(plugins.ToArray());
}
private static bool IsPluginAvailable(ILanguageAnalyzerPlugin plugin, IServiceProvider services)
{
try
{
return plugin.IsAvailable(services);
}
catch
{
return false;
}
}
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.Core.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Plugin;
public interface ILanguageAnalyzerPluginCatalog
{
IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins { get; }
void LoadFromDirectory(string directory, bool seal = true);
IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services);
}
public sealed class LanguageAnalyzerPluginCatalog : ILanguageAnalyzerPluginCatalog
{
private readonly ILogger<LanguageAnalyzerPluginCatalog> _logger;
private readonly IPluginCatalogGuard _guard;
private readonly ConcurrentDictionary<string, Assembly> _assemblies = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<ILanguageAnalyzerPlugin> _plugins = Array.Empty<ILanguageAnalyzerPlugin>();
public LanguageAnalyzerPluginCatalog(IPluginCatalogGuard guard, ILogger<LanguageAnalyzerPluginCatalog> logger)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins => _plugins;
public void LoadFromDirectory(string directory, bool seal = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add("StellaOps.Scanner.Analyzers.*.dll");
var result = PluginHost.LoadPlugins(options, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning("No language analyzer plug-ins discovered under '{Directory}'.", fullDirectory);
}
foreach (var descriptor in result.Plugins)
{
try
{
_guard.EnsureRegistrationAllowed(descriptor.AssemblyPath);
_assemblies[descriptor.AssemblyPath] = descriptor.Assembly;
_logger.LogInformation(
"Registered language analyzer plug-in assembly '{Assembly}' from '{Path}'.",
descriptor.Assembly.FullName,
descriptor.AssemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register language analyzer plug-in '{Path}'.", descriptor.AssemblyPath);
}
}
RefreshPluginList();
if (seal)
{
_guard.Seal();
}
}
public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
if (_plugins.Count == 0)
{
_logger.LogWarning("No language analyzer plug-ins available; skipping language analysis.");
return Array.Empty<ILanguageAnalyzer>();
}
var analyzers = new List<ILanguageAnalyzer>(_plugins.Count);
foreach (var plugin in _plugins)
{
if (!IsPluginAvailable(plugin, services))
{
continue;
}
try
{
var analyzer = plugin.CreateAnalyzer(services);
if (analyzer is null)
{
continue;
}
analyzers.Add(analyzer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Language analyzer plug-in '{Plugin}' failed to create analyzer instance.", plugin.Name);
}
}
if (analyzers.Count == 0)
{
_logger.LogWarning("All language analyzer plug-ins were unavailable.");
return Array.Empty<ILanguageAnalyzer>();
}
analyzers.Sort(static (a, b) => string.CompareOrdinal(a.Id, b.Id));
return new ReadOnlyCollection<ILanguageAnalyzer>(analyzers);
}
private void RefreshPluginList()
{
var assemblies = _assemblies.Values.ToArray();
var plugins = PluginLoader.LoadPlugins<ILanguageAnalyzerPlugin>(assemblies);
_plugins = plugins is IReadOnlyList<ILanguageAnalyzerPlugin> list
? list
: new ReadOnlyCollection<ILanguageAnalyzerPlugin>(plugins.ToArray());
}
private static bool IsPluginAvailable(ILanguageAnalyzerPlugin plugin, IServiceProvider services)
{
try
{
return plugin.IsAvailable(services);
}
catch
{
return false;
}
}
}

View File

@@ -1,21 +1,21 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Apk;
public sealed class ApkAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Apk";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new ApkPackageAnalyzer(loggerFactory.CreateLogger<ApkPackageAnalyzer>());
}
}
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Apk;
public sealed class ApkAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Apk";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new ApkPackageAnalyzer(loggerFactory.CreateLogger<ApkPackageAnalyzer>());
}
}

View File

@@ -1,203 +1,203 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace StellaOps.Scanner.Analyzers.OS.Apk;
internal sealed class ApkDatabaseParser
{
public IReadOnlyList<ApkPackageEntry> Parse(Stream stream, CancellationToken cancellationToken)
{
var packages = new List<ApkPackageEntry>();
var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var current = new ApkPackageEntry();
string? currentDirectory = "/";
string? pendingDigest = null;
bool pendingConfig = false;
string? line;
while ((line = reader.ReadLine()) != null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
CommitCurrent();
current = new ApkPackageEntry();
currentDirectory = "/";
pendingDigest = null;
pendingConfig = false;
continue;
}
if (line.Length < 2)
{
continue;
}
var key = line[0];
var value = line.Length > 2 ? line[2..] : string.Empty;
switch (key)
{
case 'C':
current.Channel = value;
break;
case 'P':
current.Name = value;
break;
case 'V':
current.Version = value;
break;
case 'A':
current.Architecture = value;
break;
case 'S':
current.InstalledSize = value;
break;
case 'I':
current.PackageSize = value;
break;
case 'T':
current.Description = value;
break;
case 'U':
current.Url = value;
break;
case 'L':
current.License = value;
break;
case 'o':
current.Origin = value;
break;
case 'm':
current.Maintainer = value;
break;
case 't':
current.BuildTime = value;
break;
case 'c':
current.Checksum = value;
break;
case 'D':
current.Depends.AddRange(SplitList(value));
break;
case 'p':
current.Provides.AddRange(SplitList(value));
break;
case 'F':
currentDirectory = NormalizeDirectory(value);
current.Files.Add(new ApkFileEntry(currentDirectory, true, false, null));
break;
case 'R':
if (currentDirectory is null)
{
currentDirectory = "/";
}
var fullPath = CombinePath(currentDirectory, value);
current.Files.Add(new ApkFileEntry(fullPath, false, pendingConfig, pendingDigest));
pendingDigest = null;
pendingConfig = false;
break;
case 'Z':
pendingDigest = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
break;
case 'a':
pendingConfig = value.Contains("cfg", StringComparison.OrdinalIgnoreCase);
break;
default:
current.Metadata[key.ToString()] = value;
break;
}
}
CommitCurrent();
return packages;
void CommitCurrent()
{
if (!string.IsNullOrWhiteSpace(current.Name) &&
!string.IsNullOrWhiteSpace(current.Version) &&
!string.IsNullOrWhiteSpace(current.Architecture))
{
packages.Add(current);
}
}
}
private static IEnumerable<string> SplitList(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
foreach (var token in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return token;
}
}
private static string NormalizeDirectory(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "/";
}
var path = value.Trim();
if (!path.StartsWith('/'))
{
path = "/" + path;
}
if (!path.EndsWith('/'))
{
path += "/";
}
return path.Replace("//", "/");
}
private static string CombinePath(string directory, string relative)
{
if (string.IsNullOrWhiteSpace(relative))
{
return directory.TrimEnd('/');
}
if (!directory.EndsWith('/'))
{
directory += "/";
}
return (directory + relative.TrimStart('/')).Replace("//", "/");
}
}
internal sealed class ApkPackageEntry
{
public string? Channel { get; set; }
public string? Name { get; set; }
public string? Version { get; set; }
public string? Architecture { get; set; }
public string? InstalledSize { get; set; }
public string? PackageSize { get; set; }
public string? Description { get; set; }
public string? Url { get; set; }
public string? License { get; set; }
public string? Origin { get; set; }
public string? Maintainer { get; set; }
public string? BuildTime { get; set; }
public string? Checksum { get; set; }
public List<string> Depends { get; } = new();
public List<string> Provides { get; } = new();
public List<ApkFileEntry> Files { get; } = new();
public Dictionary<string, string?> Metadata { get; } = new(StringComparer.Ordinal);
}
internal sealed record ApkFileEntry(string Path, bool IsDirectory, bool IsConfig, string? Digest);
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace StellaOps.Scanner.Analyzers.OS.Apk;
internal sealed class ApkDatabaseParser
{
public IReadOnlyList<ApkPackageEntry> Parse(Stream stream, CancellationToken cancellationToken)
{
var packages = new List<ApkPackageEntry>();
var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var current = new ApkPackageEntry();
string? currentDirectory = "/";
string? pendingDigest = null;
bool pendingConfig = false;
string? line;
while ((line = reader.ReadLine()) != null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
CommitCurrent();
current = new ApkPackageEntry();
currentDirectory = "/";
pendingDigest = null;
pendingConfig = false;
continue;
}
if (line.Length < 2)
{
continue;
}
var key = line[0];
var value = line.Length > 2 ? line[2..] : string.Empty;
switch (key)
{
case 'C':
current.Channel = value;
break;
case 'P':
current.Name = value;
break;
case 'V':
current.Version = value;
break;
case 'A':
current.Architecture = value;
break;
case 'S':
current.InstalledSize = value;
break;
case 'I':
current.PackageSize = value;
break;
case 'T':
current.Description = value;
break;
case 'U':
current.Url = value;
break;
case 'L':
current.License = value;
break;
case 'o':
current.Origin = value;
break;
case 'm':
current.Maintainer = value;
break;
case 't':
current.BuildTime = value;
break;
case 'c':
current.Checksum = value;
break;
case 'D':
current.Depends.AddRange(SplitList(value));
break;
case 'p':
current.Provides.AddRange(SplitList(value));
break;
case 'F':
currentDirectory = NormalizeDirectory(value);
current.Files.Add(new ApkFileEntry(currentDirectory, true, false, null));
break;
case 'R':
if (currentDirectory is null)
{
currentDirectory = "/";
}
var fullPath = CombinePath(currentDirectory, value);
current.Files.Add(new ApkFileEntry(fullPath, false, pendingConfig, pendingDigest));
pendingDigest = null;
pendingConfig = false;
break;
case 'Z':
pendingDigest = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
break;
case 'a':
pendingConfig = value.Contains("cfg", StringComparison.OrdinalIgnoreCase);
break;
default:
current.Metadata[key.ToString()] = value;
break;
}
}
CommitCurrent();
return packages;
void CommitCurrent()
{
if (!string.IsNullOrWhiteSpace(current.Name) &&
!string.IsNullOrWhiteSpace(current.Version) &&
!string.IsNullOrWhiteSpace(current.Architecture))
{
packages.Add(current);
}
}
}
private static IEnumerable<string> SplitList(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
foreach (var token in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return token;
}
}
private static string NormalizeDirectory(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "/";
}
var path = value.Trim();
if (!path.StartsWith('/'))
{
path = "/" + path;
}
if (!path.EndsWith('/'))
{
path += "/";
}
return path.Replace("//", "/");
}
private static string CombinePath(string directory, string relative)
{
if (string.IsNullOrWhiteSpace(relative))
{
return directory.TrimEnd('/');
}
if (!directory.EndsWith('/'))
{
directory += "/";
}
return (directory + relative.TrimStart('/')).Replace("//", "/");
}
}
internal sealed class ApkPackageEntry
{
public string? Channel { get; set; }
public string? Name { get; set; }
public string? Version { get; set; }
public string? Architecture { get; set; }
public string? InstalledSize { get; set; }
public string? PackageSize { get; set; }
public string? Description { get; set; }
public string? Url { get; set; }
public string? License { get; set; }
public string? Origin { get; set; }
public string? Maintainer { get; set; }
public string? BuildTime { get; set; }
public string? Checksum { get; set; }
public List<string> Depends { get; } = new();
public List<string> Provides { get; } = new();
public List<ApkFileEntry> Files { get; } = new();
public Dictionary<string, string?> Metadata { get; } = new(StringComparer.Ordinal);
}
internal sealed record ApkFileEntry(string Path, bool IsDirectory, bool IsConfig, string? Digest);

View File

@@ -1,110 +1,110 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Apk;
internal sealed class ApkPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(System.Array.Empty<OSPackageRecord>());
private readonly ApkDatabaseParser _parser = new();
public ApkPackageAnalyzer(ILogger<ApkPackageAnalyzer> logger)
: base(logger)
{
}
public override string AnalyzerId => "apk";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
var installedPath = Path.Combine(context.RootPath, "lib", "apk", "db", "installed");
if (!File.Exists(installedPath))
{
Logger.LogInformation("Apk installed database not found at {Path}; skipping analyzer.", installedPath);
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
using var stream = File.OpenRead(installedPath);
var entries = _parser.Parse(stream, cancellationToken);
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var records = new List<OSPackageRecord>(entries.Count);
foreach (var entry in entries)
{
if (string.IsNullOrWhiteSpace(entry.Name) ||
string.IsNullOrWhiteSpace(entry.Version) ||
string.IsNullOrWhiteSpace(entry.Architecture))
{
continue;
}
var versionParts = PackageVersionParser.ParseApkVersion(entry.Version);
var purl = PackageUrlBuilder.BuildAlpine(entry.Name, entry.Version, entry.Architecture);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["origin"] = entry.Origin,
["description"] = entry.Description,
["homepage"] = entry.Url,
["maintainer"] = entry.Maintainer,
["checksum"] = entry.Checksum,
["buildTime"] = entry.BuildTime,
};
foreach (var pair in entry.Metadata)
{
vendorMetadata[$"apk:{pair.Key}"] = pair.Value;
}
var files = entry.Files
.Select(file => evidenceFactory.Create(
file.Path,
file.IsConfig,
string.IsNullOrWhiteSpace(file.Digest)
? null
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["sha256"] = file.Digest
}))
.ToList();
var cveHints = CveHintExtractor.Extract(
string.Join(' ', entry.Depends),
string.Join(' ', entry.Provides));
var record = new OSPackageRecord(
AnalyzerId,
purl,
entry.Name,
versionParts.BaseVersion,
entry.Architecture,
PackageEvidenceSource.ApkDatabase,
epoch: null,
release: versionParts.Release,
sourcePackage: entry.Origin,
license: entry.License,
cveHints: cveHints,
provides: entry.Provides,
depends: entry.Depends,
files: files,
vendorMetadata: vendorMetadata);
records.Add(record);
}
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
}
}
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Apk;
internal sealed class ApkPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(System.Array.Empty<OSPackageRecord>());
private readonly ApkDatabaseParser _parser = new();
public ApkPackageAnalyzer(ILogger<ApkPackageAnalyzer> logger)
: base(logger)
{
}
public override string AnalyzerId => "apk";
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
var installedPath = Path.Combine(context.RootPath, "lib", "apk", "db", "installed");
if (!File.Exists(installedPath))
{
Logger.LogInformation("Apk installed database not found at {Path}; skipping analyzer.", installedPath);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
using var stream = File.OpenRead(installedPath);
var entries = _parser.Parse(stream, cancellationToken);
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var records = new List<OSPackageRecord>(entries.Count);
foreach (var entry in entries)
{
if (string.IsNullOrWhiteSpace(entry.Name) ||
string.IsNullOrWhiteSpace(entry.Version) ||
string.IsNullOrWhiteSpace(entry.Architecture))
{
continue;
}
var versionParts = PackageVersionParser.ParseApkVersion(entry.Version);
var purl = PackageUrlBuilder.BuildAlpine(entry.Name, entry.Version, entry.Architecture);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["origin"] = entry.Origin,
["description"] = entry.Description,
["homepage"] = entry.Url,
["maintainer"] = entry.Maintainer,
["checksum"] = entry.Checksum,
["buildTime"] = entry.BuildTime,
};
foreach (var pair in entry.Metadata)
{
vendorMetadata[$"apk:{pair.Key}"] = pair.Value;
}
var files = entry.Files
.Select(file => evidenceFactory.Create(
file.Path,
file.IsConfig,
string.IsNullOrWhiteSpace(file.Digest)
? null
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["sha256"] = file.Digest
}))
.ToList();
var cveHints = CveHintExtractor.Extract(
string.Join(' ', entry.Depends),
string.Join(' ', entry.Provides));
var record = new OSPackageRecord(
AnalyzerId,
purl,
entry.Name,
versionParts.BaseVersion,
entry.Architecture,
PackageEvidenceSource.ApkDatabase,
epoch: null,
release: versionParts.Release,
sourcePackage: entry.Origin,
license: entry.License,
cveHints: cveHints,
provides: entry.Provides,
depends: entry.Depends,
files: files,
vendorMetadata: vendorMetadata);
records.Add(record);
}
records.Sort();
return ValueTask.FromResult(ExecutionResult.FromPackages(records));
}
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]

View File

@@ -1,21 +1,21 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
public sealed class DpkgAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Dpkg";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new DpkgPackageAnalyzer(loggerFactory.CreateLogger<DpkgPackageAnalyzer>());
}
}
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
public sealed class DpkgAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Dpkg";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new DpkgPackageAnalyzer(loggerFactory.CreateLogger<DpkgPackageAnalyzer>());
}
}

View File

@@ -1,268 +1,268 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
internal sealed class DpkgPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(System.Array.Empty<OSPackageRecord>());
private readonly DpkgStatusParser _parser = new();
public DpkgPackageAnalyzer(ILogger<DpkgPackageAnalyzer> logger)
: base(logger)
{
}
public override string AnalyzerId => "dpkg";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
var statusPath = Path.Combine(context.RootPath, "var", "lib", "dpkg", "status");
if (!File.Exists(statusPath))
{
Logger.LogInformation("dpkg status file not found at {Path}; skipping analyzer.", statusPath);
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
using var stream = File.OpenRead(statusPath);
var entries = _parser.Parse(stream, cancellationToken);
var infoDirectory = Path.Combine(context.RootPath, "var", "lib", "dpkg", "info");
var records = new List<OSPackageRecord>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
foreach (var entry in entries)
{
if (!IsInstalled(entry.Status))
{
continue;
}
if (string.IsNullOrWhiteSpace(entry.Name) || string.IsNullOrWhiteSpace(entry.Version) || string.IsNullOrWhiteSpace(entry.Architecture))
{
continue;
}
var versionParts = PackageVersionParser.ParseDebianVersion(entry.Version);
var sourceName = ParseSource(entry.Source) ?? entry.Name;
var distribution = entry.Origin;
if (distribution is null && entry.Metadata.TryGetValue("origin", out var originValue))
{
distribution = originValue;
}
distribution ??= "debian";
var purl = PackageUrlBuilder.BuildDebian(distribution!, entry.Name, entry.Version, entry.Architecture);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["source"] = entry.Source,
["homepage"] = entry.Homepage,
["maintainer"] = entry.Maintainer,
["origin"] = entry.Origin,
["priority"] = entry.Priority,
["section"] = entry.Section,
};
foreach (var kvp in entry.Metadata)
{
vendorMetadata[$"dpkg:{kvp.Key}"] = kvp.Value;
}
var dependencies = entry.Depends.Concat(entry.PreDepends).ToArray();
var provides = entry.Provides.ToArray();
var fileEvidence = BuildFileEvidence(infoDirectory, entry, evidenceFactory, cancellationToken);
var cveHints = CveHintExtractor.Extract(entry.Description, string.Join(' ', dependencies), string.Join(' ', provides));
var record = new OSPackageRecord(
AnalyzerId,
purl,
entry.Name,
versionParts.UpstreamVersion,
entry.Architecture,
PackageEvidenceSource.DpkgStatus,
epoch: versionParts.Epoch,
release: versionParts.Revision,
sourcePackage: sourceName,
license: entry.License,
cveHints: cveHints,
provides: provides,
depends: dependencies,
files: fileEvidence,
vendorMetadata: vendorMetadata);
records.Add(record);
}
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
}
private static bool IsInstalled(string? status)
=> status?.Contains("install ok installed", System.StringComparison.OrdinalIgnoreCase) == true;
private static string? ParseSource(string? sourceField)
{
if (string.IsNullOrWhiteSpace(sourceField))
{
return null;
}
var parts = sourceField.Split(' ', 2, System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? null : parts[0];
}
private static IReadOnlyList<OSPackageFileEvidence> BuildFileEvidence(
string infoDirectory,
DpkgPackageEntry entry,
OsFileEvidenceFactory evidenceFactory,
CancellationToken cancellationToken)
{
if (!Directory.Exists(infoDirectory))
{
return Array.Empty<OSPackageFileEvidence>();
}
var files = new Dictionary<string, FileEvidenceBuilder>(StringComparer.Ordinal);
void EnsureFile(string path)
{
if (!files.TryGetValue(path, out _))
{
files[path] = new FileEvidenceBuilder(path);
}
}
foreach (var conffile in entry.Conffiles)
{
var normalized = conffile.Path.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
EnsureFile(normalized);
files[normalized].IsConfig = true;
if (!string.IsNullOrWhiteSpace(conffile.Checksum))
{
files[normalized].Digests["md5"] = conffile.Checksum.Trim();
}
}
foreach (var candidate in GetInfoFileCandidates(entry.Name!, entry.Architecture!))
{
var listPath = Path.Combine(infoDirectory, candidate + ".list");
if (File.Exists(listPath))
{
foreach (var line in File.ReadLines(listPath))
{
cancellationToken.ThrowIfCancellationRequested();
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
EnsureFile(trimmed);
}
}
var confFilePath = Path.Combine(infoDirectory, candidate + ".conffiles");
if (File.Exists(confFilePath))
{
foreach (var line in File.ReadLines(confFilePath))
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var parts = line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
continue;
}
var path = parts[0];
EnsureFile(path);
files[path].IsConfig = true;
if (parts.Length >= 2)
{
files[path].Digests["md5"] = parts[1];
}
}
}
var md5sumsPath = Path.Combine(infoDirectory, candidate + ".md5sums");
if (File.Exists(md5sumsPath))
{
foreach (var line in File.ReadLines(md5sumsPath))
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var parts = line.Split(' ', 2, System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
continue;
}
var hash = parts[0];
var path = parts[1];
EnsureFile(path);
files[path].Digests["md5"] = hash;
}
}
}
if (files.Count == 0)
{
return Array.Empty<OSPackageFileEvidence>();
}
var evidence = files.Values
.Select(builder => evidenceFactory.Create(builder.Path, builder.IsConfig, builder.Digests))
.OrderBy(e => e)
.ToArray();
return new ReadOnlyCollection<OSPackageFileEvidence>(evidence);
}
private static IEnumerable<string> GetInfoFileCandidates(string packageName, string architecture)
{
yield return packageName + ":" + architecture;
yield return packageName;
}
private sealed class FileEvidenceBuilder
{
public FileEvidenceBuilder(string path)
{
Path = path;
}
public string Path { get; }
public bool IsConfig { get; set; }
public Dictionary<string, string> Digests { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
internal sealed class DpkgPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(System.Array.Empty<OSPackageRecord>());
private readonly DpkgStatusParser _parser = new();
public DpkgPackageAnalyzer(ILogger<DpkgPackageAnalyzer> logger)
: base(logger)
{
}
public override string AnalyzerId => "dpkg";
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
var statusPath = Path.Combine(context.RootPath, "var", "lib", "dpkg", "status");
if (!File.Exists(statusPath))
{
Logger.LogInformation("dpkg status file not found at {Path}; skipping analyzer.", statusPath);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
using var stream = File.OpenRead(statusPath);
var entries = _parser.Parse(stream, cancellationToken);
var infoDirectory = Path.Combine(context.RootPath, "var", "lib", "dpkg", "info");
var records = new List<OSPackageRecord>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
foreach (var entry in entries)
{
if (!IsInstalled(entry.Status))
{
continue;
}
if (string.IsNullOrWhiteSpace(entry.Name) || string.IsNullOrWhiteSpace(entry.Version) || string.IsNullOrWhiteSpace(entry.Architecture))
{
continue;
}
var versionParts = PackageVersionParser.ParseDebianVersion(entry.Version);
var sourceName = ParseSource(entry.Source) ?? entry.Name;
var distribution = entry.Origin;
if (distribution is null && entry.Metadata.TryGetValue("origin", out var originValue))
{
distribution = originValue;
}
distribution ??= "debian";
var purl = PackageUrlBuilder.BuildDebian(distribution!, entry.Name, entry.Version, entry.Architecture);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["source"] = entry.Source,
["homepage"] = entry.Homepage,
["maintainer"] = entry.Maintainer,
["origin"] = entry.Origin,
["priority"] = entry.Priority,
["section"] = entry.Section,
};
foreach (var kvp in entry.Metadata)
{
vendorMetadata[$"dpkg:{kvp.Key}"] = kvp.Value;
}
var dependencies = entry.Depends.Concat(entry.PreDepends).ToArray();
var provides = entry.Provides.ToArray();
var fileEvidence = BuildFileEvidence(infoDirectory, entry, evidenceFactory, cancellationToken);
var cveHints = CveHintExtractor.Extract(entry.Description, string.Join(' ', dependencies), string.Join(' ', provides));
var record = new OSPackageRecord(
AnalyzerId,
purl,
entry.Name,
versionParts.UpstreamVersion,
entry.Architecture,
PackageEvidenceSource.DpkgStatus,
epoch: versionParts.Epoch,
release: versionParts.Revision,
sourcePackage: sourceName,
license: entry.License,
cveHints: cveHints,
provides: provides,
depends: dependencies,
files: fileEvidence,
vendorMetadata: vendorMetadata);
records.Add(record);
}
records.Sort();
return ValueTask.FromResult(ExecutionResult.FromPackages(records));
}
private static bool IsInstalled(string? status)
=> status?.Contains("install ok installed", System.StringComparison.OrdinalIgnoreCase) == true;
private static string? ParseSource(string? sourceField)
{
if (string.IsNullOrWhiteSpace(sourceField))
{
return null;
}
var parts = sourceField.Split(' ', 2, System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? null : parts[0];
}
private static IReadOnlyList<OSPackageFileEvidence> BuildFileEvidence(
string infoDirectory,
DpkgPackageEntry entry,
OsFileEvidenceFactory evidenceFactory,
CancellationToken cancellationToken)
{
if (!Directory.Exists(infoDirectory))
{
return Array.Empty<OSPackageFileEvidence>();
}
var files = new Dictionary<string, FileEvidenceBuilder>(StringComparer.Ordinal);
void EnsureFile(string path)
{
if (!files.TryGetValue(path, out _))
{
files[path] = new FileEvidenceBuilder(path);
}
}
foreach (var conffile in entry.Conffiles)
{
var normalized = conffile.Path.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
EnsureFile(normalized);
files[normalized].IsConfig = true;
if (!string.IsNullOrWhiteSpace(conffile.Checksum))
{
files[normalized].Digests["md5"] = conffile.Checksum.Trim();
}
}
foreach (var candidate in GetInfoFileCandidates(entry.Name!, entry.Architecture!))
{
var listPath = Path.Combine(infoDirectory, candidate + ".list");
if (File.Exists(listPath))
{
foreach (var line in File.ReadLines(listPath))
{
cancellationToken.ThrowIfCancellationRequested();
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
EnsureFile(trimmed);
}
}
var confFilePath = Path.Combine(infoDirectory, candidate + ".conffiles");
if (File.Exists(confFilePath))
{
foreach (var line in File.ReadLines(confFilePath))
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var parts = line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
continue;
}
var path = parts[0];
EnsureFile(path);
files[path].IsConfig = true;
if (parts.Length >= 2)
{
files[path].Digests["md5"] = parts[1];
}
}
}
var md5sumsPath = Path.Combine(infoDirectory, candidate + ".md5sums");
if (File.Exists(md5sumsPath))
{
foreach (var line in File.ReadLines(md5sumsPath))
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var parts = line.Split(' ', 2, System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
continue;
}
var hash = parts[0];
var path = parts[1];
EnsureFile(path);
files[path].Digests["md5"] = hash;
}
}
}
if (files.Count == 0)
{
return Array.Empty<OSPackageFileEvidence>();
}
var evidence = files.Values
.Select(builder => evidenceFactory.Create(builder.Path, builder.IsConfig, builder.Digests))
.OrderBy(e => e)
.ToArray();
return new ReadOnlyCollection<OSPackageFileEvidence>(evidence);
}
private static IEnumerable<string> GetInfoFileCandidates(string packageName, string architecture)
{
yield return packageName + ":" + architecture;
yield return packageName;
}
private sealed class FileEvidenceBuilder
{
public FileEvidenceBuilder(string path)
{
Path = path;
}
public string Path { get; }
public bool IsConfig { get; set; }
public Dictionary<string, string> Digests { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -1,253 +1,253 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
internal sealed class DpkgStatusParser
{
public IReadOnlyList<DpkgPackageEntry> Parse(Stream stream, CancellationToken cancellationToken)
{
var packages = new List<DpkgPackageEntry>();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var current = new DpkgPackageEntry();
string? currentField = null;
string? line;
while ((line = reader.ReadLine()) != null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
CommitField();
CommitPackage();
current = new DpkgPackageEntry();
currentField = null;
continue;
}
if (char.IsWhiteSpace(line, 0))
{
var continuation = line.Trim();
if (currentField is not null)
{
current.AppendContinuation(currentField, continuation);
}
continue;
}
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
CommitField();
var fieldName = line[..separator];
var value = line[(separator + 1)..].TrimStart();
currentField = fieldName;
current.SetField(fieldName, value);
}
CommitField();
CommitPackage();
return packages;
void CommitField()
{
if (currentField is not null)
{
current.FieldCompleted(currentField);
}
}
void CommitPackage()
{
if (current.IsValid)
{
packages.Add(current);
}
}
}
}
internal sealed class DpkgPackageEntry
{
private readonly StringBuilder _descriptionBuilder = new();
private readonly Dictionary<string, string?> _metadata = new(StringComparer.OrdinalIgnoreCase);
private string? _currentMultilineField;
public string? Name { get; private set; }
public string? Version { get; private set; }
public string? Architecture { get; private set; }
public string? Status { get; private set; }
public string? Source { get; private set; }
public string? Description { get; private set; }
public string? Homepage { get; private set; }
public string? Maintainer { get; private set; }
public string? Origin { get; private set; }
public string? Priority { get; private set; }
public string? Section { get; private set; }
public string? License { get; private set; }
public List<string> Depends { get; } = new();
public List<string> PreDepends { get; } = new();
public List<string> Provides { get; } = new();
public List<string> Recommends { get; } = new();
public List<string> Suggests { get; } = new();
public List<string> Replaces { get; } = new();
public List<DpkgConffileEntry> Conffiles { get; } = new();
public IReadOnlyDictionary<string, string?> Metadata => _metadata;
public bool IsValid => !string.IsNullOrWhiteSpace(Name)
&& !string.IsNullOrWhiteSpace(Version)
&& !string.IsNullOrWhiteSpace(Architecture)
&& !string.IsNullOrWhiteSpace(Status);
public void SetField(string fieldName, string value)
{
switch (fieldName)
{
case "Package":
Name = value;
break;
case "Version":
Version = value;
break;
case "Architecture":
Architecture = value;
break;
case "Status":
Status = value;
break;
case "Source":
Source = value;
break;
case "Description":
_descriptionBuilder.Clear();
_descriptionBuilder.Append(value);
Description = _descriptionBuilder.ToString();
_currentMultilineField = fieldName;
break;
case "Homepage":
Homepage = value;
break;
case "Maintainer":
Maintainer = value;
break;
case "Origin":
Origin = value;
break;
case "Priority":
Priority = value;
break;
case "Section":
Section = value;
break;
case "License":
License = value;
break;
case "Depends":
Depends.AddRange(ParseRelations(value));
break;
case "Pre-Depends":
PreDepends.AddRange(ParseRelations(value));
break;
case "Provides":
Provides.AddRange(ParseRelations(value));
break;
case "Recommends":
Recommends.AddRange(ParseRelations(value));
break;
case "Suggests":
Suggests.AddRange(ParseRelations(value));
break;
case "Replaces":
Replaces.AddRange(ParseRelations(value));
break;
case "Conffiles":
_currentMultilineField = fieldName;
if (!string.IsNullOrWhiteSpace(value))
{
AddConffile(value);
}
break;
default:
_metadata[fieldName] = value;
break;
}
}
public void AppendContinuation(string fieldName, string continuation)
{
if (string.Equals(fieldName, "Description", StringComparison.OrdinalIgnoreCase))
{
if (_descriptionBuilder.Length > 0)
{
_descriptionBuilder.AppendLine();
}
_descriptionBuilder.Append(continuation);
Description = _descriptionBuilder.ToString();
_currentMultilineField = fieldName;
return;
}
if (string.Equals(fieldName, "Conffiles", StringComparison.OrdinalIgnoreCase))
{
AddConffile(continuation);
_currentMultilineField = fieldName;
return;
}
if (_metadata.TryGetValue(fieldName, out var existing) && existing is not null)
{
_metadata[fieldName] = $"{existing}{Environment.NewLine}{continuation}";
}
else
{
_metadata[fieldName] = continuation;
}
}
public void FieldCompleted(string fieldName)
{
if (string.Equals(fieldName, _currentMultilineField, StringComparison.OrdinalIgnoreCase))
{
_currentMultilineField = null;
}
}
private void AddConffile(string value)
{
var tokens = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length >= 1)
{
var path = tokens[0];
var checksum = tokens.Length >= 2 ? tokens[1] : null;
Conffiles.Add(new DpkgConffileEntry(path, checksum));
}
}
private static IEnumerable<string> ParseRelations(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
foreach (var segment in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return segment;
}
}
}
internal sealed record DpkgConffileEntry(string Path, string? Checksum);
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace StellaOps.Scanner.Analyzers.OS.Dpkg;
internal sealed class DpkgStatusParser
{
public IReadOnlyList<DpkgPackageEntry> Parse(Stream stream, CancellationToken cancellationToken)
{
var packages = new List<DpkgPackageEntry>();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var current = new DpkgPackageEntry();
string? currentField = null;
string? line;
while ((line = reader.ReadLine()) != null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
CommitField();
CommitPackage();
current = new DpkgPackageEntry();
currentField = null;
continue;
}
if (char.IsWhiteSpace(line, 0))
{
var continuation = line.Trim();
if (currentField is not null)
{
current.AppendContinuation(currentField, continuation);
}
continue;
}
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
CommitField();
var fieldName = line[..separator];
var value = line[(separator + 1)..].TrimStart();
currentField = fieldName;
current.SetField(fieldName, value);
}
CommitField();
CommitPackage();
return packages;
void CommitField()
{
if (currentField is not null)
{
current.FieldCompleted(currentField);
}
}
void CommitPackage()
{
if (current.IsValid)
{
packages.Add(current);
}
}
}
}
internal sealed class DpkgPackageEntry
{
private readonly StringBuilder _descriptionBuilder = new();
private readonly Dictionary<string, string?> _metadata = new(StringComparer.OrdinalIgnoreCase);
private string? _currentMultilineField;
public string? Name { get; private set; }
public string? Version { get; private set; }
public string? Architecture { get; private set; }
public string? Status { get; private set; }
public string? Source { get; private set; }
public string? Description { get; private set; }
public string? Homepage { get; private set; }
public string? Maintainer { get; private set; }
public string? Origin { get; private set; }
public string? Priority { get; private set; }
public string? Section { get; private set; }
public string? License { get; private set; }
public List<string> Depends { get; } = new();
public List<string> PreDepends { get; } = new();
public List<string> Provides { get; } = new();
public List<string> Recommends { get; } = new();
public List<string> Suggests { get; } = new();
public List<string> Replaces { get; } = new();
public List<DpkgConffileEntry> Conffiles { get; } = new();
public IReadOnlyDictionary<string, string?> Metadata => _metadata;
public bool IsValid => !string.IsNullOrWhiteSpace(Name)
&& !string.IsNullOrWhiteSpace(Version)
&& !string.IsNullOrWhiteSpace(Architecture)
&& !string.IsNullOrWhiteSpace(Status);
public void SetField(string fieldName, string value)
{
switch (fieldName)
{
case "Package":
Name = value;
break;
case "Version":
Version = value;
break;
case "Architecture":
Architecture = value;
break;
case "Status":
Status = value;
break;
case "Source":
Source = value;
break;
case "Description":
_descriptionBuilder.Clear();
_descriptionBuilder.Append(value);
Description = _descriptionBuilder.ToString();
_currentMultilineField = fieldName;
break;
case "Homepage":
Homepage = value;
break;
case "Maintainer":
Maintainer = value;
break;
case "Origin":
Origin = value;
break;
case "Priority":
Priority = value;
break;
case "Section":
Section = value;
break;
case "License":
License = value;
break;
case "Depends":
Depends.AddRange(ParseRelations(value));
break;
case "Pre-Depends":
PreDepends.AddRange(ParseRelations(value));
break;
case "Provides":
Provides.AddRange(ParseRelations(value));
break;
case "Recommends":
Recommends.AddRange(ParseRelations(value));
break;
case "Suggests":
Suggests.AddRange(ParseRelations(value));
break;
case "Replaces":
Replaces.AddRange(ParseRelations(value));
break;
case "Conffiles":
_currentMultilineField = fieldName;
if (!string.IsNullOrWhiteSpace(value))
{
AddConffile(value);
}
break;
default:
_metadata[fieldName] = value;
break;
}
}
public void AppendContinuation(string fieldName, string continuation)
{
if (string.Equals(fieldName, "Description", StringComparison.OrdinalIgnoreCase))
{
if (_descriptionBuilder.Length > 0)
{
_descriptionBuilder.AppendLine();
}
_descriptionBuilder.Append(continuation);
Description = _descriptionBuilder.ToString();
_currentMultilineField = fieldName;
return;
}
if (string.Equals(fieldName, "Conffiles", StringComparison.OrdinalIgnoreCase))
{
AddConffile(continuation);
_currentMultilineField = fieldName;
return;
}
if (_metadata.TryGetValue(fieldName, out var existing) && existing is not null)
{
_metadata[fieldName] = $"{existing}{Environment.NewLine}{continuation}";
}
else
{
_metadata[fieldName] = continuation;
}
}
public void FieldCompleted(string fieldName)
{
if (string.Equals(fieldName, _currentMultilineField, StringComparison.OrdinalIgnoreCase))
{
_currentMultilineField = null;
}
}
private void AddConffile(string value)
{
var tokens = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length >= 1)
{
var path = tokens[0];
var checksum = tokens.Length >= 2 ? tokens[1] : null;
Conffiles.Add(new DpkgConffileEntry(path, checksum));
}
}
private static IEnumerable<string> ParseRelations(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
foreach (var segment in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return segment;
}
}
}
internal sealed record DpkgConffileEntry(string Path, string? Checksum);

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]

View File

@@ -44,12 +44,13 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
public override string AnalyzerId => "homebrew";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
var records = new List<OSPackageRecord>();
var warnings = new List<string>();
var warnings = new List<AnalyzerWarning>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
foreach (var cellarRelativePath in CellarPaths)
{
@@ -63,7 +64,7 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
try
{
DiscoverFormulas(cellarPath, records, warnings, cancellationToken);
DiscoverFormulas(context.RootPath, cellarPath, evidenceFactory, records, warnings, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -74,25 +75,27 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
if (records.Count == 0)
{
Logger.LogInformation("No Homebrew formulas found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
foreach (var warning in warnings)
{
Logger.LogWarning("Homebrew scan warning: {Warning}", warning);
Logger.LogWarning("Homebrew scan warning ({Code}): {Message}", warning.Code, warning.Message);
}
Logger.LogInformation("Discovered {Count} Homebrew formulas", records.Count);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
return ValueTask.FromResult(ExecutionResult.From(records, warnings));
}
private void DiscoverFormulas(
string rootPath,
string cellarPath,
OsFileEvidenceFactory evidenceFactory,
List<OSPackageRecord> records,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
// Enumerate formula directories (e.g., /usr/local/Cellar/openssl@3)
@@ -118,9 +121,9 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
}
// Check size guardrail
if (!CheckFormulaSizeGuardrail(versionDir, out var sizeWarning))
if (!CheckFormulaSizeGuardrail(rootPath, versionDir, out var sizeWarning))
{
warnings.Add(sizeWarning!);
warnings.Add(AnalyzerWarning.From("homebrew/formula-too-large", sizeWarning!));
continue;
}
@@ -128,7 +131,7 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
var receiptPath = Path.Combine(versionDir, "INSTALL_RECEIPT.json");
if (File.Exists(receiptPath))
{
var record = ParseReceiptAndCreateRecord(receiptPath, formulaName, versionName, versionDir);
var record = ParseReceiptAndCreateRecord(rootPath, evidenceFactory, receiptPath, formulaName, versionName, versionDir, warnings);
if (record is not null)
{
records.Add(record);
@@ -137,11 +140,13 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
else
{
// Fallback: create record from directory structure
var record = CreateRecordFromDirectory(formulaName, versionName, versionDir);
var record = CreateRecordFromDirectory(rootPath, evidenceFactory, formulaName, versionName, versionDir);
if (record is not null)
{
records.Add(record);
warnings.Add($"No INSTALL_RECEIPT.json for {formulaName}@{versionName}; using directory-based discovery.");
warnings.Add(AnalyzerWarning.From(
"homebrew/missing-receipt",
$"No INSTALL_RECEIPT.json for {formulaName}@{versionName}; using directory-based discovery."));
}
}
}
@@ -149,15 +154,21 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
}
private OSPackageRecord? ParseReceiptAndCreateRecord(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string receiptPath,
string formulaName,
string versionFromDir,
string versionDir)
string versionDir,
List<AnalyzerWarning> warnings)
{
var receipt = _parser.Parse(receiptPath);
if (receipt is null)
{
Logger.LogWarning("Failed to parse INSTALL_RECEIPT.json at {Path}", receiptPath);
warnings.Add(AnalyzerWarning.From(
"homebrew/receipt-parse-failed",
$"Failed to parse INSTALL_RECEIPT.json at {OsPath.TryGetRootfsRelative(rootPath, receiptPath) ?? "unknown"}"));
return null;
}
@@ -171,8 +182,8 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
version,
receipt.Revision);
var vendorMetadata = BuildVendorMetadata(receipt, versionDir);
var files = DiscoverFormulaFiles(versionDir);
var vendorMetadata = BuildVendorMetadata(rootPath, receipt, versionDir);
var files = DiscoverFormulaFiles(rootPath, evidenceFactory, versionDir);
return new OSPackageRecord(
AnalyzerId,
@@ -193,6 +204,8 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
}
private OSPackageRecord? CreateRecordFromDirectory(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string formulaName,
string version,
string versionDir)
@@ -204,12 +217,12 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
var purl = PackageUrlBuilder.BuildHomebrew("homebrew/core", formulaName, version, revision: 0);
var architecture = DetectArchitectureFromPath(versionDir);
var files = DiscoverFormulaFiles(versionDir);
var files = DiscoverFormulaFiles(rootPath, evidenceFactory, versionDir);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["brew:discovery_method"] = "directory",
["brew:install_path"] = versionDir,
["brew:install_path"] = ToRootfsStylePath(OsPath.TryGetRootfsRelative(rootPath, versionDir)) ?? versionDir,
};
return new OSPackageRecord(
@@ -230,7 +243,7 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
vendorMetadata: vendorMetadata);
}
private static Dictionary<string, string?> BuildVendorMetadata(HomebrewReceipt receipt, string versionDir)
private static Dictionary<string, string?> BuildVendorMetadata(string rootPath, HomebrewReceipt receipt, string versionDir)
{
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
@@ -238,7 +251,7 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
["brew:poured_from_bottle"] = receipt.PouredFromBottle.ToString().ToLowerInvariant(),
["brew:installed_as_dependency"] = receipt.InstalledAsDependency.ToString().ToLowerInvariant(),
["brew:installed_on_request"] = receipt.InstalledOnRequest.ToString().ToLowerInvariant(),
["brew:install_path"] = versionDir,
["brew:install_path"] = ToRootfsStylePath(OsPath.TryGetRootfsRelative(rootPath, versionDir)) ?? versionDir,
};
if (receipt.InstallTime.HasValue)
@@ -275,7 +288,7 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
return metadata;
}
private List<OSPackageFileEvidence> DiscoverFormulaFiles(string versionDir)
private List<OSPackageFileEvidence> DiscoverFormulaFiles(string rootPath, OsFileEvidenceFactory evidenceFactory, string versionDir)
{
var files = new List<OSPackageFileEvidence>();
@@ -295,13 +308,13 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
foreach (var file in Directory.EnumerateFiles(keyPath, "*", SearchOption.TopDirectoryOnly))
{
var relativePath = Path.GetRelativePath(versionDir, file);
files.Add(new OSPackageFileEvidence(
relativePath,
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: false));
var relativePath = OsPath.TryGetRootfsRelative(rootPath, file);
if (relativePath is null)
{
continue;
}
files.Add(evidenceFactory.Create(relativePath, isConfigFile: false));
}
}
}
@@ -313,10 +326,13 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
return files;
}
private static bool CheckFormulaSizeGuardrail(string versionDir, out string? warning)
private static bool CheckFormulaSizeGuardrail(string rootPath, string versionDir, out string? warning)
{
warning = null;
var relative = OsPath.TryGetRootfsRelative(rootPath, versionDir);
var display = ToRootfsStylePath(relative) ?? versionDir;
try
{
long totalSize = 0;
@@ -327,7 +343,7 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
if (totalSize > MaxFormulaSizeBytes)
{
warning = $"Formula at {versionDir} exceeds {MaxFormulaSizeBytes / (1024 * 1024)}MB size limit; skipping.";
warning = $"Formula at {display} exceeds {MaxFormulaSizeBytes / (1024 * 1024)}MB size limit; skipping.";
return false;
}
}
@@ -344,7 +360,8 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
{
// /opt/homebrew is Apple Silicon (arm64)
// /usr/local is Intel (x86_64)
if (path.Contains("/opt/homebrew/", StringComparison.OrdinalIgnoreCase))
var normalized = path.Replace('\\', '/');
if (normalized.Contains("/opt/homebrew/", StringComparison.OrdinalIgnoreCase))
{
return "arm64";
}
@@ -352,6 +369,9 @@ internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
return "x86_64";
}
private static string? ToRootfsStylePath(string? relativePath)
=> relativePath is null ? null : "/" + relativePath.TrimStart('/');
private static IEnumerable<string> EnumerateDirectoriesSafe(string path)
{
try

View File

@@ -46,12 +46,13 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
public override string AnalyzerId => "macos-bundle";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
var records = new List<OSPackageRecord>();
var warnings = new List<string>();
var warnings = new List<AnalyzerWarning>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
// Scan standard application paths
foreach (var appPath in ApplicationPaths)
@@ -66,7 +67,7 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
try
{
DiscoverBundles(fullPath, records, warnings, 0, cancellationToken);
DiscoverBundles(context.RootPath, evidenceFactory, fullPath, records, warnings, 0, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -87,7 +88,7 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
var userAppsPath = Path.Combine(userDir, "Applications");
if (Directory.Exists(userAppsPath))
{
DiscoverBundles(userAppsPath, records, warnings, 0, cancellationToken);
DiscoverBundles(context.RootPath, evidenceFactory, userAppsPath, records, warnings, 0, cancellationToken);
}
}
}
@@ -100,25 +101,27 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
if (records.Count == 0)
{
Logger.LogInformation("No application bundles found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
foreach (var warning in warnings.Take(10)) // Limit warning output
{
Logger.LogWarning("Bundle scan warning: {Warning}", warning);
Logger.LogWarning("Bundle scan warning ({Code}): {Message}", warning.Code, warning.Message);
}
Logger.LogInformation("Discovered {Count} application bundles", records.Count);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
return ValueTask.FromResult(ExecutionResult.From(records, warnings));
}
private void DiscoverBundles(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string searchPath,
List<OSPackageRecord> records,
List<string> warnings,
List<AnalyzerWarning> warnings,
int depth,
CancellationToken cancellationToken)
{
@@ -150,7 +153,7 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
// Check if this is an app bundle
if (name.EndsWith(".app", StringComparison.OrdinalIgnoreCase))
{
var record = AnalyzeBundle(entry, warnings, cancellationToken);
var record = AnalyzeBundle(rootPath, evidenceFactory, entry, warnings, cancellationToken);
if (record is not null)
{
records.Add(record);
@@ -159,14 +162,16 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
else
{
// Recurse into subdirectories (e.g., for nested apps)
DiscoverBundles(entry, records, warnings, depth + 1, cancellationToken);
DiscoverBundles(rootPath, evidenceFactory, entry, records, warnings, depth + 1, cancellationToken);
}
}
}
private OSPackageRecord? AnalyzeBundle(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string bundlePath,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
// Find and parse Info.plist
@@ -179,14 +184,18 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
if (!File.Exists(infoPlistPath))
{
warnings.Add($"No Info.plist found in {bundlePath}");
warnings.Add(AnalyzerWarning.From(
"macos-bundle/missing-info-plist",
$"No Info.plist found in {ToRootfsStylePath(OsPath.TryGetRootfsRelative(rootPath, bundlePath)) ?? "bundle"}"));
return null;
}
var bundleInfo = _infoPlistParser.Parse(infoPlistPath, cancellationToken);
if (bundleInfo is null)
{
warnings.Add($"Failed to parse Info.plist in {bundlePath}");
warnings.Add(AnalyzerWarning.From(
"macos-bundle/invalid-info-plist",
$"Failed to parse Info.plist in {ToRootfsStylePath(OsPath.TryGetRootfsRelative(rootPath, bundlePath)) ?? "bundle"}"));
return null;
}
@@ -208,10 +217,10 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
var purl = PackageUrlBuilder.BuildMacOsBundle(bundleInfo.BundleIdentifier, version);
// Build vendor metadata
var vendorMetadata = BuildVendorMetadata(bundleInfo, entitlements, codeResourcesHash, bundlePath);
var vendorMetadata = BuildVendorMetadata(rootPath, bundleInfo, entitlements, codeResourcesHash, bundlePath);
// Discover key files
var files = DiscoverBundleFiles(bundlePath, bundleInfo);
var files = DiscoverBundleFiles(rootPath, evidenceFactory, bundlePath, bundleInfo);
// Extract display name
var displayName = bundleInfo.BundleDisplayName ?? bundleInfo.BundleName;
@@ -221,7 +230,7 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
purl,
displayName,
version,
DetermineArchitecture(bundlePath),
DetermineArchitecture(bundlePath, bundleInfo),
PackageEvidenceSource.MacOsBundle,
epoch: null,
release: bundleInfo.Version != version ? bundleInfo.Version : null,
@@ -235,16 +244,19 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
}
private static Dictionary<string, string?> BuildVendorMetadata(
string rootPath,
BundleInfo bundleInfo,
BundleEntitlements entitlements,
string? codeResourcesHash,
string bundlePath)
{
var bundlePathRelative = OsPath.TryGetRootfsRelative(rootPath, bundlePath);
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["macos:bundle_id"] = bundleInfo.BundleIdentifier,
["macos:bundle_type"] = bundleInfo.BundlePackageType,
["macos:bundle_path"] = bundlePath,
["macos:bundle_path"] = ToRootfsStylePath(bundlePathRelative) ?? bundlePath,
};
if (!string.IsNullOrWhiteSpace(bundleInfo.MinimumSystemVersion))
@@ -304,7 +316,11 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
}
}
private static List<OSPackageFileEvidence> DiscoverBundleFiles(string bundlePath, BundleInfo bundleInfo)
private static List<OSPackageFileEvidence> DiscoverBundleFiles(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string bundlePath,
BundleInfo bundleInfo)
{
var files = new List<OSPackageFileEvidence>();
@@ -317,44 +333,99 @@ internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
var execPath = Path.Combine(contentsPath, "MacOS", bundleInfo.Executable);
if (File.Exists(execPath))
{
files.Add(new OSPackageFileEvidence(
$"Contents/MacOS/{bundleInfo.Executable}",
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: false));
var relative = OsPath.TryGetRootfsRelative(rootPath, execPath);
if (relative is not null)
{
files.Add(evidenceFactory.Create(relative, isConfigFile: false));
}
}
}
// Info.plist
var infoPlistRelative = "Contents/Info.plist";
if (File.Exists(Path.Combine(bundlePath, infoPlistRelative)))
var infoPlistPath = Path.Combine(bundlePath, "Contents", "Info.plist");
if (!File.Exists(infoPlistPath))
{
files.Add(new OSPackageFileEvidence(
infoPlistRelative,
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: true));
infoPlistPath = Path.Combine(bundlePath, "Info.plist");
}
if (File.Exists(infoPlistPath))
{
var relative = OsPath.TryGetRootfsRelative(rootPath, infoPlistPath);
if (relative is not null)
{
files.Add(evidenceFactory.Create(relative, isConfigFile: true));
}
}
return files;
}
private static string DetermineArchitecture(string bundlePath)
private static string DetermineArchitecture(string bundlePath, BundleInfo bundleInfo)
{
// Check for universal binary indicators
if (!string.IsNullOrWhiteSpace(bundleInfo.Executable))
{
var execPath = Path.Combine(bundlePath, "Contents", "MacOS", bundleInfo.Executable);
var detected = TryDetectMachOArchitecture(execPath);
if (!string.IsNullOrWhiteSpace(detected))
{
return detected;
}
}
// Default to universal (noarch) for macOS bundles.
var macosPath = Path.Combine(bundlePath, "Contents", "MacOS");
if (Directory.Exists(macosPath))
{
// Look for architecture-specific subdirectories or lipo info
// For now, default to universal
return "universal";
}
return "universal";
}
private static string? TryDetectMachOArchitecture(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
try
{
using var stream = File.OpenRead(path);
Span<byte> header = stackalloc byte[16];
var read = stream.Read(header);
if (read < 12)
{
return null;
}
var magic = BitConverter.ToUInt32(header[..4]);
return magic switch
{
0xCAFEBABE or 0xBEBAFECA => "universal",
0xFEEDFACE or 0xCEFAEDFE => MapMachCpuType(BitConverter.ToUInt32(header.Slice(4, 4))),
0xFEEDFACF or 0xCFFAEDFE => MapMachCpuType(BitConverter.ToUInt32(header.Slice(4, 4))),
_ => null
};
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static string? MapMachCpuType(uint cpuType) => cpuType switch
{
0x00000007 => "x86",
0x01000007 => "x86_64",
0x0000000C => "arm",
0x0100000C => "arm64",
_ => null,
};
private static string? ToRootfsStylePath(string? relativePath)
=> relativePath is null ? null : "/" + relativePath.TrimStart('/');
private static string? ExtractVendorFromBundleId(string bundleId)
{
var parts = bundleId.Split('.', StringSplitOptions.RemoveEmptyEntries);

View File

@@ -31,7 +31,7 @@ internal sealed class PkgutilPackageAnalyzer : OsPackageAnalyzerBase
public override string AnalyzerId => "pkgutil";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
@@ -39,24 +39,26 @@ internal sealed class PkgutilPackageAnalyzer : OsPackageAnalyzerBase
if (!Directory.Exists(receiptsPath))
{
Logger.LogInformation("pkgutil receipts directory not found at {Path}; skipping analyzer.", receiptsPath);
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
var receipts = _receiptParser.DiscoverReceipts(context.RootPath, cancellationToken);
if (receipts.Count == 0)
{
Logger.LogInformation("No pkgutil receipts found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
Logger.LogInformation("Discovered {Count} pkgutil receipts", receipts.Count);
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var records = new List<OSPackageRecord>(receipts.Count);
foreach (var receipt in receipts)
{
cancellationToken.ThrowIfCancellationRequested();
var record = CreateRecordFromReceipt(receipt, cancellationToken);
var record = CreateRecordFromReceipt(receipt, evidenceFactory, cancellationToken);
if (record is not null)
{
records.Add(record);
@@ -67,11 +69,12 @@ internal sealed class PkgutilPackageAnalyzer : OsPackageAnalyzerBase
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
return ValueTask.FromResult(ExecutionResult.FromPackages(records));
}
private OSPackageRecord? CreateRecordFromReceipt(
PkgutilReceipt receipt,
OsFileEvidenceFactory evidenceFactory,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(receipt.Identifier) ||
@@ -104,12 +107,7 @@ internal sealed class PkgutilPackageAnalyzer : OsPackageAnalyzerBase
if (!entry.IsDirectory)
{
files.Add(new OSPackageFileEvidence(
entry.Path,
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: IsConfigPath(entry.Path)));
files.Add(evidenceFactory.Create(entry.Path, IsConfigPath(entry.Path)));
count++;
}
}

View File

@@ -124,7 +124,17 @@ internal sealed class PkgutilReceiptParser
{
if (dict.TryGetValue(key, out var value) && value is NSDate nsDate)
{
return new DateTimeOffset(nsDate.Date, TimeSpan.Zero);
var dateTime = nsDate.Date;
if (dateTime.Kind == DateTimeKind.Local)
{
dateTime = dateTime.ToUniversalTime();
}
else if (dateTime.Kind == DateTimeKind.Unspecified)
{
dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
}
return new DateTimeOffset(dateTime, TimeSpan.Zero);
}
return null;

View File

@@ -1,10 +1,10 @@
using System.Collections.Generic;
using System.Threading;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
internal interface IRpmDatabaseReader
{
IReadOnlyList<RpmHeader> ReadHeaders(string rootPath, CancellationToken cancellationToken);
}
using System.Collections.Generic;
using System.Threading;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
internal interface IRpmDatabaseReader
{
IReadOnlyList<RpmHeader> ReadHeaders(string rootPath, CancellationToken cancellationToken);
}

View File

@@ -1,86 +1,86 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
internal sealed class RpmHeader
{
public RpmHeader(
string name,
string version,
string architecture,
string? release,
string? epoch,
string? summary,
string? description,
string? license,
string? sourceRpm,
string? url,
string? vendor,
long? buildTime,
long? installTime,
IReadOnlyList<string> provides,
IReadOnlyList<string> provideVersions,
IReadOnlyList<string> requires,
IReadOnlyList<string> requireVersions,
IReadOnlyList<RpmFileEntry> files,
IReadOnlyList<string> changeLogs,
IReadOnlyDictionary<string, string?> metadata)
{
Name = name;
Version = version;
Architecture = architecture;
Release = release;
Epoch = epoch;
Summary = summary;
Description = description;
License = license;
SourceRpm = sourceRpm;
Url = url;
Vendor = vendor;
BuildTime = buildTime;
InstallTime = installTime;
Provides = provides;
ProvideVersions = provideVersions;
Requires = requires;
RequireVersions = requireVersions;
Files = files;
ChangeLogs = changeLogs;
Metadata = metadata;
}
public string Name { get; }
public string Version { get; }
public string Architecture { get; }
public string? Release { get; }
public string? Epoch { get; }
public string? Summary { get; }
public string? Description { get; }
public string? License { get; }
public string? SourceRpm { get; }
public string? Url { get; }
public string? Vendor { get; }
public long? BuildTime { get; }
public long? InstallTime { get; }
public IReadOnlyList<string> Provides { get; }
public IReadOnlyList<string> ProvideVersions { get; }
public IReadOnlyList<string> Requires { get; }
public IReadOnlyList<string> RequireVersions { get; }
public IReadOnlyList<RpmFileEntry> Files { get; }
public IReadOnlyList<string> ChangeLogs { get; }
public IReadOnlyDictionary<string, string?> Metadata { get; }
}
internal sealed class RpmFileEntry
{
public RpmFileEntry(string path, bool isConfig, IReadOnlyDictionary<string, string> digests)
{
Path = path;
IsConfig = isConfig;
Digests = digests;
}
public string Path { get; }
public bool IsConfig { get; }
public IReadOnlyDictionary<string, string> Digests { get; }
}
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
internal sealed class RpmHeader
{
public RpmHeader(
string name,
string version,
string architecture,
string? release,
string? epoch,
string? summary,
string? description,
string? license,
string? sourceRpm,
string? url,
string? vendor,
long? buildTime,
long? installTime,
IReadOnlyList<string> provides,
IReadOnlyList<string> provideVersions,
IReadOnlyList<string> requires,
IReadOnlyList<string> requireVersions,
IReadOnlyList<RpmFileEntry> files,
IReadOnlyList<string> changeLogs,
IReadOnlyDictionary<string, string?> metadata)
{
Name = name;
Version = version;
Architecture = architecture;
Release = release;
Epoch = epoch;
Summary = summary;
Description = description;
License = license;
SourceRpm = sourceRpm;
Url = url;
Vendor = vendor;
BuildTime = buildTime;
InstallTime = installTime;
Provides = provides;
ProvideVersions = provideVersions;
Requires = requires;
RequireVersions = requireVersions;
Files = files;
ChangeLogs = changeLogs;
Metadata = metadata;
}
public string Name { get; }
public string Version { get; }
public string Architecture { get; }
public string? Release { get; }
public string? Epoch { get; }
public string? Summary { get; }
public string? Description { get; }
public string? License { get; }
public string? SourceRpm { get; }
public string? Url { get; }
public string? Vendor { get; }
public long? BuildTime { get; }
public long? InstallTime { get; }
public IReadOnlyList<string> Provides { get; }
public IReadOnlyList<string> ProvideVersions { get; }
public IReadOnlyList<string> Requires { get; }
public IReadOnlyList<string> RequireVersions { get; }
public IReadOnlyList<RpmFileEntry> Files { get; }
public IReadOnlyList<string> ChangeLogs { get; }
public IReadOnlyDictionary<string, string?> Metadata { get; }
}
internal sealed class RpmFileEntry
{
public RpmFileEntry(string path, bool isConfig, IReadOnlyDictionary<string, string> digests)
{
Path = path;
IsConfig = isConfig;
Digests = digests;
}
public string Path { get; }
public bool IsConfig { get; }
public IReadOnlyDictionary<string, string> Digests { get; }
}

View File

@@ -1,479 +1,479 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
namespace StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
internal sealed class RpmHeaderParser
{
private const uint HeaderMagic = 0x8eade8ab;
private const int RpmFileConfigFlag = 1;
public RpmHeader Parse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 16)
{
throw new InvalidOperationException("RPM header buffer too small.");
}
var reader = new HeaderReader(buffer);
var magic = reader.ReadUInt32();
if (magic != HeaderMagic)
{
throw new InvalidOperationException("Invalid RPM header magic.");
}
reader.ReadByte(); // version
reader.ReadByte(); // reserved
reader.ReadUInt16(); // reserved
var indexCount = reader.ReadInt32();
var storeSize = reader.ReadInt32();
if (indexCount < 0 || storeSize < 0)
{
throw new InvalidOperationException("Corrupt RPM header lengths.");
}
var entries = new IndexEntry[indexCount];
for (var i = 0; i < indexCount; i++)
{
var tag = reader.ReadInt32();
var type = (RpmDataType)reader.ReadInt32();
var offset = reader.ReadInt32();
var count = reader.ReadInt32();
entries[i] = new IndexEntry(tag, type, offset, count);
}
var store = reader.ReadBytes(storeSize);
for (var i = 0; i < entries.Length; i++)
{
var current = entries[i];
var nextOffset = i + 1 < entries.Length ? entries[i + 1].Offset : storeSize;
var length = Math.Max(0, nextOffset - current.Offset);
current.SetLength(length);
entries[i] = current;
}
var values = new Dictionary<int, object?>(entries.Length);
foreach (var entry in entries)
{
if (entry.Offset < 0 || entry.Offset + entry.Length > store.Length)
{
continue;
}
var slice = store.Slice(entry.Offset, entry.Length);
values[entry.Tag] = entry.Type switch
{
RpmDataType.Null => null,
RpmDataType.Char => slice.ToArray(),
RpmDataType.Int8 => ReadSByteArray(slice, entry.Count),
RpmDataType.Int16 => ReadInt16Array(slice, entry.Count),
RpmDataType.Int32 => ReadInt32Array(slice, entry.Count),
RpmDataType.Int64 => ReadInt64Array(slice, entry.Count),
RpmDataType.String => ReadString(slice),
RpmDataType.Bin => slice.ToArray(),
RpmDataType.StringArray => ReadStringArray(slice, entry.Count),
RpmDataType.I18NString => ReadStringArray(slice, entry.Count),
_ => null,
};
}
var name = RequireString(values, RpmTags.Name);
var version = RequireString(values, RpmTags.Version);
var arch = GetString(values, RpmTags.Arch) ?? "noarch";
var release = GetString(values, RpmTags.Release);
var epoch = GetEpoch(values);
var summary = GetString(values, RpmTags.Summary);
var description = GetString(values, RpmTags.Description);
var license = GetString(values, RpmTags.License);
var sourceRpm = GetString(values, RpmTags.SourceRpm);
var url = GetString(values, RpmTags.Url);
var vendor = GetString(values, RpmTags.Vendor);
var buildTime = GetFirstInt64(values, RpmTags.BuildTime);
var installTime = GetFirstInt64(values, RpmTags.InstallTime);
var provides = GetStringArray(values, RpmTags.ProvideName);
var provideVersions = GetStringArray(values, RpmTags.ProvideVersion);
var requires = GetStringArray(values, RpmTags.RequireName);
var requireVersions = GetStringArray(values, RpmTags.RequireVersion);
var changeLogs = GetStringArray(values, RpmTags.ChangeLogText);
var fileEntries = BuildFiles(values);
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal)
{
["summary"] = summary,
["description"] = description,
["vendor"] = vendor,
["url"] = url,
["packager"] = GetString(values, RpmTags.Packager),
["group"] = GetString(values, RpmTags.Group),
["buildHost"] = GetString(values, RpmTags.BuildHost),
["size"] = GetFirstInt64(values, RpmTags.Size)?.ToString(System.Globalization.CultureInfo.InvariantCulture),
["buildTime"] = buildTime?.ToString(System.Globalization.CultureInfo.InvariantCulture),
["installTime"] = installTime?.ToString(System.Globalization.CultureInfo.InvariantCulture),
["os"] = GetString(values, RpmTags.Os),
};
return new RpmHeader(
name,
version,
arch,
release,
epoch,
summary,
description,
license,
sourceRpm,
url,
vendor,
buildTime,
installTime,
provides,
provideVersions,
requires,
requireVersions,
fileEntries,
changeLogs,
new ReadOnlyDictionary<string, string?>(metadata));
}
private static IReadOnlyList<RpmFileEntry> BuildFiles(Dictionary<int, object?> values)
{
var directories = GetStringArray(values, RpmTags.DirNames);
var basenames = GetStringArray(values, RpmTags.BaseNames);
var dirIndexes = GetInt32Array(values, RpmTags.DirIndexes);
var fileFlags = GetInt32Array(values, RpmTags.FileFlags);
var fileMd5 = GetStringArray(values, RpmTags.FileMd5);
var fileDigests = GetStringArray(values, RpmTags.FileDigests);
var digestAlgorithm = GetFirstInt32(values, RpmTags.FileDigestAlgorithm) ?? 1;
if (basenames.Count == 0)
{
return Array.Empty<RpmFileEntry>();
}
var result = new List<RpmFileEntry>(basenames.Count);
for (var i = 0; i < basenames.Count; i++)
{
var dirIndex = dirIndexes.Count > i ? dirIndexes[i] : 0;
var directory = directories.Count > dirIndex ? directories[dirIndex] : "/";
if (!directory.EndsWith('/'))
{
directory += "/";
}
var fullPath = (directory + basenames[i]).Replace("//", "/");
var isConfig = fileFlags.Count > i && (fileFlags[i] & RpmFileConfigFlag) == RpmFileConfigFlag;
var digests = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (fileDigests.Count > i && !string.IsNullOrWhiteSpace(fileDigests[i]))
{
digests[ResolveDigestName(digestAlgorithm)] = fileDigests[i];
}
else if (fileMd5.Count > i && !string.IsNullOrWhiteSpace(fileMd5[i]))
{
digests["md5"] = fileMd5[i];
}
result.Add(new RpmFileEntry(fullPath, isConfig, new ReadOnlyDictionary<string, string>(digests)));
}
return new ReadOnlyCollection<RpmFileEntry>(result);
}
private static string ResolveDigestName(int algorithm)
=> algorithm switch
{
1 => "md5",
2 => "sha1",
8 => "sha256",
9 => "sha384",
10 => "sha512",
_ => "md5",
};
private static string RequireString(Dictionary<int, object?> values, int tag)
{
var value = GetString(values, tag);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"Required RPM tag {tag} missing.");
}
return value;
}
private static string? GetString(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return null;
}
return value switch
{
string s => s,
string[] array when array.Length > 0 => array[0],
byte[] bytes => Encoding.UTF8.GetString(bytes).TrimEnd('\0'),
_ => value.ToString(),
};
}
private static IReadOnlyList<string> GetStringArray(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return Array.Empty<string>();
}
return value switch
{
string[] array => array,
string s => new[] { s },
_ => Array.Empty<string>(),
};
}
private static IReadOnlyList<int> GetInt32Array(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return Array.Empty<int>();
}
return value switch
{
int[] array => array,
int i => new[] { i },
_ => Array.Empty<int>(),
};
}
private static long? GetFirstInt64(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return null;
}
return value switch
{
long[] array when array.Length > 0 => array[0],
long l => l,
int[] ints when ints.Length > 0 => ints[0],
int i => i,
_ => null,
};
}
private static int? GetFirstInt32(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return null;
}
return value switch
{
int[] array when array.Length > 0 => array[0],
int i => i,
_ => null,
};
}
private static string? GetEpoch(Dictionary<int, object?> values)
{
if (!values.TryGetValue(RpmTags.Epoch, out var value) || value is null)
{
return null;
}
return value switch
{
int i when i > 0 => i.ToString(System.Globalization.CultureInfo.InvariantCulture),
int[] array when array.Length > 0 => array[0].ToString(System.Globalization.CultureInfo.InvariantCulture),
string s => s,
_ => null,
};
}
private static sbyte[] ReadSByteArray(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<sbyte>();
}
var result = new sbyte[count];
for (var i = 0; i < count && i < slice.Length; i++)
{
result[i] = unchecked((sbyte)slice[i]);
}
return result;
}
private static short[] ReadInt16Array(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<short>();
}
var result = new short[count];
for (var i = 0; i < count && (i * 2 + 2) <= slice.Length; i++)
{
result[i] = unchecked((short)BinaryPrimitives.ReadInt16BigEndian(slice[(i * 2)..]));
}
return result;
}
private static int[] ReadInt32Array(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<int>();
}
var result = new int[count];
for (var i = 0; i < count && (i * 4 + 4) <= slice.Length; i++)
{
result[i] = BinaryPrimitives.ReadInt32BigEndian(slice[(i * 4)..]);
}
return result;
}
private static long[] ReadInt64Array(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<long>();
}
var result = new long[count];
for (var i = 0; i < count && (i * 8 + 8) <= slice.Length; i++)
{
result[i] = BinaryPrimitives.ReadInt64BigEndian(slice[(i * 8)..]);
}
return result;
}
private static string ReadString(ReadOnlySpan<byte> slice)
{
var zero = slice.IndexOf((byte)0);
if (zero >= 0)
{
slice = slice[..zero];
}
return Encoding.UTF8.GetString(slice);
}
private static string[] ReadStringArray(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<string>();
}
var list = new List<string>(count);
var span = slice;
for (var i = 0; i < count && span.Length > 0; i++)
{
var zero = span.IndexOf((byte)0);
if (zero < 0)
{
list.Add(Encoding.UTF8.GetString(span).TrimEnd('\0'));
break;
}
var value = Encoding.UTF8.GetString(span[..zero]);
list.Add(value);
span = span[(zero + 1)..];
}
return list.ToArray();
}
private struct IndexEntry
{
public IndexEntry(int tag, RpmDataType type, int offset, int count)
{
Tag = tag;
Type = type;
Offset = offset;
Count = count;
Length = 0;
}
public int Tag { get; }
public RpmDataType Type { get; }
public int Offset { get; }
public int Count { get; }
public int Length { readonly get; private set; }
public void SetLength(int length) => Length = length;
}
private enum RpmDataType
{
Null = 0,
Char = 1,
Int8 = 2,
Int16 = 3,
Int32 = 4,
Int64 = 5,
String = 6,
Bin = 7,
StringArray = 8,
I18NString = 9,
}
private ref struct HeaderReader
{
private readonly ReadOnlySpan<byte> _buffer;
private int _offset;
public HeaderReader(ReadOnlySpan<byte> buffer)
{
_buffer = buffer;
_offset = 0;
}
public uint ReadUInt32()
{
var value = BinaryPrimitives.ReadUInt32BigEndian(_buffer[_offset..]);
_offset += 4;
return value;
}
public int ReadInt32() => (int)ReadUInt32();
public ushort ReadUInt16()
{
var value = BinaryPrimitives.ReadUInt16BigEndian(_buffer[_offset..]);
_offset += 2;
return value;
}
public byte ReadByte()
{
return _buffer[_offset++];
}
public ReadOnlySpan<byte> ReadBytes(int length)
{
var slice = _buffer.Slice(_offset, length);
_offset += length;
return slice;
}
}
}
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
namespace StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
internal sealed class RpmHeaderParser
{
private const uint HeaderMagic = 0x8eade8ab;
private const int RpmFileConfigFlag = 1;
public RpmHeader Parse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 16)
{
throw new InvalidOperationException("RPM header buffer too small.");
}
var reader = new HeaderReader(buffer);
var magic = reader.ReadUInt32();
if (magic != HeaderMagic)
{
throw new InvalidOperationException("Invalid RPM header magic.");
}
reader.ReadByte(); // version
reader.ReadByte(); // reserved
reader.ReadUInt16(); // reserved
var indexCount = reader.ReadInt32();
var storeSize = reader.ReadInt32();
if (indexCount < 0 || storeSize < 0)
{
throw new InvalidOperationException("Corrupt RPM header lengths.");
}
var entries = new IndexEntry[indexCount];
for (var i = 0; i < indexCount; i++)
{
var tag = reader.ReadInt32();
var type = (RpmDataType)reader.ReadInt32();
var offset = reader.ReadInt32();
var count = reader.ReadInt32();
entries[i] = new IndexEntry(tag, type, offset, count);
}
var store = reader.ReadBytes(storeSize);
for (var i = 0; i < entries.Length; i++)
{
var current = entries[i];
var nextOffset = i + 1 < entries.Length ? entries[i + 1].Offset : storeSize;
var length = Math.Max(0, nextOffset - current.Offset);
current.SetLength(length);
entries[i] = current;
}
var values = new Dictionary<int, object?>(entries.Length);
foreach (var entry in entries)
{
if (entry.Offset < 0 || entry.Offset + entry.Length > store.Length)
{
continue;
}
var slice = store.Slice(entry.Offset, entry.Length);
values[entry.Tag] = entry.Type switch
{
RpmDataType.Null => null,
RpmDataType.Char => slice.ToArray(),
RpmDataType.Int8 => ReadSByteArray(slice, entry.Count),
RpmDataType.Int16 => ReadInt16Array(slice, entry.Count),
RpmDataType.Int32 => ReadInt32Array(slice, entry.Count),
RpmDataType.Int64 => ReadInt64Array(slice, entry.Count),
RpmDataType.String => ReadString(slice),
RpmDataType.Bin => slice.ToArray(),
RpmDataType.StringArray => ReadStringArray(slice, entry.Count),
RpmDataType.I18NString => ReadStringArray(slice, entry.Count),
_ => null,
};
}
var name = RequireString(values, RpmTags.Name);
var version = RequireString(values, RpmTags.Version);
var arch = GetString(values, RpmTags.Arch) ?? "noarch";
var release = GetString(values, RpmTags.Release);
var epoch = GetEpoch(values);
var summary = GetString(values, RpmTags.Summary);
var description = GetString(values, RpmTags.Description);
var license = GetString(values, RpmTags.License);
var sourceRpm = GetString(values, RpmTags.SourceRpm);
var url = GetString(values, RpmTags.Url);
var vendor = GetString(values, RpmTags.Vendor);
var buildTime = GetFirstInt64(values, RpmTags.BuildTime);
var installTime = GetFirstInt64(values, RpmTags.InstallTime);
var provides = GetStringArray(values, RpmTags.ProvideName);
var provideVersions = GetStringArray(values, RpmTags.ProvideVersion);
var requires = GetStringArray(values, RpmTags.RequireName);
var requireVersions = GetStringArray(values, RpmTags.RequireVersion);
var changeLogs = GetStringArray(values, RpmTags.ChangeLogText);
var fileEntries = BuildFiles(values);
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal)
{
["summary"] = summary,
["description"] = description,
["vendor"] = vendor,
["url"] = url,
["packager"] = GetString(values, RpmTags.Packager),
["group"] = GetString(values, RpmTags.Group),
["buildHost"] = GetString(values, RpmTags.BuildHost),
["size"] = GetFirstInt64(values, RpmTags.Size)?.ToString(System.Globalization.CultureInfo.InvariantCulture),
["buildTime"] = buildTime?.ToString(System.Globalization.CultureInfo.InvariantCulture),
["installTime"] = installTime?.ToString(System.Globalization.CultureInfo.InvariantCulture),
["os"] = GetString(values, RpmTags.Os),
};
return new RpmHeader(
name,
version,
arch,
release,
epoch,
summary,
description,
license,
sourceRpm,
url,
vendor,
buildTime,
installTime,
provides,
provideVersions,
requires,
requireVersions,
fileEntries,
changeLogs,
new ReadOnlyDictionary<string, string?>(metadata));
}
private static IReadOnlyList<RpmFileEntry> BuildFiles(Dictionary<int, object?> values)
{
var directories = GetStringArray(values, RpmTags.DirNames);
var basenames = GetStringArray(values, RpmTags.BaseNames);
var dirIndexes = GetInt32Array(values, RpmTags.DirIndexes);
var fileFlags = GetInt32Array(values, RpmTags.FileFlags);
var fileMd5 = GetStringArray(values, RpmTags.FileMd5);
var fileDigests = GetStringArray(values, RpmTags.FileDigests);
var digestAlgorithm = GetFirstInt32(values, RpmTags.FileDigestAlgorithm) ?? 1;
if (basenames.Count == 0)
{
return Array.Empty<RpmFileEntry>();
}
var result = new List<RpmFileEntry>(basenames.Count);
for (var i = 0; i < basenames.Count; i++)
{
var dirIndex = dirIndexes.Count > i ? dirIndexes[i] : 0;
var directory = directories.Count > dirIndex ? directories[dirIndex] : "/";
if (!directory.EndsWith('/'))
{
directory += "/";
}
var fullPath = (directory + basenames[i]).Replace("//", "/");
var isConfig = fileFlags.Count > i && (fileFlags[i] & RpmFileConfigFlag) == RpmFileConfigFlag;
var digests = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (fileDigests.Count > i && !string.IsNullOrWhiteSpace(fileDigests[i]))
{
digests[ResolveDigestName(digestAlgorithm)] = fileDigests[i];
}
else if (fileMd5.Count > i && !string.IsNullOrWhiteSpace(fileMd5[i]))
{
digests["md5"] = fileMd5[i];
}
result.Add(new RpmFileEntry(fullPath, isConfig, new ReadOnlyDictionary<string, string>(digests)));
}
return new ReadOnlyCollection<RpmFileEntry>(result);
}
private static string ResolveDigestName(int algorithm)
=> algorithm switch
{
1 => "md5",
2 => "sha1",
8 => "sha256",
9 => "sha384",
10 => "sha512",
_ => "md5",
};
private static string RequireString(Dictionary<int, object?> values, int tag)
{
var value = GetString(values, tag);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"Required RPM tag {tag} missing.");
}
return value;
}
private static string? GetString(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return null;
}
return value switch
{
string s => s,
string[] array when array.Length > 0 => array[0],
byte[] bytes => Encoding.UTF8.GetString(bytes).TrimEnd('\0'),
_ => value.ToString(),
};
}
private static IReadOnlyList<string> GetStringArray(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return Array.Empty<string>();
}
return value switch
{
string[] array => array,
string s => new[] { s },
_ => Array.Empty<string>(),
};
}
private static IReadOnlyList<int> GetInt32Array(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return Array.Empty<int>();
}
return value switch
{
int[] array => array,
int i => new[] { i },
_ => Array.Empty<int>(),
};
}
private static long? GetFirstInt64(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return null;
}
return value switch
{
long[] array when array.Length > 0 => array[0],
long l => l,
int[] ints when ints.Length > 0 => ints[0],
int i => i,
_ => null,
};
}
private static int? GetFirstInt32(Dictionary<int, object?> values, int tag)
{
if (!values.TryGetValue(tag, out var value) || value is null)
{
return null;
}
return value switch
{
int[] array when array.Length > 0 => array[0],
int i => i,
_ => null,
};
}
private static string? GetEpoch(Dictionary<int, object?> values)
{
if (!values.TryGetValue(RpmTags.Epoch, out var value) || value is null)
{
return null;
}
return value switch
{
int i when i > 0 => i.ToString(System.Globalization.CultureInfo.InvariantCulture),
int[] array when array.Length > 0 => array[0].ToString(System.Globalization.CultureInfo.InvariantCulture),
string s => s,
_ => null,
};
}
private static sbyte[] ReadSByteArray(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<sbyte>();
}
var result = new sbyte[count];
for (var i = 0; i < count && i < slice.Length; i++)
{
result[i] = unchecked((sbyte)slice[i]);
}
return result;
}
private static short[] ReadInt16Array(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<short>();
}
var result = new short[count];
for (var i = 0; i < count && (i * 2 + 2) <= slice.Length; i++)
{
result[i] = unchecked((short)BinaryPrimitives.ReadInt16BigEndian(slice[(i * 2)..]));
}
return result;
}
private static int[] ReadInt32Array(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<int>();
}
var result = new int[count];
for (var i = 0; i < count && (i * 4 + 4) <= slice.Length; i++)
{
result[i] = BinaryPrimitives.ReadInt32BigEndian(slice[(i * 4)..]);
}
return result;
}
private static long[] ReadInt64Array(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<long>();
}
var result = new long[count];
for (var i = 0; i < count && (i * 8 + 8) <= slice.Length; i++)
{
result[i] = BinaryPrimitives.ReadInt64BigEndian(slice[(i * 8)..]);
}
return result;
}
private static string ReadString(ReadOnlySpan<byte> slice)
{
var zero = slice.IndexOf((byte)0);
if (zero >= 0)
{
slice = slice[..zero];
}
return Encoding.UTF8.GetString(slice);
}
private static string[] ReadStringArray(ReadOnlySpan<byte> slice, int count)
{
if (count <= 0)
{
return Array.Empty<string>();
}
var list = new List<string>(count);
var span = slice;
for (var i = 0; i < count && span.Length > 0; i++)
{
var zero = span.IndexOf((byte)0);
if (zero < 0)
{
list.Add(Encoding.UTF8.GetString(span).TrimEnd('\0'));
break;
}
var value = Encoding.UTF8.GetString(span[..zero]);
list.Add(value);
span = span[(zero + 1)..];
}
return list.ToArray();
}
private struct IndexEntry
{
public IndexEntry(int tag, RpmDataType type, int offset, int count)
{
Tag = tag;
Type = type;
Offset = offset;
Count = count;
Length = 0;
}
public int Tag { get; }
public RpmDataType Type { get; }
public int Offset { get; }
public int Count { get; }
public int Length { readonly get; private set; }
public void SetLength(int length) => Length = length;
}
private enum RpmDataType
{
Null = 0,
Char = 1,
Int8 = 2,
Int16 = 3,
Int32 = 4,
Int64 = 5,
String = 6,
Bin = 7,
StringArray = 8,
I18NString = 9,
}
private ref struct HeaderReader
{
private readonly ReadOnlySpan<byte> _buffer;
private int _offset;
public HeaderReader(ReadOnlySpan<byte> buffer)
{
_buffer = buffer;
_offset = 0;
}
public uint ReadUInt32()
{
var value = BinaryPrimitives.ReadUInt32BigEndian(_buffer[_offset..]);
_offset += 4;
return value;
}
public int ReadInt32() => (int)ReadUInt32();
public ushort ReadUInt16()
{
var value = BinaryPrimitives.ReadUInt16BigEndian(_buffer[_offset..]);
_offset += 2;
return value;
}
public byte ReadByte()
{
return _buffer[_offset++];
}
public ReadOnlySpan<byte> ReadBytes(int length)
{
var slice = _buffer.Slice(_offset, length);
_offset += length;
return slice;
}
}
}

View File

@@ -1,36 +1,36 @@
namespace StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
internal static class RpmTags
{
public const int Name = 1000;
public const int Version = 1001;
public const int Release = 1002;
public const int Epoch = 1003;
public const int Summary = 1004;
public const int Description = 1005;
public const int BuildTime = 1006;
public const int InstallTime = 1008;
public const int Size = 1009;
public const int Vendor = 1011;
public const int License = 1014;
public const int Packager = 1015;
public const int BuildHost = 1007;
public const int Group = 1016;
public const int Url = 1020;
public const int Os = 1021;
public const int Arch = 1022;
public const int SourceRpm = 1044;
public const int ProvideName = 1047;
public const int ProvideVersion = 1048;
public const int RequireName = 1049;
public const int RequireVersion = 1050;
public const int DirNames = 1098;
public const int ChangeLogText = 1082;
public const int DirIndexes = 1116;
public const int BaseNames = 1117;
public const int FileFlags = 1037;
public const int FileSizes = 1028;
public const int FileMd5 = 1035;
public const int FileDigests = 1146;
public const int FileDigestAlgorithm = 5011;
}
namespace StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
internal static class RpmTags
{
public const int Name = 1000;
public const int Version = 1001;
public const int Release = 1002;
public const int Epoch = 1003;
public const int Summary = 1004;
public const int Description = 1005;
public const int BuildTime = 1006;
public const int InstallTime = 1008;
public const int Size = 1009;
public const int Vendor = 1011;
public const int License = 1014;
public const int Packager = 1015;
public const int BuildHost = 1007;
public const int Group = 1016;
public const int Url = 1020;
public const int Os = 1021;
public const int Arch = 1022;
public const int SourceRpm = 1044;
public const int ProvideName = 1047;
public const int ProvideVersion = 1048;
public const int RequireName = 1049;
public const int RequireVersion = 1050;
public const int DirNames = 1098;
public const int ChangeLogText = 1082;
public const int DirIndexes = 1116;
public const int BaseNames = 1117;
public const int FileFlags = 1037;
public const int FileSizes = 1028;
public const int FileMd5 = 1035;
public const int FileDigests = 1146;
public const int FileDigestAlgorithm = 5011;
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]

View File

@@ -1,23 +1,23 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
public sealed class RpmAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Rpm";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new RpmPackageAnalyzer(
loggerFactory.CreateLogger<RpmPackageAnalyzer>(),
new RpmDatabaseReader(loggerFactory.CreateLogger<RpmDatabaseReader>()));
}
}
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
public sealed class RpmAnalyzerPlugin : IOSAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.OS.Rpm";
public bool IsAvailable(IServiceProvider services) => services is not null;
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new RpmPackageAnalyzer(
loggerFactory.CreateLogger<RpmPackageAnalyzer>(),
new RpmDatabaseReader(loggerFactory.CreateLogger<RpmDatabaseReader>()));
}
}

View File

@@ -1,352 +1,416 @@
using System;
using System.Collections.Generic;
using System.Buffers.Binary;
using System.IO;
using System.Threading;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
internal sealed class RpmDatabaseReader : IRpmDatabaseReader
{
private readonly ILogger _logger;
private readonly RpmHeaderParser _parser = new();
public RpmDatabaseReader(ILogger logger)
{
_logger = logger;
}
public IReadOnlyList<RpmHeader> ReadHeaders(string rootPath, CancellationToken cancellationToken)
{
var sqlitePath = ResolveSqlitePath(rootPath);
if (sqlitePath is null)
{
_logger.LogWarning("rpmdb.sqlite not found under root {RootPath}; attempting legacy rpmdb fallback.", rootPath);
return ReadLegacyHeaders(rootPath, cancellationToken);
}
var headers = new List<RpmHeader>();
try
{
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString();
using var connection = new SqliteConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM Packages";
using var reader = command.ExecuteReader();
while (reader.Read())
{
cancellationToken.ThrowIfCancellationRequested();
var blob = ExtractHeaderBlob(reader);
if (blob is null)
{
continue;
}
try
{
headers.Add(_parser.Parse(blob));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse RPM header record (pkgKey={PkgKey}).", TryGetPkgKey(reader));
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to read rpmdb.sqlite at {Path}.", sqlitePath);
return ReadLegacyHeaders(rootPath, cancellationToken);
}
if (headers.Count == 0)
{
return ReadLegacyHeaders(rootPath, cancellationToken);
}
return headers;
}
private static string? ResolveSqlitePath(string rootPath)
{
var candidates = new[]
{
Path.Combine(rootPath, "var", "lib", "rpm", "rpmdb.sqlite"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "rpmdb.sqlite"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private IReadOnlyList<RpmHeader> ReadLegacyHeaders(string rootPath, CancellationToken cancellationToken)
{
var packagesPath = ResolveLegacyPackagesPath(rootPath);
if (packagesPath is null)
{
_logger.LogWarning("Legacy rpmdb Packages file not found under root {RootPath}; rpm analyzer will skip.", rootPath);
return Array.Empty<RpmHeader>();
}
byte[] data;
try
{
data = File.ReadAllBytes(packagesPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to read legacy rpmdb Packages file at {Path}.", packagesPath);
return Array.Empty<RpmHeader>();
}
// Detect BerkeleyDB format and use appropriate extraction method
if (BerkeleyDbReader.IsBerkeleyDb(data))
{
_logger.LogDebug("Detected BerkeleyDB format for rpmdb at {Path}; using BDB extraction.", packagesPath);
return ReadBerkeleyDbHeaders(data, packagesPath, cancellationToken);
}
// Fall back to raw RPM header scanning for non-BDB files
return ReadRawRpmHeaders(data, packagesPath, cancellationToken);
}
private IReadOnlyList<RpmHeader> ReadBerkeleyDbHeaders(byte[] data, string packagesPath, CancellationToken cancellationToken)
{
var results = new List<RpmHeader>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Try page-aware extraction first
var headerBlobs = BerkeleyDbReader.ExtractValues(data);
if (headerBlobs.Count == 0)
{
// Fall back to overflow-aware extraction for fragmented data
headerBlobs = BerkeleyDbReader.ExtractValuesWithOverflow(data);
}
foreach (var blob in headerBlobs)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var header = _parser.Parse(blob);
var key = $"{header.Name}::{header.Version}::{header.Release}::{header.Architecture}";
if (seen.Add(key))
{
results.Add(header);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse RPM header blob from BerkeleyDB.");
}
}
if (results.Count == 0)
{
_logger.LogWarning("No RPM headers parsed from BerkeleyDB rpmdb at {Path}.", packagesPath);
}
else
{
_logger.LogDebug("Extracted {Count} RPM headers from BerkeleyDB rpmdb at {Path}.", results.Count, packagesPath);
}
return results;
}
private IReadOnlyList<RpmHeader> ReadRawRpmHeaders(byte[] data, string packagesPath, CancellationToken cancellationToken)
{
var headerBlobs = new List<byte[]>();
if (BerkeleyDbReader.IsBerkeleyDb(data))
{
headerBlobs.AddRange(BerkeleyDbReader.ExtractValues(data));
if (headerBlobs.Count == 0)
{
headerBlobs.AddRange(BerkeleyDbReader.ExtractValuesWithOverflow(data));
}
}
else
{
headerBlobs.AddRange(ExtractRpmHeadersFromRaw(data, cancellationToken));
}
if (headerBlobs.Count == 0)
{
_logger.LogWarning("No RPM headers parsed from legacy rpmdb Packages at {Path}.", packagesPath);
return Array.Empty<RpmHeader>();
}
var results = new List<RpmHeader>(headerBlobs.Count);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var blob in headerBlobs)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var header = _parser.Parse(blob);
var key = $"{header.Name}::{header.Version}::{header.Release}::{header.Architecture}";
if (seen.Add(key))
{
results.Add(header);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse RPM header from legacy rpmdb blob.");
}
}
return results;
}
private static string? ResolveLegacyPackagesPath(string rootPath)
{
var candidates = new[]
{
Path.Combine(rootPath, "var", "lib", "rpm", "Packages"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "Packages"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static IEnumerable<byte[]> ExtractRpmHeadersFromRaw(byte[] data, CancellationToken cancellationToken)
{
var magicBytes = new byte[] { 0x8e, 0xad, 0xe8, 0xab };
var seenOffsets = new HashSet<int>();
var offset = 0;
while (offset <= data.Length - magicBytes.Length)
{
cancellationToken.ThrowIfCancellationRequested();
var candidateIndex = FindNextMagic(data, magicBytes, offset);
if (candidateIndex < 0)
{
yield break;
}
if (!seenOffsets.Add(candidateIndex))
{
offset = candidateIndex + 1;
continue;
}
if (TryExtractHeaderSlice(data, candidateIndex, out var slice))
{
yield return slice;
}
offset = candidateIndex + 1;
}
}
private static bool TryExtractHeaderSlice(byte[] data, int offset, out byte[] slice)
{
slice = Array.Empty<byte>();
if (offset + 16 >= data.Length)
{
return false;
}
try
{
var span = data.AsSpan(offset);
var indexCount = BinaryPrimitives.ReadInt32BigEndian(span.Slice(8, 4));
var storeSize = BinaryPrimitives.ReadInt32BigEndian(span.Slice(12, 4));
if (indexCount <= 0 || storeSize <= 0)
{
return false;
}
var totalLength = 16 + (indexCount * 16) + storeSize;
if (totalLength <= 0 || offset + totalLength > data.Length)
{
return false;
}
slice = new byte[totalLength];
Buffer.BlockCopy(data, offset, slice, 0, totalLength);
return true;
}
catch
{
return false;
}
}
private static int FindNextMagic(byte[] data, byte[] magic, int startIndex)
{
for (var i = startIndex; i <= data.Length - magic.Length; i++)
{
if (data[i] == magic[0] &&
data[i + 1] == magic[1] &&
data[i + 2] == magic[2] &&
data[i + 3] == magic[3])
{
return i;
}
}
return -1;
}
private static byte[]? ExtractHeaderBlob(SqliteDataReader reader)
{
for (var i = 0; i < reader.FieldCount; i++)
{
if (reader.GetFieldType(i) == typeof(byte[]))
{
return reader.GetFieldValue<byte[]>(i);
}
}
return null;
}
private static object? TryGetPkgKey(SqliteDataReader reader)
{
try
{
var ordinal = reader.GetOrdinal("pkgKey");
if (ordinal >= 0)
{
return reader.GetValue(ordinal);
}
}
catch
{
}
return null;
}
}
using System;
using System.Collections.Generic;
using System.Buffers.Binary;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
internal sealed class RpmDatabaseReader : IRpmDatabaseReader
{
private readonly ILogger _logger;
private readonly RpmHeaderParser _parser = new();
public RpmDatabaseReader(ILogger logger)
{
_logger = logger;
}
public IReadOnlyList<RpmHeader> ReadHeaders(string rootPath, CancellationToken cancellationToken)
{
var sqlitePath = ResolveSqlitePath(rootPath);
if (sqlitePath is null)
{
_logger.LogWarning("rpmdb.sqlite not found under root {RootPath}; attempting legacy rpmdb fallback.", rootPath);
return ReadLegacyHeaders(rootPath, cancellationToken);
}
var headers = new List<RpmHeader>();
try
{
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString();
using var connection = new SqliteConnection(connectionString);
connection.Open();
var headerColumn = TryResolveSqliteHeaderColumn(connection);
if (headerColumn is null)
{
_logger.LogWarning("rpmdb.sqlite Packages table does not expose a recognizable header blob column; falling back to legacy rpmdb.");
return ReadLegacyHeaders(rootPath, cancellationToken);
}
var includesPkgKey = HasColumn(connection, tableName: "Packages", columnName: "pkgKey");
var selectList = includesPkgKey
? $"pkgKey, {QuoteIdentifier(headerColumn)}"
: QuoteIdentifier(headerColumn);
using var command = connection.CreateCommand();
command.CommandText = $"SELECT {selectList} FROM Packages";
using var reader = command.ExecuteReader();
while (reader.Read())
{
cancellationToken.ThrowIfCancellationRequested();
byte[] blob;
try
{
blob = includesPkgKey
? reader.GetFieldValue<byte[]>(1)
: reader.GetFieldValue<byte[]>(0);
}
catch
{
continue;
}
try
{
headers.Add(_parser.Parse(blob));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse RPM header record (pkgKey={PkgKey}).", includesPkgKey ? reader.GetValue(0) : null);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to read rpmdb.sqlite at {Path}.", sqlitePath);
return ReadLegacyHeaders(rootPath, cancellationToken);
}
if (headers.Count == 0)
{
return ReadLegacyHeaders(rootPath, cancellationToken);
}
return headers;
}
private static string? ResolveSqlitePath(string rootPath)
{
var candidates = new[]
{
Path.Combine(rootPath, "var", "lib", "rpm", "rpmdb.sqlite"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "rpmdb.sqlite"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static string? TryResolveSqliteHeaderColumn(SqliteConnection connection)
{
var columns = GetTableColumns(connection, "Packages");
if (columns.Count == 0)
{
return null;
}
var blobColumns = columns
.Where(column => column.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase))
.Select(column => column.Name)
.ToList();
if (blobColumns.Count == 0)
{
return null;
}
static string? FindPreferred(IReadOnlyList<string> candidates, IReadOnlyList<string> names)
{
foreach (var name in names)
{
foreach (var candidate in candidates)
{
if (string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
}
return null;
}
var preferred = FindPreferred(blobColumns, new[] { "hdr", "header", "rpmheader", "headerblob", "blob" });
if (preferred is not null)
{
return preferred;
}
var nonPkgId = blobColumns.FirstOrDefault(column => !string.Equals(column, "pkgId", StringComparison.OrdinalIgnoreCase));
return nonPkgId ?? blobColumns[0];
}
private static IReadOnlyList<(string Name, string Type)> GetTableColumns(SqliteConnection connection, string tableName)
{
var columns = new List<(string Name, string Type)>();
using var command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({QuoteIdentifier(tableName)})";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var name = reader["name"]?.ToString();
var type = reader["type"]?.ToString();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(type))
{
continue;
}
columns.Add((name, type));
}
return columns;
}
private static bool HasColumn(SqliteConnection connection, string tableName, string columnName)
{
var columns = GetTableColumns(connection, tableName);
return columns.Any(column => string.Equals(column.Name, columnName, StringComparison.OrdinalIgnoreCase));
}
private static string QuoteIdentifier(string name)
=> "\"" + name.Replace("\"", "\"\"") + "\"";
private IReadOnlyList<RpmHeader> ReadLegacyHeaders(string rootPath, CancellationToken cancellationToken)
{
var packagesPath = ResolveLegacyPackagesPath(rootPath);
if (packagesPath is null)
{
_logger.LogWarning("Legacy rpmdb Packages file not found under root {RootPath}; rpm analyzer will skip.", rootPath);
return Array.Empty<RpmHeader>();
}
byte[] data;
try
{
data = File.ReadAllBytes(packagesPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to read legacy rpmdb Packages file at {Path}.", packagesPath);
return Array.Empty<RpmHeader>();
}
// Detect BerkeleyDB format and use appropriate extraction method
if (BerkeleyDbReader.IsBerkeleyDb(data))
{
_logger.LogDebug("Detected BerkeleyDB format for rpmdb at {Path}; using BDB extraction.", packagesPath);
return ReadBerkeleyDbHeaders(data, packagesPath, cancellationToken);
}
// Fall back to raw RPM header scanning for non-BDB files
return ReadRawRpmHeaders(data, packagesPath, cancellationToken);
}
private IReadOnlyList<RpmHeader> ReadBerkeleyDbHeaders(byte[] data, string packagesPath, CancellationToken cancellationToken)
{
var results = new List<RpmHeader>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Try page-aware extraction first
var headerBlobs = BerkeleyDbReader.ExtractValues(data);
if (headerBlobs.Count == 0)
{
// Fall back to overflow-aware extraction for fragmented data
headerBlobs = BerkeleyDbReader.ExtractValuesWithOverflow(data);
}
foreach (var blob in headerBlobs)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var header = _parser.Parse(blob);
var key = $"{header.Name}::{header.Version}::{header.Release}::{header.Architecture}";
if (seen.Add(key))
{
results.Add(header);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse RPM header blob from BerkeleyDB.");
}
}
if (results.Count == 0)
{
_logger.LogWarning("No RPM headers parsed from BerkeleyDB rpmdb at {Path}.", packagesPath);
}
else
{
_logger.LogDebug("Extracted {Count} RPM headers from BerkeleyDB rpmdb at {Path}.", results.Count, packagesPath);
}
return results;
}
private IReadOnlyList<RpmHeader> ReadRawRpmHeaders(byte[] data, string packagesPath, CancellationToken cancellationToken)
{
var headerBlobs = new List<byte[]>();
if (BerkeleyDbReader.IsBerkeleyDb(data))
{
headerBlobs.AddRange(BerkeleyDbReader.ExtractValues(data));
if (headerBlobs.Count == 0)
{
headerBlobs.AddRange(BerkeleyDbReader.ExtractValuesWithOverflow(data));
}
}
else
{
headerBlobs.AddRange(ExtractRpmHeadersFromRaw(data, cancellationToken));
}
if (headerBlobs.Count == 0)
{
_logger.LogWarning("No RPM headers parsed from legacy rpmdb Packages at {Path}.", packagesPath);
return Array.Empty<RpmHeader>();
}
var results = new List<RpmHeader>(headerBlobs.Count);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var blob in headerBlobs)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var header = _parser.Parse(blob);
var key = $"{header.Name}::{header.Version}::{header.Release}::{header.Architecture}";
if (seen.Add(key))
{
results.Add(header);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse RPM header from legacy rpmdb blob.");
}
}
return results;
}
private static string? ResolveLegacyPackagesPath(string rootPath)
{
var candidates = new[]
{
Path.Combine(rootPath, "var", "lib", "rpm", "Packages"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "Packages"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static IEnumerable<byte[]> ExtractRpmHeadersFromRaw(byte[] data, CancellationToken cancellationToken)
{
var magicBytes = new byte[] { 0x8e, 0xad, 0xe8, 0xab };
var seenOffsets = new HashSet<int>();
var offset = 0;
while (offset <= data.Length - magicBytes.Length)
{
cancellationToken.ThrowIfCancellationRequested();
var candidateIndex = FindNextMagic(data, magicBytes, offset);
if (candidateIndex < 0)
{
yield break;
}
if (!seenOffsets.Add(candidateIndex))
{
offset = candidateIndex + 1;
continue;
}
if (TryExtractHeaderSlice(data, candidateIndex, out var slice))
{
yield return slice;
}
offset = candidateIndex + 1;
}
}
private static bool TryExtractHeaderSlice(byte[] data, int offset, out byte[] slice)
{
slice = Array.Empty<byte>();
if (offset + 16 >= data.Length)
{
return false;
}
try
{
var span = data.AsSpan(offset);
var indexCount = BinaryPrimitives.ReadInt32BigEndian(span.Slice(8, 4));
var storeSize = BinaryPrimitives.ReadInt32BigEndian(span.Slice(12, 4));
if (indexCount <= 0 || storeSize <= 0)
{
return false;
}
var totalLength = 16 + (indexCount * 16) + storeSize;
if (totalLength <= 0 || offset + totalLength > data.Length)
{
return false;
}
slice = new byte[totalLength];
Buffer.BlockCopy(data, offset, slice, 0, totalLength);
return true;
}
catch
{
return false;
}
}
private static int FindNextMagic(byte[] data, byte[] magic, int startIndex)
{
for (var i = startIndex; i <= data.Length - magic.Length; i++)
{
if (data[i] == magic[0] &&
data[i + 1] == magic[1] &&
data[i + 2] == magic[2] &&
data[i + 3] == magic[3])
{
return i;
}
}
return -1;
}
}

View File

@@ -1,137 +1,137 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
internal sealed class RpmPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
private readonly IRpmDatabaseReader _reader;
public RpmPackageAnalyzer(ILogger<RpmPackageAnalyzer> logger)
: this(logger, null)
{
}
internal RpmPackageAnalyzer(ILogger<RpmPackageAnalyzer> logger, IRpmDatabaseReader? reader)
: base(logger)
{
_reader = reader ?? new RpmDatabaseReader(logger);
}
public override string AnalyzerId => "rpm";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
var headers = _reader.ReadHeaders(context.RootPath, cancellationToken);
if (headers.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var records = new List<OSPackageRecord>(headers.Count);
foreach (var header in headers)
{
try
{
var purl = PackageUrlBuilder.BuildRpm(header.Name, header.Epoch, header.Version, header.Release, header.Architecture);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["summary"] = header.Summary,
["description"] = header.Description,
["vendor"] = header.Vendor,
["url"] = header.Url,
["sourceRpm"] = header.SourceRpm,
["buildTime"] = header.BuildTime?.ToString(CultureInfo.InvariantCulture),
["installTime"] = header.InstallTime?.ToString(CultureInfo.InvariantCulture),
};
foreach (var kvp in header.Metadata)
{
vendorMetadata[$"rpm:{kvp.Key}"] = kvp.Value;
}
var provides = ComposeRelations(header.Provides, header.ProvideVersions);
var requires = ComposeRelations(header.Requires, header.RequireVersions);
var files = new List<OSPackageFileEvidence>(header.Files.Count);
foreach (var file in header.Files)
{
IDictionary<string, string>? digests = null;
if (file.Digests.Count > 0)
{
digests = new Dictionary<string, string>(file.Digests, StringComparer.OrdinalIgnoreCase);
}
files.Add(evidenceFactory.Create(file.Path, file.IsConfig, digests));
}
var cveHints = CveHintExtractor.Extract(
header.Description,
string.Join('\n', header.ChangeLogs));
var record = new OSPackageRecord(
AnalyzerId,
purl,
header.Name,
header.Version,
header.Architecture,
PackageEvidenceSource.RpmDatabase,
epoch: header.Epoch,
release: header.Release,
sourcePackage: header.SourceRpm,
license: header.License,
cveHints: cveHints,
provides: provides,
depends: requires,
files: files,
vendorMetadata: vendorMetadata);
records.Add(record);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to convert RPM header for package {Name}.", header.Name);
}
}
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
}
private static IReadOnlyList<string> ComposeRelations(IReadOnlyList<string> names, IReadOnlyList<string> versions)
{
if (names.Count == 0)
{
return Array.Empty<string>();
}
var result = new string[names.Count];
for (var i = 0; i < names.Count; i++)
{
var version = versions.Count > i ? versions[i] : null;
result[i] = string.IsNullOrWhiteSpace(version)
? names[i]
: $"{names[i]} = {version}";
}
return result;
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Rpm;
internal sealed class RpmPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
private readonly IRpmDatabaseReader _reader;
public RpmPackageAnalyzer(ILogger<RpmPackageAnalyzer> logger)
: this(logger, null)
{
}
internal RpmPackageAnalyzer(ILogger<RpmPackageAnalyzer> logger, IRpmDatabaseReader? reader)
: base(logger)
{
_reader = reader ?? new RpmDatabaseReader(logger);
}
public override string AnalyzerId => "rpm";
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
var headers = _reader.ReadHeaders(context.RootPath, cancellationToken);
if (headers.Count == 0)
{
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var records = new List<OSPackageRecord>(headers.Count);
foreach (var header in headers)
{
try
{
var purl = PackageUrlBuilder.BuildRpm(header.Name, header.Epoch, header.Version, header.Release, header.Architecture);
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["summary"] = header.Summary,
["description"] = header.Description,
["vendor"] = header.Vendor,
["url"] = header.Url,
["sourceRpm"] = header.SourceRpm,
["buildTime"] = header.BuildTime?.ToString(CultureInfo.InvariantCulture),
["installTime"] = header.InstallTime?.ToString(CultureInfo.InvariantCulture),
};
foreach (var kvp in header.Metadata)
{
vendorMetadata[$"rpm:{kvp.Key}"] = kvp.Value;
}
var provides = ComposeRelations(header.Provides, header.ProvideVersions);
var requires = ComposeRelations(header.Requires, header.RequireVersions);
var files = new List<OSPackageFileEvidence>(header.Files.Count);
foreach (var file in header.Files)
{
IDictionary<string, string>? digests = null;
if (file.Digests.Count > 0)
{
digests = new Dictionary<string, string>(file.Digests, StringComparer.OrdinalIgnoreCase);
}
files.Add(evidenceFactory.Create(file.Path, file.IsConfig, digests));
}
var cveHints = CveHintExtractor.Extract(
header.Description,
string.Join('\n', header.ChangeLogs));
var record = new OSPackageRecord(
AnalyzerId,
purl,
header.Name,
header.Version,
header.Architecture,
PackageEvidenceSource.RpmDatabase,
epoch: header.Epoch,
release: header.Release,
sourcePackage: header.SourceRpm,
license: header.License,
cveHints: cveHints,
provides: provides,
depends: requires,
files: files,
vendorMetadata: vendorMetadata);
records.Add(record);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to convert RPM header for package {Name}.", header.Name);
}
}
records.Sort();
return ValueTask.FromResult(ExecutionResult.FromPackages(records));
}
private static IReadOnlyList<string> ComposeRelations(IReadOnlyList<string> names, IReadOnlyList<string> versions)
{
if (names.Count == 0)
{
return Array.Empty<string>();
}
var result = new string[names.Count];
for (var i = 0; i < names.Count; i++)
{
var version = versions.Count > i ? versions[i] : null;
result[i] = string.IsNullOrWhiteSpace(version)
? names[i]
: $"{names[i]} = {version}";
}
return result;
}
}

View File

@@ -33,13 +33,15 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
public override string AnalyzerId => "windows-chocolatey";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
var records = new List<OSPackageRecord>();
var warnings = new List<string>();
var warnings = new List<AnalyzerWarning>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var chocolateyFound = false;
var scannedLibDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var chocoPath in ChocolateyPaths)
{
@@ -49,12 +51,18 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
continue;
}
var normalizedDir = Path.GetFullPath(libDir);
if (!scannedLibDirs.Add(normalizedDir))
{
continue;
}
chocolateyFound = true;
Logger.LogInformation("Scanning Chocolatey packages in {Path}", libDir);
try
{
DiscoverPackages(libDir, records, warnings, cancellationToken);
DiscoverPackages(context.RootPath, evidenceFactory, libDir, records, warnings, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -65,31 +73,33 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
if (!chocolateyFound)
{
Logger.LogInformation("Chocolatey installation not found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
if (records.Count == 0)
{
Logger.LogInformation("No Chocolatey packages found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
foreach (var warning in warnings.Take(10))
{
Logger.LogWarning("Chocolatey scan warning: {Warning}", warning);
Logger.LogWarning("Chocolatey scan warning ({Code}): {Message}", warning.Code, warning.Message);
}
Logger.LogInformation("Discovered {Count} Chocolatey packages", records.Count);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
return ValueTask.FromResult(ExecutionResult.From(records, warnings));
}
private void DiscoverPackages(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string libDir,
List<OSPackageRecord> records,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
IEnumerable<string> packageDirs;
@@ -112,7 +122,7 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
continue;
}
var record = AnalyzePackage(packageDir, warnings, cancellationToken);
var record = AnalyzePackage(rootPath, evidenceFactory, packageDir, warnings, cancellationToken);
if (record is not null)
{
records.Add(record);
@@ -121,8 +131,10 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
}
private OSPackageRecord? AnalyzePackage(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string packageDir,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
// Look for .nuspec file
@@ -143,7 +155,9 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
var parsed = NuspecParser.ParsePackageDirectory(dirName);
if (parsed is null)
{
warnings.Add($"Could not parse package info from {packageDir}");
warnings.Add(AnalyzerWarning.From(
"windows-chocolatey/unparseable-package-dir",
$"Could not parse package info from {Path.GetFileName(packageDir)}"));
return null;
}
@@ -173,12 +187,14 @@ internal sealed class ChocolateyPackageAnalyzer : OsPackageAnalyzerBase
var files = metadata.InstalledFiles
.Where(f => IsKeyFile(f))
.Take(100) // Limit file evidence
.Select(f => new OSPackageFileEvidence(
f,
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: IsConfigFile(f)))
.Select(f =>
{
var fullPath = Path.Combine(packageDir, f);
var relativePath = OsPath.TryGetRootfsRelative(rootPath, fullPath) ?? OsPath.NormalizeRelative(f);
return relativePath is null ? null : evidenceFactory.Create(relativePath, IsConfigFile(f));
})
.Where(static file => file is not null)
.Select(static file => file!)
.ToList();
return new OSPackageRecord(

View File

@@ -38,13 +38,14 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
public override string AnalyzerId => "windows-msi";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
var records = new List<OSPackageRecord>();
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var warnings = new List<string>();
var warnings = new List<AnalyzerWarning>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
// Scan standard MSI cache paths
foreach (var searchPath in MsiSearchPaths)
@@ -59,7 +60,7 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
try
{
DiscoverMsiFiles(fullPath, records, processedFiles, warnings, cancellationToken);
DiscoverMsiFiles(context.RootPath, evidenceFactory, fullPath, records, processedFiles, warnings, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -81,7 +82,7 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
var localAppData = Path.Combine(userDir, "AppData", "Local", "Package Cache");
if (Directory.Exists(localAppData))
{
DiscoverMsiFiles(localAppData, records, processedFiles, warnings, cancellationToken);
DiscoverMsiFiles(context.RootPath, evidenceFactory, localAppData, records, processedFiles, warnings, cancellationToken);
}
}
}
@@ -94,26 +95,28 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
if (records.Count == 0)
{
Logger.LogInformation("No MSI packages found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
foreach (var warning in warnings.Take(10))
{
Logger.LogWarning("MSI scan warning: {Warning}", warning);
Logger.LogWarning("MSI scan warning ({Code}): {Message}", warning.Code, warning.Message);
}
Logger.LogInformation("Discovered {Count} MSI packages", records.Count);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
return ValueTask.FromResult(ExecutionResult.From(records, warnings));
}
private void DiscoverMsiFiles(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string searchPath,
List<OSPackageRecord> records,
HashSet<string> processedFiles,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
IEnumerable<string> msiFiles;
@@ -142,11 +145,13 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
var fileInfo = new FileInfo(msiPath);
if (fileInfo.Length > MaxFileSizeBytes)
{
warnings.Add($"Skipping large MSI file ({fileInfo.Length} bytes): {msiPath}");
warnings.Add(AnalyzerWarning.From(
"windows-msi/too-large",
$"Skipping large MSI file ({fileInfo.Length} bytes): {OsPath.TryGetRootfsRelative(rootPath, msiPath) ?? Path.GetFileName(msiPath)}"));
continue;
}
var record = AnalyzeMsiFile(msiPath, warnings, cancellationToken);
var record = AnalyzeMsiFile(rootPath, evidenceFactory, msiPath, warnings, cancellationToken);
if (record is not null)
{
records.Add(record);
@@ -160,17 +165,23 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
}
private OSPackageRecord? AnalyzeMsiFile(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string msiPath,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
var metadata = _msiParser.Parse(msiPath, cancellationToken);
if (metadata is null)
{
warnings.Add($"Failed to parse MSI file: {msiPath}");
warnings.Add(AnalyzerWarning.From(
"windows-msi/parse-failed",
$"Failed to parse MSI file: {OsPath.TryGetRootfsRelative(rootPath, msiPath) ?? Path.GetFileName(msiPath)}"));
return null;
}
var relativeMsiPath = OsPath.TryGetRootfsRelative(rootPath, msiPath);
// Build PURL
var purl = PackageUrlBuilder.BuildWindowsMsi(
metadata.ProductName,
@@ -178,18 +189,18 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
metadata.UpgradeCode);
// Build vendor metadata
var vendorMetadata = BuildVendorMetadata(metadata);
var vendorMetadata = BuildVendorMetadata(metadata, relativeMsiPath);
// Build file evidence
var files = new List<OSPackageFileEvidence>
var digests = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(metadata.FileHash))
{
new(
Path.GetFileName(msiPath),
layerDigest: null,
sha256: metadata.FileHash,
sizeBytes: metadata.FileSize,
isConfigFile: false)
};
digests["sha256"] = metadata.FileHash;
}
var files = new List<OSPackageFileEvidence>(1);
var evidencePath = relativeMsiPath ?? Path.GetFileName(msiPath);
files.Add(evidenceFactory.Create(evidencePath, isConfigFile: false, digests: digests));
return new OSPackageRecord(
AnalyzerId,
@@ -209,11 +220,11 @@ internal sealed class MsiPackageAnalyzer : OsPackageAnalyzerBase
vendorMetadata: vendorMetadata);
}
private static Dictionary<string, string?> BuildVendorMetadata(MsiMetadata metadata)
private static Dictionary<string, string?> BuildVendorMetadata(MsiMetadata metadata, string? relativeMsiPath)
{
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["msi:file_path"] = metadata.FilePath,
["msi:file_path"] = relativeMsiPath is null ? metadata.FilePath : "/" + relativeMsiPath.TrimStart('/'),
};
if (!string.IsNullOrWhiteSpace(metadata.ProductCode))

View File

@@ -34,7 +34,7 @@ internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
public override string AnalyzerId => "windows-winsxs";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
protected override ValueTask<ExecutionResult> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
@@ -42,11 +42,12 @@ internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
if (!Directory.Exists(manifestsDir))
{
Logger.LogInformation("WinSxS manifests directory not found at {Path}; skipping analyzer.", manifestsDir);
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
var records = new List<OSPackageRecord>();
var warnings = new List<string>();
var warnings = new List<AnalyzerWarning>();
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
var processedCount = 0;
Logger.LogInformation("Scanning WinSxS manifests in {Path}", manifestsDir);
@@ -62,10 +63,13 @@ internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
if (processedCount >= MaxManifests)
{
Logger.LogWarning("Reached maximum manifest limit ({Max}); truncating results.", MaxManifests);
warnings.Add(AnalyzerWarning.From(
"windows-winsxs/truncated",
$"Reached maximum manifest limit ({MaxManifests}); results truncated."));
break;
}
var record = AnalyzeManifest(manifestPath, warnings, cancellationToken);
var record = AnalyzeManifest(context.RootPath, evidenceFactory, manifestPath, warnings, cancellationToken);
if (record is not null)
{
records.Add(record);
@@ -82,24 +86,26 @@ internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
if (records.Count == 0)
{
Logger.LogInformation("No valid WinSxS assemblies found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
}
foreach (var warning in warnings.Take(10))
{
Logger.LogWarning("WinSxS scan warning: {Warning}", warning);
Logger.LogWarning("WinSxS scan warning ({Code}): {Message}", warning.Code, warning.Message);
}
Logger.LogInformation("Discovered {Count} WinSxS assemblies from {Processed} manifests", records.Count, processedCount);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
return ValueTask.FromResult(ExecutionResult.From(records, warnings));
}
private OSPackageRecord? AnalyzeManifest(
string rootPath,
OsFileEvidenceFactory evidenceFactory,
string manifestPath,
List<string> warnings,
List<AnalyzerWarning> warnings,
CancellationToken cancellationToken)
{
var metadata = _parser.Parse(manifestPath, cancellationToken);
@@ -112,25 +118,27 @@ internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
var assemblyIdentity = WinSxSManifestParser.BuildAssemblyIdentityString(metadata);
var purl = PackageUrlBuilder.BuildWindowsWinSxS(metadata.Name, metadata.Version, metadata.ProcessorArchitecture);
var relativeManifestPath = OsPath.TryGetRootfsRelative(rootPath, manifestPath);
// Build vendor metadata
var vendorMetadata = BuildVendorMetadata(metadata);
var vendorMetadata = BuildVendorMetadata(metadata, relativeManifestPath);
// Build file evidence
var files = metadata.Files.Select(f => new OSPackageFileEvidence(
f.Name,
layerDigest: null,
sha256: FormatHash(f.Hash, f.HashAlgorithm),
sizeBytes: f.Size,
isConfigFile: f.Name.EndsWith(".config", StringComparison.OrdinalIgnoreCase)
)).ToList();
var files = new List<OSPackageFileEvidence>(metadata.Files.Count + 1);
var manifestEvidencePath = relativeManifestPath ?? Path.GetFileName(manifestPath);
var manifestEvidence = evidenceFactory.Create(manifestEvidencePath, isConfigFile: true);
files.Add(manifestEvidence);
// Add manifest file itself
files.Insert(0, new OSPackageFileEvidence(
Path.GetFileName(manifestPath),
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: true));
var manifestDisplayName = Path.GetFileName(manifestEvidencePath);
foreach (var file in metadata.Files)
{
files.Add(new OSPackageFileEvidence(
$"{manifestDisplayName}::{file.Name}",
layerDigest: manifestEvidence.LayerDigest,
sha256: FormatHash(file.Hash, file.HashAlgorithm),
sizeBytes: file.Size,
isConfigFile: file.Name.EndsWith(".config", StringComparison.OrdinalIgnoreCase)));
}
return new OSPackageRecord(
AnalyzerId,
@@ -150,14 +158,14 @@ internal sealed class WinSxSPackageAnalyzer : OsPackageAnalyzerBase
vendorMetadata: vendorMetadata);
}
private static Dictionary<string, string?> BuildVendorMetadata(WinSxSAssemblyMetadata metadata)
private static Dictionary<string, string?> BuildVendorMetadata(WinSxSAssemblyMetadata metadata, string? relativeManifestPath)
{
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["winsxs:name"] = metadata.Name,
["winsxs:version"] = metadata.Version,
["winsxs:arch"] = metadata.ProcessorArchitecture,
["winsxs:manifest_path"] = metadata.ManifestPath,
["winsxs:manifest_path"] = relativeManifestPath is null ? metadata.ManifestPath : "/" + relativeManifestPath.TrimStart('/'),
};
if (!string.IsNullOrWhiteSpace(metadata.PublicKeyToken))

View File

@@ -1,24 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Analyzers.OS.Abstractions;
/// <summary>
/// Represents a deterministic analyzer capable of extracting operating-system package
/// evidence from a container root filesystem snapshot.
/// </summary>
public interface IOSPackageAnalyzer
{
/// <summary>
/// Gets the identifier used for logging and manifest composition (e.g. <c>apk</c>, <c>dpkg</c>).
/// </summary>
string AnalyzerId { get; }
/// <summary>
/// Executes the analyzer against the provided context, producing a deterministic set of packages.
/// </summary>
/// <param name="context">Analysis context surfaced by the worker.</param>
/// <param name="cancellationToken">Cancellation token propagated from the orchestration pipeline.</param>
/// <returns>A result describing discovered packages, metadata, and telemetry.</returns>
ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Analyzers.OS.Abstractions;
/// <summary>
/// Represents a deterministic analyzer capable of extracting operating-system package
/// evidence from a container root filesystem snapshot.
/// </summary>
public interface IOSPackageAnalyzer
{
/// <summary>
/// Gets the identifier used for logging and manifest composition (e.g. <c>apk</c>, <c>dpkg</c>).
/// </summary>
string AnalyzerId { get; }
/// <summary>
/// Executes the analyzer against the provided context, producing a deterministic set of packages.
/// </summary>
/// <param name="context">Analysis context surfaced by the worker.</param>
/// <param name="cancellationToken">Cancellation token propagated from the orchestration pipeline.</param>
/// <returns>A result describing discovered packages, metadata, and telemetry.</returns>
ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
}

View File

@@ -1,41 +1,77 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
namespace StellaOps.Scanner.Analyzers.OS.Analyzers;
public abstract class OsPackageAnalyzerBase : IOSPackageAnalyzer
{
protected OsPackageAnalyzerBase(ILogger logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public abstract string AnalyzerId { get; }
protected ILogger Logger { get; }
public async ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var stopwatch = Stopwatch.StartNew();
var packages = await ExecuteCoreAsync(context, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var packageCount = packages.Count;
var fileEvidenceCount = 0;
foreach (var package in packages)
{
fileEvidenceCount += package.Files.Count;
}
var telemetry = new OSAnalyzerTelemetry(stopwatch.Elapsed, packageCount, fileEvidenceCount);
return new OSPackageAnalyzerResult(AnalyzerId, packages, telemetry);
}
protected abstract ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
}
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
namespace StellaOps.Scanner.Analyzers.OS.Analyzers;
public abstract class OsPackageAnalyzerBase : IOSPackageAnalyzer
{
private static readonly IReadOnlyList<AnalyzerWarning> EmptyWarnings =
new ReadOnlyCollection<AnalyzerWarning>(Array.Empty<AnalyzerWarning>());
private const int MaxWarningCount = 50;
protected readonly record struct ExecutionResult(IReadOnlyList<OSPackageRecord> Packages, IReadOnlyList<AnalyzerWarning> Warnings)
{
public static ExecutionResult FromPackages(IReadOnlyList<OSPackageRecord> packages)
=> new(packages, EmptyWarnings);
public static ExecutionResult From(IReadOnlyList<OSPackageRecord> packages, IReadOnlyList<AnalyzerWarning> warnings)
=> new(packages, warnings);
}
protected OsPackageAnalyzerBase(ILogger logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public abstract string AnalyzerId { get; }
protected ILogger Logger { get; }
public async ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var stopwatch = Stopwatch.StartNew();
var core = await ExecuteCoreAsync(context, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var packages = core.Packages ?? Array.Empty<OSPackageRecord>();
var packageCount = packages.Count;
var fileEvidenceCount = 0;
foreach (var package in packages)
{
fileEvidenceCount += package.Files.Count;
}
var telemetry = new OSAnalyzerTelemetry(stopwatch.Elapsed, packageCount, fileEvidenceCount);
var warnings = NormalizeWarnings(core.Warnings);
return new OSPackageAnalyzerResult(AnalyzerId, packages, telemetry, warnings);
}
protected abstract ValueTask<ExecutionResult> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
private static IReadOnlyList<AnalyzerWarning> NormalizeWarnings(IReadOnlyList<AnalyzerWarning>? warnings)
{
if (warnings is null || warnings.Count == 0)
{
return EmptyWarnings;
}
var buffer = warnings
.Where(static warning => warning is not null && !string.IsNullOrWhiteSpace(warning.Code) && !string.IsNullOrWhiteSpace(warning.Message))
.DistinctBy(static warning => (warning.Code, warning.Message))
.OrderBy(static warning => warning.Code, StringComparer.Ordinal)
.ThenBy(static warning => warning.Message, StringComparer.Ordinal)
.Take(MaxWarningCount)
.ToArray();
return buffer.Length == 0 ? EmptyWarnings : new ReadOnlyCollection<AnalyzerWarning>(buffer);
}
}

View File

@@ -1,39 +1,39 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class CveHintExtractor
{
private static readonly Regex CveRegex = new(@"CVE-\d{4}-\d{4,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static IReadOnlyList<string> Extract(params string?[] inputs)
{
if (inputs is { Length: > 0 })
{
var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var input in inputs)
{
if (string.IsNullOrWhiteSpace(input))
{
continue;
}
foreach (Match match in CveRegex.Matches(input))
{
set.Add(match.Value.ToUpperInvariant());
}
}
if (set.Count > 0)
{
return new ReadOnlyCollection<string>(set.ToArray());
}
}
return Array.Empty<string>();
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class CveHintExtractor
{
private static readonly Regex CveRegex = new(@"CVE-\d{4}-\d{4,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static IReadOnlyList<string> Extract(params string?[] inputs)
{
if (inputs is { Length: > 0 })
{
var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var input in inputs)
{
if (string.IsNullOrWhiteSpace(input))
{
continue;
}
foreach (Match match in CveRegex.Matches(input))
{
set.Add(match.Value.ToUpperInvariant());
}
}
if (set.Count > 0)
{
return new ReadOnlyCollection<string>(set.ToArray());
}
}
return Array.Empty<string>();
}
}

View File

@@ -15,6 +15,8 @@ namespace StellaOps.Scanner.Analyzers.OS.Helpers;
/// </summary>
public sealed class OsFileEvidenceFactory
{
private const long MaxComputedSha256Bytes = 16L * 1024L * 1024L;
private readonly string _rootPath;
private readonly ImmutableArray<(string? Digest, string Path)> _layerDirectories;
private readonly string? _defaultLayerDigest;
@@ -55,7 +57,12 @@ public sealed class OsFileEvidenceFactory
var info = new FileInfo(fullPath);
size = info.Length;
if (info.Length > 0 && !digestMap.TryGetValue("sha256", out sha256))
digestMap.TryGetValue("sha256", out sha256);
if (info.Length > 0 &&
sha256 is null &&
digestMap.Count == 0 &&
info.Length <= MaxComputedSha256Bytes)
{
sha256 = ComputeSha256(fullPath);
digestMap["sha256"] = sha256;

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class OsPath
{
public static string? NormalizeRelative(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var trimmed = path.Trim().TrimStart('/', '\\');
return trimmed.Replace('\\', '/');
}
public static string? TryGetRootfsRelative(string rootPath, string fullPath)
{
if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(fullPath))
{
return null;
}
try
{
var fullRoot = Path.GetFullPath(rootPath);
var full = Path.GetFullPath(fullPath);
var relative = Path.GetRelativePath(fullRoot, full);
relative = NormalizeRelative(relative);
if (relative is null ||
relative == "." ||
relative.StartsWith("..", StringComparison.Ordinal))
{
return null;
}
return relative;
}
catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException)
{
return null;
}
}
}

View File

@@ -1,171 +1,171 @@
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageUrlBuilder
{
public static string BuildAlpine(string name, string version, string architecture)
=> $"pkg:alpine/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
public static string BuildDebian(string distribution, string name, string version, string architecture)
{
var distro = string.IsNullOrWhiteSpace(distribution) ? "debian" : distribution.Trim().ToLowerInvariant();
return $"pkg:deb/{Escape(distro)}/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
}
public static string BuildRpm(string name, string? epoch, string version, string? release, string architecture)
{
var versionComponent = string.IsNullOrWhiteSpace(epoch)
? Escape(version)
: $"{Escape(epoch)}:{Escape(version)}";
var releaseComponent = string.IsNullOrWhiteSpace(release)
? string.Empty
: $"-{Escape(release!)}";
return $"pkg:rpm/{Escape(name)}@{versionComponent}{releaseComponent}?arch={EscapeQuery(architecture)}";
}
/// <summary>
/// Builds a PURL for a Homebrew formula.
/// Format: pkg:brew/{tap}/{formula}@{version}?revision={revision}
/// </summary>
public static string BuildHomebrew(string tap, string formula, string version, int revision)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tap);
ArgumentException.ThrowIfNullOrWhiteSpace(formula);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedTap = tap.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:brew/");
builder.Append(Escape(normalizedTap));
builder.Append('/');
builder.Append(Escape(formula));
builder.Append('@');
builder.Append(Escape(version));
if (revision > 0)
{
builder.Append("?revision=");
builder.Append(revision);
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a macOS pkgutil receipt.
/// Format: pkg:generic/apple/{identifier}@{version}
/// </summary>
public static string BuildPkgutil(string identifier, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/apple/{Escape(identifier)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a macOS application bundle.
/// Format: pkg:generic/macos-app/{bundleId}@{version}
/// </summary>
public static string BuildMacOsBundle(string bundleId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a Windows MSI package.
/// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode}
/// </summary>
public static string BuildWindowsMsi(string productName, string version, string? upgradeCode = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(productName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = productName.Trim().ToLowerInvariant().Replace(' ', '-');
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-msi/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(upgradeCode))
{
builder.Append("?upgrade_code=");
builder.Append(EscapeQuery(upgradeCode));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows WinSxS assembly.
/// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch}
/// </summary>
public static string BuildWindowsWinSxS(string assemblyName, string version, string? architecture = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = assemblyName.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-winsxs/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(architecture))
{
builder.Append("?arch=");
builder.Append(EscapeQuery(architecture));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows Chocolatey package.
/// Format: pkg:chocolatey/{packageId}@{version}
/// </summary>
public static string BuildChocolatey(string packageId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedId = packageId.Trim().ToLowerInvariant();
return $"pkg:chocolatey/{Escape(normalizedId)}@{Escape(version)}";
}
private static string Escape(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return Uri.EscapeDataString(value.Trim());
}
private static string EscapeQuery(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmed = value.Trim();
var builder = new StringBuilder(trimmed.Length);
foreach (var ch in trimmed)
{
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
builder.Append(ch);
}
else
{
builder.Append('%');
builder.Append(((int)ch).ToString("X2"));
}
}
return builder.ToString();
}
}
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageUrlBuilder
{
public static string BuildAlpine(string name, string version, string architecture)
=> $"pkg:alpine/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
public static string BuildDebian(string distribution, string name, string version, string architecture)
{
var distro = string.IsNullOrWhiteSpace(distribution) ? "debian" : distribution.Trim().ToLowerInvariant();
return $"pkg:deb/{Escape(distro)}/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
}
public static string BuildRpm(string name, string? epoch, string version, string? release, string architecture)
{
var versionComponent = string.IsNullOrWhiteSpace(epoch)
? Escape(version)
: $"{Escape(epoch)}:{Escape(version)}";
var releaseComponent = string.IsNullOrWhiteSpace(release)
? string.Empty
: $"-{Escape(release!)}";
return $"pkg:rpm/{Escape(name)}@{versionComponent}{releaseComponent}?arch={EscapeQuery(architecture)}";
}
/// <summary>
/// Builds a PURL for a Homebrew formula.
/// Format: pkg:brew/{tap}/{formula}@{version}?revision={revision}
/// </summary>
public static string BuildHomebrew(string tap, string formula, string version, int revision)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tap);
ArgumentException.ThrowIfNullOrWhiteSpace(formula);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedTap = tap.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:brew/");
builder.Append(Escape(normalizedTap));
builder.Append('/');
builder.Append(Escape(formula));
builder.Append('@');
builder.Append(Escape(version));
if (revision > 0)
{
builder.Append("?revision=");
builder.Append(revision);
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a macOS pkgutil receipt.
/// Format: pkg:generic/apple/{identifier}@{version}
/// </summary>
public static string BuildPkgutil(string identifier, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/apple/{Escape(identifier)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a macOS application bundle.
/// Format: pkg:generic/macos-app/{bundleId}@{version}
/// </summary>
public static string BuildMacOsBundle(string bundleId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a Windows MSI package.
/// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode}
/// </summary>
public static string BuildWindowsMsi(string productName, string version, string? upgradeCode = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(productName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = productName.Trim().ToLowerInvariant().Replace(' ', '-');
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-msi/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(upgradeCode))
{
builder.Append("?upgrade_code=");
builder.Append(EscapeQuery(upgradeCode));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows WinSxS assembly.
/// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch}
/// </summary>
public static string BuildWindowsWinSxS(string assemblyName, string version, string? architecture = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = assemblyName.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-winsxs/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(architecture))
{
builder.Append("?arch=");
builder.Append(EscapeQuery(architecture));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows Chocolatey package.
/// Format: pkg:chocolatey/{packageId}@{version}
/// </summary>
public static string BuildChocolatey(string packageId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedId = packageId.Trim().ToLowerInvariant();
return $"pkg:chocolatey/{Escape(normalizedId)}@{Escape(version)}";
}
private static string Escape(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return Uri.EscapeDataString(value.Trim());
}
private static string EscapeQuery(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmed = value.Trim();
var builder = new StringBuilder(trimmed.Length);
foreach (var ch in trimmed)
{
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
builder.Append(ch);
}
else
{
builder.Append('%');
builder.Append(((int)ch).ToString("X2"));
}
}
return builder.ToString();
}
}

View File

@@ -1,57 +1,57 @@
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageVersionParser
{
private static readonly Regex DebianVersionRegex = new(@"^(?<epoch>\d+):(?<version>.+)$", RegexOptions.Compiled);
private static readonly Regex DebianRevisionRegex = new(@"^(?<base>.+?)(?<revision>-[^-]+)?$", RegexOptions.Compiled);
private static readonly Regex ApkVersionRegex = new(@"^(?<version>.+?)(?:-(?<release>r\d+))?$", RegexOptions.Compiled);
public static DebianVersionParts ParseDebianVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var trimmed = version.Trim();
string? epoch = null;
string baseVersion = trimmed;
var epochMatch = DebianVersionRegex.Match(trimmed);
if (epochMatch.Success)
{
epoch = epochMatch.Groups["epoch"].Value;
baseVersion = epochMatch.Groups["version"].Value;
}
string? revision = null;
var revisionMatch = DebianRevisionRegex.Match(baseVersion);
if (revisionMatch.Success && revisionMatch.Groups["revision"].Success)
{
revision = revisionMatch.Groups["revision"].Value.TrimStart('-');
baseVersion = revisionMatch.Groups["base"].Value;
}
return new DebianVersionParts(epoch, baseVersion, revision, trimmed);
}
public static ApkVersionParts ParseApkVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var match = ApkVersionRegex.Match(version.Trim());
if (!match.Success)
{
return new ApkVersionParts(null, version.Trim());
}
var release = match.Groups["release"].Success ? match.Groups["release"].Value : null;
return new ApkVersionParts(release, match.Groups["version"].Value);
}
}
public sealed record DebianVersionParts(string? Epoch, string UpstreamVersion, string? Revision, string Original)
{
public string ForPackageUrl => Epoch is null ? Original : $"{Epoch}:{UpstreamVersion}{(Revision is null ? string.Empty : "-" + Revision)}";
}
public sealed record ApkVersionParts(string? Release, string BaseVersion);
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageVersionParser
{
private static readonly Regex DebianVersionRegex = new(@"^(?<epoch>\d+):(?<version>.+)$", RegexOptions.Compiled);
private static readonly Regex DebianRevisionRegex = new(@"^(?<base>.+?)(?<revision>-[^-]+)?$", RegexOptions.Compiled);
private static readonly Regex ApkVersionRegex = new(@"^(?<version>.+?)(?:-(?<release>r\d+))?$", RegexOptions.Compiled);
public static DebianVersionParts ParseDebianVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var trimmed = version.Trim();
string? epoch = null;
string baseVersion = trimmed;
var epochMatch = DebianVersionRegex.Match(trimmed);
if (epochMatch.Success)
{
epoch = epochMatch.Groups["epoch"].Value;
baseVersion = epochMatch.Groups["version"].Value;
}
string? revision = null;
var revisionMatch = DebianRevisionRegex.Match(baseVersion);
if (revisionMatch.Success && revisionMatch.Groups["revision"].Success)
{
revision = revisionMatch.Groups["revision"].Value.TrimStart('-');
baseVersion = revisionMatch.Groups["base"].Value;
}
return new DebianVersionParts(epoch, baseVersion, revision, trimmed);
}
public static ApkVersionParts ParseApkVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var match = ApkVersionRegex.Match(version.Trim());
if (!match.Success)
{
return new ApkVersionParts(null, version.Trim());
}
var release = match.Groups["release"].Success ? match.Groups["release"].Value : null;
return new ApkVersionParts(release, match.Groups["version"].Value);
}
}
public sealed record DebianVersionParts(string? Epoch, string UpstreamVersion, string? Revision, string Original)
{
public string ForPackageUrl => Epoch is null ? Original : $"{Epoch}:{UpstreamVersion}{(Revision is null ? string.Empty : "-" + Revision)}";
}
public sealed record ApkVersionParts(string? Release, string BaseVersion);

View File

@@ -0,0 +1,280 @@
namespace StellaOps.Scanner.Analyzers.OS.Internal;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.FS;
public readonly record struct OsAnalyzerSurfaceCacheEntry(OSPackageAnalyzerResult Result, bool IsHit);
public sealed class OsAnalyzerSurfaceCache
{
private const string CacheNamespace = "scanner/os/analyzers";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = false,
};
private readonly ISurfaceCache _cache;
private readonly string _tenant;
public OsAnalyzerSurfaceCache(ISurfaceCache cache, string tenant)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_tenant = string.IsNullOrWhiteSpace(tenant) ? "default" : tenant.Trim();
}
public async ValueTask<OsAnalyzerSurfaceCacheEntry> GetOrCreateEntryAsync(
ILogger logger,
string analyzerId,
string fingerprint,
Func<CancellationToken, ValueTask<OSPackageAnalyzerResult>> factory,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(factory);
if (string.IsNullOrWhiteSpace(analyzerId))
{
throw new ArgumentException("Analyzer identifier is required.", nameof(analyzerId));
}
if (string.IsNullOrWhiteSpace(fingerprint))
{
throw new ArgumentException("Fingerprint is required.", nameof(fingerprint));
}
analyzerId = analyzerId.Trim();
fingerprint = fingerprint.Trim();
var contentKey = $"{fingerprint}:{analyzerId}";
var key = new SurfaceCacheKey(CacheNamespace, _tenant, contentKey);
var cacheHit = true;
var stopwatch = Stopwatch.StartNew();
CachePayload payload;
try
{
payload = await _cache.GetOrCreateAsync(
key,
async token =>
{
cacheHit = false;
var result = await factory(token).ConfigureAwait(false);
return ToPayload(result);
},
Serialize,
Deserialize,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
cacheHit = false;
stopwatch.Stop();
logger.LogWarning(
ex,
"Surface cache lookup failed for OS analyzer {AnalyzerId} (tenant {Tenant}, fingerprint {Fingerprint}); running analyzer without cache.",
analyzerId,
_tenant,
fingerprint);
var result = await factory(cancellationToken).ConfigureAwait(false);
return new OsAnalyzerSurfaceCacheEntry(result, false);
}
stopwatch.Stop();
if (cacheHit)
{
logger.LogDebug(
"Surface cache hit for OS analyzer {AnalyzerId} (tenant {Tenant}, fingerprint {Fingerprint}).",
analyzerId,
_tenant,
fingerprint);
}
else
{
logger.LogDebug(
"Surface cache miss for OS analyzer {AnalyzerId} (tenant {Tenant}, fingerprint {Fingerprint}); stored result.",
analyzerId,
_tenant,
fingerprint);
}
var packages = payload.Packages
.Select(snapshot => snapshot.ToRecord(analyzerId))
.ToArray();
var warnings = payload.Warnings
.Select(snapshot => AnalyzerWarning.From(snapshot.Code, snapshot.Message))
.ToArray();
var fileEvidenceCount = 0;
foreach (var package in packages)
{
fileEvidenceCount += package.Files.Count;
}
var telemetry = new OSAnalyzerTelemetry(stopwatch.Elapsed, packages.Length, fileEvidenceCount);
var mappedResult = new OSPackageAnalyzerResult(analyzerId, packages, telemetry, warnings);
return new OsAnalyzerSurfaceCacheEntry(mappedResult, cacheHit);
}
private static ReadOnlyMemory<byte> Serialize(CachePayload payload)
=> JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions);
private static CachePayload Deserialize(ReadOnlyMemory<byte> payload)
{
if (payload.IsEmpty)
{
return CachePayload.Empty;
}
return JsonSerializer.Deserialize<CachePayload>(payload.Span, JsonOptions) ?? CachePayload.Empty;
}
private static CachePayload ToPayload(OSPackageAnalyzerResult result)
{
var warnings = result.Warnings
.OrderBy(static warning => warning.Code, StringComparer.Ordinal)
.ThenBy(static warning => warning.Message, StringComparer.Ordinal)
.Select(static warning => new WarningSnapshot(warning.Code, warning.Message))
.ToArray();
var packages = result.Packages
.OrderBy(static package => package, Comparer<OSPackageRecord>.Default)
.Select(static package => PackageSnapshot.FromRecord(package))
.ToArray();
return new CachePayload
{
Packages = packages,
Warnings = warnings
};
}
private sealed record CachePayload
{
public static CachePayload Empty { get; } = new()
{
Packages = Array.Empty<PackageSnapshot>(),
Warnings = Array.Empty<WarningSnapshot>()
};
public IReadOnlyList<PackageSnapshot> Packages { get; init; } = Array.Empty<PackageSnapshot>();
public IReadOnlyList<WarningSnapshot> Warnings { get; init; } = Array.Empty<WarningSnapshot>();
}
private sealed record WarningSnapshot(string Code, string Message);
private sealed record PackageSnapshot
{
public string PackageUrl { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string Architecture { get; init; } = string.Empty;
public string EvidenceSource { get; init; } = string.Empty;
public string? Epoch { get; init; }
public string? Release { get; init; }
public string? SourcePackage { get; init; }
public string? License { get; init; }
public IReadOnlyList<string> CveHints { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Provides { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Depends { get; init; } = Array.Empty<string>();
public IReadOnlyList<FileEvidenceSnapshot> Files { get; init; } = Array.Empty<FileEvidenceSnapshot>();
public IReadOnlyDictionary<string, string?> VendorMetadata { get; init; } = new Dictionary<string, string?>(StringComparer.Ordinal);
public static PackageSnapshot FromRecord(OSPackageRecord package)
{
var files = package.Files
.OrderBy(static file => file, Comparer<OSPackageFileEvidence>.Default)
.Select(static file => FileEvidenceSnapshot.FromRecord(file))
.ToArray();
return new PackageSnapshot
{
PackageUrl = package.PackageUrl,
Name = package.Name,
Version = package.Version,
Architecture = package.Architecture,
EvidenceSource = package.EvidenceSource.ToString(),
Epoch = package.Epoch,
Release = package.Release,
SourcePackage = package.SourcePackage,
License = package.License,
CveHints = package.CveHints.ToArray(),
Provides = package.Provides.ToArray(),
Depends = package.Depends.ToArray(),
Files = files,
VendorMetadata = package.VendorMetadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal),
};
}
public OSPackageRecord ToRecord(string analyzerId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
var evidenceSource = Enum.TryParse<PackageEvidenceSource>(EvidenceSource, ignoreCase: true, out var parsed)
? parsed
: PackageEvidenceSource.Unknown;
var files = Files.Select(static file => file.ToRecord()).ToArray();
return new OSPackageRecord(
analyzerId: analyzerId.Trim(),
packageUrl: PackageUrl,
name: Name,
version: Version,
architecture: Architecture,
evidenceSource: evidenceSource,
epoch: Epoch,
release: Release,
sourcePackage: SourcePackage,
license: License,
cveHints: CveHints,
provides: Provides,
depends: Depends,
files: files,
vendorMetadata: new Dictionary<string, string?>(VendorMetadata, StringComparer.Ordinal));
}
}
private sealed record FileEvidenceSnapshot
{
public string Path { get; init; } = string.Empty;
public string? LayerDigest { get; init; }
public long? SizeBytes { get; init; }
public bool? IsConfigFile { get; init; }
public IReadOnlyDictionary<string, string> Digests { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public static FileEvidenceSnapshot FromRecord(OSPackageFileEvidence file)
{
return new FileEvidenceSnapshot
{
Path = file.Path,
LayerDigest = file.LayerDigest,
SizeBytes = file.SizeBytes,
IsConfigFile = file.IsConfigFile,
Digests = file.Digests.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase),
};
}
public OSPackageFileEvidence ToRecord()
{
var digests = Digests.Count == 0
? null
: new Dictionary<string, string>(Digests, StringComparer.OrdinalIgnoreCase);
return new OSPackageFileEvidence(
Path,
layerDigest: LayerDigest,
sha256: null,
sizeBytes: SizeBytes,
isConfigFile: IsConfigFile,
digests: digests);
}
}
}

View File

@@ -0,0 +1,155 @@
namespace StellaOps.Scanner.Analyzers.OS.Internal;
using System.Buffers;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
public static class OsRootfsFingerprint
{
private const long ContentHashThresholdBytes = 8L * 1024L * 1024L;
public static string? TryCompute(string analyzerId, string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(analyzerId))
{
throw new ArgumentException("Analyzer identifier is required.", nameof(analyzerId));
}
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("Root filesystem path is required.", nameof(rootPath));
}
analyzerId = analyzerId.Trim().ToLowerInvariant();
var fullRoot = Path.GetFullPath(rootPath);
if (!Directory.Exists(fullRoot))
{
return HashPrimitive($"{analyzerId}|{fullRoot}");
}
var fingerprintFile = ResolveFingerprintFile(analyzerId, fullRoot);
if (fingerprintFile is null || !File.Exists(fingerprintFile))
{
return null;
}
using var aggregate = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
Append(aggregate, $"ROOT|{NormalizePath(fullRoot)}");
Append(aggregate, $"ANALYZER|{analyzerId}");
var relative = NormalizeRelative(fullRoot, fingerprintFile);
FileInfo info;
try
{
info = new FileInfo(fingerprintFile);
}
catch
{
return null;
}
var timestamp = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds();
Append(aggregate, $"F|{relative}|{info.Length}|{timestamp}");
if (info.Length > 0 && info.Length <= ContentHashThresholdBytes)
{
try
{
Append(aggregate, $"H|{ComputeFileHash(fingerprintFile, cancellationToken)}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Append(aggregate, $"HERR|{ex.GetType().Name}");
}
}
return Convert.ToHexString(aggregate.GetHashAndReset()).ToLowerInvariant();
}
private static string? ResolveFingerprintFile(string analyzerId, string rootPath)
{
Debug.Assert(Path.IsPathFullyQualified(rootPath), "Expected root path to be full.");
return analyzerId switch
{
"apk" => Path.Combine(rootPath, "lib", "apk", "db", "installed"),
"dpkg" => Path.Combine(rootPath, "var", "lib", "dpkg", "status"),
"rpm" => ResolveRpmFingerprintFile(rootPath),
_ => null,
};
}
private static string? ResolveRpmFingerprintFile(string rootPath)
{
var candidates = new[]
{
Path.Combine(rootPath, "var", "lib", "rpm", "rpmdb.sqlite"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "rpmdb.sqlite"),
Path.Combine(rootPath, "var", "lib", "rpm", "Packages"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "Packages"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static string ComputeFileHash(string path, CancellationToken cancellationToken)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
cancellationToken.ThrowIfCancellationRequested();
hash.AppendData(buffer, 0, read);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static void Append(IncrementalHash hash, string value)
{
var bytes = Encoding.UTF8.GetBytes(value + "\n");
hash.AppendData(bytes);
}
private static string NormalizeRelative(string root, string path)
{
if (string.Equals(root, path, StringComparison.Ordinal))
{
return ".";
}
var relative = Path.GetRelativePath(root, path);
return NormalizePath(relative);
}
private static string NormalizePath(string value)
=> value.Replace('\\', '/');
private static string HashPrimitive(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
}

View File

@@ -1,192 +1,199 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Mapping;
public static class OsComponentMapper
{
private const string ComponentType = "os-package";
public static ImmutableArray<LayerComponentFragment> ToLayerFragments(IEnumerable<OSPackageAnalyzerResult> results)
{
ArgumentNullException.ThrowIfNull(results);
var fragmentsByLayer = new Dictionary<string, List<ComponentRecord>>(StringComparer.OrdinalIgnoreCase);
foreach (var result in results)
{
if (result is null || string.IsNullOrWhiteSpace(result.AnalyzerId))
{
continue;
}
var syntheticDigest = ComputeLayerDigest(result.AnalyzerId);
foreach (var package in result.Packages ?? Enumerable.Empty<OSPackageRecord>())
{
var actualLayerDigest = ResolveLayerDigest(package) ?? syntheticDigest;
var record = ToComponentRecord(result.AnalyzerId, actualLayerDigest, package);
if (!fragmentsByLayer.TryGetValue(actualLayerDigest, out var records))
{
records = new List<ComponentRecord>();
fragmentsByLayer[actualLayerDigest] = records;
}
records.Add(record);
}
}
var builder = ImmutableArray.CreateBuilder<LayerComponentFragment>(fragmentsByLayer.Count);
foreach (var (layerDigest, records) in fragmentsByLayer)
{
builder.Add(LayerComponentFragment.Create(layerDigest, ImmutableArray.CreateRange(records)));
}
return builder.ToImmutable();
}
private static string? ResolveLayerDigest(OSPackageRecord package)
{
foreach (var file in package.Files)
{
if (!string.IsNullOrWhiteSpace(file.LayerDigest))
{
return file.LayerDigest;
}
}
return null;
}
private static ComponentRecord ToComponentRecord(string analyzerId, string layerDigest, OSPackageRecord package)
{
var identity = ComponentIdentity.Create(
key: package.PackageUrl,
name: package.Name,
version: package.Version,
purl: package.PackageUrl,
componentType: ComponentType,
group: package.SourcePackage);
var evidence = package.Files.Select(file =>
new ComponentEvidence
{
Kind = file.IsConfigFile is true ? "config-file" : "file",
Value = file.Path,
Source = ResolvePrimaryDigest(file),
}).ToImmutableArray();
var dependencies = package.Depends.Count == 0
? ImmutableArray<string>.Empty
: ImmutableArray.CreateRange(package.Depends);
var metadata = BuildMetadata(analyzerId, package);
return new ComponentRecord
{
Identity = identity,
LayerDigest = layerDigest,
Evidence = evidence,
Dependencies = dependencies,
Metadata = metadata,
Usage = ComponentUsage.Unused,
};
}
private static ComponentMetadata? BuildMetadata(string analyzerId, OSPackageRecord package)
{
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["stellaops.os.analyzer"] = analyzerId,
["stellaops.os.architecture"] = package.Architecture,
["stellaops.os.evidenceSource"] = package.EvidenceSource.ToString(),
};
if (!string.IsNullOrWhiteSpace(package.SourcePackage))
{
properties["stellaops.os.sourcePackage"] = package.SourcePackage!;
}
if (package.CveHints.Count > 0)
{
properties["stellaops.os.cveHints"] = string.Join(",", package.CveHints);
}
if (package.Provides.Count > 0)
{
properties["stellaops.os.provides"] = string.Join(",", package.Provides);
}
foreach (var pair in package.VendorMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
properties[$"vendor.{pair.Key}"] = pair.Value!.Trim();
}
foreach (var file in package.Files)
{
foreach (var digest in file.Digests)
{
if (string.IsNullOrWhiteSpace(digest.Value))
{
continue;
}
properties[$"digest.{digest.Key}.{NormalizePathKey(file.Path)}"] = digest.Value.Trim();
}
if (file.SizeBytes.HasValue)
{
properties[$"size.{NormalizePathKey(file.Path)}"] = file.SizeBytes.Value.ToString(CultureInfo.InvariantCulture);
}
}
IReadOnlyList<string>? licenses = null;
if (!string.IsNullOrWhiteSpace(package.License))
{
licenses = new[] { package.License!.Trim() };
}
return new ComponentMetadata
{
Licenses = licenses,
Properties = properties.Count == 0 ? null : properties,
};
}
private static string NormalizePathKey(string path)
=> path.Replace('/', '_').Replace('\\', '_').Trim('_');
private static string? ResolvePrimaryDigest(OSPackageFileEvidence file)
{
if (!string.IsNullOrWhiteSpace(file.Sha256))
{
return file.Sha256;
}
if (file.Digests.TryGetValue("sha256", out var sha256) && !string.IsNullOrWhiteSpace(sha256))
{
return sha256;
}
return null;
}
private static string ComputeLayerDigest(string analyzerId)
{
var normalized = $"stellaops:os:{analyzerId.Trim().ToLowerInvariant()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Mapping;
public static class OsComponentMapper
{
private const string ComponentType = "os-package";
public static ImmutableArray<LayerComponentFragment> ToLayerFragments(IEnumerable<OSPackageAnalyzerResult> results)
{
ArgumentNullException.ThrowIfNull(results);
var fragmentsByLayer = new Dictionary<string, List<ComponentRecord>>(StringComparer.OrdinalIgnoreCase);
foreach (var result in results)
{
if (result is null || string.IsNullOrWhiteSpace(result.AnalyzerId))
{
continue;
}
var syntheticDigest = ComputeLayerDigest(result.AnalyzerId);
foreach (var package in result.Packages ?? Enumerable.Empty<OSPackageRecord>())
{
var actualLayerDigest = ResolveLayerDigest(package) ?? syntheticDigest;
var record = ToComponentRecord(result.AnalyzerId, actualLayerDigest, package);
if (!fragmentsByLayer.TryGetValue(actualLayerDigest, out var records))
{
records = new List<ComponentRecord>();
fragmentsByLayer[actualLayerDigest] = records;
}
records.Add(record);
}
}
var builder = ImmutableArray.CreateBuilder<LayerComponentFragment>(fragmentsByLayer.Count);
foreach (var (layerDigest, records) in fragmentsByLayer)
{
builder.Add(LayerComponentFragment.Create(layerDigest, ImmutableArray.CreateRange(records)));
}
return builder.ToImmutable();
}
private static string? ResolveLayerDigest(OSPackageRecord package)
{
foreach (var file in package.Files)
{
if (!string.IsNullOrWhiteSpace(file.LayerDigest))
{
return file.LayerDigest;
}
}
return null;
}
private static ComponentRecord ToComponentRecord(string analyzerId, string layerDigest, OSPackageRecord package)
{
var identity = ComponentIdentity.Create(
key: package.PackageUrl,
name: package.Name,
version: package.Version,
purl: package.PackageUrl,
componentType: ComponentType,
group: package.SourcePackage);
var evidence = package.Files.Select(file =>
new ComponentEvidence
{
Kind = file.IsConfigFile is true ? "config-file" : "file",
Value = file.Path,
Source = ResolvePrimaryDigest(file),
}).ToImmutableArray();
var dependencies = package.Depends.Count == 0
? ImmutableArray<string>.Empty
: ImmutableArray.CreateRange(package.Depends);
var metadata = BuildMetadata(analyzerId, package);
return new ComponentRecord
{
Identity = identity,
LayerDigest = layerDigest,
Evidence = evidence,
Dependencies = dependencies,
Metadata = metadata,
Usage = ComponentUsage.Unused,
};
}
private static ComponentMetadata? BuildMetadata(string analyzerId, OSPackageRecord package)
{
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["stellaops.os.analyzer"] = analyzerId,
["stellaops.os.architecture"] = package.Architecture,
["stellaops.os.evidenceSource"] = package.EvidenceSource.ToString(),
};
if (!string.IsNullOrWhiteSpace(package.SourcePackage))
{
properties["stellaops.os.sourcePackage"] = package.SourcePackage!;
}
if (package.CveHints.Count > 0)
{
properties["stellaops.os.cveHints"] = string.Join(",", package.CveHints);
}
if (package.Provides.Count > 0)
{
properties["stellaops.os.provides"] = string.Join(",", package.Provides);
}
foreach (var pair in package.VendorMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
properties[$"vendor.{pair.Key}"] = pair.Value!.Trim();
}
foreach (var file in package.Files)
{
foreach (var digest in file.Digests)
{
if (string.IsNullOrWhiteSpace(digest.Value))
{
continue;
}
properties[$"digest.{digest.Key}.{NormalizePathKey(file.Path)}"] = digest.Value.Trim();
}
if (file.SizeBytes.HasValue)
{
properties[$"size.{NormalizePathKey(file.Path)}"] = file.SizeBytes.Value.ToString(CultureInfo.InvariantCulture);
}
}
IReadOnlyList<string>? licenses = null;
if (!string.IsNullOrWhiteSpace(package.License))
{
licenses = new[] { package.License!.Trim() };
}
return new ComponentMetadata
{
Licenses = licenses,
Properties = properties.Count == 0 ? null : properties,
};
}
private static string NormalizePathKey(string path)
=> path.Replace('/', '_').Replace('\\', '_').Trim('_');
private static string? ResolvePrimaryDigest(OSPackageFileEvidence file)
{
if (file is null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(file.Sha256))
{
return file.Sha256;
}
static string? SelectDigest(OSPackageFileEvidence file, string key)
=> file.Digests.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null;
return SelectDigest(file, "sha512")
?? SelectDigest(file, "sha384")
?? SelectDigest(file, "sha256")
?? SelectDigest(file, "sha1")
?? SelectDigest(file, "md5");
}
private static string ComputeLayerDigest(string analyzerId)
{
var normalized = $"stellaops:os:{analyzerId.Trim().ToLowerInvariant()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -1,13 +1,13 @@
using System;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed record AnalyzerWarning(string Code, string Message)
{
public static AnalyzerWarning From(string code, string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
ArgumentException.ThrowIfNullOrWhiteSpace(message);
return new AnalyzerWarning(code.Trim(), message.Trim());
}
}
using System;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed record AnalyzerWarning(string Code, string Message)
{
public static AnalyzerWarning From(string code, string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
ArgumentException.ThrowIfNullOrWhiteSpace(message);
return new AnalyzerWarning(code.Trim(), message.Trim());
}
}

Some files were not shown because too many files have changed in this diff Show More