Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,10 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.IO.Compression;
global using System.Security.Cryptography;
global using System.Text;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

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

View File

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

View File

@@ -0,0 +1,660 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
internal static class JavaClassPathBuilder
{
private const string ClassFileSuffix = ".class";
public static JavaClassPathAnalysis Build(JavaWorkspace workspace, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(workspace);
var segments = new List<JavaClassPathSegment>();
var modules = new List<JavaModuleDescriptor>();
var duplicateMap = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var packageMap = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
var order = 0;
foreach (var archive in workspace.Archives)
{
cancellationToken.ThrowIfCancellationRequested();
ProcessArchive(archive, segments, modules, duplicateMap, packageMap, ref order, cancellationToken);
}
var duplicateClasses = duplicateMap
.Where(static pair => pair.Value.Count > 1)
.Select(pair => new JavaClassDuplicate(
pair.Key,
pair.Value
.Distinct(StringComparer.Ordinal)
.OrderBy(static identifier => identifier, StringComparer.Ordinal)
.ToImmutableArray()))
.ToImmutableArray();
var splitPackages = packageMap
.Where(static pair => pair.Value.Count > 1)
.Select(pair => new JavaSplitPackage(
pair.Key,
pair.Value
.OrderBy(static identifier => identifier, StringComparer.Ordinal)
.ToImmutableArray()))
.ToImmutableArray();
return new JavaClassPathAnalysis(segments, modules, duplicateClasses, splitPackages);
}
private static void ProcessArchive(
JavaArchive archive,
List<JavaClassPathSegment> segments,
List<JavaModuleDescriptor> modules,
Dictionary<string, List<string>> duplicateMap,
Dictionary<string, HashSet<string>> packageMap,
ref int order,
CancellationToken cancellationToken)
{
var identifier = NormalizeArchivePath(archive.RelativePath);
var baseClasses = new List<JavaClassRecord>();
var baseServices = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var bootClasses = new List<JavaClassRecord>();
var bootServices = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var webClasses = new List<JavaClassRecord>();
var webServices = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var embeddedEntries = new List<JavaArchiveEntry>();
JavaModuleDescriptor? moduleDescriptor = ParseModuleDescriptor(archive, identifier, cancellationToken);
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
var path = entry.EffectivePath;
if (path.Length == 0)
{
continue;
}
if (string.Equals(path, "module-info.class", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (path.StartsWith("BOOT-INF/classes/", StringComparison.OrdinalIgnoreCase))
{
var relative = path["BOOT-INF/classes/".Length..];
if (TryHandleServiceDescriptor(archive, entry, relative, bootServices, cancellationToken))
{
continue;
}
if (relative.EndsWith(ClassFileSuffix, StringComparison.OrdinalIgnoreCase))
{
AddClassRecord(bootClasses, archive, entry, relative);
}
continue;
}
if (path.StartsWith("WEB-INF/classes/", StringComparison.OrdinalIgnoreCase))
{
var relative = path["WEB-INF/classes/".Length..];
if (TryHandleServiceDescriptor(archive, entry, relative, webServices, cancellationToken))
{
continue;
}
if (relative.EndsWith(ClassFileSuffix, StringComparison.OrdinalIgnoreCase))
{
AddClassRecord(webClasses, archive, entry, relative);
}
continue;
}
if (TryHandleServiceDescriptor(archive, entry, path, baseServices, cancellationToken))
{
continue;
}
if (path.EndsWith(ClassFileSuffix, StringComparison.OrdinalIgnoreCase))
{
if (archive.Packaging == JavaPackagingKind.JMod && path.StartsWith("classes/", StringComparison.OrdinalIgnoreCase))
{
var relative = path["classes/".Length..];
AddClassRecord(baseClasses, archive, entry, relative);
}
else
{
AddClassRecord(baseClasses, archive, entry, path);
}
}
else if (path.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) && IsEmbeddedLibrary(path))
{
embeddedEntries.Add(entry);
}
}
// Base archive segment.
if (baseClasses.Count > 0 || moduleDescriptor is not null)
{
AddSegment(
segments,
modules,
duplicateMap,
packageMap,
ref order,
identifier,
identifier,
JavaClassPathSegmentKind.Archive,
archive.Packaging,
moduleDescriptor,
baseClasses,
ToImmutableServiceMap(baseServices));
}
if (bootClasses.Count > 0)
{
var bootIdentifier = string.Concat(identifier, "!BOOT-INF/classes/");
AddSegment(
segments,
modules,
duplicateMap,
packageMap,
ref order,
bootIdentifier,
bootIdentifier,
JavaClassPathSegmentKind.Directory,
JavaPackagingKind.Unknown,
module: null,
bootClasses,
ToImmutableServiceMap(bootServices));
}
if (webClasses.Count > 0)
{
var webIdentifier = string.Concat(identifier, "!WEB-INF/classes/");
AddSegment(
segments,
modules,
duplicateMap,
packageMap,
ref order,
webIdentifier,
webIdentifier,
JavaClassPathSegmentKind.Directory,
JavaPackagingKind.Unknown,
module: null,
webClasses,
ToImmutableServiceMap(webServices));
}
foreach (var embedded in embeddedEntries.OrderBy(static entry => entry.EffectivePath, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var childIdentifier = string.Concat(identifier, "!", embedded.EffectivePath.Replace('\\', '/'));
var analysis = AnalyzeEmbeddedArchive(archive, embedded, childIdentifier, cancellationToken);
if (analysis is null)
{
continue;
}
AddSegment(
segments,
modules,
duplicateMap,
packageMap,
ref order,
childIdentifier,
childIdentifier,
JavaClassPathSegmentKind.Archive,
JavaPackagingKind.Jar,
analysis.Module,
analysis.Classes,
analysis.Services);
}
}
private static JavaModuleDescriptor? ParseModuleDescriptor(JavaArchive archive, string identifier, CancellationToken cancellationToken)
{
if (!archive.TryGetEntry("module-info.class", out var entry))
{
return null;
}
using var stream = archive.OpenEntry(entry);
return JavaModuleInfoParser.TryParse(stream, identifier, cancellationToken);
}
private static void AddSegment(
List<JavaClassPathSegment> segments,
List<JavaModuleDescriptor> modules,
Dictionary<string, List<string>> duplicateMap,
Dictionary<string, HashSet<string>> packageMap,
ref int order,
string identifier,
string displayPath,
JavaClassPathSegmentKind kind,
JavaPackagingKind packaging,
JavaModuleDescriptor? module,
IReadOnlyCollection<JavaClassRecord> classes,
ImmutableDictionary<string, ImmutableArray<string>> serviceDefinitions)
{
if ((classes is null || classes.Count == 0) && module is null && (serviceDefinitions is null || serviceDefinitions.Count == 0))
{
return;
}
var normalizedClasses = classes ?? Array.Empty<JavaClassRecord>();
var classSet = normalizedClasses.Count == 0
? ImmutableSortedSet<string>.Empty
: ImmutableSortedSet.CreateRange(StringComparer.Ordinal, normalizedClasses.Select(static record => record.ClassName));
var packageFingerprints = BuildPackageFingerprints(normalizedClasses);
var classLocations = normalizedClasses.Count == 0
? ImmutableDictionary<string, JavaClassLocation>.Empty
: normalizedClasses.ToImmutableDictionary(
static record => record.ClassName,
static record => record.Location,
StringComparer.Ordinal);
var segment = new JavaClassPathSegment(
identifier,
displayPath,
kind,
packaging,
order++,
module,
classSet,
packageFingerprints,
serviceDefinitions ?? ImmutableDictionary<string, ImmutableArray<string>>.Empty,
classLocations);
segments.Add(segment);
if (module is not null)
{
modules.Add(module);
}
foreach (var className in classSet)
{
if (!duplicateMap.TryGetValue(className, out var locations))
{
locations = new List<string>();
duplicateMap[className] = locations;
}
locations.Add(identifier);
}
foreach (var fingerprint in packageFingerprints.Values)
{
if (!packageMap.TryGetValue(fingerprint.PackageName, out var segmentsSet))
{
segmentsSet = new HashSet<string>(StringComparer.Ordinal);
packageMap[fingerprint.PackageName] = segmentsSet;
}
segmentsSet.Add(identifier);
}
}
private static bool TryHandleServiceDescriptor(
JavaArchive archive,
JavaArchiveEntry entry,
string relativePath,
Dictionary<string, List<string>> target,
CancellationToken cancellationToken)
{
if (!TryGetServiceId(relativePath, out var serviceId))
{
return false;
}
try
{
var providers = ReadServiceProviders(() => archive.OpenEntry(entry), cancellationToken);
if (providers.Count == 0)
{
return true;
}
if (!target.TryGetValue(serviceId, out var list))
{
list = new List<string>();
target[serviceId] = list;
}
list.AddRange(providers);
}
catch (IOException)
{
// Ignore malformed service descriptor.
}
catch (InvalidDataException)
{
// Ignore malformed service descriptor.
}
return true;
}
private static bool TryGetServiceId(string relativePath, out string serviceId)
{
const string Prefix = "META-INF/services/";
if (!relativePath.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
{
serviceId = string.Empty;
return false;
}
var candidate = relativePath[Prefix.Length..].Trim();
if (candidate.Length == 0)
{
serviceId = string.Empty;
return false;
}
serviceId = candidate;
return true;
}
private static List<string> ReadServiceProviders(Func<Stream> streamFactory, CancellationToken cancellationToken)
{
using var stream = streamFactory();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false);
var providers = new List<string>();
while (reader.ReadLine() is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
var commentIndex = line.IndexOf('#');
if (commentIndex >= 0)
{
line = line[..commentIndex];
}
line = line.Trim();
if (line.Length == 0)
{
continue;
}
providers.Add(line);
}
return providers;
}
private static ImmutableDictionary<string, ImmutableArray<string>> ToImmutableServiceMap(Dictionary<string, List<string>> source)
{
if (source.Count == 0)
{
return ImmutableDictionary<string, ImmutableArray<string>>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, ImmutableArray<string>>(StringComparer.Ordinal);
foreach (var pair in source.OrderBy(static entry => entry.Key, StringComparer.Ordinal))
{
var cleaned = new List<string>(pair.Value.Count);
foreach (var value in pair.Value)
{
var trimmed = value?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
continue;
}
cleaned.Add(trimmed);
}
if (cleaned.Count == 0)
{
continue;
}
builder[pair.Key] = ImmutableArray.CreateRange(cleaned);
}
return builder.ToImmutable();
}
private static ImmutableDictionary<string, JavaPackageFingerprint> BuildPackageFingerprints(IReadOnlyCollection<JavaClassRecord> classes)
{
if (classes is null || classes.Count == 0)
{
return ImmutableDictionary<string, JavaPackageFingerprint>.Empty;
}
var packages = classes
.GroupBy(static record => record.PackageName, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal);
var builder = ImmutableDictionary.CreateBuilder<string, JavaPackageFingerprint>(StringComparer.Ordinal);
foreach (var group in packages)
{
var simpleNames = group
.Select(static record => record.SimpleName)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var fingerprint = ComputeFingerprint(simpleNames);
builder[group.Key] = new JavaPackageFingerprint(group.Key, simpleNames.Length, fingerprint);
}
return builder.ToImmutable();
}
private static string ComputeFingerprint(IEnumerable<string> values)
{
using var sha = SHA256.Create();
var buffer = string.Join('\n', values);
var bytes = Encoding.UTF8.GetBytes(buffer);
return Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
private static EmbeddedArchiveAnalysis? AnalyzeEmbeddedArchive(JavaArchive parentArchive, JavaArchiveEntry entry, string identifier, CancellationToken cancellationToken)
{
using var sourceStream = parentArchive.OpenEntry(entry);
using var buffer = new MemoryStream();
sourceStream.CopyTo(buffer);
buffer.Position = 0;
using var zip = new ZipArchive(buffer, ZipArchiveMode.Read, leaveOpen: true);
var candidates = new Dictionary<string, EmbeddedClassCandidate>(StringComparer.Ordinal);
var services = new Dictionary<string, EmbeddedServiceCandidate>(StringComparer.Ordinal);
JavaModuleDescriptor? moduleDescriptor = null;
foreach (var zipEntry in zip.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
var normalized = JavaZipEntryUtilities.NormalizeEntryName(zipEntry.FullName);
if (normalized.Length == 0)
{
continue;
}
if (string.Equals(normalized, "module-info.class", StringComparison.OrdinalIgnoreCase))
{
using var moduleStream = zipEntry.Open();
moduleDescriptor = JavaModuleInfoParser.TryParse(moduleStream, identifier, cancellationToken);
continue;
}
var effectivePath = normalized;
var version = 0;
if (JavaZipEntryUtilities.TryParseMultiReleasePath(normalized, out var candidatePath, out var candidateVersion))
{
effectivePath = candidatePath;
version = candidateVersion;
}
if (string.Equals(effectivePath, "module-info.class", StringComparison.OrdinalIgnoreCase))
{
using var moduleStream = zipEntry.Open();
moduleDescriptor = JavaModuleInfoParser.TryParse(moduleStream, identifier, cancellationToken);
continue;
}
if (TryGetServiceId(effectivePath, out var serviceId))
{
try
{
var providers = ReadServiceProviders(() => zipEntry.Open(), cancellationToken);
if (providers.Count == 0)
{
continue;
}
var providerArray = ImmutableArray.CreateRange(providers);
if (!services.TryGetValue(serviceId, out var existingService)
|| version > existingService.Version
|| (version == existingService.Version && string.CompareOrdinal(zipEntry.FullName, existingService.OriginalPath) < 0))
{
services[serviceId] = new EmbeddedServiceCandidate(zipEntry.FullName, version, providerArray);
}
}
catch (IOException)
{
// Ignore malformed descriptor.
}
catch (InvalidDataException)
{
// Ignore malformed descriptor.
}
continue;
}
if (!effectivePath.EndsWith(ClassFileSuffix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!candidates.TryGetValue(effectivePath, out var existing) || version > existing.Version || (version == existing.Version && string.CompareOrdinal(zipEntry.FullName, existing.OriginalPath) < 0))
{
candidates[effectivePath] = new EmbeddedClassCandidate(zipEntry.FullName, version);
}
}
var classes = new List<JavaClassRecord>(candidates.Count);
foreach (var pair in candidates)
{
AddClassRecord(classes, parentArchive, entry, pair.Key, pair.Value.OriginalPath);
}
var serviceMap = services.Count == 0
? ImmutableDictionary<string, ImmutableArray<string>>.Empty
: services
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableDictionary(
static pair => pair.Key,
static pair => pair.Value.Providers,
StringComparer.Ordinal);
if (classes.Count == 0 && moduleDescriptor is null && serviceMap.Count == 0)
{
return null;
}
return new EmbeddedArchiveAnalysis(classes, moduleDescriptor, serviceMap);
}
private static bool IsEmbeddedLibrary(string path)
{
if (path.StartsWith("BOOT-INF/lib/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (path.StartsWith("WEB-INF/lib/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (path.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("APP-INF/lib/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private static void AddClassRecord(
ICollection<JavaClassRecord> target,
JavaArchive archive,
JavaArchiveEntry entry,
string path,
string? nestedClassPath = null)
{
if (string.IsNullOrEmpty(path) || !path.EndsWith(ClassFileSuffix, StringComparison.OrdinalIgnoreCase))
{
return;
}
var withoutExtension = path[..^ClassFileSuffix.Length];
if (withoutExtension.Length == 0)
{
return;
}
var className = withoutExtension.Replace('/', '.');
var lastDot = className.LastIndexOf('.');
string packageName;
string simpleName;
if (lastDot >= 0)
{
packageName = className[..lastDot];
simpleName = className[(lastDot + 1)..];
}
else
{
packageName = string.Empty;
simpleName = className;
}
var location = nestedClassPath is null
? JavaClassLocation.ForArchive(archive, entry)
: JavaClassLocation.ForEmbedded(archive, entry, nestedClassPath);
target.Add(new JavaClassRecord(className, packageName, simpleName, location));
}
private static string NormalizeArchivePath(string relativePath)
{
if (string.IsNullOrEmpty(relativePath))
{
return ".";
}
var normalized = relativePath.Replace('\\', '/');
return normalized.Length == 0 ? "." : normalized;
}
private readonly record struct JavaClassRecord(
string ClassName,
string PackageName,
string SimpleName,
JavaClassLocation Location);
private sealed record EmbeddedArchiveAnalysis(
IReadOnlyCollection<JavaClassRecord> Classes,
JavaModuleDescriptor? Module,
ImmutableDictionary<string, ImmutableArray<string>> Services);
private readonly record struct EmbeddedClassCandidate(string OriginalPath, int Version);
private readonly record struct EmbeddedServiceCandidate(string OriginalPath, int Version, ImmutableArray<string> Providers);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,716 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Text;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.ClassPath;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Reflection;
internal static class JavaReflectionAnalyzer
{
public static JavaReflectionAnalysis Analyze(JavaClassPathAnalysis classPath, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(classPath);
if (classPath.Segments.IsDefaultOrEmpty)
{
return JavaReflectionAnalysis.Empty;
}
var edges = new List<JavaReflectionEdge>();
var warnings = new List<JavaReflectionWarning>();
foreach (var segment in classPath.Segments)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var kvp in segment.ClassLocations)
{
var className = kvp.Key;
var location = kvp.Value;
using var stream = location.OpenClassStream(cancellationToken);
var classFile = JavaClassFile.Parse(stream, cancellationToken);
foreach (var method in classFile.Methods)
{
cancellationToken.ThrowIfCancellationRequested();
AnalyzeMethod(classFile, method, segment.Identifier, className, edges, warnings);
}
}
}
if (edges.Count == 0 && warnings.Count == 0)
{
return JavaReflectionAnalysis.Empty;
}
return new JavaReflectionAnalysis(
edges.ToImmutableArray(),
warnings.ToImmutableArray());
}
private static void AnalyzeMethod(
JavaClassFile classFile,
JavaMethod method,
string segmentIdentifier,
string className,
List<JavaReflectionEdge> edges,
List<JavaReflectionWarning> warnings)
{
var pool = classFile.ConstantPool;
string? pendingStringLiteral = null;
string? pendingClassLiteral = null;
var sawCurrentThread = false;
var emittedTcclWarning = false;
var code = method.Code;
var offset = 0;
var length = code.Length;
while (offset < length)
{
var instructionOffset = offset;
var opcode = code[offset++];
switch (opcode)
{
case 0x12: // LDC
{
var index = code[offset++];
HandleLdc(index, pool, ref pendingStringLiteral, ref pendingClassLiteral);
break;
}
case 0x13: // LDC_W
case 0x14: // LDC2_W
{
var index = (code[offset++] << 8) | code[offset++];
HandleLdc(index, pool, ref pendingStringLiteral, ref pendingClassLiteral);
break;
}
case 0xB8: // invokestatic
case 0xB6: // invokevirtual
case 0xB7: // invokespecial
case 0xB9: // invokeinterface
{
var methodIndex = (code[offset++] << 8) | code[offset++];
if (opcode == 0xB9)
{
offset += 2; // count and zero
}
HandleInvocation(
pool,
method,
segmentIdentifier,
className,
instructionOffset,
methodIndex,
opcode,
ref pendingStringLiteral,
ref pendingClassLiteral,
ref sawCurrentThread,
ref emittedTcclWarning,
edges,
warnings);
pendingStringLiteral = null;
pendingClassLiteral = null;
break;
}
default:
{
if (IsStoreInstruction(opcode))
{
pendingStringLiteral = null;
pendingClassLiteral = null;
if (IsStoreWithExplicitIndex(opcode))
{
offset++;
}
}
else if (IsLoadInstructionWithIndex(opcode))
{
offset++;
}
else if (IsStackMutation(opcode))
{
pendingStringLiteral = null;
pendingClassLiteral = null;
}
break;
}
}
}
// When the method calls Thread.currentThread without accessing the context loader, we do not emit warnings.
}
private static void HandleLdc(
int constantIndex,
JavaConstantPool pool,
ref string? pendingString,
ref string? pendingClassLiteral)
{
var constantKind = pool.GetConstantKind(constantIndex);
switch (constantKind)
{
case JavaConstantKind.String:
pendingString = pool.GetString(constantIndex);
pendingClassLiteral = null;
break;
case JavaConstantKind.Class:
pendingClassLiteral = pool.GetClassName(constantIndex);
pendingString = null;
break;
default:
pendingString = null;
pendingClassLiteral = null;
break;
}
}
private static void HandleInvocation(
JavaConstantPool pool,
JavaMethod method,
string segmentIdentifier,
string className,
int instructionOffset,
int methodIndex,
byte opcode,
ref string? pendingString,
ref string? pendingClassLiteral,
ref bool sawCurrentThread,
ref bool emittedTcclWarning,
List<JavaReflectionEdge> edges,
List<JavaReflectionWarning> warnings)
{
var methodRef = pool.GetMethodReference(methodIndex);
var owner = methodRef.OwnerInternalName;
var name = methodRef.Name;
var descriptor = methodRef.Descriptor;
var normalizedOwner = owner ?? string.Empty;
var normalizedSource = NormalizeClassName(className) ?? className ?? string.Empty;
if (normalizedOwner == "java/lang/Class" && name == "forName")
{
var target = NormalizeClassName(pendingString);
var confidence = pendingString is null ? JavaReflectionConfidence.Low : JavaReflectionConfidence.High;
edges.Add(new JavaReflectionEdge(
normalizedSource,
segmentIdentifier,
target,
JavaReflectionReason.ClassForName,
confidence,
method.Name,
method.Descriptor,
instructionOffset,
null));
}
else if (normalizedOwner == "java/lang/ClassLoader" && name == "loadClass")
{
var target = NormalizeClassName(pendingString);
var confidence = pendingString is null ? JavaReflectionConfidence.Low : JavaReflectionConfidence.High;
edges.Add(new JavaReflectionEdge(
normalizedSource,
segmentIdentifier,
target,
JavaReflectionReason.ClassLoaderLoadClass,
confidence,
method.Name,
method.Descriptor,
instructionOffset,
null));
}
else if (normalizedOwner == "java/util/ServiceLoader" && name.StartsWith("load", StringComparison.Ordinal))
{
var target = NormalizeClassName(pendingClassLiteral);
var confidence = pendingClassLiteral is null ? JavaReflectionConfidence.Low : JavaReflectionConfidence.High;
edges.Add(new JavaReflectionEdge(
normalizedSource,
segmentIdentifier,
target,
JavaReflectionReason.ServiceLoaderLoad,
confidence,
method.Name,
method.Descriptor,
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/Thread" && name == "getContextClassLoader")
{
if (sawCurrentThread && !emittedTcclWarning)
{
warnings.Add(new JavaReflectionWarning(
normalizedSource,
segmentIdentifier,
"tccl",
"Thread context class loader access detected.",
method.Name,
method.Descriptor));
emittedTcclWarning = true;
}
}
pendingString = null;
pendingClassLiteral = null;
}
private static string? NormalizeClassName(string? internalName)
{
if (string.IsNullOrWhiteSpace(internalName))
{
return null;
}
return internalName.Replace('/', '.');
}
private static bool IsStoreInstruction(byte opcode)
=> (opcode >= 0x3B && opcode <= 0x4E) || (opcode >= 0x4F && opcode <= 0x56) || (opcode >= 0x36 && opcode <= 0x3A);
private static bool IsStoreWithExplicitIndex(byte opcode)
=> opcode >= 0x36 && opcode <= 0x3A;
private static bool IsLoadInstructionWithIndex(byte opcode)
=> opcode >= 0x15 && opcode <= 0x19;
private static bool IsStackMutation(byte opcode)
=> opcode is 0x57 or 0x58 or 0x59 or 0x5A or 0x5B or 0x5C or 0x5D or 0x5E or 0x5F;
private sealed class JavaClassFile
{
public JavaClassFile(string thisClassName, JavaConstantPool constantPool, ImmutableArray<JavaMethod> methods)
{
ThisClassName = thisClassName;
ConstantPool = constantPool;
Methods = methods;
}
public string ThisClassName { get; }
public JavaConstantPool ConstantPool { get; }
public ImmutableArray<JavaMethod> Methods { get; }
public static JavaClassFile Parse(Stream stream, CancellationToken cancellationToken)
{
var reader = new BigEndianReader(stream, leaveOpen: true);
if (reader.ReadUInt32() != 0xCAFEBABE)
{
throw new InvalidDataException("Invalid Java class file magic header.");
}
_ = reader.ReadUInt16(); // minor
_ = reader.ReadUInt16(); // major
var constantPoolCount = reader.ReadUInt16();
var pool = new JavaConstantPool(constantPoolCount);
var index = 1;
while (index < constantPoolCount)
{
cancellationToken.ThrowIfCancellationRequested();
var tag = reader.ReadByte();
switch ((JavaConstantTag)tag)
{
case JavaConstantTag.Utf8:
{
pool.Set(index, JavaConstantPoolEntry.Utf8(reader.ReadUtf8()));
index++;
break;
}
case JavaConstantTag.Integer:
reader.Skip(4);
pool.Set(index, JavaConstantPoolEntry.Other(tag));
index++;
break;
case JavaConstantTag.Float:
reader.Skip(4);
pool.Set(index, JavaConstantPoolEntry.Other(tag));
index++;
break;
case JavaConstantTag.Long:
case JavaConstantTag.Double:
reader.Skip(8);
pool.Set(index, JavaConstantPoolEntry.Other(tag));
index += 2;
break;
case JavaConstantTag.Class:
case JavaConstantTag.String:
case JavaConstantTag.MethodType:
pool.Set(index, JavaConstantPoolEntry.Indexed(tag, reader.ReadUInt16()));
index++;
break;
case JavaConstantTag.Fieldref:
case JavaConstantTag.Methodref:
case JavaConstantTag.InterfaceMethodref:
case JavaConstantTag.NameAndType:
case JavaConstantTag.InvokeDynamic:
pool.Set(index, JavaConstantPoolEntry.IndexedPair(tag, reader.ReadUInt16(), reader.ReadUInt16()));
index++;
break;
case JavaConstantTag.MethodHandle:
reader.Skip(1); // reference kind
pool.Set(index, JavaConstantPoolEntry.Indexed(tag, reader.ReadUInt16()));
index++;
break;
default:
throw new InvalidDataException($"Unsupported constant pool tag {tag}.");
}
}
var accessFlags = reader.ReadUInt16();
var thisClassIndex = reader.ReadUInt16();
_ = reader.ReadUInt16(); // super
var interfacesCount = reader.ReadUInt16();
reader.Skip(interfacesCount * 2);
var fieldsCount = reader.ReadUInt16();
for (var i = 0; i < fieldsCount; i++)
{
SkipMember(reader);
}
var methodsCount = reader.ReadUInt16();
var methods = ImmutableArray.CreateBuilder<JavaMethod>(methodsCount);
for (var i = 0; i < methodsCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
_ = reader.ReadUInt16(); // method access flags
var nameIndex = reader.ReadUInt16();
var descriptorIndex = reader.ReadUInt16();
var attributesCount = reader.ReadUInt16();
byte[]? code = null;
for (var attr = 0; attr < attributesCount; attr++)
{
var attributeNameIndex = reader.ReadUInt16();
var attributeLength = reader.ReadUInt32();
var attributeName = pool.GetUtf8(attributeNameIndex) ?? string.Empty;
if (attributeName == "Code")
{
var maxStack = reader.ReadUInt16();
var maxLocals = reader.ReadUInt16();
var codeLength = reader.ReadUInt32();
code = reader.ReadBytes((int)codeLength);
var exceptionTableLength = reader.ReadUInt16();
reader.Skip(exceptionTableLength * 8);
var codeAttributeCount = reader.ReadUInt16();
for (var c = 0; c < codeAttributeCount; c++)
{
reader.Skip(2); // name index
var len = reader.ReadUInt32();
reader.Skip((int)len);
}
}
else
{
reader.Skip((int)attributeLength);
}
}
if (code is not null)
{
var name = pool.GetUtf8(nameIndex) ?? string.Empty;
var descriptor = pool.GetUtf8(descriptorIndex) ?? string.Empty;
methods.Add(new JavaMethod(name, descriptor, code));
}
}
var classAttributesCount = reader.ReadUInt16();
for (var a = 0; a < classAttributesCount; a++)
{
reader.Skip(2);
var len = reader.ReadUInt32();
reader.Skip((int)len);
}
var thisClassName = pool.GetClassName(thisClassIndex) ?? string.Empty;
return new JavaClassFile(thisClassName, pool, methods.ToImmutable());
}
private static void SkipMember(BigEndianReader reader)
{
reader.Skip(6); // access, name, descriptor
var attributeCount = reader.ReadUInt16();
for (var i = 0; i < attributeCount; i++)
{
reader.Skip(2);
var len = reader.ReadUInt32();
reader.Skip((int)len);
}
}
}
private sealed class JavaMethod
{
public JavaMethod(string name, string descriptor, byte[] code)
{
Name = name;
Descriptor = descriptor;
Code = code;
}
public string Name { get; }
public string Descriptor { get; }
public byte[] Code { get; }
}
private sealed class JavaConstantPool
{
private readonly JavaConstantPoolEntry?[] _entries;
public JavaConstantPool(int count)
{
_entries = new JavaConstantPoolEntry?[count];
}
public void Set(int index, JavaConstantPoolEntry entry)
{
_entries[index] = entry;
}
public JavaConstantKind GetConstantKind(int index)
{
var entry = _entries[index];
return entry?.Kind ?? JavaConstantKind.Other;
}
public string? GetUtf8(int index)
{
if (index <= 0 || index >= _entries.Length)
{
return null;
}
return _entries[index] is JavaConstantPoolEntry.Utf8Entry utf8 ? utf8.Value : null;
}
public string? GetString(int index)
{
if (_entries[index] is JavaConstantPoolEntry.IndexedEntry { Kind: JavaConstantKind.String, Index: var utf8Index })
{
return GetUtf8(utf8Index);
}
return null;
}
public string? GetClassName(int index)
{
if (_entries[index] is JavaConstantPoolEntry.IndexedEntry { Kind: JavaConstantKind.Class, Index: var nameIndex })
{
return GetUtf8(nameIndex);
}
return null;
}
public JavaMethodReference GetMethodReference(int index)
{
if (_entries[index] is not JavaConstantPoolEntry.IndexedPairEntry pair || pair.Kind is not (JavaConstantKind.Methodref or JavaConstantKind.InterfaceMethodref))
{
throw new InvalidDataException($"Constant pool entry {index} is not a method reference.");
}
var owner = GetClassName(pair.FirstIndex) ?? string.Empty;
var nameAndType = _entries[pair.SecondIndex] as JavaConstantPoolEntry.IndexedPairEntry;
if (nameAndType is null || nameAndType.Kind != JavaConstantKind.NameAndType)
{
throw new InvalidDataException("Invalid NameAndType entry for method reference.");
}
var name = GetUtf8(nameAndType.FirstIndex) ?? string.Empty;
var descriptor = GetUtf8(nameAndType.SecondIndex) ?? string.Empty;
return new JavaMethodReference(owner, name, descriptor);
}
}
private readonly record struct JavaMethodReference(string OwnerInternalName, string Name, string Descriptor);
private abstract record JavaConstantPoolEntry(JavaConstantKind Kind)
{
public sealed record Utf8Entry(string Value) : JavaConstantPoolEntry(JavaConstantKind.Utf8);
public sealed record IndexedEntry(JavaConstantKind Kind, ushort Index) : JavaConstantPoolEntry(Kind);
public sealed record IndexedPairEntry(JavaConstantKind Kind, ushort FirstIndex, ushort SecondIndex) : JavaConstantPoolEntry(Kind);
public sealed record OtherEntry(byte Tag) : JavaConstantPoolEntry(JavaConstantKind.Other);
public static JavaConstantPoolEntry Utf8(string value) => new Utf8Entry(value);
public static JavaConstantPoolEntry Indexed(byte tag, ushort index)
=> new IndexedEntry(ToKind(tag), index);
public static JavaConstantPoolEntry IndexedPair(byte tag, ushort first, ushort second)
=> new IndexedPairEntry(ToKind(tag), first, second);
public static JavaConstantPoolEntry Other(byte tag) => new OtherEntry(tag);
private static JavaConstantKind ToKind(byte tag)
=> tag switch
{
7 => JavaConstantKind.Class,
8 => JavaConstantKind.String,
9 => JavaConstantKind.Fieldref,
10 => JavaConstantKind.Methodref,
11 => JavaConstantKind.InterfaceMethodref,
12 => JavaConstantKind.NameAndType,
15 => JavaConstantKind.MethodHandle,
16 => JavaConstantKind.MethodType,
18 => JavaConstantKind.InvokeDynamic,
_ => JavaConstantKind.Other,
};
}
private enum JavaConstantKind
{
Utf8,
Integer,
Float,
Long,
Double,
Class,
String,
Fieldref,
Methodref,
InterfaceMethodref,
NameAndType,
MethodHandle,
MethodType,
InvokeDynamic,
Other,
}
private enum JavaConstantTag : byte
{
Utf8 = 1,
Integer = 3,
Float = 4,
Long = 5,
Double = 6,
Class = 7,
String = 8,
Fieldref = 9,
Methodref = 10,
InterfaceMethodref = 11,
NameAndType = 12,
MethodHandle = 15,
MethodType = 16,
InvokeDynamic = 18,
}
private sealed class BigEndianReader
{
private readonly Stream _stream;
private readonly BinaryReader _reader;
public BigEndianReader(Stream stream, bool leaveOpen)
{
_stream = stream;
_reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen);
}
public ushort ReadUInt16()
{
Span<byte> buffer = stackalloc byte[2];
Fill(buffer);
return BinaryPrimitives.ReadUInt16BigEndian(buffer);
}
public uint ReadUInt32()
{
Span<byte> buffer = stackalloc byte[4];
Fill(buffer);
return BinaryPrimitives.ReadUInt32BigEndian(buffer);
}
public int ReadInt32()
{
Span<byte> buffer = stackalloc byte[4];
Fill(buffer);
return BinaryPrimitives.ReadInt32BigEndian(buffer);
}
public byte ReadByte() => _reader.ReadByte();
public string ReadUtf8()
{
var length = ReadUInt16();
var bytes = ReadBytes(length);
return Encoding.UTF8.GetString(bytes);
}
public byte[] ReadBytes(int count)
{
var bytes = _reader.ReadBytes(count);
if (bytes.Length != count)
{
throw new EndOfStreamException();
}
return bytes;
}
public void Skip(int count)
{
if (count <= 0)
{
return;
}
var buffer = new byte[Math.Min(count, 4096)];
var remaining = count;
while (remaining > 0)
{
var read = _stream.Read(buffer, 0, Math.Min(buffer.Length, remaining));
if (read == 0)
{
throw new EndOfStreamException();
}
remaining -= read;
}
}
private void Fill(Span<byte> buffer)
{
var read = _stream.Read(buffer);
if (read != buffer.Length)
{
throw new EndOfStreamException();
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
[
{
"serviceId": "java.sql.Driver",
"category": "jdk",
"displayName": "JDBC Driver"
},
{
"serviceId": "javax.annotation.processing.Processor",
"category": "jdk",
"displayName": "Annotation Processor"
},
{
"serviceId": "org.slf4j.spi.SLF4JServiceProvider",
"category": "logging",
"displayName": "SLF4J Service Provider"
},
{
"serviceId": "ch.qos.logback.classic.spi.Configurator",
"category": "logging",
"displayName": "Logback Configurator"
},
{
"serviceId": "com.fasterxml.jackson.core.TokenStreamFactory",
"category": "jackson",
"displayName": "Jackson Token Stream Factory"
},
{
"serviceId": "com.fasterxml.jackson.databind.Module",
"category": "jackson",
"displayName": "Jackson Module"
},
{
"serviceId": "org.springframework.boot.Bootstrapper",
"category": "spring",
"displayName": "Spring Boot Bootstrapper"
},
{
"serviceId": "org.springframework.boot.SpringApplicationRunListener",
"category": "spring",
"displayName": "Spring Application Run Listener"
},
{
"serviceId": "org.eclipse.microprofile.config.spi.ConfigSourceProvider",
"category": "microprofile",
"displayName": "MicroProfile Config Source Provider"
},
{
"serviceId": "org.eclipse.microprofile.config.spi.Converter",
"category": "microprofile",
"displayName": "MicroProfile Converter"
}
]

View File

@@ -0,0 +1,344 @@
using StellaOps.Scanner.Analyzers.Lang.Java.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Java;
public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "java";
public string DisplayName => "Java/Maven Analyzer";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var workspace = JavaWorkspaceNormalizer.Normalize(context, cancellationToken);
foreach (var archive in workspace.Archives)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ProcessArchiveAsync(archive, context, writer, cancellationToken).ConfigureAwait(false);
}
catch (IOException)
{
// Corrupt archives should not abort the scan.
}
catch (InvalidDataException)
{
// Skip non-zip payloads despite supported extensions.
}
}
}
private async ValueTask ProcessArchiveAsync(JavaArchive archive, LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ManifestMetadata? manifestMetadata = null;
if (archive.TryGetEntry("META-INF/MANIFEST.MF", out var manifestEntry))
{
manifestMetadata = await ParseManifestAsync(archive, manifestEntry, cancellationToken).ConfigureAwait(false);
}
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (IsManifestEntry(entry.EffectivePath))
{
continue;
}
if (!IsPomPropertiesEntry(entry.EffectivePath))
{
continue;
}
var artifact = await ParsePomPropertiesAsync(archive, entry, cancellationToken).ConfigureAwait(false);
if (artifact is null)
{
continue;
}
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["groupId"] = artifact.GroupId,
["artifactId"] = artifact.ArtifactId,
["jarPath"] = NormalizeArchivePath(archive.RelativePath),
};
if (!string.IsNullOrEmpty(artifact.Packaging))
{
metadata["packaging"] = artifact.Packaging;
}
if (!string.IsNullOrEmpty(artifact.Name))
{
metadata["displayName"] = artifact.Name;
}
if (manifestMetadata is not null)
{
manifestMetadata.ApplyMetadata(metadata);
}
var evidence = new List<LanguageComponentEvidence>
{
new(LanguageEvidenceKind.File, "pom.properties", BuildLocator(archive, entry.OriginalPath), null, artifact.PomSha256),
};
if (manifestMetadata is not null)
{
evidence.Add(manifestMetadata.CreateEvidence(archive));
}
var usedByEntrypoint = context.UsageHints.IsPathUsed(archive.AbsolutePath);
writer.AddFromPurl(
analyzerId: Id,
purl: artifact.Purl,
name: artifact.ArtifactId,
version: artifact.Version,
type: "maven",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: usedByEntrypoint);
}
}
private static string BuildLocator(JavaArchive archive, string entryPath)
{
var relativeArchive = NormalizeArchivePath(archive.RelativePath);
var normalizedEntry = NormalizeEntry(entryPath);
if (string.Equals(relativeArchive, ".", StringComparison.Ordinal) || string.IsNullOrEmpty(relativeArchive))
{
return normalizedEntry;
}
return string.Concat(relativeArchive, "!", normalizedEntry);
}
private static string NormalizeEntry(string entryPath)
=> entryPath.Replace('\\', '/');
private static string NormalizeArchivePath(string relativePath)
{
if (string.IsNullOrEmpty(relativePath) || string.Equals(relativePath, ".", StringComparison.Ordinal))
{
return ".";
}
return relativePath.Replace('\\', '/');
}
private static bool IsPomPropertiesEntry(string entryName)
=> entryName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase)
&& entryName.EndsWith("/pom.properties", StringComparison.OrdinalIgnoreCase);
private static bool IsManifestEntry(string entryName)
=> string.Equals(entryName, "META-INF/MANIFEST.MF", StringComparison.OrdinalIgnoreCase);
private static async ValueTask<MavenArtifact?> ParsePomPropertiesAsync(JavaArchive archive, JavaArchiveEntry entry, CancellationToken cancellationToken)
{
await using var entryStream = archive.OpenEntry(entry);
using var buffer = new MemoryStream();
await entryStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
buffer.Position = 0;
using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
line = line.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
if (key.Length == 0)
{
continue;
}
properties[key] = value;
}
if (!properties.TryGetValue("groupId", out var groupId) || string.IsNullOrWhiteSpace(groupId))
{
return null;
}
if (!properties.TryGetValue("artifactId", out var artifactId) || string.IsNullOrWhiteSpace(artifactId))
{
return null;
}
if (!properties.TryGetValue("version", out var version) || string.IsNullOrWhiteSpace(version))
{
return null;
}
var packaging = properties.TryGetValue("packaging", out var packagingValue) ? packagingValue : "jar";
var name = properties.TryGetValue("name", out var nameValue) ? nameValue : null;
var purl = BuildPurl(groupId, artifactId, version, packaging);
buffer.Position = 0;
var pomSha = Convert.ToHexString(SHA256.HashData(buffer)).ToLowerInvariant();
return new MavenArtifact(
GroupId: groupId.Trim(),
ArtifactId: artifactId.Trim(),
Version: version.Trim(),
Packaging: packaging?.Trim(),
Name: name?.Trim(),
Purl: purl,
PomSha256: pomSha);
}
private static async ValueTask<ManifestMetadata?> ParseManifestAsync(JavaArchive archive, JavaArchiveEntry entry, CancellationToken cancellationToken)
{
await using var entryStream = archive.OpenEntry(entry);
using var reader = new StreamReader(entryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false);
string? title = null;
string? version = null;
string? vendor = null;
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
if (key.Equals("Implementation-Title", StringComparison.OrdinalIgnoreCase))
{
title ??= value;
}
else if (key.Equals("Implementation-Version", StringComparison.OrdinalIgnoreCase))
{
version ??= value;
}
else if (key.Equals("Implementation-Vendor", StringComparison.OrdinalIgnoreCase))
{
vendor ??= value;
}
}
if (title is null && version is null && vendor is null)
{
return null;
}
return new ManifestMetadata(title, version, vendor);
}
private static string BuildPurl(string groupId, string artifactId, string version, string? packaging)
{
var normalizedGroup = groupId.Replace('.', '/');
var builder = new StringBuilder();
builder.Append("pkg:maven/");
builder.Append(normalizedGroup);
builder.Append('/');
builder.Append(artifactId);
builder.Append('@');
builder.Append(version);
if (!string.IsNullOrWhiteSpace(packaging) && !packaging.Equals("jar", StringComparison.OrdinalIgnoreCase))
{
builder.Append("?type=");
builder.Append(packaging);
}
return builder.ToString();
}
private sealed record MavenArtifact(
string GroupId,
string ArtifactId,
string Version,
string? Packaging,
string? Name,
string Purl,
string PomSha256);
private sealed record ManifestMetadata(string? ImplementationTitle, string? ImplementationVersion, string? ImplementationVendor)
{
public void ApplyMetadata(IDictionary<string, string?> target)
{
if (!string.IsNullOrWhiteSpace(ImplementationTitle))
{
target["manifestTitle"] = ImplementationTitle;
}
if (!string.IsNullOrWhiteSpace(ImplementationVersion))
{
target["manifestVersion"] = ImplementationVersion;
}
if (!string.IsNullOrWhiteSpace(ImplementationVendor))
{
target["manifestVendor"] = ImplementationVendor;
}
}
public LanguageComponentEvidence CreateEvidence(JavaArchive archive)
{
var locator = BuildLocator(archive, "META-INF/MANIFEST.MF");
var valueBuilder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(ImplementationTitle))
{
valueBuilder.Append("title=").Append(ImplementationTitle);
}
if (!string.IsNullOrWhiteSpace(ImplementationVersion))
{
if (valueBuilder.Length > 0)
{
valueBuilder.Append(';');
}
valueBuilder.Append("version=").Append(ImplementationVersion);
}
if (!string.IsNullOrWhiteSpace(ImplementationVendor))
{
if (valueBuilder.Length > 0)
{
valueBuilder.Append(';');
}
valueBuilder.Append("vendor=").Append(ImplementationVendor);
}
var value = valueBuilder.Length > 0 ? valueBuilder.ToString() : null;
return new LanguageComponentEvidence(LanguageEvidenceKind.File, "MANIFEST.MF", locator, value, null);
}
}
}

View File

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

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
<None Include="**\\*" Exclude="**\\*.cs;**\\*.json;bin\\**;obj\\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
# Java Analyzer Task Board
> **Imposed rule:** work of this type or tasks of this type on this component — and everywhere else it should be applied.
## Java Static Core (Sprint 39)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| 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-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. |
> 2025-10-27 — SCANNER-ANALYZERS-JAVA-21-001 implemented `JavaWorkspaceNormalizer` + fixtures covering packaging, layered directories, multi-release overlays, and runtime image metadata.
>
> 2025-10-27 — SCANNER-ANALYZERS-JAVA-21-002 delivered `JavaClassPathBuilder` producing ordered segments (jar/war/boot fat, embedded libs), JPMS descriptors via `JavaModuleInfoParser`, and duplicate/split-package detection with package fingerprints + unit tests.
>
> 2025-10-27 — SCANNER-ANALYZERS-JAVA-21-004 in progress: added bytecode-driven `JavaReflectionAnalyzer` covering `Class.forName`, `ClassLoader.loadClass`, `ServiceLoader.load`, resource lookups, and TCCL warnings with unit fixtures (boot jar, embedded jar, synthetic classes).
>
> 2025-10-27 — SCANNER-ANALYZERS-JAVA-21-003 added SPI catalog + `JavaServiceProviderScanner`, capturing META-INF/services across layered jars, selecting first-wins providers, and emitting duplicate warnings with coverage tests (fat-jar, duplicates, simple jars).
## Java Observation & Runtime (Sprint 40)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-JAVA-21-008 | BLOCKED (2025-10-27) | Java Analyzer Guild | SCANNER-ANALYZERS-JAVA-21-003, SCANNER-ANALYZERS-JAVA-21-004, SCANNER-ANALYZERS-JAVA-21-005 | Implement resolver + AOC writer: produce entrypoints (env profiles, warnings), components (jar_id + semantic ids), edges (jpms, cp, spi, reflect, jni) with reason codes/confidence. | Observation JSON for fixtures deterministic; includes entrypoints, edges, warnings; passes AOC compliance lint. |
| SCANNER-ANALYZERS-JAVA-21-009 | TODO | Java Analyzer Guild, QA Guild | SCANNER-ANALYZERS-JAVA-21-008 | Author comprehensive fixtures (modular app, boot fat jar, war, ear, MR-jar, jlink image, JNI, reflection heavy, signed jar, microprofile) with golden outputs and perf benchmarks. | Fixture suite committed under `fixtures/lang/java/ep`; determinism + benchmark gates (<300ms fat jar) configured in CI. |
| SCANNER-ANALYZERS-JAVA-21-010 | TODO | Java Analyzer Guild, Signals Guild | SCANNER-ANALYZERS-JAVA-21-008 | Optional runtime ingestion: Java agent + JFR reader capturing class load, ServiceLoader, and System.load events with path scrubbing. Emit append-only runtime edges `runtime-class`/`runtime-spi`/`runtime-load`. | Runtime harness produces scrubbed events for sample app; edges merge with static output; docs describe sandbox & privacy. |
| SCANNER-ANALYZERS-JAVA-21-011 | TODO | Java Analyzer Guild, DevOps Guild | SCANNER-ANALYZERS-JAVA-21-008 | Package analyzer as restart-time plug-in (manifest/DI), update Offline Kit docs, add CLI/worker hooks for Java inspection commands. | Plugin manifest deployed to `plugins/scanner/analyzers/lang/`; Worker loads new analyzer; Offline Kit + CLI instructions updated; smoke test verifies packaging. |
> 2025-10-27 — SCANNER-ANALYZERS-JAVA-21-008 blocked pending upstream completion of tasks 003005 (module/classpath resolver, SPI scanner, reflection/config extraction). Observation writer needs their outputs for components/edges/warnings per exit criteria.

View File

@@ -0,0 +1,22 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.java",
"displayName": "StellaOps Java / Maven Analyzer",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Java.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Java.JavaLanguageAnalyzer"
},
"capabilities": [
"language-analyzer",
"java",
"maven"
],
"metadata": {
"org.stellaops.analyzer.language": "java",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true"
}
}