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
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 '_';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
global using System;
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user