Add unit tests for RancherHubConnector and various exporters

- Implemented tests for RancherHubConnector to validate fetching documents, handling errors, and managing state.
- Added tests for CsafExporter to ensure deterministic serialization of CSAF documents.
- Created tests for CycloneDX exporters and reconciler to verify correct handling of VEX claims and output structure.
- Developed OpenVEX exporter tests to confirm the generation of canonical OpenVEX documents and statement merging logic.
- Introduced Rust file caching and license scanning functionality, including a cache key structure and hash computation.
- Added sample Cargo.toml and LICENSE files for testing Rust license scanning functionality.
This commit is contained in:
2025-10-30 07:52:39 +02:00
parent 7600caea63
commit 02e384a7d6
62 changed files with 3631 additions and 423 deletions

View File

@@ -241,25 +241,40 @@ internal static class JavaReflectionAnalyzer
instructionOffset,
null));
}
else if (normalizedOwner == "java/lang/ClassLoader" && (name == "getResource" || name == "getResourceAsStream" || name == "getResources"))
{
var target = pendingString;
var confidence = pendingString is null ? JavaReflectionConfidence.Low : JavaReflectionConfidence.High;
edges.Add(new JavaReflectionEdge(
normalizedSource,
segmentIdentifier,
target,
JavaReflectionReason.ResourceLookup,
confidence,
method.Name,
method.Descriptor,
instructionOffset,
null));
}
else if (normalizedOwner == "java/lang/Thread" && name == "currentThread")
{
sawCurrentThread = true;
}
else if (normalizedOwner == "java/lang/ClassLoader" && (name == "getResource" || name == "getResourceAsStream" || name == "getResources"))
{
var target = pendingString;
var confidence = pendingString is null ? JavaReflectionConfidence.Low : JavaReflectionConfidence.High;
edges.Add(new JavaReflectionEdge(
normalizedSource,
segmentIdentifier,
target,
JavaReflectionReason.ResourceLookup,
confidence,
method.Name,
method.Descriptor,
instructionOffset,
null));
}
else if (normalizedOwner == "java/lang/Class" && (name == "getResource" || name == "getResourceAsStream"))
{
var target = pendingString;
var confidence = pendingString is null ? JavaReflectionConfidence.Low : JavaReflectionConfidence.High;
edges.Add(new JavaReflectionEdge(
normalizedSource,
segmentIdentifier,
target,
JavaReflectionReason.ResourceLookup,
confidence,
method.Name,
method.Descriptor,
instructionOffset,
null));
}
else if (normalizedOwner == "java/lang/Thread" && name == "currentThread")
{
sawCurrentThread = true;
}
else if (normalizedOwner == "java/lang/Thread" && name == "getContextClassLoader")
{
if (sawCurrentThread && !emittedTcclWarning)

View File

@@ -7,7 +7,7 @@
| SCANNER-ANALYZERS-JAVA-21-001 | DONE (2025-10-27) | Java Analyzer Guild | SCANNER-CORE-09-501 | Build input normalizer and virtual file system for JAR/WAR/EAR/fat-jar/JMOD/jimage/container roots. Detect packaging type, layered dirs (BOOT-INF/WEB-INF), multi-release overlays, and jlink runtime metadata. | Normalizer walks fixtures without extraction, classifies packaging, selects MR overlays deterministically, records java version + vendor from runtime images. |
| SCANNER-ANALYZERS-JAVA-21-002 | DONE (2025-10-27) | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-001 | Implement module/classpath builder: JPMS graph parser (`module-info.class`), classpath order rules (fat jar, war, ear), duplicate & split-package detection, package fingerprinting. | Classpath order reproduced for fixtures; module graph serialized; duplicate provider + split-package warnings emitted deterministically. |
| SCANNER-ANALYZERS-JAVA-21-003 | DONE (2025-10-27) | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-002 | SPI scanner covering META-INF/services, provider selection, and warning generation. Include configurable SPI corpus (JDK, Spring, logging, Jackson, MicroProfile). | SPI tables produced with selected provider + candidates; fixtures show first-wins behaviour; warnings recorded for duplicate providers. |
| SCANNER-ANALYZERS-JAVA-21-004 | DOING (2025-10-27) | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-002 | Reflection/dynamic loader heuristics: scan constant pools, bytecode sites (Class.forName, loadClass, TCCL usage), resource-based plugin hints, manifest loader hints. Emit edges with reason codes + confidence. | Reflection edges generated for fixtures (classpath, boot, war); includes call site metadata and confidence scoring; TCCL warning emitted where detected. |
| SCANNER-ANALYZERS-JAVA-21-004 | DONE (2025-10-29) | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-002 | Reflection/dynamic loader heuristics: scan constant pools, bytecode sites (Class.forName, loadClass, TCCL usage), resource-based plugin hints, manifest loader hints. Emit edges with reason codes + confidence. | Reflection edges generated for fixtures (classpath, boot, war); includes call site metadata and confidence scoring; TCCL warning emitted where detected. |
| SCANNER-ANALYZERS-JAVA-21-005 | TODO | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-002 | Framework config extraction: Spring Boot imports, spring.factories, application properties/yaml, Jakarta web.xml & fragments, JAX-RS/JPA/CDI/JAXB configs, logging files, Graal native-image configs. | Framework fixtures parsed; relevant class FQCNs surfaced with reasons (`config-spring`, `config-jaxrs`, etc.); non-class config ignored; determinism guard passes. |
| SCANNER-ANALYZERS-JAVA-21-006 | TODO | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-002 | JNI/native hint scanner: detect native methods, System.load/Library literals, bundled native libs, Graal JNI configs; emit `jni-load` edges for native analyzer correlation. | JNI fixtures produce hint edges pointing at embedded libs; metadata includes candidate paths and reason `jni`. |
| SCANNER-ANALYZERS-JAVA-21-007 | TODO | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-003 | Signature and manifest metadata collector: verify JAR signature structure, capture signers, manifest loader attributes (Main-Class, Agent-Class, Start-Class, Class-Path). | Signed jar fixture reports signer info and structural validation result; manifest metadata attached to entrypoints. |

View File

@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
@@ -30,6 +29,7 @@ internal static class RustAnalyzerCollector
private readonly Dictionary<string, List<RustCrateBuilder>> _cratesByName = new(StringComparer.Ordinal);
private readonly Dictionary<string, RustHeuristicBuilder> _heuristics = new(StringComparer.Ordinal);
private readonly Dictionary<string, RustBinaryRecord> _binaries = new(StringComparer.Ordinal);
private RustLicenseIndex _licenseIndex = RustLicenseIndex.Empty;
public Collector(LanguageAnalyzerContext context)
{
@@ -38,6 +38,7 @@ internal static class RustAnalyzerCollector
public void Execute(CancellationToken cancellationToken)
{
_licenseIndex = RustLicenseScanner.GetOrCreate(_context.RootPath, cancellationToken);
CollectCargoLocks(cancellationToken);
CollectFingerprints(cancellationToken);
CollectBinaries(cancellationToken);
@@ -81,6 +82,7 @@ internal static class RustAnalyzerCollector
{
var builder = GetOrCreateCrate(package.Name, package.Version);
builder.ApplyCargoPackage(package, relativePath);
TryApplyLicense(builder);
}
}
}
@@ -95,6 +97,7 @@ internal static class RustAnalyzerCollector
var builder = GetOrCreateCrate(record.Name, record.Version);
var relative = NormalizeRelative(_context.GetRelativePath(record.AbsolutePath));
builder.ApplyFingerprint(record, relative);
TryApplyLicense(builder);
}
}
@@ -149,25 +152,30 @@ internal static class RustAnalyzerCollector
private RustCrateBuilder GetOrCreateCrate(string name, string? version)
{
var key = new RustCrateKey(name, version);
if (_crates.TryGetValue(key, out var existing))
{
existing.EnsureVersion(version);
return existing;
}
var key = new RustCrateKey(name, version);
if (_crates.TryGetValue(key, out var existing))
{
existing.EnsureVersion(version);
if (!existing.HasLicenseMetadata)
{
TryApplyLicense(existing);
}
return existing;
}
var builder = new RustCrateBuilder(name, version);
_crates[key] = builder;
var builder = new RustCrateBuilder(name, version);
_crates[key] = builder;
if (!_cratesByName.TryGetValue(builder.Name, out var list))
{
list = new List<RustCrateBuilder>();
_cratesByName[builder.Name] = list;
}
{
list = new List<RustCrateBuilder>();
_cratesByName[builder.Name] = list;
}
list.Add(builder);
return builder;
}
list.Add(builder);
TryApplyLicense(builder);
return builder;
}
private RustCrateBuilder? FindCrateByName(string candidate)
{
@@ -250,6 +258,15 @@ internal static class RustAnalyzerCollector
return relativePath.Replace('\\', '/');
}
private void TryApplyLicense(RustCrateBuilder builder)
{
var info = _licenseIndex.Find(builder.Name, builder.Version);
if (info is not null)
{
builder.ApplyLicense(info);
}
}
}
}
@@ -274,6 +291,8 @@ internal sealed class RustCrateBuilder
private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer());
private readonly SortedSet<string> _binaryPaths = new(StringComparer.Ordinal);
private readonly SortedSet<string> _binaryHashes = new(StringComparer.Ordinal);
private readonly SortedSet<string> _licenseExpressions = new(StringComparer.OrdinalIgnoreCase);
private readonly SortedDictionary<string, string?> _licenseFiles = new(StringComparer.Ordinal);
private string? _version;
private string? _source;
@@ -290,6 +309,8 @@ internal sealed class RustCrateBuilder
public string? Version => _version;
public bool HasLicenseMetadata => _licenseExpressions.Count > 0 || _licenseFiles.Count > 0;
public static string NormalizeName(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -399,9 +420,40 @@ internal sealed class RustCrateBuilder
var metadata = _metadata
.Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value))
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToList();
if (_licenseExpressions.Count > 0)
{
var index = 0;
foreach (var expression in _licenseExpressions)
{
if (string.IsNullOrWhiteSpace(expression))
{
continue;
}
metadata.Add(new KeyValuePair<string, string?>($"license.expression[{index}]", expression));
index++;
}
}
if (_licenseFiles.Count > 0)
{
var index = 0;
foreach (var pair in _licenseFiles)
{
metadata.Add(new KeyValuePair<string, string?>($"license.file[{index}]", pair.Key));
if (!string.IsNullOrWhiteSpace(pair.Value))
{
metadata.Add(new KeyValuePair<string, string?>($"license.file.sha256[{index}]", pair.Value));
}
index++;
}
}
metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
var evidence = _evidence
.OrderBy(static item => item.ComparisonKey, StringComparer.Ordinal)
.ToImmutableArray();
@@ -422,6 +474,45 @@ internal sealed class RustCrateBuilder
UsedByEntrypoint: _usedByEntrypoint);
}
public void ApplyLicense(RustLicenseInfo info)
{
if (info is null)
{
return;
}
foreach (var expression in info.Expressions)
{
if (!string.IsNullOrWhiteSpace(expression))
{
_licenseExpressions.Add(expression.Trim());
}
}
foreach (var file in info.Files)
{
if (string.IsNullOrWhiteSpace(file.RelativePath))
{
continue;
}
var normalized = file.RelativePath.Replace('\\', '/');
if (_licenseFiles.ContainsKey(normalized))
{
continue;
}
if (string.IsNullOrWhiteSpace(file.Sha256))
{
_licenseFiles[normalized] = null;
}
else
{
_licenseFiles[normalized] = file.Sha256!.Trim();
}
}
}
private void AddMetadataIfEmpty(string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
@@ -577,28 +668,14 @@ internal sealed class RustBinaryRecord
_hash ??= hash;
}
if (_hash is null)
if (!string.IsNullOrEmpty(_hash))
{
_hash = ComputeHashSafely();
return;
}
}
private string? ComputeHashSafely()
{
try
if (RustFileHashCache.TryGetSha256(AbsolutePath, out var computed) && !string.IsNullOrEmpty(computed))
{
using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
_hash = computed;
}
}
}

View File

@@ -1,7 +1,7 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
@@ -32,6 +32,8 @@ internal static class RustBinaryClassifier
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))
@@ -49,7 +51,16 @@ internal static class RustBinaryClassifier
continue;
}
var candidates = ExtractCrateNames(path, cancellationToken);
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));
}
@@ -220,31 +231,13 @@ internal static class RustBinaryClassifier
internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates)
{
private string? _sha256;
public string ComputeSha256()
{
if (_sha256 is not null)
if (RustFileHashCache.TryGetSha256(AbsolutePath, out var sha256) && !string.IsNullOrEmpty(sha256))
{
return _sha256;
return sha256;
}
try
{
using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
_sha256 = Convert.ToHexString(hash).ToLowerInvariant();
}
catch (IOException)
{
_sha256 = string.Empty;
}
catch (UnauthorizedAccessException)
{
_sha256 = string.Empty;
}
return _sha256 ?? string.Empty;
return string.Empty;
}
}

View File

@@ -1,7 +1,12 @@
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))
@@ -9,17 +14,26 @@ internal static class RustCargoLockParser
throw new ArgumentException("Lock path is required", nameof(path));
}
var info = new FileInfo(path);
if (!info.Exists)
if (!RustFileCacheKey.TryCreate(path, out var key))
{
return Array.Empty<RustCargoPackage>();
}
var packages = new List<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? builder = null;
RustCargoPackageBuilder? packageBuilder = null;
string? currentArrayKey = null;
var arrayValues = new List<string>();
@@ -41,14 +55,14 @@ internal static class RustCargoLockParser
if (IsPackageHeader(trimmed))
{
FlushCurrent(builder, packages);
builder = new RustCargoPackageBuilder();
FlushCurrent(packageBuilder, resultBuilder);
packageBuilder = new RustCargoPackageBuilder();
currentArrayKey = null;
arrayValues.Clear();
continue;
}
if (builder is null)
if (packageBuilder is null)
{
continue;
}
@@ -94,12 +108,12 @@ internal static class RustCargoLockParser
}
if (valuePart[0] == '[')
{
currentArrayKey = key.ToString();
arrayValues.Clear();
if (valuePart.Length > 1 && valuePart[^1] == ']')
{
currentArrayKey = key.ToString();
arrayValues.Clear();
if (valuePart.Length > 1 && valuePart[^1] == ']')
{
var inline = valuePart[1..^1].Trim();
if (inline.Length > 0)
{
@@ -113,7 +127,7 @@ internal static class RustCargoLockParser
}
}
builder.SetArray(currentArrayKey, arrayValues);
packageBuilder.SetArray(currentArrayKey, arrayValues);
currentArrayKey = null;
arrayValues.Clear();
}
@@ -124,17 +138,17 @@ internal static class RustCargoLockParser
var parsed = ExtractString(valuePart);
if (parsed is not null)
{
builder.SetField(key, parsed);
packageBuilder.SetField(key, parsed);
}
}
if (currentArrayKey is not null && arrayValues.Count > 0)
{
builder?.SetArray(currentArrayKey, arrayValues);
packageBuilder?.SetArray(currentArrayKey, arrayValues);
}
FlushCurrent(builder, packages);
return packages;
FlushCurrent(packageBuilder, resultBuilder);
return resultBuilder.ToImmutable();
}
private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line)
@@ -204,14 +218,14 @@ internal static class RustCargoLockParser
return trimmed.Length == 0 ? null : trimmed.ToString();
}
private static void FlushCurrent(RustCargoPackageBuilder? builder, List<RustCargoPackage> packages)
private static void FlushCurrent(RustCargoPackageBuilder? packageBuilder, ImmutableArray<RustCargoPackage>.Builder packages)
{
if (builder is null || !builder.HasData)
if (packageBuilder is null || !packageBuilder.HasData)
{
return;
}
if (builder.TryBuild(out var package))
if (packageBuilder.TryBuild(out var package))
{
packages.Add(package);
}

View File

@@ -0,0 +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);
}

View File

@@ -0,0 +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();
}
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
@@ -13,6 +14,7 @@ internal static class RustFingerprintScanner
};
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)
{
@@ -31,7 +33,17 @@ internal static class RustFingerprintScanner
continue;
}
if (TryParse(path, out var record))
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);
}
@@ -40,10 +52,8 @@ internal static class RustFingerprintScanner
return results;
}
private static bool TryParse(string path, out RustFingerprintRecord record)
private static RustFingerprintRecord? ParseFingerprint(string path)
{
record = default!;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
@@ -57,33 +67,31 @@ internal static class RustFingerprintScanner
var (name, version, source) = ParseIdentity(pkgId, path);
if (string.IsNullOrWhiteSpace(name))
{
return false;
return null;
}
var profile = TryGetString(root, "profile");
var targetKind = TryGetKind(root);
record = new RustFingerprintRecord(
return new RustFingerprintRecord(
Name: name!,
Version: version,
Source: source,
TargetKind: targetKind,
Profile: profile,
AbsolutePath: path);
return true;
}
catch (JsonException)
{
return false;
return null;
}
catch (IOException)
{
return false;
return null;
}
catch (UnauthorizedAccessException)
{
return false;
return null;
}
}

View File

@@ -0,0 +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);

View File

@@ -5,6 +5,6 @@
| 1 | SCANNER-ANALYZERS-LANG-10-306A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. | Fixtures confirm crate attribution ≥85% coverage; metadata normalized; evidence includes path + hash. |
| 2 | SCANNER-ANALYZERS-LANG-10-306B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306A | Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries. | Heuristic output flagged as `heuristic`; regression tests ensure no false “observed” classifications. |
| 3 | SCANNER-ANALYZERS-LANG-10-306C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306B | Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers. | Fallback path deterministic; shared helpers reused; tests verify consistent hashing. |
| 4 | SCANNER-ANALYZERS-LANG-10-307R | DOING (2025-10-23) | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. |
| 4 | SCANNER-ANALYZERS-LANG-10-307R | DONE (2025-10-29) | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. |
| 5 | SCANNER-ANALYZERS-LANG-10-308R | TODO | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15% better coverage vs competitor. |
| 6 | SCANNER-ANALYZERS-LANG-10-309R | TODO | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. |

View File

@@ -77,26 +77,59 @@ public sealed class JavaReflectionAnalyzerTests
}
[Fact]
public void Analyze_SpringBootFatJar_ScansEmbeddedAndBootSegments()
{
var root = TestPaths.CreateTemporaryDirectory();
try
{
JavaFixtureBuilder.CreateSpringBootFatJar(root, "apps/app-fat.jar");
var cancellationToken = TestContext.Current.CancellationToken;
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
var workspace = JavaWorkspaceNormalizer.Normalize(context, cancellationToken);
var classPath = JavaClassPathBuilder.Build(workspace, cancellationToken);
var analysis = JavaReflectionAnalyzer.Analyze(classPath, cancellationToken);
// Expect at least one edge originating from BOOT-INF classes
Assert.Contains(analysis.Edges, edge => edge.SourceClass == "com.example.App" && edge.Reason == JavaReflectionReason.ClassForName);
Assert.Contains(analysis.Edges, edge => edge.SourceClass == "com.example.Lib" && edge.Reason == JavaReflectionReason.ClassForName);
}
finally
{
TestPaths.SafeDelete(root);
}
}
}
public void Analyze_SpringBootFatJar_ScansEmbeddedAndBootSegments()
{
var root = TestPaths.CreateTemporaryDirectory();
try
{
JavaFixtureBuilder.CreateSpringBootFatJar(root, "apps/app-fat.jar");
var cancellationToken = TestContext.Current.CancellationToken;
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
var workspace = JavaWorkspaceNormalizer.Normalize(context, cancellationToken);
var classPath = JavaClassPathBuilder.Build(workspace, cancellationToken);
var analysis = JavaReflectionAnalyzer.Analyze(classPath, cancellationToken);
// Expect at least one edge originating from BOOT-INF classes
Assert.Contains(analysis.Edges, edge => edge.SourceClass == "com.example.App" && edge.Reason == JavaReflectionReason.ClassForName);
Assert.Contains(analysis.Edges, edge => edge.SourceClass == "com.example.Lib" && edge.Reason == JavaReflectionReason.ClassForName);
}
finally
{
TestPaths.SafeDelete(root);
}
}
[Fact]
public void Analyze_ClassResourceLookup_ProducesResourceEdge()
{
var root = TestPaths.CreateTemporaryDirectory();
try
{
var jarPath = Path.Combine(root, "libs", "resources.jar");
Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
using (var archive = new ZipArchive(new FileStream(jarPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None), ZipArchiveMode.Create, leaveOpen: false))
{
var entry = archive.CreateEntry("com/example/Resources.class");
var bytes = JavaClassFileFactory.CreateClassResourceLookup("com/example/Resources", "/META-INF/plugin.properties");
using var stream = entry.Open();
stream.Write(bytes);
}
var cancellationToken = TestContext.Current.CancellationToken;
var context = new LanguageAnalyzerContext(root, TimeProvider.System);
var workspace = JavaWorkspaceNormalizer.Normalize(context, cancellationToken);
var classPath = JavaClassPathBuilder.Build(workspace, cancellationToken);
var analysis = JavaReflectionAnalyzer.Analyze(classPath, cancellationToken);
var edge = Assert.Single(analysis.Edges.Where(edge => edge.Reason == JavaReflectionReason.ResourceLookup));
Assert.Equal("com.example.Resources", edge.SourceClass);
Assert.Equal("/META-INF/plugin.properties", edge.TargetType);
Assert.Equal(JavaReflectionConfidence.High, edge.Confidence);
}
finally
{
TestPaths.SafeDelete(root);
}
}
}

View File

@@ -0,0 +1,4 @@
[package]
name = "my_app"
version = "0.1.0"
license = "MIT"

View File

@@ -0,0 +1,16 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -7,12 +7,13 @@
"version": "0.1.0",
"type": "cargo",
"usedByEntrypoint": false,
"metadata": {
"cargo.lock.path": "Cargo.lock",
"fingerprint.profile": "debug",
"fingerprint.targetKind": "bin",
"source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index"
},
"metadata": {
"cargo.lock.path": "Cargo.lock",
"fingerprint.profile": "debug",
"fingerprint.targetKind": "bin",
"license.expression[0]": "MIT",
"source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index"
},
"evidence": [
{
"kind": "file",
@@ -36,13 +37,14 @@
"version": "1.0.188",
"type": "cargo",
"usedByEntrypoint": false,
"metadata": {
"cargo.lock.path": "Cargo.lock",
"checksum": "abc123",
"fingerprint.profile": "release",
"fingerprint.targetKind": "lib",
"source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index"
},
"metadata": {
"cargo.lock.path": "Cargo.lock",
"checksum": "abc123",
"fingerprint.profile": "release",
"fingerprint.targetKind": "lib",
"license.expression[0]": "Apache-2.0",
"source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index"
},
"evidence": [
{
"kind": "file",
@@ -59,4 +61,4 @@
}
]
}
]
]

View File

@@ -0,0 +1,4 @@
[package]
name = "serde"
version = "1.0.188"
license = "Apache-2.0"

View File

@@ -1,4 +1,6 @@
using System;
using System.IO;
using System.Linq;
using StellaOps.Scanner.Analyzers.Lang.Rust;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
@@ -31,4 +33,27 @@ public sealed class RustLanguageAnalyzerTests
cancellationToken,
usageHints);
}
[Fact]
public async Task AnalyzerIsThreadSafeUnderConcurrencyAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple");
var analyzers = new ILanguageAnalyzer[]
{
new RustLanguageAnalyzer()
};
var workers = Math.Max(Environment.ProcessorCount, 4);
var tasks = Enumerable.Range(0, workers)
.Select(_ => LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken));
var results = await Task.WhenAll(tasks);
var baseline = results[0];
foreach (var result in results)
{
Assert.Equal(baseline, result);
}
}
}

View File

@@ -5,11 +5,11 @@ namespace StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
public static class JavaClassFileFactory
{
public static byte[] CreateClassForNameInvoker(string internalClassName, string targetClassName)
{
using var buffer = new MemoryStream();
using var writer = new BigEndianWriter(buffer);
public static byte[] CreateClassForNameInvoker(string internalClassName, string targetClassName)
{
using var buffer = new MemoryStream();
using var writer = new BigEndianWriter(buffer);
WriteClassFileHeader(writer, constantPoolCount: 16);
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8(internalClassName); // #1
@@ -40,8 +40,46 @@ public static class JavaClassFileFactory
writer.WriteUInt16(0); // class attributes
return buffer.ToArray();
}
return buffer.ToArray();
}
public static byte[] CreateClassResourceLookup(string internalClassName, string resourcePath)
{
using var buffer = new MemoryStream();
using var writer = new BigEndianWriter(buffer);
WriteClassFileHeader(writer, constantPoolCount: 18);
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8(internalClassName); // #1
writer.WriteByte((byte)ConstantTag.Class); writer.WriteUInt16(1); // #2
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("java/lang/Object"); // #3
writer.WriteByte((byte)ConstantTag.Class); writer.WriteUInt16(3); // #4
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("load"); // #5
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("()V"); // #6
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("Code"); // #7
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8(resourcePath); // #8
writer.WriteByte((byte)ConstantTag.String); writer.WriteUInt16(8); // #9
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("java/lang/Class"); // #10
writer.WriteByte((byte)ConstantTag.Class); writer.WriteUInt16(10); // #11
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("getResource"); // #12
writer.WriteByte((byte)ConstantTag.Utf8); writer.WriteUtf8("(Ljava/lang/String;)Ljava/net/URL;"); // #13
writer.WriteByte((byte)ConstantTag.NameAndType); writer.WriteUInt16(12); writer.WriteUInt16(13); // #14
writer.WriteByte((byte)ConstantTag.Methodref); writer.WriteUInt16(11); writer.WriteUInt16(14); // #15
writer.WriteUInt16(0x0001); // public
writer.WriteUInt16(2); // this class
writer.WriteUInt16(4); // super class
writer.WriteUInt16(0); // interfaces
writer.WriteUInt16(0); // fields
writer.WriteUInt16(1); // methods
WriteResourceLookupMethod(writer, methodNameIndex: 5, descriptorIndex: 6, classConstantIndex: 4, stringIndex: 9, methodRefIndex: 15);
writer.WriteUInt16(0); // class attributes
return buffer.ToArray();
}
public static byte[] CreateTcclChecker(string internalClassName)
{
@@ -119,11 +157,11 @@ public static class JavaClassFileFactory
writer.WriteBytes(codeBytes);
}
private static void WriteTcclMethod(BigEndianWriter writer, ushort methodNameIndex, ushort descriptorIndex, ushort currentThreadMethodRefIndex, ushort getContextMethodRefIndex)
{
writer.WriteUInt16(0x0009);
writer.WriteUInt16(methodNameIndex);
writer.WriteUInt16(descriptorIndex);
private static void WriteTcclMethod(BigEndianWriter writer, ushort methodNameIndex, ushort descriptorIndex, ushort currentThreadMethodRefIndex, ushort getContextMethodRefIndex)
{
writer.WriteUInt16(0x0009);
writer.WriteUInt16(methodNameIndex);
writer.WriteUInt16(descriptorIndex);
writer.WriteUInt16(1);
writer.WriteUInt16(7);
@@ -144,9 +182,40 @@ public static class JavaClassFileFactory
}
var codeBytes = codeBuffer.ToArray();
writer.WriteUInt32((uint)codeBytes.Length);
writer.WriteBytes(codeBytes);
}
writer.WriteUInt32((uint)codeBytes.Length);
writer.WriteBytes(codeBytes);
}
private static void WriteResourceLookupMethod(BigEndianWriter writer, ushort methodNameIndex, ushort descriptorIndex, ushort classConstantIndex, ushort stringIndex, ushort methodRefIndex)
{
writer.WriteUInt16(0x0009);
writer.WriteUInt16(methodNameIndex);
writer.WriteUInt16(descriptorIndex);
writer.WriteUInt16(1);
writer.WriteUInt16(7);
using var codeBuffer = new MemoryStream();
using (var codeWriter = new BigEndianWriter(codeBuffer))
{
codeWriter.WriteUInt16(2);
codeWriter.WriteUInt16(0);
codeWriter.WriteUInt32(8);
codeWriter.WriteByte(0x13); // ldc_w for class literal
codeWriter.WriteUInt16(classConstantIndex);
codeWriter.WriteByte(0x12);
codeWriter.WriteByte((byte)stringIndex);
codeWriter.WriteByte(0xB6);
codeWriter.WriteUInt16(methodRefIndex);
codeWriter.WriteByte(0x57);
codeWriter.WriteByte(0xB1);
codeWriter.WriteUInt16(0);
codeWriter.WriteUInt16(0);
}
var codeBytes = codeBuffer.ToArray();
writer.WriteUInt32((uint)codeBytes.Length);
writer.WriteBytes(codeBytes);
}
private sealed class BigEndianWriter : IDisposable
{