Restructure solution layout by module
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
|
||||
|
||||
internal enum JavaPackagingKind
|
||||
{
|
||||
Jar,
|
||||
SpringBootFatJar,
|
||||
War,
|
||||
Ear,
|
||||
JMod,
|
||||
JImage,
|
||||
Unknown,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal;
|
||||
|
||||
internal sealed record JavaRuntimeImage(
|
||||
string AbsolutePath,
|
||||
string RelativePath,
|
||||
string JavaVersion,
|
||||
string Vendor);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.Lang.Java.Tests")]
|
||||
@@ -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>
|
||||
@@ -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 003–005 (module/classpath resolver, SPI scanner, reflection/config extraction). Observation writer needs their outputs for components/edges/warnings per exit criteria.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user