Refactor code structure for improved readability and maintainability
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-06 21:48:12 +02:00
parent f6c22854a4
commit dd0067ea0b
105 changed files with 12662 additions and 427 deletions

View File

@@ -0,0 +1,161 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
/// <summary>
/// Represents a declared Java dependency with full GAV coordinates, scope, and exclusions.
/// Used across both Maven and Gradle parsers.
/// </summary>
internal sealed record JavaDependencyDeclaration
{
public required string GroupId { get; init; }
public required string ArtifactId { get; init; }
/// <summary>
/// Version string. May contain property placeholders (e.g., "${spring.version}") that need resolution.
/// </summary>
public required string? Version { get; init; }
/// <summary>
/// Dependency scope: compile, test, provided, runtime, system, import.
/// </summary>
public string? Scope { get; init; }
/// <summary>
/// Classifier for the artifact (e.g., "sources", "javadoc", "jdk11").
/// </summary>
public string? Classifier { get; init; }
/// <summary>
/// Packaging type (e.g., "jar", "pom", "war").
/// </summary>
public string? Type { get; init; }
/// <summary>
/// Whether this is an optional dependency.
/// </summary>
public bool Optional { get; init; }
/// <summary>
/// Exclusions for transitive dependencies.
/// </summary>
public ImmutableArray<JavaExclusion> Exclusions { get; init; } = [];
/// <summary>
/// Source of this declaration (e.g., "pom.xml", "build.gradle", "build.gradle.kts").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// File path locator relative to the project root.
/// </summary>
public string? Locator { get; init; }
/// <summary>
/// Indicates how the version was resolved.
/// </summary>
public JavaVersionSource VersionSource { get; init; } = JavaVersionSource.Direct;
/// <summary>
/// Original property name if version came from a property (e.g., "spring.version").
/// </summary>
public string? VersionProperty { get; init; }
/// <summary>
/// Whether version is fully resolved (no remaining ${...} placeholders).
/// </summary>
public bool IsVersionResolved => Version is not null &&
!Version.Contains("${", StringComparison.Ordinal);
/// <summary>
/// Returns the GAV coordinate as "groupId:artifactId:version".
/// </summary>
public string Gav => Version is null
? $"{GroupId}:{ArtifactId}"
: $"{GroupId}:{ArtifactId}:{Version}";
/// <summary>
/// Returns the unique key for deduplication.
/// </summary>
public string Key => BuildKey(GroupId, ArtifactId, Version ?? "*");
private static string BuildKey(string groupId, string artifactId, string version)
=> $"{groupId}:{artifactId}:{version}".ToLowerInvariant();
}
/// <summary>
/// Represents an exclusion for transitive dependencies.
/// </summary>
internal sealed record JavaExclusion(string GroupId, string ArtifactId);
/// <summary>
/// Indicates the source of version resolution.
/// </summary>
internal enum JavaVersionSource
{
/// <summary>
/// Version declared directly in the dependency.
/// </summary>
Direct,
/// <summary>
/// Version inherited from parent POM.
/// </summary>
Parent,
/// <summary>
/// Version resolved from dependencyManagement in current POM.
/// </summary>
DependencyManagement,
/// <summary>
/// Version resolved from an imported BOM.
/// </summary>
Bom,
/// <summary>
/// Version resolved from a property placeholder.
/// </summary>
Property,
/// <summary>
/// Version resolved from Gradle version catalog.
/// </summary>
VersionCatalog,
/// <summary>
/// Version could not be resolved.
/// </summary>
Unresolved
}
/// <summary>
/// Maps dependency scopes to risk levels for security analysis.
/// </summary>
internal static class JavaScopeClassifier
{
/// <summary>
/// Maps a Maven/Gradle scope to a risk level.
/// </summary>
public static string GetRiskLevel(string? scope) => scope?.ToLowerInvariant() switch
{
null or "" or "compile" or "implementation" or "api" => "production",
"runtime" or "runtimeOnly" => "production",
"test" or "testImplementation" or "testCompileOnly" or "testRuntimeOnly" => "development",
"provided" or "compileOnly" => "provided",
"system" => "system",
_ => "production" // Default to production for unknown scopes
};
/// <summary>
/// Returns true if the scope indicates a direct (not transitive) dependency.
/// </summary>
public static bool IsDirect(string? scope) => scope?.ToLowerInvariant() switch
{
"compile" or "implementation" or "api" or "test" or "testImplementation" => true,
"runtime" or "runtimeOnly" or "testRuntimeOnly" => false,
"provided" or "compileOnly" or "testCompileOnly" => true,
_ => true
};
}

View File

@@ -0,0 +1,238 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
/// <summary>
/// Represents unified project metadata from Maven POM or Gradle build files.
/// </summary>
internal sealed record JavaProjectMetadata
{
/// <summary>
/// Project group ID (Maven groupId or Gradle group).
/// </summary>
public string? GroupId { get; init; }
/// <summary>
/// Project artifact ID (Maven artifactId or Gradle name).
/// </summary>
public string? ArtifactId { get; init; }
/// <summary>
/// Project version.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Packaging type (jar, war, pom, etc.).
/// </summary>
public string? Packaging { get; init; }
/// <summary>
/// Parent project reference (Maven parent POM or Gradle parent project).
/// </summary>
public JavaParentReference? Parent { get; init; }
/// <summary>
/// Project properties (Maven properties or Gradle ext properties).
/// </summary>
public ImmutableDictionary<string, string> Properties { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Declared licenses for the project.
/// </summary>
public ImmutableArray<JavaLicenseInfo> Licenses { get; init; } = [];
/// <summary>
/// Dependencies declared in this project.
/// </summary>
public ImmutableArray<JavaDependencyDeclaration> Dependencies { get; init; } = [];
/// <summary>
/// Dependency management entries (Maven dependencyManagement or Gradle platform).
/// </summary>
public ImmutableArray<JavaDependencyDeclaration> DependencyManagement { get; init; } = [];
/// <summary>
/// Source file path relative to the project root.
/// </summary>
public string? SourcePath { get; init; }
/// <summary>
/// Build system type.
/// </summary>
public JavaBuildSystem BuildSystem { get; init; } = JavaBuildSystem.Unknown;
/// <summary>
/// Returns the GAV coordinate of this project.
/// </summary>
public string? Gav => GroupId is not null && ArtifactId is not null
? Version is not null
? $"{GroupId}:{ArtifactId}:{Version}"
: $"{GroupId}:{ArtifactId}"
: null;
/// <summary>
/// Resolves the effective group ID, falling back to parent if not set.
/// </summary>
public string? GetEffectiveGroupId()
=> GroupId ?? Parent?.GroupId;
/// <summary>
/// Resolves the effective version, falling back to parent if not set.
/// </summary>
public string? GetEffectiveVersion()
=> Version ?? Parent?.Version;
}
/// <summary>
/// Represents a reference to a parent project.
/// </summary>
internal sealed record JavaParentReference
{
/// <summary>
/// Parent group ID.
/// </summary>
public required string GroupId { get; init; }
/// <summary>
/// Parent artifact ID.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Parent version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Relative path to parent POM (Maven only).
/// </summary>
public string? RelativePath { get; init; }
/// <summary>
/// Whether the parent was successfully resolved.
/// </summary>
public bool IsResolved { get; init; }
/// <summary>
/// The resolved parent metadata (null if unresolved).
/// </summary>
public JavaProjectMetadata? ResolvedParent { get; init; }
/// <summary>
/// Returns the GAV coordinate of the parent.
/// </summary>
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
}
/// <summary>
/// Represents license information extracted from project metadata.
/// </summary>
internal sealed record JavaLicenseInfo
{
/// <summary>
/// License name as declared in the project file.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// License URL if available.
/// </summary>
public string? Url { get; init; }
/// <summary>
/// License distribution type (repo, manual, etc.).
/// </summary>
public string? Distribution { get; init; }
/// <summary>
/// Comments about the license.
/// </summary>
public string? Comments { get; init; }
/// <summary>
/// Normalized SPDX identifier (null if not normalized).
/// </summary>
public string? SpdxId { get; init; }
/// <summary>
/// Confidence level of the SPDX normalization.
/// </summary>
public SpdxConfidence SpdxConfidence { get; init; } = SpdxConfidence.None;
}
/// <summary>
/// Confidence level for SPDX license normalization.
/// </summary>
internal enum SpdxConfidence
{
/// <summary>
/// No SPDX mapping available.
/// </summary>
None,
/// <summary>
/// Low confidence mapping (partial match).
/// </summary>
Low,
/// <summary>
/// Medium confidence mapping (common name or URL match).
/// </summary>
Medium,
/// <summary>
/// High confidence mapping (exact name or official URL).
/// </summary>
High
}
/// <summary>
/// Build system type.
/// </summary>
internal enum JavaBuildSystem
{
Unknown,
Maven,
GradleGroovy,
GradleKotlin,
Ant,
Bazel
}
/// <summary>
/// Represents a BOM (Bill of Materials) import.
/// </summary>
internal sealed record JavaBomImport
{
/// <summary>
/// BOM group ID.
/// </summary>
public required string GroupId { get; init; }
/// <summary>
/// BOM artifact ID.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// BOM version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Whether the BOM was successfully resolved.
/// </summary>
public bool IsResolved { get; init; }
/// <summary>
/// Resolved dependency management entries from the BOM.
/// </summary>
public ImmutableArray<JavaDependencyDeclaration> ManagedDependencies { get; init; } = [];
/// <summary>
/// Returns the GAV coordinate of the BOM.
/// </summary>
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
}

View File

@@ -0,0 +1,280 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Conflicts;
/// <summary>
/// Detects version conflicts where the same artifact appears with multiple versions.
/// </summary>
internal static class VersionConflictDetector
{
/// <summary>
/// Analyzes dependencies for version conflicts.
/// </summary>
public static VersionConflictAnalysis Analyze(IEnumerable<JavaDependencyDeclaration> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var dependencyList = dependencies.ToList();
if (dependencyList.Count == 0)
{
return VersionConflictAnalysis.Empty;
}
// Group by groupId:artifactId
var groups = dependencyList
.Where(d => !string.IsNullOrWhiteSpace(d.Version))
.GroupBy(d => $"{d.GroupId}:{d.ArtifactId}".ToLowerInvariant())
.Where(g => g.Select(d => d.Version).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
.ToList();
if (groups.Count == 0)
{
return VersionConflictAnalysis.Empty;
}
var conflicts = new List<VersionConflict>();
foreach (var group in groups)
{
var versions = group
.Select(d => new VersionOccurrence(
d.Version!,
d.Source,
d.Locator,
d.Scope ?? "compile"))
.OrderBy(v => v.Version, VersionComparer.Instance)
.ToImmutableArray();
var parts = group.Key.Split(':');
var groupId = parts[0];
var artifactId = parts.Length > 1 ? parts[1] : string.Empty;
// Determine severity based on version distance
var severity = CalculateSeverity(versions);
conflicts.Add(new VersionConflict(
groupId,
artifactId,
versions,
severity));
}
return new VersionConflictAnalysis(
[.. conflicts.OrderBy(c => c.GroupId).ThenBy(c => c.ArtifactId)],
conflicts.Count,
conflicts.Max(c => c.Severity));
}
/// <summary>
/// Analyzes artifacts (from JARs) for version conflicts.
/// </summary>
public static VersionConflictAnalysis AnalyzeArtifacts(
IEnumerable<(string GroupId, string ArtifactId, string Version, string Source)> artifacts)
{
var dependencies = artifacts
.Select(a => new JavaDependencyDeclaration
{
GroupId = a.GroupId,
ArtifactId = a.ArtifactId,
Version = a.Version,
Source = a.Source,
Locator = a.Source
})
.ToList();
return Analyze(dependencies);
}
private static ConflictSeverity CalculateSeverity(ImmutableArray<VersionOccurrence> versions)
{
var versionStrings = versions.Select(v => v.Version).Distinct().ToList();
if (versionStrings.Count == 1)
{
return ConflictSeverity.None;
}
// Try to parse as semantic versions
var semvers = versionStrings
.Select(TryParseSemanticVersion)
.Where(v => v is not null)
.Cast<SemanticVersion>()
.ToList();
if (semvers.Count < 2)
{
// Can't determine severity without parseable versions
return ConflictSeverity.Medium;
}
// Check for major version differences (high severity)
var majorVersions = semvers.Select(v => v.Major).Distinct().ToList();
if (majorVersions.Count > 1)
{
return ConflictSeverity.High;
}
// Check for minor version differences (medium severity)
var minorVersions = semvers.Select(v => v.Minor).Distinct().ToList();
if (minorVersions.Count > 1)
{
return ConflictSeverity.Medium;
}
// Only patch version differences (low severity)
return ConflictSeverity.Low;
}
private static SemanticVersion? TryParseSemanticVersion(string version)
{
// Handle versions like "1.2.3", "1.2.3-SNAPSHOT", "1.2.3.Final"
var cleanVersion = version
.Split('-')[0] // Remove suffix like -SNAPSHOT
.Split('.', 4); // Split into parts
if (cleanVersion.Length == 0)
{
return null;
}
if (!int.TryParse(cleanVersion[0], out var major))
{
return null;
}
var minor = cleanVersion.Length > 1 && int.TryParse(cleanVersion[1], out var m) ? m : 0;
var patch = cleanVersion.Length > 2 && int.TryParse(cleanVersion[2], out var p) ? p : 0;
return new SemanticVersion(major, minor, patch);
}
private sealed record SemanticVersion(int Major, int Minor, int Patch);
}
/// <summary>
/// Result of version conflict analysis.
/// </summary>
internal sealed record VersionConflictAnalysis(
ImmutableArray<VersionConflict> Conflicts,
int TotalConflicts,
ConflictSeverity MaxSeverity)
{
public static readonly VersionConflictAnalysis Empty = new([], 0, ConflictSeverity.None);
/// <summary>
/// Returns true if any conflicts were found.
/// </summary>
public bool HasConflicts => TotalConflicts > 0;
/// <summary>
/// Gets conflicts for a specific artifact.
/// </summary>
public VersionConflict? GetConflict(string groupId, string artifactId)
=> Conflicts.FirstOrDefault(c =>
string.Equals(c.GroupId, groupId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.ArtifactId, artifactId, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Represents a version conflict for a single artifact.
/// </summary>
internal sealed record VersionConflict(
string GroupId,
string ArtifactId,
ImmutableArray<VersionOccurrence> Versions,
ConflictSeverity Severity)
{
/// <summary>
/// Gets the artifact coordinate (groupId:artifactId).
/// </summary>
public string Coordinate => $"{GroupId}:{ArtifactId}";
/// <summary>
/// Gets all unique version strings.
/// </summary>
public IEnumerable<string> UniqueVersions
=> Versions.Select(v => v.Version).Distinct();
/// <summary>
/// Gets the versions as a comma-separated string.
/// </summary>
public string VersionsString
=> string.Join(",", UniqueVersions);
}
/// <summary>
/// Represents a single occurrence of a version.
/// </summary>
internal sealed record VersionOccurrence(
string Version,
string? Source,
string? Locator,
string Scope);
/// <summary>
/// Severity level of a version conflict.
/// </summary>
internal enum ConflictSeverity
{
/// <summary>
/// No conflict.
/// </summary>
None = 0,
/// <summary>
/// Only patch version differences (likely compatible).
/// </summary>
Low = 1,
/// <summary>
/// Minor version differences (may have API changes).
/// </summary>
Medium = 2,
/// <summary>
/// Major version differences (likely incompatible).
/// </summary>
High = 3
}
/// <summary>
/// Comparer for semantic version strings.
/// </summary>
internal sealed class VersionComparer : IComparer<string>
{
public static readonly VersionComparer Instance = new();
public int Compare(string? x, string? y)
{
if (x is null && y is null) return 0;
if (x is null) return -1;
if (y is null) return 1;
var xParts = x.Split(['.', '-'], StringSplitOptions.RemoveEmptyEntries);
var yParts = y.Split(['.', '-'], StringSplitOptions.RemoveEmptyEntries);
var maxParts = Math.Max(xParts.Length, yParts.Length);
for (int i = 0; i < maxParts; i++)
{
var xPart = i < xParts.Length ? xParts[i] : "0";
var yPart = i < yParts.Length ? yParts[i] : "0";
// Try numeric comparison first
if (int.TryParse(xPart, out var xNum) && int.TryParse(yPart, out var yNum))
{
var numCompare = xNum.CompareTo(yNum);
if (numCompare != 0) return numCompare;
}
else
{
// Fall back to string comparison
var strCompare = string.Compare(xPart, yPart, StringComparison.OrdinalIgnoreCase);
if (strCompare != 0) return strCompare;
}
}
return 0;
}
}

View File

@@ -0,0 +1,342 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Discovery;
/// <summary>
/// Discovers Java/JVM build files in a directory tree.
/// </summary>
internal static class JavaBuildFileDiscovery
{
private static readonly string[] MavenFiles = ["pom.xml"];
private static readonly string[] GradleGroovyFiles = ["build.gradle", "settings.gradle"];
private static readonly string[] GradleKotlinFiles = ["build.gradle.kts", "settings.gradle.kts"];
private static readonly string[] GradleLockFiles = ["gradle.lockfile"];
private static readonly string[] GradlePropertiesFiles = ["gradle.properties"];
private static readonly string[] GradleVersionCatalogFiles = ["libs.versions.toml", "gradle/libs.versions.toml"];
/// <summary>
/// Discovers all Java build files in the given directory tree.
/// </summary>
public static JavaBuildFiles Discover(string rootPath, int maxDepth = 10)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
if (!Directory.Exists(rootPath))
{
return JavaBuildFiles.Empty;
}
var maven = new List<DiscoveredBuildFile>();
var gradleGroovy = new List<DiscoveredBuildFile>();
var gradleKotlin = new List<DiscoveredBuildFile>();
var gradleLock = new List<DiscoveredBuildFile>();
var gradleProperties = new List<DiscoveredBuildFile>();
var versionCatalogs = new List<DiscoveredBuildFile>();
DiscoverRecursive(rootPath, rootPath, 0, maxDepth,
maven, gradleGroovy, gradleKotlin, gradleLock, gradleProperties, versionCatalogs);
return new JavaBuildFiles(
[.. maven.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
[.. gradleGroovy.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
[.. gradleKotlin.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
[.. gradleLock.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
[.. gradleProperties.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
[.. versionCatalogs.OrderBy(f => f.RelativePath, StringComparer.Ordinal)]);
}
private static void DiscoverRecursive(
string currentPath,
string rootPath,
int currentDepth,
int maxDepth,
List<DiscoveredBuildFile> maven,
List<DiscoveredBuildFile> gradleGroovy,
List<DiscoveredBuildFile> gradleKotlin,
List<DiscoveredBuildFile> gradleLock,
List<DiscoveredBuildFile> gradleProperties,
List<DiscoveredBuildFile> versionCatalogs)
{
if (currentDepth > maxDepth)
{
return;
}
try
{
// Check for files in current directory
foreach (var file in MavenFiles)
{
var path = Path.Combine(currentPath, file);
if (File.Exists(path))
{
maven.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.Maven));
}
}
foreach (var file in GradleGroovyFiles)
{
var path = Path.Combine(currentPath, file);
if (File.Exists(path))
{
gradleGroovy.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
}
}
foreach (var file in GradleKotlinFiles)
{
var path = Path.Combine(currentPath, file);
if (File.Exists(path))
{
gradleKotlin.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleKotlin));
}
}
foreach (var file in GradleLockFiles)
{
var path = Path.Combine(currentPath, file);
if (File.Exists(path))
{
gradleLock.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
}
}
foreach (var file in GradlePropertiesFiles)
{
var path = Path.Combine(currentPath, file);
if (File.Exists(path))
{
gradleProperties.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
}
}
// Check for version catalog files (can be in root or gradle/ subdirectory)
foreach (var file in GradleVersionCatalogFiles)
{
var path = Path.Combine(currentPath, file);
if (File.Exists(path))
{
versionCatalogs.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
}
}
// Also check gradle/dependency-locks directory for lock files
var dependencyLocksDir = Path.Combine(currentPath, "gradle", "dependency-locks");
if (Directory.Exists(dependencyLocksDir))
{
foreach (var lockFile in Directory.EnumerateFiles(dependencyLocksDir, "*.lockfile", SearchOption.AllDirectories))
{
gradleLock.Add(CreateDiscoveredFile(lockFile, rootPath, JavaBuildSystem.GradleGroovy));
}
}
// Recurse into subdirectories
foreach (var subDir in Directory.EnumerateDirectories(currentPath))
{
var dirName = Path.GetFileName(subDir);
// Skip common non-project directories
if (ShouldSkipDirectory(dirName))
{
continue;
}
DiscoverRecursive(subDir, rootPath, currentDepth + 1, maxDepth,
maven, gradleGroovy, gradleKotlin, gradleLock, gradleProperties, versionCatalogs);
}
}
catch (UnauthorizedAccessException)
{
// Skip directories we can't access
}
catch (DirectoryNotFoundException)
{
// Directory was deleted while scanning
}
}
private static DiscoveredBuildFile CreateDiscoveredFile(string absolutePath, string rootPath, JavaBuildSystem buildSystem)
{
var relativePath = Path.GetRelativePath(rootPath, absolutePath).Replace('\\', '/');
var projectDirectory = Path.GetDirectoryName(relativePath) ?? ".";
if (string.IsNullOrEmpty(projectDirectory))
{
projectDirectory = ".";
}
return new DiscoveredBuildFile(
absolutePath,
relativePath,
projectDirectory,
Path.GetFileName(absolutePath),
buildSystem);
}
private static bool ShouldSkipDirectory(string dirName)
{
return dirName switch
{
"node_modules" or ".git" or ".svn" or ".hg" => true,
"target" or "build" or "out" or "bin" or "obj" => true,
".gradle" or ".idea" or ".vscode" or ".settings" => true,
"__pycache__" or "vendor" or "dist" => true,
_ when dirName.StartsWith('.') => true,
_ => false
};
}
}
/// <summary>
/// Represents a discovered build file.
/// </summary>
internal sealed record DiscoveredBuildFile(
string AbsolutePath,
string RelativePath,
string ProjectDirectory,
string FileName,
JavaBuildSystem BuildSystem);
/// <summary>
/// Collection of discovered Java build files.
/// </summary>
internal sealed record JavaBuildFiles(
ImmutableArray<DiscoveredBuildFile> MavenPoms,
ImmutableArray<DiscoveredBuildFile> GradleGroovyFiles,
ImmutableArray<DiscoveredBuildFile> GradleKotlinFiles,
ImmutableArray<DiscoveredBuildFile> GradleLockFiles,
ImmutableArray<DiscoveredBuildFile> GradlePropertiesFiles,
ImmutableArray<DiscoveredBuildFile> VersionCatalogFiles)
{
public static readonly JavaBuildFiles Empty = new([], [], [], [], [], []);
/// <summary>
/// Returns true if any build files were found.
/// </summary>
public bool HasAny =>
MavenPoms.Length > 0 ||
GradleGroovyFiles.Length > 0 ||
GradleKotlinFiles.Length > 0 ||
GradleLockFiles.Length > 0;
/// <summary>
/// Returns true if the project uses Maven.
/// </summary>
public bool UsesMaven => MavenPoms.Length > 0;
/// <summary>
/// Returns true if the project uses Gradle.
/// </summary>
public bool UsesGradle =>
GradleGroovyFiles.Length > 0 ||
GradleKotlinFiles.Length > 0 ||
GradleLockFiles.Length > 0;
/// <summary>
/// Returns true if Gradle lockfiles are present (preferred source).
/// </summary>
public bool HasGradleLockFiles => GradleLockFiles.Length > 0;
/// <summary>
/// Returns true if a version catalog is present.
/// </summary>
public bool HasVersionCatalog => VersionCatalogFiles.Length > 0;
/// <summary>
/// Determines the primary build system.
/// </summary>
public JavaBuildSystem PrimaryBuildSystem
{
get
{
// Gradle lockfiles take precedence
if (HasGradleLockFiles)
{
return JavaBuildSystem.GradleGroovy;
}
// Then Gradle build files
if (GradleKotlinFiles.Length > 0)
{
return JavaBuildSystem.GradleKotlin;
}
if (GradleGroovyFiles.Length > 0)
{
return JavaBuildSystem.GradleGroovy;
}
// Fall back to Maven
if (UsesMaven)
{
return JavaBuildSystem.Maven;
}
return JavaBuildSystem.Unknown;
}
}
/// <summary>
/// Gets all discovered projects grouped by directory.
/// </summary>
public IEnumerable<JavaProjectFiles> GetProjectsByDirectory()
{
var allFiles = MavenPoms
.Concat(GradleGroovyFiles)
.Concat(GradleKotlinFiles)
.Concat(GradleLockFiles)
.Concat(GradlePropertiesFiles)
.Concat(VersionCatalogFiles);
return allFiles
.GroupBy(f => f.ProjectDirectory, StringComparer.OrdinalIgnoreCase)
.Select(g => new JavaProjectFiles(
g.Key,
g.FirstOrDefault(f => f.FileName == "pom.xml"),
g.FirstOrDefault(f => f.FileName == "build.gradle"),
g.FirstOrDefault(f => f.FileName == "build.gradle.kts"),
g.FirstOrDefault(f => f.FileName == "gradle.lockfile"),
g.FirstOrDefault(f => f.FileName == "gradle.properties"),
g.FirstOrDefault(f => f.FileName == "libs.versions.toml")))
.OrderBy(p => p.Directory, StringComparer.Ordinal);
}
}
/// <summary>
/// Represents the build files for a single project directory.
/// </summary>
internal sealed record JavaProjectFiles(
string Directory,
DiscoveredBuildFile? PomXml,
DiscoveredBuildFile? BuildGradle,
DiscoveredBuildFile? BuildGradleKts,
DiscoveredBuildFile? GradleLockfile,
DiscoveredBuildFile? GradleProperties,
DiscoveredBuildFile? VersionCatalog)
{
/// <summary>
/// Determines the primary build system for this project.
/// </summary>
public JavaBuildSystem PrimaryBuildSystem
{
get
{
if (GradleLockfile is not null || BuildGradle is not null)
{
return JavaBuildSystem.GradleGroovy;
}
if (BuildGradleKts is not null)
{
return JavaBuildSystem.GradleKotlin;
}
if (PomXml is not null)
{
return JavaBuildSystem.Maven;
}
return JavaBuildSystem.Unknown;
}
}
}

View File

@@ -0,0 +1,377 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
/// <summary>
/// Parses Gradle Groovy DSL build files (build.gradle).
/// Uses regex-based parsing to extract dependency declarations from common patterns.
/// </summary>
internal static partial class GradleGroovyParser
{
/// <summary>
/// Gradle configuration names that indicate dependency declarations.
/// </summary>
private static readonly string[] DependencyConfigurations =
[
"implementation", "api", "compileOnly", "runtimeOnly",
"testImplementation", "testCompileOnly", "testRuntimeOnly",
"annotationProcessor", "kapt", "ksp",
"compile", "runtime", "testCompile", "testRuntime", // Legacy
"providedCompile", "providedRuntime" // Legacy WAR plugin
];
/// <summary>
/// Parses a build.gradle file asynchronously.
/// </summary>
public static async Task<GradleBuildFile> ParseAsync(
string path,
GradleProperties? properties = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return GradleBuildFile.Empty;
}
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return Parse(content, path, properties);
}
/// <summary>
/// Parses build.gradle content.
/// </summary>
public static GradleBuildFile Parse(string content, string sourcePath, GradleProperties? properties = null)
{
if (string.IsNullOrWhiteSpace(content))
{
return GradleBuildFile.Empty;
}
var dependencies = new List<JavaDependencyDeclaration>();
var plugins = new List<GradlePlugin>();
var unresolvedDependencies = new List<string>();
// Extract group and version from build file
var group = ExtractProperty(content, "group");
var version = ExtractProperty(content, "version");
// Parse plugins block
ParsePlugins(content, plugins);
// Parse dependencies block
ParseDependencies(content, sourcePath, properties, dependencies, unresolvedDependencies);
// Parse platform/BOM declarations
ParsePlatformDependencies(content, sourcePath, dependencies);
return new GradleBuildFile(
sourcePath,
JavaBuildSystem.GradleGroovy,
group,
version,
[.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)],
[.. plugins.OrderBy(p => p.Id, StringComparer.Ordinal)],
[.. unresolvedDependencies.Distinct().OrderBy(u => u, StringComparer.Ordinal)]);
}
private static string? ExtractProperty(string content, string propertyName)
{
// Match: group = 'com.example' or group 'com.example'
var pattern = $@"(?:^|\s){propertyName}\s*[=]?\s*['""]([^'""]+)['""]";
var match = Regex.Match(content, pattern, RegexOptions.Multiline);
return match.Success ? match.Groups[1].Value : null;
}
private static void ParsePlugins(string content, List<GradlePlugin> plugins)
{
// Match plugins { ... } block
var pluginsBlockMatch = PluginsBlockPattern().Match(content);
if (!pluginsBlockMatch.Success)
{
return;
}
var block = pluginsBlockMatch.Groups[1].Value;
// Match id 'plugin-id' version 'x.y.z'
foreach (Match match in PluginPattern().Matches(block))
{
var id = match.Groups[1].Value;
var version = match.Groups.Count > 2 ? match.Groups[2].Value : null;
if (!string.IsNullOrWhiteSpace(id))
{
plugins.Add(new GradlePlugin(id, version));
}
}
}
private static void ParseDependencies(
string content,
string sourcePath,
GradleProperties? properties,
List<JavaDependencyDeclaration> dependencies,
List<string> unresolved)
{
// Match dependencies { ... } block
var dependenciesBlock = ExtractDependenciesBlock(content);
if (string.IsNullOrWhiteSpace(dependenciesBlock))
{
return;
}
foreach (var config in DependencyConfigurations)
{
// Pattern 1: implementation 'group:artifact:version'
var stringPattern = $@"{config}\s+['""]([^'""]+)['""]";
foreach (Match match in Regex.Matches(dependenciesBlock, stringPattern))
{
var coordinate = match.Groups[1].Value;
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
if (dependency is not null)
{
dependencies.Add(dependency);
}
else if (!string.IsNullOrWhiteSpace(coordinate))
{
unresolved.Add(coordinate);
}
}
// Pattern 2: implementation group: 'com.example', name: 'artifact', version: '1.0'
var mapPattern = $@"{config}\s+group:\s*['""]([^'""]+)['""]\s*,\s*name:\s*['""]([^'""]+)['""]\s*(?:,\s*version:\s*['""]([^'""]+)['""])?";
foreach (Match match in Regex.Matches(dependenciesBlock, mapPattern))
{
var groupId = match.Groups[1].Value;
var artifactId = match.Groups[2].Value;
var version = match.Groups.Count > 3 && match.Groups[3].Success
? match.Groups[3].Value
: null;
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
{
dependencies.Add(new JavaDependencyDeclaration
{
GroupId = groupId,
ArtifactId = artifactId,
Version = ResolveVersionProperty(version, properties),
Scope = MapConfigurationToScope(config),
Source = "build.gradle",
Locator = sourcePath
});
}
}
// Pattern 3: implementation(libs.some.library) - version catalog reference
var catalogPattern = $@"{config}\s*\(\s*libs\.([a-zA-Z0-9_.]+)\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, catalogPattern))
{
var alias = match.Groups[1].Value;
// Mark as unresolved until version catalog is parsed
unresolved.Add($"libs.{alias}");
}
// Pattern 4: implementation("group:artifact:version") - with parentheses
var parenPattern = $@"{config}\s*\(\s*['""]([^'""]+)['""]\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, parenPattern))
{
var coordinate = match.Groups[1].Value;
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
if (dependency is not null)
{
dependencies.Add(dependency);
}
else if (!string.IsNullOrWhiteSpace(coordinate))
{
unresolved.Add(coordinate);
}
}
}
}
private static void ParsePlatformDependencies(
string content,
string sourcePath,
List<JavaDependencyDeclaration> dependencies)
{
var dependenciesBlock = ExtractDependenciesBlock(content);
if (string.IsNullOrWhiteSpace(dependenciesBlock))
{
return;
}
// Match: implementation platform('group:artifact:version')
var platformPattern = @"(?:implementation|api)\s+platform\s*\(\s*['""]([^'""]+)['""]\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, platformPattern))
{
var coordinate = match.Groups[1].Value;
var parts = coordinate.Split(':');
if (parts.Length >= 2)
{
dependencies.Add(new JavaDependencyDeclaration
{
GroupId = parts[0],
ArtifactId = parts[1],
Version = parts.Length > 2 ? parts[2] : null,
Type = "pom",
Scope = "import",
Source = "build.gradle",
Locator = sourcePath
});
}
}
}
private static string? ExtractDependenciesBlock(string content)
{
// Simple extraction - find matching braces after 'dependencies'
var match = DependenciesBlockPattern().Match(content);
if (!match.Success)
{
return null;
}
var startIndex = match.Index + match.Length;
var braceCount = 1;
var endIndex = startIndex;
while (endIndex < content.Length && braceCount > 0)
{
if (content[endIndex] == '{') braceCount++;
else if (content[endIndex] == '}') braceCount--;
endIndex++;
}
if (braceCount == 0)
{
return content[startIndex..(endIndex - 1)];
}
return null;
}
private static JavaDependencyDeclaration? ParseCoordinate(
string coordinate,
string configuration,
string sourcePath,
GradleProperties? properties)
{
var parts = coordinate.Split(':');
if (parts.Length < 2)
{
return null;
}
var groupId = parts[0];
var artifactId = parts[1];
var version = parts.Length > 2 ? parts[2] : null;
string? classifier = null;
// Handle classifier: group:artifact:version:classifier
if (parts.Length > 3)
{
classifier = parts[3];
}
// Handle version ranges or dynamic versions
if (version is not null && (version.Contains('[') || version.Contains('+') || version == "latest.release"))
{
// Keep dynamic versions as-is but mark them
}
return new JavaDependencyDeclaration
{
GroupId = groupId,
ArtifactId = artifactId,
Version = ResolveVersionProperty(version, properties),
Classifier = classifier,
Scope = MapConfigurationToScope(configuration),
Source = "build.gradle",
Locator = sourcePath
};
}
private static string? ResolveVersionProperty(string? version, GradleProperties? properties)
{
if (version is null || properties is null)
{
return version;
}
// Handle $property or ${property} syntax
if (version.StartsWith('$'))
{
var propertyName = version.TrimStart('$').Trim('{', '}');
return properties.GetProperty(propertyName) ?? version;
}
return version;
}
private static string MapConfigurationToScope(string configuration)
{
return configuration.ToLowerInvariant() switch
{
"implementation" or "api" or "compile" => "compile",
"compileonly" or "providedcompile" => "provided",
"runtimeonly" or "runtime" or "providedruntime" => "runtime",
"testimplementation" or "testcompile" => "test",
"testcompileonly" => "test",
"testruntimeonly" or "testruntime" => "test",
"annotationprocessor" or "kapt" or "ksp" => "compile",
_ => "compile"
};
}
[GeneratedRegex(@"plugins\s*\{([^}]+)\}", RegexOptions.Singleline)]
private static partial Regex PluginsBlockPattern();
[GeneratedRegex(@"id\s*['""]([^'""]+)['""]\s*(?:version\s*['""]([^'""]+)['""])?", RegexOptions.Singleline)]
private static partial Regex PluginPattern();
[GeneratedRegex(@"dependencies\s*\{", RegexOptions.Multiline)]
private static partial Regex DependenciesBlockPattern();
}
/// <summary>
/// Represents a parsed Gradle build file.
/// </summary>
internal sealed record GradleBuildFile(
string SourcePath,
JavaBuildSystem BuildSystem,
string? Group,
string? Version,
ImmutableArray<JavaDependencyDeclaration> Dependencies,
ImmutableArray<GradlePlugin> Plugins,
ImmutableArray<string> UnresolvedDependencies)
{
public static readonly GradleBuildFile Empty = new(
string.Empty,
JavaBuildSystem.GradleGroovy,
null,
null,
[],
[],
[]);
/// <summary>
/// Returns true if parsing found any dependencies.
/// </summary>
public bool HasDependencies => Dependencies.Length > 0;
/// <summary>
/// Returns true if there are unresolved dependencies.
/// </summary>
public bool HasUnresolvedDependencies => UnresolvedDependencies.Length > 0;
}
/// <summary>
/// Represents a Gradle plugin declaration.
/// </summary>
internal sealed record GradlePlugin(string Id, string? Version);

View File

@@ -0,0 +1,375 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
/// <summary>
/// Parses Gradle Kotlin DSL build files (build.gradle.kts).
/// Uses regex-based parsing to extract dependency declarations.
/// </summary>
internal static partial class GradleKotlinParser
{
/// <summary>
/// Gradle Kotlin DSL configuration functions.
/// </summary>
private static readonly string[] DependencyConfigurations =
[
"implementation", "api", "compileOnly", "runtimeOnly",
"testImplementation", "testCompileOnly", "testRuntimeOnly",
"annotationProcessor", "kapt", "ksp"
];
/// <summary>
/// Parses a build.gradle.kts file asynchronously.
/// </summary>
public static async Task<GradleBuildFile> ParseAsync(
string path,
GradleProperties? properties = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return GradleBuildFile.Empty;
}
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return Parse(content, path, properties);
}
/// <summary>
/// Parses build.gradle.kts content.
/// </summary>
public static GradleBuildFile Parse(string content, string sourcePath, GradleProperties? properties = null)
{
if (string.IsNullOrWhiteSpace(content))
{
return GradleBuildFile.Empty;
}
var dependencies = new List<JavaDependencyDeclaration>();
var plugins = new List<GradlePlugin>();
var unresolvedDependencies = new List<string>();
// Extract group and version
var group = ExtractProperty(content, "group");
var version = ExtractProperty(content, "version");
// Parse plugins block
ParsePlugins(content, plugins);
// Parse dependencies block
ParseDependencies(content, sourcePath, properties, dependencies, unresolvedDependencies);
// Parse platform/BOM declarations
ParsePlatformDependencies(content, sourcePath, dependencies);
return new GradleBuildFile(
sourcePath,
JavaBuildSystem.GradleKotlin,
group,
version,
[.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)],
[.. plugins.OrderBy(p => p.Id, StringComparer.Ordinal)],
[.. unresolvedDependencies.Distinct().OrderBy(u => u, StringComparer.Ordinal)]);
}
private static string? ExtractProperty(string content, string propertyName)
{
// Match: group = "com.example" or group.set("com.example")
var assignPattern = $@"{propertyName}\s*=\s*""([^""]+)""";
var match = Regex.Match(content, assignPattern);
if (match.Success)
{
return match.Groups[1].Value;
}
var setPattern = $@"{propertyName}\.set\s*\(\s*""([^""]+)""\s*\)";
match = Regex.Match(content, setPattern);
return match.Success ? match.Groups[1].Value : null;
}
private static void ParsePlugins(string content, List<GradlePlugin> plugins)
{
// Match plugins { ... } block
var pluginsBlock = ExtractBlock(content, "plugins");
if (string.IsNullOrWhiteSpace(pluginsBlock))
{
return;
}
// Match id("plugin-id") version "x.y.z"
foreach (Match match in PluginIdPattern().Matches(pluginsBlock))
{
var id = match.Groups[1].Value;
var version = match.Groups.Count > 2 && match.Groups[2].Success
? match.Groups[2].Value
: null;
if (!string.IsNullOrWhiteSpace(id))
{
plugins.Add(new GradlePlugin(id, version));
}
}
// Match kotlin("jvm") style
foreach (Match match in KotlinPluginPattern().Matches(pluginsBlock))
{
var type = match.Groups[1].Value;
var version = match.Groups.Count > 2 && match.Groups[2].Success
? match.Groups[2].Value
: null;
plugins.Add(new GradlePlugin($"org.jetbrains.kotlin.{type}", version));
}
// Match `java` or similar bare plugins
foreach (Match match in BarePluginPattern().Matches(pluginsBlock))
{
var id = match.Groups[1].Value;
if (!id.Contains('"') && !id.Contains('('))
{
plugins.Add(new GradlePlugin(id, null));
}
}
}
private static void ParseDependencies(
string content,
string sourcePath,
GradleProperties? properties,
List<JavaDependencyDeclaration> dependencies,
List<string> unresolved)
{
var dependenciesBlock = ExtractBlock(content, "dependencies");
if (string.IsNullOrWhiteSpace(dependenciesBlock))
{
return;
}
foreach (var config in DependencyConfigurations)
{
// Pattern 1: implementation("group:artifact:version")
var stringPattern = $@"{config}\s*\(\s*""([^""]+)""\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, stringPattern))
{
var coordinate = match.Groups[1].Value;
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
if (dependency is not null)
{
dependencies.Add(dependency);
}
else if (!string.IsNullOrWhiteSpace(coordinate))
{
unresolved.Add(coordinate);
}
}
// Pattern 2: implementation(group = "com.example", name = "artifact", version = "1.0")
var namedArgsPattern = $@"{config}\s*\(\s*group\s*=\s*""([^""]+)""\s*,\s*name\s*=\s*""([^""]+)""(?:\s*,\s*version\s*=\s*""([^""]+)"")?\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, namedArgsPattern))
{
var groupId = match.Groups[1].Value;
var artifactId = match.Groups[2].Value;
var version = match.Groups.Count > 3 && match.Groups[3].Success
? match.Groups[3].Value
: null;
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
{
dependencies.Add(new JavaDependencyDeclaration
{
GroupId = groupId,
ArtifactId = artifactId,
Version = ResolveVersionProperty(version, properties),
Scope = MapConfigurationToScope(config),
Source = "build.gradle.kts",
Locator = sourcePath
});
}
}
// Pattern 3: implementation(libs.some.library) - version catalog reference
var catalogPattern = $@"{config}\s*\(\s*libs\.([a-zA-Z0-9_.]+)\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, catalogPattern))
{
var alias = match.Groups[1].Value;
unresolved.Add($"libs.{alias}");
}
// Pattern 4: implementation(project(":module"))
var projectPattern = $@"{config}\s*\(\s*project\s*\(\s*"":([^""]+)""\s*\)\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, projectPattern))
{
// Skip project dependencies - they're internal modules
}
}
}
private static void ParsePlatformDependencies(
string content,
string sourcePath,
List<JavaDependencyDeclaration> dependencies)
{
var dependenciesBlock = ExtractBlock(content, "dependencies");
if (string.IsNullOrWhiteSpace(dependenciesBlock))
{
return;
}
// Match: implementation(platform("group:artifact:version"))
var platformPattern = @"(?:implementation|api)\s*\(\s*platform\s*\(\s*""([^""]+)""\s*\)\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, platformPattern))
{
var coordinate = match.Groups[1].Value;
var parts = coordinate.Split(':');
if (parts.Length >= 2)
{
dependencies.Add(new JavaDependencyDeclaration
{
GroupId = parts[0],
ArtifactId = parts[1],
Version = parts.Length > 2 ? parts[2] : null,
Type = "pom",
Scope = "import",
Source = "build.gradle.kts",
Locator = sourcePath
});
}
}
// Match: implementation(enforcedPlatform("group:artifact:version"))
var enforcedPattern = @"(?:implementation|api)\s*\(\s*enforcedPlatform\s*\(\s*""([^""]+)""\s*\)\s*\)";
foreach (Match match in Regex.Matches(dependenciesBlock, enforcedPattern))
{
var coordinate = match.Groups[1].Value;
var parts = coordinate.Split(':');
if (parts.Length >= 2)
{
dependencies.Add(new JavaDependencyDeclaration
{
GroupId = parts[0],
ArtifactId = parts[1],
Version = parts.Length > 2 ? parts[2] : null,
Type = "pom",
Scope = "import",
Source = "build.gradle.kts",
Locator = sourcePath
});
}
}
}
private static string? ExtractBlock(string content, string blockName)
{
var pattern = $@"{blockName}\s*\{{";
var match = Regex.Match(content, pattern);
if (!match.Success)
{
return null;
}
var startIndex = match.Index + match.Length;
var braceCount = 1;
var endIndex = startIndex;
while (endIndex < content.Length && braceCount > 0)
{
if (content[endIndex] == '{') braceCount++;
else if (content[endIndex] == '}') braceCount--;
endIndex++;
}
if (braceCount == 0)
{
return content[startIndex..(endIndex - 1)];
}
return null;
}
private static JavaDependencyDeclaration? ParseCoordinate(
string coordinate,
string configuration,
string sourcePath,
GradleProperties? properties)
{
// Handle string interpolation like "$group:$artifact:$version"
if (coordinate.Contains('$'))
{
return null; // Unresolved variable reference
}
var parts = coordinate.Split(':');
if (parts.Length < 2)
{
return null;
}
var groupId = parts[0];
var artifactId = parts[1];
var version = parts.Length > 2 ? parts[2] : null;
string? classifier = null;
if (parts.Length > 3)
{
classifier = parts[3];
}
return new JavaDependencyDeclaration
{
GroupId = groupId,
ArtifactId = artifactId,
Version = ResolveVersionProperty(version, properties),
Classifier = classifier,
Scope = MapConfigurationToScope(configuration),
Source = "build.gradle.kts",
Locator = sourcePath
};
}
private static string? ResolveVersionProperty(string? version, GradleProperties? properties)
{
if (version is null || properties is null)
{
return version;
}
// Handle $property syntax in Kotlin
if (version.StartsWith('$'))
{
var propertyName = version.TrimStart('$');
return properties.GetProperty(propertyName) ?? version;
}
return version;
}
private static string MapConfigurationToScope(string configuration)
{
return configuration.ToLowerInvariant() switch
{
"implementation" or "api" => "compile",
"compileonly" => "provided",
"runtimeonly" => "runtime",
"testimplementation" => "test",
"testcompileonly" or "testruntimeonly" => "test",
"annotationprocessor" or "kapt" or "ksp" => "compile",
_ => "compile"
};
}
[GeneratedRegex(@"id\s*\(\s*""([^""]+)""\s*\)(?:\s*version\s*""([^""]+)"")?", RegexOptions.Singleline)]
private static partial Regex PluginIdPattern();
[GeneratedRegex(@"kotlin\s*\(\s*""([^""]+)""\s*\)(?:\s*version\s*""([^""]+)"")?", RegexOptions.Singleline)]
private static partial Regex KotlinPluginPattern();
[GeneratedRegex(@"^\s*`?([a-zA-Z-]+)`?\s*$", RegexOptions.Multiline)]
private static partial Regex BarePluginPattern();
}

View File

@@ -0,0 +1,191 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
/// <summary>
/// Parses gradle.properties files to extract key-value properties.
/// </summary>
internal static partial class GradlePropertiesParser
{
/// <summary>
/// Parses a gradle.properties file asynchronously.
/// </summary>
public static async Task<GradleProperties> ParseAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return GradleProperties.Empty;
}
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return Parse(content);
}
/// <summary>
/// Parses gradle.properties content.
/// </summary>
public static GradleProperties Parse(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GradleProperties.Empty;
}
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var systemProperties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using var reader = new StringReader(content);
string? line;
string? continuationKey = null;
var continuationValue = new System.Text.StringBuilder();
while ((line = reader.ReadLine()) is not null)
{
// Handle line continuation
if (continuationKey is not null)
{
if (line.EndsWith('\\'))
{
continuationValue.Append(line[..^1]);
continue;
}
else
{
continuationValue.Append(line);
AddProperty(properties, systemProperties, continuationKey, continuationValue.ToString());
continuationKey = null;
continuationValue.Clear();
continue;
}
}
// Trim and skip empty lines/comments
line = line.Trim();
if (string.IsNullOrEmpty(line) || line.StartsWith('#') || line.StartsWith('!'))
{
continue;
}
// Find the key-value separator (= or :)
var separatorIndex = FindSeparator(line);
if (separatorIndex < 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].TrimStart();
// Handle line continuation
if (value.EndsWith('\\'))
{
continuationKey = key;
continuationValue.Append(value[..^1]);
continue;
}
AddProperty(properties, systemProperties, key, value);
}
// Handle any remaining continuation
if (continuationKey is not null)
{
AddProperty(properties, systemProperties, continuationKey, continuationValue.ToString());
}
return new GradleProperties(
properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
systemProperties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase));
}
private static int FindSeparator(string line)
{
var equalsIndex = line.IndexOf('=');
var colonIndex = line.IndexOf(':');
if (equalsIndex < 0) return colonIndex;
if (colonIndex < 0) return equalsIndex;
return Math.Min(equalsIndex, colonIndex);
}
private static void AddProperty(
Dictionary<string, string> properties,
Dictionary<string, string> systemProperties,
string key,
string value)
{
// Unescape common escape sequences
value = UnescapeValue(value);
// Check if it's a system property
if (key.StartsWith("systemProp.", StringComparison.OrdinalIgnoreCase))
{
var systemKey = key["systemProp.".Length..];
systemProperties[systemKey] = value;
}
else
{
properties[key] = value;
}
}
private static string UnescapeValue(string value)
{
if (!value.Contains('\\'))
{
return value;
}
return value
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t")
.Replace("\\\\", "\\");
}
}
/// <summary>
/// Represents parsed gradle.properties content.
/// </summary>
internal sealed record GradleProperties(
ImmutableDictionary<string, string> Properties,
ImmutableDictionary<string, string> SystemProperties)
{
public static readonly GradleProperties Empty = new(
ImmutableDictionary<string, string>.Empty,
ImmutableDictionary<string, string>.Empty);
/// <summary>
/// Gets a property value, returning null if not found.
/// </summary>
public string? GetProperty(string key)
=> Properties.TryGetValue(key, out var value) ? value : null;
/// <summary>
/// Gets the project group if defined.
/// </summary>
public string? Group => GetProperty("group");
/// <summary>
/// Gets the project version if defined.
/// </summary>
public string? Version => GetProperty("version");
/// <summary>
/// Gets commonly used version properties.
/// </summary>
public IEnumerable<KeyValuePair<string, string>> GetVersionProperties()
{
foreach (var (key, value) in Properties)
{
if (key.EndsWith("Version", StringComparison.OrdinalIgnoreCase) ||
key.EndsWith(".version", StringComparison.OrdinalIgnoreCase))
{
yield return new KeyValuePair<string, string>(key, value);
}
}
}
}

View File

@@ -0,0 +1,397 @@
using System.Collections.Frozen;
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
/// <summary>
/// Parses Gradle Version Catalog files (libs.versions.toml).
/// </summary>
internal static class GradleVersionCatalogParser
{
/// <summary>
/// Parses a version catalog file asynchronously.
/// </summary>
public static async Task<GradleVersionCatalog> ParseAsync(
string path,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return GradleVersionCatalog.Empty;
}
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return Parse(content, path);
}
/// <summary>
/// Parses version catalog content.
/// </summary>
public static GradleVersionCatalog Parse(string content, string sourcePath)
{
if (string.IsNullOrWhiteSpace(content))
{
return GradleVersionCatalog.Empty;
}
var document = TomlParser.Parse(content);
var versions = ParseVersions(document);
var libraries = ParseLibraries(document, versions, sourcePath);
var plugins = ParsePlugins(document, versions);
var bundles = ParseBundles(document);
return new GradleVersionCatalog(
sourcePath,
versions.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
libraries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
plugins.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
bundles.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
private static Dictionary<string, string> ParseVersions(TomlDocument document)
{
var versions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var versionsTable = document.GetTable("versions");
if (versionsTable is null)
{
return versions;
}
foreach (var (key, value) in versionsTable.Entries)
{
if (value.Kind == TomlValueKind.String)
{
versions[key] = value.StringValue;
}
else if (value.Kind == TomlValueKind.InlineTable)
{
// Handle { strictly = "x.y.z" } or { prefer = "x.y.z" }
var strictly = value.GetNestedString("strictly");
var prefer = value.GetNestedString("prefer");
var require = value.GetNestedString("require");
versions[key] = strictly ?? prefer ?? require ?? string.Empty;
}
}
return versions;
}
private static Dictionary<string, CatalogLibrary> ParseLibraries(
TomlDocument document,
Dictionary<string, string> versions,
string sourcePath)
{
var libraries = new Dictionary<string, CatalogLibrary>(StringComparer.OrdinalIgnoreCase);
var librariesTable = document.GetTable("libraries");
if (librariesTable is null)
{
return libraries;
}
foreach (var (alias, value) in librariesTable.Entries)
{
CatalogLibrary? library = null;
if (value.Kind == TomlValueKind.String)
{
// Short notation: "group:artifact:version"
library = ParseLibraryString(alias, value.StringValue, sourcePath);
}
else if (value.Kind == TomlValueKind.InlineTable)
{
// Full notation with module or group/name
library = ParseLibraryTable(alias, value, versions, sourcePath);
}
if (library is not null)
{
libraries[alias] = library;
}
}
return libraries;
}
private static CatalogLibrary? ParseLibraryString(string alias, string value, string sourcePath)
{
var parts = value.Split(':');
if (parts.Length < 2)
{
return null;
}
return new CatalogLibrary(
alias,
parts[0],
parts[1],
parts.Length > 2 ? parts[2] : null,
null,
sourcePath);
}
private static CatalogLibrary? ParseLibraryTable(
string alias,
TomlValue value,
Dictionary<string, string> versions,
string sourcePath)
{
var module = value.GetNestedString("module");
string? groupId = null;
string? artifactId = null;
string? version = null;
string? versionRef = null;
if (!string.IsNullOrEmpty(module))
{
// module = "group:artifact"
var parts = module.Split(':');
if (parts.Length >= 2)
{
groupId = parts[0];
artifactId = parts[1];
}
}
else
{
// group = "...", name = "..."
groupId = value.GetNestedString("group");
artifactId = value.GetNestedString("name");
}
if (string.IsNullOrEmpty(groupId) || string.IsNullOrEmpty(artifactId))
{
return null;
}
// Handle version - can be direct or reference
version = value.GetNestedString("version");
if (string.IsNullOrEmpty(version))
{
// Check for version.ref
var versionValue = value.TableValue?.GetValueOrDefault("version");
if (versionValue?.Kind == TomlValueKind.InlineTable)
{
versionRef = versionValue.GetNestedString("ref");
if (!string.IsNullOrEmpty(versionRef) && versions.TryGetValue(versionRef, out var resolvedVersion))
{
version = resolvedVersion;
}
}
else if (versionValue?.Kind == TomlValueKind.String)
{
version = versionValue.StringValue;
}
}
return new CatalogLibrary(
alias,
groupId,
artifactId,
version,
versionRef,
sourcePath);
}
private static Dictionary<string, CatalogPlugin> ParsePlugins(
TomlDocument document,
Dictionary<string, string> versions)
{
var plugins = new Dictionary<string, CatalogPlugin>(StringComparer.OrdinalIgnoreCase);
var pluginsTable = document.GetTable("plugins");
if (pluginsTable is null)
{
return plugins;
}
foreach (var (alias, value) in pluginsTable.Entries)
{
if (value.Kind == TomlValueKind.String)
{
// Short notation: "plugin.id:version"
var parts = value.StringValue.Split(':');
plugins[alias] = new CatalogPlugin(
alias,
parts[0],
parts.Length > 1 ? parts[1] : null,
null);
}
else if (value.Kind == TomlValueKind.InlineTable)
{
var id = value.GetNestedString("id");
var version = value.GetNestedString("version");
string? versionRef = null;
if (string.IsNullOrEmpty(version))
{
var versionValue = value.TableValue?.GetValueOrDefault("version");
if (versionValue?.Kind == TomlValueKind.InlineTable)
{
versionRef = versionValue.GetNestedString("ref");
if (!string.IsNullOrEmpty(versionRef) && versions.TryGetValue(versionRef, out var resolved))
{
version = resolved;
}
}
}
if (!string.IsNullOrEmpty(id))
{
plugins[alias] = new CatalogPlugin(alias, id, version, versionRef);
}
}
}
return plugins;
}
private static Dictionary<string, CatalogBundle> ParseBundles(TomlDocument document)
{
var bundles = new Dictionary<string, CatalogBundle>(StringComparer.OrdinalIgnoreCase);
var bundlesTable = document.GetTable("bundles");
if (bundlesTable is null)
{
return bundles;
}
foreach (var (alias, value) in bundlesTable.Entries)
{
if (value.Kind == TomlValueKind.Array)
{
var libraryRefs = value.GetArrayItems()
.Where(v => v.Kind == TomlValueKind.String)
.Select(v => v.StringValue)
.ToImmutableArray();
bundles[alias] = new CatalogBundle(alias, libraryRefs);
}
}
return bundles;
}
}
/// <summary>
/// Represents a parsed Gradle Version Catalog.
/// </summary>
internal sealed record GradleVersionCatalog(
string SourcePath,
FrozenDictionary<string, string> Versions,
FrozenDictionary<string, CatalogLibrary> Libraries,
FrozenDictionary<string, CatalogPlugin> Plugins,
FrozenDictionary<string, CatalogBundle> Bundles)
{
public static readonly GradleVersionCatalog Empty = new(
string.Empty,
FrozenDictionary<string, string>.Empty,
FrozenDictionary<string, CatalogLibrary>.Empty,
FrozenDictionary<string, CatalogPlugin>.Empty,
FrozenDictionary<string, CatalogBundle>.Empty);
/// <summary>
/// Returns true if the catalog has any libraries.
/// </summary>
public bool HasLibraries => Libraries.Count > 0;
/// <summary>
/// Gets a library by its alias.
/// </summary>
public CatalogLibrary? GetLibrary(string alias)
{
// Handle dotted notation: libs.some.library -> some-library or some.library
var normalizedAlias = alias
.Replace("libs.", "", StringComparison.OrdinalIgnoreCase)
.Replace('.', '-');
if (Libraries.TryGetValue(normalizedAlias, out var library))
{
return library;
}
// Try with dots
normalizedAlias = alias.Replace("libs.", "", StringComparison.OrdinalIgnoreCase);
return Libraries.TryGetValue(normalizedAlias, out library) ? library : null;
}
/// <summary>
/// Converts all libraries to dependency declarations.
/// </summary>
public IEnumerable<JavaDependencyDeclaration> ToDependencies()
{
foreach (var library in Libraries.Values)
{
yield return new JavaDependencyDeclaration
{
GroupId = library.GroupId,
ArtifactId = library.ArtifactId,
Version = library.Version,
VersionSource = library.VersionRef is not null
? JavaVersionSource.VersionCatalog
: JavaVersionSource.Direct,
VersionProperty = library.VersionRef,
Source = "libs.versions.toml",
Locator = SourcePath
};
}
}
}
/// <summary>
/// Represents a library entry in the version catalog.
/// </summary>
internal sealed record CatalogLibrary(
string Alias,
string GroupId,
string ArtifactId,
string? Version,
string? VersionRef,
string SourcePath)
{
/// <summary>
/// Returns the GAV coordinate.
/// </summary>
public string Gav => Version is not null
? $"{GroupId}:{ArtifactId}:{Version}"
: $"{GroupId}:{ArtifactId}";
/// <summary>
/// Converts to a dependency declaration.
/// </summary>
public JavaDependencyDeclaration ToDependency(string? scope = null) => new()
{
GroupId = GroupId,
ArtifactId = ArtifactId,
Version = Version,
Scope = scope,
VersionSource = VersionRef is not null
? JavaVersionSource.VersionCatalog
: JavaVersionSource.Direct,
VersionProperty = VersionRef,
Source = "libs.versions.toml",
Locator = SourcePath
};
}
/// <summary>
/// Represents a plugin entry in the version catalog.
/// </summary>
internal sealed record CatalogPlugin(
string Alias,
string Id,
string? Version,
string? VersionRef);
/// <summary>
/// Represents a bundle (group of libraries) in the version catalog.
/// </summary>
internal sealed record CatalogBundle(
string Alias,
ImmutableArray<string> LibraryRefs);

View File

@@ -0,0 +1,316 @@
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
/// <summary>
/// Minimal TOML parser for parsing Gradle version catalog files.
/// Supports the subset of TOML needed for libs.versions.toml parsing.
/// </summary>
internal static partial class TomlParser
{
/// <summary>
/// Parses a TOML file.
/// </summary>
public static TomlDocument Parse(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return TomlDocument.Empty;
}
var tables = new Dictionary<string, TomlTable>(StringComparer.OrdinalIgnoreCase);
var rootTable = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
var currentTable = rootTable;
var currentTableName = string.Empty;
using var reader = new StringReader(content);
string? line;
while ((line = reader.ReadLine()) is not null)
{
line = line.Trim();
// Skip empty lines and comments
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
{
continue;
}
// Table header: [tableName]
var tableMatch = TableHeaderPattern().Match(line);
if (tableMatch.Success)
{
// Save previous table
if (!string.IsNullOrEmpty(currentTableName))
{
tables[currentTableName] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
else if (currentTable.Count > 0)
{
tables[string.Empty] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
currentTableName = tableMatch.Groups[1].Value;
currentTable = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
continue;
}
// Key-value pair: key = value
var kvMatch = KeyValuePattern().Match(line);
if (kvMatch.Success)
{
var key = kvMatch.Groups[1].Value.Trim().Trim('"');
var valueStr = kvMatch.Groups[2].Value.Trim();
var value = ParseValue(valueStr);
currentTable[key] = value;
}
}
// Save the last table
if (!string.IsNullOrEmpty(currentTableName))
{
tables[currentTableName] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
else if (currentTable.Count > 0)
{
tables[string.Empty] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
return new TomlDocument(tables.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
private static TomlValue ParseValue(string valueStr)
{
// Remove trailing comment
var commentIndex = valueStr.IndexOf('#');
if (commentIndex > 0)
{
// But not inside a string
var inString = false;
for (int i = 0; i < commentIndex; i++)
{
if (valueStr[i] == '"' && (i == 0 || valueStr[i - 1] != '\\'))
{
inString = !inString;
}
}
if (!inString)
{
valueStr = valueStr[..commentIndex].Trim();
}
}
// String value: "value" or 'value'
if ((valueStr.StartsWith('"') && valueStr.EndsWith('"')) ||
(valueStr.StartsWith('\'') && valueStr.EndsWith('\'')))
{
return new TomlValue(TomlValueKind.String, valueStr[1..^1]);
}
// Inline table: { key = "value", ... }
if (valueStr.StartsWith('{') && valueStr.EndsWith('}'))
{
var tableContent = valueStr[1..^1];
var inlineTable = ParseInlineTable(tableContent);
return new TomlValue(TomlValueKind.InlineTable, valueStr, inlineTable);
}
// Array: [ ... ]
if (valueStr.StartsWith('[') && valueStr.EndsWith(']'))
{
var arrayContent = valueStr[1..^1];
var items = ParseArray(arrayContent);
return new TomlValue(TomlValueKind.Array, valueStr, ArrayItems: items);
}
// Boolean
if (valueStr.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return new TomlValue(TomlValueKind.Boolean, "true");
}
if (valueStr.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return new TomlValue(TomlValueKind.Boolean, "false");
}
// Number (integer or float)
if (double.TryParse(valueStr, out _))
{
return new TomlValue(TomlValueKind.Number, valueStr);
}
// Bare string (unquoted - technically not valid TOML but seen in some files)
return new TomlValue(TomlValueKind.String, valueStr);
}
private static FrozenDictionary<string, TomlValue> ParseInlineTable(string content)
{
var result = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
// Split by comma, handling nested structures
var pairs = SplitByComma(content);
foreach (var pair in pairs)
{
var eqIndex = pair.IndexOf('=');
if (eqIndex > 0)
{
var key = pair[..eqIndex].Trim().Trim('"');
var valueStr = pair[(eqIndex + 1)..].Trim();
result[key] = ParseValue(valueStr);
}
}
return result.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
private static ImmutableArray<TomlValue> ParseArray(string content)
{
var items = new List<TomlValue>();
var elements = SplitByComma(content);
foreach (var element in elements)
{
var trimmed = element.Trim();
if (!string.IsNullOrEmpty(trimmed))
{
items.Add(ParseValue(trimmed));
}
}
return [.. items];
}
private static List<string> SplitByComma(string content)
{
var result = new List<string>();
var current = new System.Text.StringBuilder();
var depth = 0;
var inString = false;
foreach (var c in content)
{
if (c == '"' && (current.Length == 0 || current[^1] != '\\'))
{
inString = !inString;
}
if (!inString)
{
if (c == '{' || c == '[') depth++;
else if (c == '}' || c == ']') depth--;
else if (c == ',' && depth == 0)
{
result.Add(current.ToString());
current.Clear();
continue;
}
}
current.Append(c);
}
if (current.Length > 0)
{
result.Add(current.ToString());
}
return result;
}
[GeneratedRegex(@"^\[([^\]]+)\]$")]
private static partial Regex TableHeaderPattern();
[GeneratedRegex(@"^([^=]+)=(.+)$")]
private static partial Regex KeyValuePattern();
}
/// <summary>
/// Represents a parsed TOML document.
/// </summary>
internal sealed record TomlDocument(FrozenDictionary<string, TomlTable> Tables)
{
public static readonly TomlDocument Empty = new(FrozenDictionary<string, TomlTable>.Empty);
/// <summary>
/// Gets a table by name.
/// </summary>
public TomlTable? GetTable(string name)
=> Tables.TryGetValue(name, out var table) ? table : null;
/// <summary>
/// Checks if a table exists.
/// </summary>
public bool HasTable(string name)
=> Tables.ContainsKey(name);
}
/// <summary>
/// Represents a TOML table (section).
/// </summary>
internal sealed record TomlTable(FrozenDictionary<string, TomlValue> Values)
{
/// <summary>
/// Gets a string value from the table.
/// </summary>
public string? GetString(string key)
=> Values.TryGetValue(key, out var value) && value.Kind == TomlValueKind.String
? value.StringValue
: null;
/// <summary>
/// Gets an inline table value.
/// </summary>
public FrozenDictionary<string, TomlValue>? GetInlineTable(string key)
=> Values.TryGetValue(key, out var value) && value.Kind == TomlValueKind.InlineTable
? value.TableValue
: null;
/// <summary>
/// Gets all entries in this table.
/// </summary>
public IEnumerable<KeyValuePair<string, TomlValue>> Entries => Values;
}
/// <summary>
/// Represents a TOML value.
/// </summary>
internal sealed record TomlValue(
TomlValueKind Kind,
string StringValue,
FrozenDictionary<string, TomlValue>? TableValue = null,
ImmutableArray<TomlValue>? ArrayItems = null)
{
/// <summary>
/// Gets a nested value from an inline table.
/// </summary>
public string? GetNestedString(string key)
{
if (Kind != TomlValueKind.InlineTable || TableValue is null)
{
return null;
}
return TableValue.TryGetValue(key, out var value) ? value.StringValue : null;
}
/// <summary>
/// Gets the array items if this is an array value.
/// </summary>
public ImmutableArray<TomlValue> GetArrayItems()
=> ArrayItems ?? [];
}
/// <summary>
/// Kind of TOML value.
/// </summary>
internal enum TomlValueKind
{
String,
Number,
Boolean,
Array,
InlineTable
}

View File

@@ -0,0 +1,352 @@
using System.Collections.Frozen;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
/// <summary>
/// Normalizes license names and URLs to SPDX identifiers.
/// </summary>
internal sealed partial class SpdxLicenseNormalizer
{
private static readonly Lazy<SpdxLicenseNormalizer> LazyInstance = new(() => new SpdxLicenseNormalizer());
private readonly FrozenDictionary<string, SpdxLicenseMapping> _nameIndex;
private readonly FrozenDictionary<string, SpdxLicenseMapping> _urlIndex;
/// <summary>
/// Gets the singleton instance.
/// </summary>
public static SpdxLicenseNormalizer Instance => LazyInstance.Value;
private SpdxLicenseNormalizer()
{
var mappings = LoadMappings();
var nameDict = new Dictionary<string, SpdxLicenseMapping>(StringComparer.OrdinalIgnoreCase);
var urlDict = new Dictionary<string, SpdxLicenseMapping>(StringComparer.OrdinalIgnoreCase);
foreach (var mapping in mappings)
{
// Index by normalized name
foreach (var name in mapping.Names)
{
var normalizedName = NormalizeName(name);
nameDict.TryAdd(normalizedName, mapping);
}
// Index by URL
foreach (var url in mapping.Urls)
{
var normalizedUrl = NormalizeUrl(url);
urlDict.TryAdd(normalizedUrl, mapping);
}
}
_nameIndex = nameDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
_urlIndex = urlDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Normalizes a license name and/or URL to an SPDX identifier.
/// </summary>
public JavaLicenseInfo Normalize(string? name, string? url)
{
var result = new JavaLicenseInfo
{
Name = name,
Url = url
};
// Try URL first (higher confidence)
if (!string.IsNullOrWhiteSpace(url))
{
var normalizedUrl = NormalizeUrl(url);
if (_urlIndex.TryGetValue(normalizedUrl, out var urlMapping))
{
return result with
{
SpdxId = urlMapping.SpdxId,
SpdxConfidence = SpdxConfidence.High
};
}
}
// Then try name
if (!string.IsNullOrWhiteSpace(name))
{
var normalizedName = NormalizeName(name);
// Exact match
if (_nameIndex.TryGetValue(normalizedName, out var nameMapping))
{
return result with
{
SpdxId = nameMapping.SpdxId,
SpdxConfidence = SpdxConfidence.High
};
}
// Fuzzy match
var fuzzyMatch = TryFuzzyMatch(normalizedName);
if (fuzzyMatch is not null)
{
return result with
{
SpdxId = fuzzyMatch.SpdxId,
SpdxConfidence = SpdxConfidence.Medium
};
}
}
return result;
}
private static string NormalizeName(string name)
{
// Remove common noise words and normalize whitespace
var normalized = name.ToLowerInvariant()
.Replace("the", "", StringComparison.OrdinalIgnoreCase)
.Replace("license", "", StringComparison.OrdinalIgnoreCase)
.Replace("licence", "", StringComparison.OrdinalIgnoreCase)
.Replace("version", "", StringComparison.OrdinalIgnoreCase)
.Replace(",", "")
.Replace("(", "")
.Replace(")", "");
return WhitespacePattern().Replace(normalized, " ").Trim();
}
private static string NormalizeUrl(string url)
{
// Normalize URL for comparison
var normalized = url.ToLowerInvariant()
.Replace("https://", "")
.Replace("http://", "")
.Replace("www.", "")
.TrimEnd('/');
return normalized;
}
private SpdxLicenseMapping? TryFuzzyMatch(string normalizedName)
{
// Check for common patterns
if (normalizedName.Contains("apache") && normalizedName.Contains("2"))
{
return _nameIndex.GetValueOrDefault("apache 2.0");
}
if (normalizedName.Contains("mit"))
{
return _nameIndex.GetValueOrDefault("mit");
}
if (normalizedName.Contains("bsd") && normalizedName.Contains("3"))
{
return _nameIndex.GetValueOrDefault("bsd 3 clause");
}
if (normalizedName.Contains("bsd") && normalizedName.Contains("2"))
{
return _nameIndex.GetValueOrDefault("bsd 2 clause");
}
if (normalizedName.Contains("gpl") && normalizedName.Contains("3"))
{
return _nameIndex.GetValueOrDefault("gpl 3.0");
}
if (normalizedName.Contains("gpl") && normalizedName.Contains("2"))
{
return _nameIndex.GetValueOrDefault("gpl 2.0");
}
if (normalizedName.Contains("lgpl") && normalizedName.Contains("2.1"))
{
return _nameIndex.GetValueOrDefault("lgpl 2.1");
}
if (normalizedName.Contains("lgpl") && normalizedName.Contains("3"))
{
return _nameIndex.GetValueOrDefault("lgpl 3.0");
}
if (normalizedName.Contains("mpl") && normalizedName.Contains("2"))
{
return _nameIndex.GetValueOrDefault("mpl 2.0");
}
if (normalizedName.Contains("cddl"))
{
return _nameIndex.GetValueOrDefault("cddl 1.0");
}
if (normalizedName.Contains("epl") && normalizedName.Contains("2"))
{
return _nameIndex.GetValueOrDefault("epl 2.0");
}
if (normalizedName.Contains("epl") && normalizedName.Contains("1"))
{
return _nameIndex.GetValueOrDefault("epl 1.0");
}
return null;
}
private static IEnumerable<SpdxLicenseMapping> LoadMappings()
{
// High-confidence SPDX mappings for common licenses
// This list focuses on licenses commonly found in Java/Maven projects
return
[
// Apache
new SpdxLicenseMapping("Apache-2.0",
["Apache License 2.0", "Apache License, Version 2.0", "Apache 2.0", "Apache-2.0", "ASL 2.0", "AL 2.0"],
["apache.org/licenses/LICENSE-2.0", "opensource.org/licenses/Apache-2.0"]),
new SpdxLicenseMapping("Apache-1.1",
["Apache License 1.1", "Apache Software License 1.1"],
["apache.org/licenses/LICENSE-1.1"]),
// MIT
new SpdxLicenseMapping("MIT",
["MIT License", "MIT", "The MIT License", "Expat License"],
["opensource.org/licenses/MIT", "mit-license.org"]),
// BSD
new SpdxLicenseMapping("BSD-2-Clause",
["BSD 2-Clause License", "BSD-2-Clause", "Simplified BSD License", "FreeBSD License"],
["opensource.org/licenses/BSD-2-Clause"]),
new SpdxLicenseMapping("BSD-3-Clause",
["BSD 3-Clause License", "BSD-3-Clause", "New BSD License", "Modified BSD License"],
["opensource.org/licenses/BSD-3-Clause"]),
// GPL
new SpdxLicenseMapping("GPL-2.0-only",
["GNU General Public License v2.0", "GPL 2.0", "GPL-2.0", "GPLv2"],
["gnu.org/licenses/old-licenses/gpl-2.0", "opensource.org/licenses/GPL-2.0"]),
new SpdxLicenseMapping("GPL-2.0-or-later",
["GNU General Public License v2.0 or later", "GPL 2.0+", "GPL-2.0+", "GPLv2+"],
[]),
new SpdxLicenseMapping("GPL-3.0-only",
["GNU General Public License v3.0", "GPL 3.0", "GPL-3.0", "GPLv3"],
["gnu.org/licenses/gpl-3.0", "opensource.org/licenses/GPL-3.0"]),
new SpdxLicenseMapping("GPL-3.0-or-later",
["GNU General Public License v3.0 or later", "GPL 3.0+", "GPL-3.0+", "GPLv3+"],
[]),
// LGPL
new SpdxLicenseMapping("LGPL-2.1-only",
["GNU Lesser General Public License v2.1", "LGPL 2.1", "LGPL-2.1", "LGPLv2.1"],
["gnu.org/licenses/old-licenses/lgpl-2.1", "opensource.org/licenses/LGPL-2.1"]),
new SpdxLicenseMapping("LGPL-3.0-only",
["GNU Lesser General Public License v3.0", "LGPL 3.0", "LGPL-3.0", "LGPLv3"],
["gnu.org/licenses/lgpl-3.0", "opensource.org/licenses/LGPL-3.0"]),
// MPL
new SpdxLicenseMapping("MPL-2.0",
["Mozilla Public License 2.0", "MPL 2.0", "MPL-2.0"],
["mozilla.org/MPL/2.0", "opensource.org/licenses/MPL-2.0"]),
new SpdxLicenseMapping("MPL-1.1",
["Mozilla Public License 1.1", "MPL 1.1", "MPL-1.1"],
["mozilla.org/MPL/1.1"]),
// Eclipse
new SpdxLicenseMapping("EPL-1.0",
["Eclipse Public License 1.0", "EPL 1.0", "EPL-1.0"],
["eclipse.org/legal/epl-v10", "opensource.org/licenses/EPL-1.0"]),
new SpdxLicenseMapping("EPL-2.0",
["Eclipse Public License 2.0", "EPL 2.0", "EPL-2.0"],
["eclipse.org/legal/epl-2.0", "opensource.org/licenses/EPL-2.0"]),
// CDDL
new SpdxLicenseMapping("CDDL-1.0",
["Common Development and Distribution License 1.0", "CDDL 1.0", "CDDL-1.0"],
["opensource.org/licenses/CDDL-1.0"]),
new SpdxLicenseMapping("CDDL-1.1",
["Common Development and Distribution License 1.1", "CDDL 1.1", "CDDL-1.1"],
["glassfish.dev.java.net/public/CDDL+GPL_1_1"]),
// Creative Commons
new SpdxLicenseMapping("CC0-1.0",
["CC0 1.0 Universal", "CC0", "Public Domain"],
["creativecommons.org/publicdomain/zero/1.0"]),
new SpdxLicenseMapping("CC-BY-4.0",
["Creative Commons Attribution 4.0", "CC BY 4.0"],
["creativecommons.org/licenses/by/4.0"]),
// Unlicense
new SpdxLicenseMapping("Unlicense",
["The Unlicense", "Unlicense"],
["unlicense.org"]),
// ISC
new SpdxLicenseMapping("ISC",
["ISC License", "ISC"],
["opensource.org/licenses/ISC"]),
// Zlib
new SpdxLicenseMapping("Zlib",
["zlib License", "zlib/libpng License"],
["opensource.org/licenses/Zlib"]),
// WTFPL
new SpdxLicenseMapping("WTFPL",
["Do What The F*ck You Want To Public License", "WTFPL"],
["wtfpl.net"]),
// BSL (Business Source License)
new SpdxLicenseMapping("BSL-1.0",
["Boost Software License 1.0", "BSL-1.0", "Boost License"],
["boost.org/LICENSE_1_0.txt", "opensource.org/licenses/BSL-1.0"]),
// JSON License
new SpdxLicenseMapping("JSON",
["The JSON License", "JSON License"],
["json.org/license"]),
// AGPL
new SpdxLicenseMapping("AGPL-3.0-only",
["GNU Affero General Public License v3.0", "AGPL 3.0", "AGPL-3.0", "AGPLv3"],
["gnu.org/licenses/agpl-3.0", "opensource.org/licenses/AGPL-3.0"]),
// PostgreSQL
new SpdxLicenseMapping("PostgreSQL",
["PostgreSQL License", "The PostgreSQL License"],
["opensource.org/licenses/PostgreSQL"]),
// Unicode
new SpdxLicenseMapping("Unicode-DFS-2016",
["Unicode License Agreement", "Unicode DFS 2016"],
["unicode.org/copyright"]),
// W3C
new SpdxLicenseMapping("W3C",
["W3C Software Notice and License", "W3C License"],
["w3.org/Consortium/Legal/2015/copyright-software-and-document"])
];
}
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespacePattern();
}
/// <summary>
/// Represents a mapping from license names/URLs to an SPDX identifier.
/// </summary>
internal sealed record SpdxLicenseMapping(
string SpdxId,
IReadOnlyList<string> Names,
IReadOnlyList<string> Urls);

View File

@@ -0,0 +1,213 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
/// <summary>
/// Imports Maven BOM (Bill of Materials) POMs to extract managed dependency versions.
/// </summary>
internal sealed class MavenBomImporter
{
private const int MaxImportDepth = 5;
private readonly string _rootPath;
private readonly MavenLocalRepository _localRepository;
private readonly Dictionary<string, ImportedBom?> _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _importing = new(StringComparer.OrdinalIgnoreCase);
public MavenBomImporter(string rootPath)
{
_rootPath = rootPath;
_localRepository = new MavenLocalRepository();
}
/// <summary>
/// Imports a BOM and returns its managed dependencies.
/// </summary>
public async Task<ImportedBom?> ImportAsync(
string groupId,
string artifactId,
string version,
CancellationToken cancellationToken = default)
{
return await ImportInternalAsync(groupId, artifactId, version, 0, cancellationToken).ConfigureAwait(false);
}
private async Task<ImportedBom?> ImportInternalAsync(
string groupId,
string artifactId,
string version,
int depth,
CancellationToken cancellationToken)
{
if (depth >= MaxImportDepth)
{
return null;
}
var key = $"{groupId}:{artifactId}:{version}".ToLowerInvariant();
// Check cache
if (_cache.TryGetValue(key, out var cached))
{
return cached;
}
// Check for cycle
if (!_importing.Add(key))
{
return null;
}
try
{
var bomPom = await TryLoadBomAsync(groupId, artifactId, version, cancellationToken).ConfigureAwait(false);
if (bomPom is null)
{
_cache[key] = null;
return null;
}
var managedDependencies = new List<JavaDependencyDeclaration>();
var nestedBoms = new List<ImportedBom>();
// Process dependency management
foreach (var dep in bomPom.DependencyManagement)
{
cancellationToken.ThrowIfCancellationRequested();
// Check if this is a nested BOM import
if (dep.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
dep.Type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true)
{
var nestedBom = await ImportInternalAsync(
dep.GroupId,
dep.ArtifactId,
dep.Version ?? string.Empty,
depth + 1,
cancellationToken).ConfigureAwait(false);
if (nestedBom is not null)
{
nestedBoms.Add(nestedBom);
}
}
else
{
managedDependencies.Add(dep);
}
}
// Merge nested BOM dependencies (earlier BOMs have lower priority)
var allManaged = new Dictionary<string, JavaDependencyDeclaration>(StringComparer.OrdinalIgnoreCase);
foreach (var nestedBom in nestedBoms)
{
foreach (var dep in nestedBom.ManagedDependencies)
{
var depKey = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
allManaged.TryAdd(depKey, dep);
}
}
// Current BOM's declarations override nested
foreach (var dep in managedDependencies)
{
var depKey = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
allManaged[depKey] = dep;
}
var result = new ImportedBom(
groupId,
artifactId,
version,
bomPom.SourcePath,
bomPom.Properties,
[.. allManaged.Values.OrderBy(d => d.Gav, StringComparer.Ordinal)],
[.. nestedBoms]);
_cache[key] = result;
return result;
}
finally
{
_importing.Remove(key);
}
}
private async Task<MavenPom?> TryLoadBomAsync(
string groupId,
string artifactId,
string version,
CancellationToken cancellationToken)
{
// Try local Maven repository first
var localPath = _localRepository.GetPomPath(groupId, artifactId, version);
if (localPath is not null && File.Exists(localPath))
{
return await MavenPomParser.ParseAsync(localPath, cancellationToken).ConfigureAwait(false);
}
// Try to find in workspace
var workspacePath = FindInWorkspace(groupId, artifactId);
if (workspacePath is not null)
{
return await MavenPomParser.ParseAsync(workspacePath, cancellationToken).ConfigureAwait(false);
}
return null;
}
private string? FindInWorkspace(string groupId, string artifactId)
{
// Search for pom.xml files that match the GAV
try
{
foreach (var pomPath in Directory.EnumerateFiles(_rootPath, "pom.xml", SearchOption.AllDirectories))
{
// Quick check by reading first few KB
var content = File.ReadAllText(pomPath);
if (content.Contains($"<groupId>{groupId}</groupId>", StringComparison.OrdinalIgnoreCase) &&
content.Contains($"<artifactId>{artifactId}</artifactId>", StringComparison.OrdinalIgnoreCase))
{
return pomPath;
}
}
}
catch
{
// Ignore file system errors
}
return null;
}
}
/// <summary>
/// Represents an imported BOM with its managed dependencies.
/// </summary>
internal sealed record ImportedBom(
string GroupId,
string ArtifactId,
string Version,
string SourcePath,
ImmutableDictionary<string, string> Properties,
ImmutableArray<JavaDependencyDeclaration> ManagedDependencies,
ImmutableArray<ImportedBom> NestedBoms)
{
/// <summary>
/// Returns the GAV coordinate.
/// </summary>
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
/// <summary>
/// Gets a managed version for an artifact.
/// </summary>
public string? GetManagedVersion(string groupId, string artifactId)
{
var key = $"{groupId}:{artifactId}".ToLowerInvariant();
return ManagedDependencies
.FirstOrDefault(d => $"{d.GroupId}:{d.ArtifactId}".Equals(key, StringComparison.OrdinalIgnoreCase))
?.Version;
}
}

View File

@@ -0,0 +1,289 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
/// <summary>
/// Builds an effective POM by merging the parent chain and resolving all properties.
/// </summary>
internal sealed class MavenEffectivePomBuilder
{
private readonly MavenParentResolver _parentResolver;
private readonly MavenBomImporter _bomImporter;
public MavenEffectivePomBuilder(string rootPath)
{
_parentResolver = new MavenParentResolver(rootPath);
_bomImporter = new MavenBomImporter(rootPath);
}
/// <summary>
/// Builds the effective POM with fully resolved dependencies.
/// </summary>
public async Task<MavenEffectivePomResult> BuildAsync(
MavenPom pom,
CancellationToken cancellationToken = default)
{
// Step 1: Resolve parent chain
var effectivePom = await _parentResolver.ResolveAsync(pom, cancellationToken).ConfigureAwait(false);
// Step 2: Import BOMs from dependency management
var bomImports = await ImportBomsAsync(pom, effectivePom.EffectiveProperties, cancellationToken).ConfigureAwait(false);
// Step 3: Build merged dependency management index
var managedVersions = BuildManagedVersionsIndex(effectivePom, bomImports);
// Step 4: Create property resolver with all properties
var allProperties = MergeProperties(effectivePom.EffectiveProperties, bomImports);
var resolver = new JavaPropertyResolver(allProperties);
// Step 5: Resolve all dependencies
var resolvedDependencies = ResolveDependencies(
pom.Dependencies,
managedVersions,
resolver);
return new MavenEffectivePomResult(
pom,
effectivePom.ParentChain,
allProperties,
managedVersions.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
resolvedDependencies,
effectivePom.AllLicenses,
bomImports,
effectivePom.UnresolvedParents);
}
private async Task<ImmutableArray<ImportedBom>> ImportBomsAsync(
MavenPom pom,
ImmutableDictionary<string, string> properties,
CancellationToken cancellationToken)
{
var bomImports = pom.GetBomImports().ToList();
if (bomImports.Count == 0)
{
return [];
}
var resolver = new JavaPropertyResolver(properties);
var imported = new List<ImportedBom>();
foreach (var bomDep in bomImports)
{
cancellationToken.ThrowIfCancellationRequested();
// Resolve version if it contains properties
var version = bomDep.Version;
if (version?.Contains("${", StringComparison.Ordinal) == true)
{
var result = resolver.Resolve(version);
version = result.ResolvedValue;
}
if (string.IsNullOrWhiteSpace(version))
{
continue;
}
var bom = await _bomImporter.ImportAsync(
bomDep.GroupId,
bomDep.ArtifactId,
version,
cancellationToken).ConfigureAwait(false);
if (bom is not null)
{
imported.Add(bom);
}
}
return [.. imported];
}
private static Dictionary<string, ManagedDependency> BuildManagedVersionsIndex(
MavenEffectivePom effectivePom,
ImmutableArray<ImportedBom> bomImports)
{
var index = new Dictionary<string, ManagedDependency>(StringComparer.OrdinalIgnoreCase);
// Start with BOM imports (lower priority)
foreach (var bom in bomImports)
{
foreach (var managed in bom.ManagedDependencies)
{
if (!string.IsNullOrWhiteSpace(managed.Version))
{
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
index.TryAdd(key, new ManagedDependency(
managed.Version,
$"bom:{bom.GroupId}:{bom.ArtifactId}:{bom.Version}",
managed.Scope));
}
}
}
// Then parent chain (higher priority, child overrides parent)
for (int i = effectivePom.ParentChain.Length - 1; i >= 0; i--)
{
var parentPom = effectivePom.ParentChain[i];
foreach (var managed in parentPom.DependencyManagement)
{
if (!string.IsNullOrWhiteSpace(managed.Version))
{
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
index[key] = new ManagedDependency(
managed.Version,
i == 0 ? "dependencyManagement" : $"parent:{parentPom.Gav}",
managed.Scope);
}
}
}
// Finally current POM's dependency management (highest priority)
foreach (var managed in effectivePom.OriginalPom.DependencyManagement)
{
// Skip BOM imports themselves
if (managed.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true)
{
continue;
}
if (!string.IsNullOrWhiteSpace(managed.Version))
{
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
index[key] = new ManagedDependency(
managed.Version,
"dependencyManagement",
managed.Scope);
}
}
return index;
}
private static ImmutableDictionary<string, string> MergeProperties(
ImmutableDictionary<string, string> effectiveProperties,
ImmutableArray<ImportedBom> bomImports)
{
var merged = effectiveProperties.ToBuilder();
// Add properties from BOMs (don't override existing)
foreach (var bom in bomImports)
{
foreach (var (key, value) in bom.Properties)
{
merged.TryAdd(key, value);
}
}
return merged.ToImmutable();
}
private static ImmutableArray<JavaDependencyDeclaration> ResolveDependencies(
ImmutableArray<JavaDependencyDeclaration> dependencies,
Dictionary<string, ManagedDependency> managedVersions,
JavaPropertyResolver resolver)
{
var resolved = new List<JavaDependencyDeclaration>();
foreach (var dep in dependencies)
{
var resolvedDep = dep;
var versionSource = dep.VersionSource;
string? versionProperty = dep.VersionProperty;
// Resolve property placeholders in version
if (dep.Version?.Contains("${", StringComparison.Ordinal) == true)
{
var result = resolver.Resolve(dep.Version);
resolvedDep = dep with { Version = result.ResolvedValue };
versionSource = result.IsFullyResolved
? JavaVersionSource.Property
: JavaVersionSource.Unresolved;
versionProperty = ExtractPropertyName(dep.Version);
}
// Look up version from managed dependencies
else if (string.IsNullOrWhiteSpace(dep.Version))
{
var key = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
if (managedVersions.TryGetValue(key, out var managed))
{
// Resolve any properties in the managed version
var managedVersion = managed.Version;
if (managedVersion.Contains("${", StringComparison.Ordinal))
{
var result = resolver.Resolve(managedVersion);
managedVersion = result.ResolvedValue;
}
resolvedDep = dep with
{
Version = managedVersion,
Scope = dep.Scope ?? managed.Scope
};
versionSource = managed.Source.StartsWith("bom:", StringComparison.Ordinal)
? JavaVersionSource.Bom
: managed.Source == "dependencyManagement"
? JavaVersionSource.DependencyManagement
: JavaVersionSource.Parent;
}
}
resolved.Add(resolvedDep with
{
VersionSource = versionSource,
VersionProperty = versionProperty
});
}
return [.. resolved.OrderBy(d => d.Gav, StringComparer.Ordinal)];
}
private static string? ExtractPropertyName(string value)
{
var start = value.IndexOf("${", StringComparison.Ordinal);
var end = value.IndexOf('}', start + 2);
if (start >= 0 && end > start)
{
return value[(start + 2)..end];
}
return null;
}
}
/// <summary>
/// Result of building an effective POM.
/// </summary>
internal sealed record MavenEffectivePomResult(
MavenPom OriginalPom,
ImmutableArray<MavenPom> ParentChain,
ImmutableDictionary<string, string> EffectiveProperties,
ImmutableDictionary<string, ManagedDependency> ManagedVersions,
ImmutableArray<JavaDependencyDeclaration> ResolvedDependencies,
ImmutableArray<JavaLicenseInfo> Licenses,
ImmutableArray<ImportedBom> ImportedBoms,
ImmutableArray<string> UnresolvedParents)
{
/// <summary>
/// Returns true if all parents and BOMs were resolved.
/// </summary>
public bool IsFullyResolved => UnresolvedParents.Length == 0;
/// <summary>
/// Gets dependencies that still have unresolved versions.
/// </summary>
public IEnumerable<JavaDependencyDeclaration> GetUnresolvedDependencies()
=> ResolvedDependencies.Where(d => !d.IsVersionResolved);
}
/// <summary>
/// Represents a managed dependency version.
/// </summary>
internal sealed record ManagedDependency(
string Version,
string Source,
string? Scope);

View File

@@ -0,0 +1,334 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
/// <summary>
/// Resolves Maven parent POM chain and builds effective POM properties.
/// </summary>
internal sealed class MavenParentResolver
{
private const int MaxDepth = 10;
private readonly string _rootPath;
private readonly Dictionary<string, MavenPom> _pomCache = new(StringComparer.OrdinalIgnoreCase);
public MavenParentResolver(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = rootPath;
}
/// <summary>
/// Resolves the parent chain for a POM and returns the effective properties.
/// </summary>
public async Task<MavenEffectivePom> ResolveAsync(
MavenPom pom,
CancellationToken cancellationToken = default)
{
var chain = new List<MavenPom> { pom };
var unresolved = new List<string>();
// Build the parent chain
await BuildParentChainAsync(pom, chain, unresolved, 0, cancellationToken).ConfigureAwait(false);
// Merge properties from all POMs in the chain (parent to child)
var effectiveProperties = BuildEffectiveProperties(chain);
// Build the property resolver
var resolver = new JavaPropertyResolver(effectiveProperties);
// Resolve dependencies with merged properties
var resolvedDependencies = ResolveDependencies(pom, chain, resolver);
// Collect all licenses from the chain
var licenses = chain
.SelectMany(p => p.Licenses)
.Distinct()
.ToImmutableArray();
return new MavenEffectivePom(
pom,
[.. chain],
effectiveProperties,
resolvedDependencies,
licenses,
[.. unresolved]);
}
private async Task BuildParentChainAsync(
MavenPom pom,
List<MavenPom> chain,
List<string> unresolved,
int depth,
CancellationToken cancellationToken)
{
if (depth >= MaxDepth || pom.Parent is null)
{
return;
}
var parent = pom.Parent;
var parentPom = await TryResolveParentAsync(pom, parent, cancellationToken).ConfigureAwait(false);
if (parentPom is null)
{
unresolved.Add(parent.GroupId + ":" + parent.ArtifactId + ":" + parent.Version);
return;
}
chain.Add(parentPom);
// Recurse for grandparent
await BuildParentChainAsync(parentPom, chain, unresolved, depth + 1, cancellationToken).ConfigureAwait(false);
}
private async Task<MavenPom?> TryResolveParentAsync(
MavenPom childPom,
MavenParentRef parent,
CancellationToken cancellationToken)
{
// Try relativePath first
if (!string.IsNullOrWhiteSpace(parent.RelativePath))
{
var childDir = Path.GetDirectoryName(childPom.SourcePath) ?? _rootPath;
var relativePomPath = Path.GetFullPath(Path.Combine(childDir, parent.RelativePath));
// If relativePath points to a directory, append pom.xml
if (Directory.Exists(relativePomPath))
{
relativePomPath = Path.Combine(relativePomPath, "pom.xml");
}
var parentPom = await TryLoadPomAsync(relativePomPath, cancellationToken).ConfigureAwait(false);
if (parentPom is not null && MatchesParent(parentPom, parent))
{
return parentPom;
}
}
// Default: look in parent directory
var defaultPath = Path.GetFullPath(Path.Combine(
Path.GetDirectoryName(childPom.SourcePath) ?? _rootPath,
"..",
"pom.xml"));
var defaultParent = await TryLoadPomAsync(defaultPath, cancellationToken).ConfigureAwait(false);
if (defaultParent is not null && MatchesParent(defaultParent, parent))
{
return defaultParent;
}
// Try to find in workspace by GAV
var workspaceParent = await TryFindInWorkspaceAsync(parent, cancellationToken).ConfigureAwait(false);
if (workspaceParent is not null)
{
return workspaceParent;
}
// Try local Maven repository
var localRepoParent = await TryFindInLocalRepositoryAsync(parent, cancellationToken).ConfigureAwait(false);
return localRepoParent;
}
private async Task<MavenPom?> TryLoadPomAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return null;
}
var normalizedPath = Path.GetFullPath(path);
if (_pomCache.TryGetValue(normalizedPath, out var cached))
{
return cached;
}
var pom = await MavenPomParser.ParseAsync(normalizedPath, cancellationToken).ConfigureAwait(false);
_pomCache[normalizedPath] = pom;
return pom;
}
private async Task<MavenPom?> TryFindInWorkspaceAsync(
MavenParentRef parent,
CancellationToken cancellationToken)
{
// Search for pom.xml files in the workspace
foreach (var pomPath in Directory.EnumerateFiles(_rootPath, "pom.xml", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
var pom = await TryLoadPomAsync(pomPath, cancellationToken).ConfigureAwait(false);
if (pom is not null && MatchesParent(pom, parent))
{
return pom;
}
}
return null;
}
private async Task<MavenPom?> TryFindInLocalRepositoryAsync(
MavenParentRef parent,
CancellationToken cancellationToken)
{
var localRepoPath = GetLocalRepositoryPath();
if (string.IsNullOrEmpty(localRepoPath) || !Directory.Exists(localRepoPath))
{
return null;
}
// Convert GAV to path: com.example:parent:1.0.0 -> com/example/parent/1.0.0/parent-1.0.0.pom
var groupPath = parent.GroupId.Replace('.', Path.DirectorySeparatorChar);
var pomFileName = $"{parent.ArtifactId}-{parent.Version}.pom";
var pomPath = Path.Combine(localRepoPath, groupPath, parent.ArtifactId, parent.Version, pomFileName);
return await TryLoadPomAsync(pomPath, cancellationToken).ConfigureAwait(false);
}
private static string? GetLocalRepositoryPath()
{
// Check M2_REPO environment variable
var m2Repo = Environment.GetEnvironmentVariable("M2_REPO");
if (!string.IsNullOrEmpty(m2Repo) && Directory.Exists(m2Repo))
{
return m2Repo;
}
// Default: ~/.m2/repository
var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var defaultPath = Path.Combine(userHome, ".m2", "repository");
return Directory.Exists(defaultPath) ? defaultPath : null;
}
private static bool MatchesParent(MavenPom pom, MavenParentRef parent)
{
return string.Equals(pom.GroupId, parent.GroupId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(pom.ArtifactId, parent.ArtifactId, StringComparison.OrdinalIgnoreCase);
}
private static ImmutableDictionary<string, string> BuildEffectiveProperties(List<MavenPom> chain)
{
var builder = new JavaPropertyBuilder();
// Start from root parent and work down to child (child properties override parent)
for (int i = chain.Count - 1; i >= 0; i--)
{
var pom = chain[i];
// Add project coordinates
builder.AddProjectCoordinates(pom.GroupId, pom.ArtifactId, pom.Version);
// Add parent coordinates
if (pom.Parent is not null)
{
builder.Add("project.parent.groupId", pom.Parent.GroupId);
builder.Add("project.parent.artifactId", pom.Parent.ArtifactId);
builder.Add("project.parent.version", pom.Parent.Version);
}
// Add declared properties
builder.AddRange(pom.Properties);
}
return builder.Build();
}
private static ImmutableArray<JavaDependencyDeclaration> ResolveDependencies(
MavenPom pom,
List<MavenPom> chain,
JavaPropertyResolver resolver)
{
// Build dependency management index from all POMs in chain
var managedVersions = BuildManagedVersionsIndex(chain);
var resolved = new List<JavaDependencyDeclaration>();
foreach (var dep in pom.Dependencies)
{
var resolvedDep = dep;
// Resolve property placeholders in version
if (dep.Version?.Contains("${", StringComparison.Ordinal) == true)
{
var result = resolver.Resolve(dep.Version);
resolvedDep = dep with
{
Version = result.ResolvedValue,
VersionSource = result.IsFullyResolved
? JavaVersionSource.Property
: JavaVersionSource.Unresolved
};
}
// Look up version from dependency management
else if (string.IsNullOrWhiteSpace(dep.Version))
{
var key = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
if (managedVersions.TryGetValue(key, out var managedVersion))
{
// Resolve any properties in the managed version
var result = resolver.Resolve(managedVersion);
resolvedDep = dep with
{
Version = result.ResolvedValue,
VersionSource = JavaVersionSource.DependencyManagement
};
}
}
resolved.Add(resolvedDep);
}
return [.. resolved.OrderBy(d => d.Gav, StringComparer.Ordinal)];
}
private static Dictionary<string, string> BuildManagedVersionsIndex(List<MavenPom> chain)
{
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Start from root parent (last in chain) so child definitions override
for (int i = chain.Count - 1; i >= 0; i--)
{
foreach (var managed in chain[i].DependencyManagement)
{
if (!string.IsNullOrWhiteSpace(managed.Version))
{
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
index[key] = managed.Version;
}
}
}
return index;
}
}
/// <summary>
/// Represents a fully resolved effective POM with merged parent chain.
/// </summary>
internal sealed record MavenEffectivePom(
MavenPom OriginalPom,
ImmutableArray<MavenPom> ParentChain,
ImmutableDictionary<string, string> EffectiveProperties,
ImmutableArray<JavaDependencyDeclaration> ResolvedDependencies,
ImmutableArray<JavaLicenseInfo> AllLicenses,
ImmutableArray<string> UnresolvedParents)
{
/// <summary>
/// Returns true if all parents were successfully resolved.
/// </summary>
public bool IsFullyResolved => UnresolvedParents.Length == 0;
/// <summary>
/// Gets the effective group ID.
/// </summary>
public string? EffectiveGroupId => OriginalPom.GroupId ?? ParentChain.FirstOrDefault()?.GroupId;
/// <summary>
/// Gets the effective version.
/// </summary>
public string? EffectiveVersion => OriginalPom.Version ?? ParentChain.FirstOrDefault()?.Version;
}

View File

@@ -0,0 +1,479 @@
using System.Collections.Immutable;
using System.Xml.Linq;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
/// <summary>
/// Parses Maven POM files (pom.xml) to extract project metadata and dependencies.
/// </summary>
internal static class MavenPomParser
{
private static readonly XNamespace PomNamespace = "http://maven.apache.org/POM/4.0.0";
/// <summary>
/// Parses a pom.xml file asynchronously.
/// </summary>
public static async Task<MavenPom> ParseAsync(string path, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
{
return MavenPom.Empty;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false);
return Parse(document, path);
}
/// <summary>
/// Parses pom.xml content from a string.
/// </summary>
public static MavenPom ParseFromString(string content, string sourcePath)
{
if (string.IsNullOrWhiteSpace(content))
{
return MavenPom.Empty;
}
var document = XDocument.Parse(content);
return Parse(document, sourcePath);
}
/// <summary>
/// Parses a pom.xml XDocument.
/// </summary>
public static MavenPom Parse(XDocument document, string sourcePath)
{
var root = document.Root;
if (root is null)
{
return MavenPom.Empty;
}
// Determine namespace (might be default or prefixed)
var ns = root.Name.Namespace;
if (ns == XNamespace.None)
{
ns = string.Empty;
}
var groupId = GetElementValue(root, ns, "groupId");
var artifactId = GetElementValue(root, ns, "artifactId");
var version = GetElementValue(root, ns, "version");
var packaging = GetElementValue(root, ns, "packaging") ?? "jar";
var name = GetElementValue(root, ns, "name");
var description = GetElementValue(root, ns, "description");
// Parse parent
var parent = ParseParent(root, ns);
// Inherit from parent if not set
groupId ??= parent?.GroupId;
version ??= parent?.Version;
// Parse properties
var properties = ParseProperties(root, ns);
// Parse licenses
var licenses = ParseLicenses(root, ns);
// Parse dependencies
var dependencies = ParseDependencies(root, ns, sourcePath);
// Parse dependency management
var dependencyManagement = ParseDependencyManagement(root, ns, sourcePath);
// Parse modules (for multi-module projects)
var modules = ParseModules(root, ns);
// Parse repositories
var repositories = ParseRepositories(root, ns);
return new MavenPom(
sourcePath,
groupId,
artifactId,
version,
packaging,
name,
description,
parent,
properties,
licenses,
dependencies,
dependencyManagement,
modules,
repositories);
}
private static string? GetElementValue(XElement parent, XNamespace ns, string name)
{
var element = parent.Element(ns + name);
return element?.Value?.Trim();
}
private static MavenParentRef? ParseParent(XElement root, XNamespace ns)
{
var parentElement = root.Element(ns + "parent");
if (parentElement is null)
{
return null;
}
var groupId = GetElementValue(parentElement, ns, "groupId");
var artifactId = GetElementValue(parentElement, ns, "artifactId");
var version = GetElementValue(parentElement, ns, "version");
var relativePath = GetElementValue(parentElement, ns, "relativePath");
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(artifactId))
{
return null;
}
return new MavenParentRef(groupId, artifactId, version ?? string.Empty, relativePath);
}
private static ImmutableDictionary<string, string> ParseProperties(XElement root, XNamespace ns)
{
var propertiesElement = root.Element(ns + "properties");
if (propertiesElement is null)
{
return ImmutableDictionary<string, string>.Empty;
}
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in propertiesElement.Elements())
{
var key = prop.Name.LocalName;
var value = prop.Value?.Trim();
if (!string.IsNullOrEmpty(value))
{
properties[key] = value;
}
}
return properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
private static ImmutableArray<JavaLicenseInfo> ParseLicenses(XElement root, XNamespace ns)
{
var licensesElement = root.Element(ns + "licenses");
if (licensesElement is null)
{
return [];
}
var licenses = new List<JavaLicenseInfo>();
foreach (var licenseElement in licensesElement.Elements(ns + "license"))
{
var name = GetElementValue(licenseElement, ns, "name");
var url = GetElementValue(licenseElement, ns, "url");
var distribution = GetElementValue(licenseElement, ns, "distribution");
var comments = GetElementValue(licenseElement, ns, "comments");
if (!string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(url))
{
// Normalize to SPDX
var normalizedLicense = SpdxLicenseNormalizer.Instance.Normalize(name, url);
licenses.Add(normalizedLicense with
{
Distribution = distribution,
Comments = comments
});
}
}
return [.. licenses];
}
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencies(
XElement root,
XNamespace ns,
string sourcePath)
{
var dependenciesElement = root.Element(ns + "dependencies");
if (dependenciesElement is null)
{
return [];
}
return ParseDependencyElements(dependenciesElement, ns, sourcePath);
}
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencyManagement(
XElement root,
XNamespace ns,
string sourcePath)
{
var dmElement = root.Element(ns + "dependencyManagement");
if (dmElement is null)
{
return [];
}
var dependenciesElement = dmElement.Element(ns + "dependencies");
if (dependenciesElement is null)
{
return [];
}
return ParseDependencyElements(dependenciesElement, ns, sourcePath, isDependencyManagement: true);
}
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencyElements(
XElement dependenciesElement,
XNamespace ns,
string sourcePath,
bool isDependencyManagement = false)
{
var dependencies = new List<JavaDependencyDeclaration>();
foreach (var depElement in dependenciesElement.Elements(ns + "dependency"))
{
var groupId = GetElementValue(depElement, ns, "groupId");
var artifactId = GetElementValue(depElement, ns, "artifactId");
var version = GetElementValue(depElement, ns, "version");
var scope = GetElementValue(depElement, ns, "scope");
var type = GetElementValue(depElement, ns, "type");
var classifier = GetElementValue(depElement, ns, "classifier");
var optional = GetElementValue(depElement, ns, "optional");
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(artifactId))
{
continue;
}
// Parse exclusions
var exclusions = ParseExclusions(depElement, ns);
// Determine version source
var versionSource = JavaVersionSource.Direct;
string? versionProperty = null;
if (version?.Contains("${", StringComparison.Ordinal) == true)
{
versionSource = JavaVersionSource.Property;
versionProperty = ExtractPropertyName(version);
}
else if (string.IsNullOrWhiteSpace(version) && !isDependencyManagement)
{
versionSource = JavaVersionSource.DependencyManagement;
}
// Check if this is a BOM import
var isBomImport = scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true;
dependencies.Add(new JavaDependencyDeclaration
{
GroupId = groupId,
ArtifactId = artifactId,
Version = version,
Scope = isBomImport ? "import" : scope,
Type = type,
Classifier = classifier,
Optional = optional?.Equals("true", StringComparison.OrdinalIgnoreCase) == true,
Exclusions = exclusions,
Source = "pom.xml",
Locator = sourcePath,
VersionSource = versionSource,
VersionProperty = versionProperty
});
}
return [.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)];
}
private static ImmutableArray<JavaExclusion> ParseExclusions(XElement depElement, XNamespace ns)
{
var exclusionsElement = depElement.Element(ns + "exclusions");
if (exclusionsElement is null)
{
return [];
}
var exclusions = new List<JavaExclusion>();
foreach (var excElement in exclusionsElement.Elements(ns + "exclusion"))
{
var groupId = GetElementValue(excElement, ns, "groupId");
var artifactId = GetElementValue(excElement, ns, "artifactId");
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
{
exclusions.Add(new JavaExclusion(groupId, artifactId));
}
}
return [.. exclusions];
}
private static ImmutableArray<string> ParseModules(XElement root, XNamespace ns)
{
var modulesElement = root.Element(ns + "modules");
if (modulesElement is null)
{
return [];
}
return
[
.. modulesElement.Elements(ns + "module")
.Select(e => e.Value?.Trim())
.Where(m => !string.IsNullOrWhiteSpace(m))
.Cast<string>()
.OrderBy(m => m, StringComparer.Ordinal)
];
}
private static ImmutableArray<MavenRepository> ParseRepositories(XElement root, XNamespace ns)
{
var repositoriesElement = root.Element(ns + "repositories");
if (repositoriesElement is null)
{
return [];
}
var repositories = new List<MavenRepository>();
foreach (var repoElement in repositoriesElement.Elements(ns + "repository"))
{
var id = GetElementValue(repoElement, ns, "id");
var name = GetElementValue(repoElement, ns, "name");
var url = GetElementValue(repoElement, ns, "url");
if (!string.IsNullOrWhiteSpace(url))
{
repositories.Add(new MavenRepository(id ?? string.Empty, name, url));
}
}
return [.. repositories.OrderBy(r => r.Id, StringComparer.Ordinal)];
}
private static string? ExtractPropertyName(string value)
{
var start = value.IndexOf("${", StringComparison.Ordinal);
var end = value.IndexOf('}', start + 2);
if (start >= 0 && end > start)
{
return value[(start + 2)..end];
}
return null;
}
}
/// <summary>
/// Represents a parsed Maven POM file.
/// </summary>
internal sealed record MavenPom(
string SourcePath,
string? GroupId,
string? ArtifactId,
string? Version,
string Packaging,
string? Name,
string? Description,
MavenParentRef? Parent,
ImmutableDictionary<string, string> Properties,
ImmutableArray<JavaLicenseInfo> Licenses,
ImmutableArray<JavaDependencyDeclaration> Dependencies,
ImmutableArray<JavaDependencyDeclaration> DependencyManagement,
ImmutableArray<string> Modules,
ImmutableArray<MavenRepository> Repositories)
{
public static readonly MavenPom Empty = new(
string.Empty,
null,
null,
null,
"jar",
null,
null,
null,
ImmutableDictionary<string, string>.Empty,
[],
[],
[],
[],
[]);
/// <summary>
/// Returns true if this is a parent/aggregator POM.
/// </summary>
public bool IsParentPom => Packaging.Equals("pom", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns true if this POM has a parent.
/// </summary>
public bool HasParent => Parent is not null;
/// <summary>
/// Returns the GAV coordinate.
/// </summary>
public string? Gav => GroupId is not null && ArtifactId is not null
? Version is not null
? $"{GroupId}:{ArtifactId}:{Version}"
: $"{GroupId}:{ArtifactId}"
: null;
/// <summary>
/// Gets BOM imports from dependency management.
/// </summary>
public IEnumerable<JavaDependencyDeclaration> GetBomImports()
=> DependencyManagement.Where(d =>
d.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
d.Type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true);
/// <summary>
/// Converts to unified project metadata.
/// </summary>
public JavaProjectMetadata ToProjectMetadata() => new()
{
GroupId = GroupId,
ArtifactId = ArtifactId,
Version = Version,
Packaging = Packaging,
Parent = Parent is not null
? new JavaParentReference
{
GroupId = Parent.GroupId,
ArtifactId = Parent.ArtifactId,
Version = Parent.Version,
RelativePath = Parent.RelativePath
}
: null,
Properties = Properties,
Licenses = Licenses,
Dependencies = Dependencies,
DependencyManagement = DependencyManagement,
SourcePath = SourcePath,
BuildSystem = JavaBuildSystem.Maven
};
}
/// <summary>
/// Represents a parent POM reference.
/// </summary>
internal sealed record MavenParentRef(
string GroupId,
string ArtifactId,
string Version,
string? RelativePath);
/// <summary>
/// Represents a Maven repository.
/// </summary>
internal sealed record MavenRepository(string Id, string? Name, string Url);

View File

@@ -0,0 +1,369 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Osgi;
/// <summary>
/// Parses OSGi bundle metadata from JAR manifest files.
/// </summary>
internal static partial class OsgiBundleParser
{
/// <summary>
/// Parses OSGi bundle information from a manifest dictionary.
/// </summary>
public static OsgiBundleInfo? Parse(IReadOnlyDictionary<string, string> manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
// Check if this is an OSGi bundle
if (!manifest.TryGetValue("Bundle-SymbolicName", out var symbolicName) ||
string.IsNullOrWhiteSpace(symbolicName))
{
return null;
}
// Parse symbolic name (may include directives like ;singleton:=true)
var parsedSymbolicName = ParseSymbolicName(symbolicName);
var bundleVersion = manifest.GetValueOrDefault("Bundle-Version", "0.0.0");
var bundleName = manifest.GetValueOrDefault("Bundle-Name");
var bundleVendor = manifest.GetValueOrDefault("Bundle-Vendor");
var bundleDescription = manifest.GetValueOrDefault("Bundle-Description");
var bundleActivator = manifest.GetValueOrDefault("Bundle-Activator");
var bundleCategory = manifest.GetValueOrDefault("Bundle-Category");
var bundleLicense = manifest.GetValueOrDefault("Bundle-License");
var fragmentHost = manifest.GetValueOrDefault("Fragment-Host");
// Parse imports and exports
var importPackage = ParsePackageList(manifest.GetValueOrDefault("Import-Package"));
var exportPackage = ParsePackageList(manifest.GetValueOrDefault("Export-Package"));
var requireBundle = ParseRequireBundle(manifest.GetValueOrDefault("Require-Bundle"));
var dynamicImport = ParsePackageList(manifest.GetValueOrDefault("DynamicImport-Package"));
// Parse capabilities and requirements (OSGi R5+)
var provideCapability = manifest.GetValueOrDefault("Provide-Capability");
var requireCapability = manifest.GetValueOrDefault("Require-Capability");
return new OsgiBundleInfo(
parsedSymbolicName.Name,
bundleVersion,
bundleName,
bundleVendor,
bundleDescription,
bundleActivator,
bundleCategory,
bundleLicense,
fragmentHost,
parsedSymbolicName.IsSingleton,
importPackage,
exportPackage,
requireBundle,
dynamicImport,
provideCapability,
requireCapability);
}
/// <summary>
/// Parses manifest content from a string.
/// </summary>
public static IReadOnlyDictionary<string, string> ParseManifest(string manifestContent)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(manifestContent))
{
return result;
}
// Manifest format uses continuation lines starting with space
var lines = manifestContent.Split('\n');
string? currentKey = null;
var currentValue = new System.Text.StringBuilder();
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');
if (line.StartsWith(' ') || line.StartsWith('\t'))
{
// Continuation line
if (currentKey is not null)
{
currentValue.Append(line.TrimStart());
}
}
else
{
// Save previous entry
if (currentKey is not null)
{
result[currentKey] = currentValue.ToString();
}
// Parse new entry
var colonIndex = line.IndexOf(':');
if (colonIndex > 0)
{
currentKey = line[..colonIndex].Trim();
currentValue.Clear();
currentValue.Append(line[(colonIndex + 1)..].Trim());
}
else
{
currentKey = null;
}
}
}
// Save last entry
if (currentKey is not null)
{
result[currentKey] = currentValue.ToString();
}
return result;
}
private static (string Name, bool IsSingleton) ParseSymbolicName(string symbolicName)
{
var semicolonIndex = symbolicName.IndexOf(';');
if (semicolonIndex < 0)
{
return (symbolicName.Trim(), false);
}
var name = symbolicName[..semicolonIndex].Trim();
var directives = symbolicName[semicolonIndex..];
var isSingleton = directives.Contains("singleton:=true", StringComparison.OrdinalIgnoreCase);
return (name, isSingleton);
}
private static ImmutableArray<OsgiPackageSpec> ParsePackageList(string? packageList)
{
if (string.IsNullOrWhiteSpace(packageList))
{
return [];
}
var packages = new List<OsgiPackageSpec>();
// Split by comma, but handle nested quotes and parentheses
var entries = SplitPackageEntries(packageList);
foreach (var entry in entries)
{
var spec = ParsePackageSpec(entry.Trim());
if (spec is not null)
{
packages.Add(spec);
}
}
return [.. packages.OrderBy(p => p.PackageName, StringComparer.Ordinal)];
}
private static OsgiPackageSpec? ParsePackageSpec(string entry)
{
if (string.IsNullOrWhiteSpace(entry))
{
return null;
}
// Package may have attributes: com.example.package;version="[1.0,2.0)"
var semicolonIndex = entry.IndexOf(';');
if (semicolonIndex < 0)
{
return new OsgiPackageSpec(entry.Trim(), null, null, false);
}
var packageName = entry[..semicolonIndex].Trim();
var attributes = entry[semicolonIndex..];
// Extract version
string? version = null;
var versionMatch = VersionPattern().Match(attributes);
if (versionMatch.Success)
{
version = versionMatch.Groups[1].Value;
}
// Check for resolution:=optional
var isOptional = attributes.Contains("resolution:=optional", StringComparison.OrdinalIgnoreCase);
// Extract uses directive
string? uses = null;
var usesMatch = UsesPattern().Match(attributes);
if (usesMatch.Success)
{
uses = usesMatch.Groups[1].Value;
}
return new OsgiPackageSpec(packageName, version, uses, isOptional);
}
private static ImmutableArray<OsgiBundleRef> ParseRequireBundle(string? requireBundle)
{
if (string.IsNullOrWhiteSpace(requireBundle))
{
return [];
}
var bundles = new List<OsgiBundleRef>();
var entries = SplitPackageEntries(requireBundle);
foreach (var entry in entries)
{
var semicolonIndex = entry.IndexOf(';');
string bundleName;
string? bundleVersion = null;
bool isOptional = false;
if (semicolonIndex < 0)
{
bundleName = entry.Trim();
}
else
{
bundleName = entry[..semicolonIndex].Trim();
var attributes = entry[semicolonIndex..];
var versionMatch = BundleVersionPattern().Match(attributes);
if (versionMatch.Success)
{
bundleVersion = versionMatch.Groups[1].Value;
}
isOptional = attributes.Contains("resolution:=optional", StringComparison.OrdinalIgnoreCase);
}
if (!string.IsNullOrWhiteSpace(bundleName))
{
bundles.Add(new OsgiBundleRef(bundleName, bundleVersion, isOptional));
}
}
return [.. bundles.OrderBy(b => b.SymbolicName, StringComparer.Ordinal)];
}
private static List<string> SplitPackageEntries(string value)
{
var result = new List<string>();
var current = new System.Text.StringBuilder();
var depth = 0;
var inQuote = false;
foreach (var c in value)
{
if (c == '"')
{
inQuote = !inQuote;
}
if (!inQuote)
{
if (c == '(' || c == '[') depth++;
else if (c == ')' || c == ']') depth--;
else if (c == ',' && depth == 0)
{
result.Add(current.ToString());
current.Clear();
continue;
}
}
current.Append(c);
}
if (current.Length > 0)
{
result.Add(current.ToString());
}
return result;
}
[GeneratedRegex(@"version\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex VersionPattern();
[GeneratedRegex(@"uses\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex UsesPattern();
[GeneratedRegex(@"bundle-version\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex BundleVersionPattern();
}
/// <summary>
/// Represents OSGi bundle metadata.
/// </summary>
internal sealed record OsgiBundleInfo(
string SymbolicName,
string Version,
string? Name,
string? Vendor,
string? Description,
string? Activator,
string? Category,
string? License,
string? FragmentHost,
bool IsSingleton,
ImmutableArray<OsgiPackageSpec> ImportPackage,
ImmutableArray<OsgiPackageSpec> ExportPackage,
ImmutableArray<OsgiBundleRef> RequireBundle,
ImmutableArray<OsgiPackageSpec> DynamicImport,
string? ProvideCapability,
string? RequireCapability)
{
/// <summary>
/// Returns true if this is a fragment bundle.
/// </summary>
public bool IsFragment => !string.IsNullOrWhiteSpace(FragmentHost);
/// <summary>
/// Gets the Import-Package header as a formatted string.
/// </summary>
public string GetImportPackageHeader()
=> string.Join(",", ImportPackage.Select(p => p.ToHeaderString()));
/// <summary>
/// Gets the Export-Package header as a formatted string.
/// </summary>
public string GetExportPackageHeader()
=> string.Join(",", ExportPackage.Select(p => p.ToHeaderString()));
}
/// <summary>
/// Represents a package specification in Import-Package or Export-Package.
/// </summary>
internal sealed record OsgiPackageSpec(
string PackageName,
string? Version,
string? Uses,
bool IsOptional)
{
/// <summary>
/// Converts to OSGi header format.
/// </summary>
public string ToHeaderString()
{
var result = PackageName;
if (Version is not null)
{
result += $";version=\"{Version}\"";
}
if (IsOptional)
{
result += ";resolution:=optional";
}
return result;
}
}
/// <summary>
/// Represents a Require-Bundle entry.
/// </summary>
internal sealed record OsgiBundleRef(
string SymbolicName,
string? BundleVersion,
bool IsOptional);

View File

@@ -0,0 +1,266 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
/// <summary>
/// Resolves property placeholders (${property.name}) in Java project metadata.
/// Supports Maven-style properties with parent chain resolution.
/// </summary>
internal sealed partial class JavaPropertyResolver
{
private const int MaxRecursionDepth = 10;
private static readonly Regex PropertyPattern = GetPropertyPattern();
private readonly ImmutableDictionary<string, string> _baseProperties;
private readonly ImmutableArray<ImmutableDictionary<string, string>> _propertyChain;
/// <summary>
/// Creates a property resolver with the given property sources.
/// </summary>
/// <param name="baseProperties">Properties from the current project.</param>
/// <param name="parentProperties">Properties from parent projects, ordered from nearest to root.</param>
public JavaPropertyResolver(
ImmutableDictionary<string, string>? baseProperties = null,
IEnumerable<ImmutableDictionary<string, string>>? parentProperties = null)
{
_baseProperties = baseProperties ?? ImmutableDictionary<string, string>.Empty;
_propertyChain = parentProperties?.ToImmutableArray() ?? [];
}
/// <summary>
/// Creates a resolver from a project metadata and its parent chain.
/// </summary>
public static JavaPropertyResolver FromProject(JavaProjectMetadata project)
{
var parentProps = new List<ImmutableDictionary<string, string>>();
var current = project.Parent?.ResolvedParent;
while (current is not null)
{
parentProps.Add(current.Properties);
current = current.Parent?.ResolvedParent;
}
return new JavaPropertyResolver(project.Properties, parentProps);
}
/// <summary>
/// Resolves all property placeholders in the given string.
/// </summary>
/// <param name="value">String containing ${property} placeholders.</param>
/// <returns>Resolved string with all placeholders replaced.</returns>
public PropertyResolutionResult Resolve(string? value)
{
if (string.IsNullOrEmpty(value))
{
return PropertyResolutionResult.Empty;
}
if (!value.Contains("${", StringComparison.Ordinal))
{
return new PropertyResolutionResult(value, true, []);
}
var unresolvedProperties = new List<string>();
var resolved = ResolveInternal(value, 0, unresolvedProperties);
return new PropertyResolutionResult(
resolved,
unresolvedProperties.Count == 0,
unresolvedProperties.ToImmutableArray());
}
private string ResolveInternal(string value, int depth, List<string> unresolved)
{
if (depth >= MaxRecursionDepth)
{
return value;
}
return PropertyPattern.Replace(value, match =>
{
var propertyName = match.Groups[1].Value;
if (TryGetProperty(propertyName, out var propertyValue))
{
// Recursively resolve nested properties
if (propertyValue.Contains("${", StringComparison.Ordinal))
{
return ResolveInternal(propertyValue, depth + 1, unresolved);
}
return propertyValue;
}
// Handle built-in Maven properties
if (TryGetBuiltInProperty(propertyName, out var builtInValue))
{
return builtInValue;
}
unresolved.Add(propertyName);
return match.Value; // Keep original placeholder
});
}
private bool TryGetProperty(string name, out string value)
{
// First check base properties
if (_baseProperties.TryGetValue(name, out value!))
{
return true;
}
// Then check parent chain in order
foreach (var parentProps in _propertyChain)
{
if (parentProps.TryGetValue(name, out value!))
{
return true;
}
}
value = string.Empty;
return false;
}
private static bool TryGetBuiltInProperty(string name, out string value)
{
// Handle common Maven built-in properties
value = name switch
{
"project.basedir" => ".",
"basedir" => ".",
"project.build.directory" => "target",
"project.build.outputDirectory" => "target/classes",
"project.build.testOutputDirectory" => "target/test-classes",
"project.build.sourceDirectory" => "src/main/java",
"project.build.testSourceDirectory" => "src/test/java",
"project.build.resourcesDirectory" => "src/main/resources",
_ => string.Empty
};
return !string.IsNullOrEmpty(value);
}
/// <summary>
/// Resolves a dependency declaration, resolving version and other placeholders.
/// </summary>
public JavaDependencyDeclaration ResolveDependency(JavaDependencyDeclaration dependency)
{
var versionResult = Resolve(dependency.Version);
return dependency with
{
Version = versionResult.ResolvedValue,
VersionSource = versionResult.IsFullyResolved
? JavaVersionSource.Property
: JavaVersionSource.Unresolved,
VersionProperty = dependency.Version?.Contains("${", StringComparison.Ordinal) == true
? ExtractPropertyName(dependency.Version)
: null
};
}
private static string? ExtractPropertyName(string value)
{
var match = PropertyPattern.Match(value);
return match.Success ? match.Groups[1].Value : null;
}
[GeneratedRegex(@"\$\{([^}]+)\}", RegexOptions.Compiled)]
private static partial Regex GetPropertyPattern();
}
/// <summary>
/// Result of a property resolution operation.
/// </summary>
internal sealed record PropertyResolutionResult(
string ResolvedValue,
bool IsFullyResolved,
ImmutableArray<string> UnresolvedProperties)
{
public static readonly PropertyResolutionResult Empty = new(string.Empty, true, []);
}
/// <summary>
/// Builder for constructing property dictionaries from various sources.
/// </summary>
internal sealed class JavaPropertyBuilder
{
private readonly Dictionary<string, string> _properties = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Adds a property if it doesn't already exist.
/// </summary>
public JavaPropertyBuilder Add(string name, string? value)
{
if (!string.IsNullOrEmpty(value) && !_properties.ContainsKey(name))
{
_properties[name] = value;
}
return this;
}
/// <summary>
/// Adds project coordinates as properties.
/// </summary>
public JavaPropertyBuilder AddProjectCoordinates(string? groupId, string? artifactId, string? version)
{
if (!string.IsNullOrEmpty(groupId))
{
Add("project.groupId", groupId);
Add("groupId", groupId);
}
if (!string.IsNullOrEmpty(artifactId))
{
Add("project.artifactId", artifactId);
Add("artifactId", artifactId);
}
if (!string.IsNullOrEmpty(version))
{
Add("project.version", version);
Add("version", version);
}
return this;
}
/// <summary>
/// Adds parent coordinates as properties.
/// </summary>
public JavaPropertyBuilder AddParentCoordinates(JavaParentReference? parent)
{
if (parent is null) return this;
Add("project.parent.groupId", parent.GroupId);
Add("project.parent.artifactId", parent.ArtifactId);
Add("project.parent.version", parent.Version);
return this;
}
/// <summary>
/// Adds all properties from an existing dictionary.
/// </summary>
public JavaPropertyBuilder AddRange(IReadOnlyDictionary<string, string>? properties)
{
if (properties is null) return this;
foreach (var (key, value) in properties)
{
Add(key, value);
}
return this;
}
/// <summary>
/// Builds an immutable property dictionary.
/// </summary>
public ImmutableDictionary<string, string> Build()
=> _properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,316 @@
using System.Collections.Immutable;
using System.IO.Compression;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Shading;
/// <summary>
/// Detects shaded/shadow JARs that bundle dependencies inside a single artifact.
/// </summary>
internal static partial class ShadedJarDetector
{
private static readonly string[] ShadingMarkerFiles =
[
"META-INF/maven/*/dependency-reduced-pom.xml", // Maven Shade plugin marker
"META-INF/maven/*/*/dependency-reduced-pom.xml"
];
/// <summary>
/// Analyzes a JAR archive to detect shading.
/// </summary>
public static ShadingAnalysis Analyze(ZipArchive archive, string jarPath)
{
ArgumentNullException.ThrowIfNull(archive);
var markers = new List<string>();
var embeddedArtifacts = new List<EmbeddedArtifact>();
var relocatedPrefixes = new List<string>();
// Check for multiple pom.properties files (indicates bundled dependencies)
var pomPropertiesFiles = archive.Entries
.Where(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
e.FullName.Contains("META-INF/maven/", StringComparison.OrdinalIgnoreCase))
.ToList();
if (pomPropertiesFiles.Count > 1)
{
markers.Add("multiple-pom-properties");
// Parse each pom.properties to extract GAV
foreach (var entry in pomPropertiesFiles)
{
var artifact = ParsePomProperties(entry);
if (artifact is not null)
{
embeddedArtifacts.Add(artifact);
}
}
}
// Check for dependency-reduced-pom.xml (Maven Shade plugin marker)
var hasReducedPom = archive.Entries.Any(e =>
e.FullName.Contains("dependency-reduced-pom.xml", StringComparison.OrdinalIgnoreCase));
if (hasReducedPom)
{
markers.Add("dependency-reduced-pom.xml");
}
// Detect relocated packages (common patterns)
var relocations = DetectRelocatedPackages(archive);
relocatedPrefixes.AddRange(relocations);
if (relocations.Count > 0)
{
markers.Add("relocated-packages");
}
// Check for shadow plugin markers
var hasShadowMarker = archive.Entries.Any(e =>
e.FullName.Contains("shadow/", StringComparison.OrdinalIgnoreCase) &&
e.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase));
if (hasShadowMarker)
{
markers.Add("gradle-shadow-plugin");
}
// Calculate confidence
var confidence = CalculateConfidence(markers, embeddedArtifacts.Count);
return new ShadingAnalysis(
jarPath,
confidence >= ShadingConfidence.Medium,
confidence,
[.. markers],
[.. embeddedArtifacts.OrderBy(a => a.Gav, StringComparer.Ordinal)],
[.. relocatedPrefixes.Distinct().OrderBy(p => p, StringComparer.Ordinal)]);
}
/// <summary>
/// Analyzes a JAR file from disk.
/// </summary>
public static async Task<ShadingAnalysis> AnalyzeAsync(
string jarPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jarPath);
if (!File.Exists(jarPath))
{
return ShadingAnalysis.NotShaded(jarPath);
}
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
return Analyze(archive, jarPath);
}
private static EmbeddedArtifact? ParsePomProperties(ZipArchiveEntry entry)
{
try
{
using var stream = entry.Open();
using var reader = new StreamReader(stream);
string? groupId = null;
string? artifactId = null;
string? version = null;
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.StartsWith("groupId=", StringComparison.OrdinalIgnoreCase))
{
groupId = line[8..].Trim();
}
else if (line.StartsWith("artifactId=", StringComparison.OrdinalIgnoreCase))
{
artifactId = line[11..].Trim();
}
else if (line.StartsWith("version=", StringComparison.OrdinalIgnoreCase))
{
version = line[8..].Trim();
}
}
if (!string.IsNullOrWhiteSpace(groupId) &&
!string.IsNullOrWhiteSpace(artifactId) &&
!string.IsNullOrWhiteSpace(version))
{
return new EmbeddedArtifact(groupId, artifactId, version, entry.FullName);
}
}
catch
{
// Ignore parsing errors
}
return null;
}
private static List<string> DetectRelocatedPackages(ZipArchive archive)
{
var relocations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Common relocation prefixes used by shade/shadow plugins
var commonRelocatedPrefixes = new[]
{
"shaded/",
"relocated/",
"hidden/",
"internal/shaded/",
"lib/"
};
var classEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase))
.Select(e => e.FullName)
.ToList();
foreach (var prefix in commonRelocatedPrefixes)
{
if (classEntries.Any(c => c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
// Extract the full relocation path
var relocated = classEntries
.Where(c => c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Select(c => ExtractPackagePrefix(c))
.Where(p => !string.IsNullOrEmpty(p))
.Cast<string>()
.Distinct()
.Take(5); // Limit to avoid noise
foreach (var r in relocated)
{
relocations.Add(r);
}
}
}
// Detect common library packages that are often shaded
var shadedLibraryPatterns = new[]
{
@"^([a-z]+)/com/google/",
@"^([a-z]+)/org/apache/",
@"^([a-z]+)/io/netty/",
@"^([a-z]+)/com/fasterxml/",
@"^([a-z]+)/org/slf4j/"
};
foreach (var pattern in shadedLibraryPatterns)
{
var regex = new Regex(pattern, RegexOptions.IgnoreCase);
foreach (var classEntry in classEntries)
{
var match = regex.Match(classEntry);
if (match.Success)
{
relocations.Add(match.Groups[1].Value + "/");
break;
}
}
}
return [.. relocations];
}
private static string? ExtractPackagePrefix(string classPath)
{
var parts = classPath.Split('/');
if (parts.Length >= 3)
{
// Return first two path segments as the relocation prefix
return $"{parts[0]}/{parts[1]}/";
}
return null;
}
private static ShadingConfidence CalculateConfidence(List<string> markers, int embeddedCount)
{
var score = 0;
// Strong indicators
if (markers.Contains("dependency-reduced-pom.xml")) score += 3;
if (markers.Contains("multiple-pom-properties")) score += 2;
if (markers.Contains("gradle-shadow-plugin")) score += 3;
// Moderate indicators
if (markers.Contains("relocated-packages")) score += 1;
// Embedded artifact count
if (embeddedCount > 5) score += 2;
else if (embeddedCount > 1) score += 1;
return score switch
{
>= 4 => ShadingConfidence.High,
>= 2 => ShadingConfidence.Medium,
>= 1 => ShadingConfidence.Low,
_ => ShadingConfidence.None
};
}
}
/// <summary>
/// Result of shaded JAR analysis.
/// </summary>
internal sealed record ShadingAnalysis(
string JarPath,
bool IsShaded,
ShadingConfidence Confidence,
ImmutableArray<string> Markers,
ImmutableArray<EmbeddedArtifact> EmbeddedArtifacts,
ImmutableArray<string> RelocatedPrefixes)
{
public static ShadingAnalysis NotShaded(string jarPath) => new(
jarPath,
false,
ShadingConfidence.None,
[],
[],
[]);
/// <summary>
/// Returns the count of embedded artifacts.
/// </summary>
public int EmbeddedCount => EmbeddedArtifacts.Length;
/// <summary>
/// Gets the embedded artifacts as a comma-separated GAV list.
/// </summary>
public string GetEmbeddedGavList()
=> string.Join(",", EmbeddedArtifacts.Select(a => a.Gav));
}
/// <summary>
/// Represents an artifact embedded inside a shaded JAR.
/// </summary>
internal sealed record EmbeddedArtifact(
string GroupId,
string ArtifactId,
string Version,
string PomPropertiesPath)
{
/// <summary>
/// Returns the GAV coordinate.
/// </summary>
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
/// <summary>
/// Returns the PURL for this artifact.
/// </summary>
public string Purl => $"pkg:maven/{GroupId}/{ArtifactId}@{Version}";
}
/// <summary>
/// Confidence level for shading detection.
/// </summary>
internal enum ShadingConfidence
{
None = 0,
Low = 1,
Medium = 2,
High = 3
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Core.Contracts;
public sealed record BunPackageInventory(
string ScanId,
string ImageDigest,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<BunPackageArtifact> Packages);
public sealed record BunPackageArtifact(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("resolved")] string? Resolved,
[property: JsonPropertyName("integrity")] string? Integrity,
[property: JsonPropertyName("isDev")] bool? IsDev,
[property: JsonPropertyName("isDirect")] bool? IsDirect,
[property: JsonPropertyName("isPatched")] bool? IsPatched,
[property: JsonPropertyName("provenance")] BunPackageProvenance? Provenance,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string?>? Metadata);
public sealed record BunPackageProvenance(
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("lockfile")] string? Lockfile,
[property: JsonPropertyName("locator")] string? Locator);
public interface IBunPackageInventoryStore
{
Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken);
Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken);
}
public sealed class NullBunPackageInventoryStore : IBunPackageInventoryStore
{
public Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(inventory);
return Task.CompletedTask;
}
public Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
return Task.FromResult<BunPackageInventory?>(null);
}
}

View File

@@ -0,0 +1,79 @@
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Storage.Catalog;
[BsonIgnoreExtraElements]
public sealed class BunPackageInventoryDocument
{
[BsonId]
public string ScanId { get; set; } = string.Empty;
[BsonElement("imageDigest")]
[BsonIgnoreIfNull]
public string? ImageDigest { get; set; }
= null;
[BsonElement("generatedAtUtc")]
public DateTime GeneratedAtUtc { get; set; }
= DateTime.UtcNow;
[BsonElement("packages")]
public List<BunPackageDocument> Packages { get; set; }
= new();
}
[BsonIgnoreExtraElements]
public sealed class BunPackageDocument
{
[BsonElement("id")]
public string Id { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("version")]
[BsonIgnoreIfNull]
public string? Version { get; set; }
= null;
[BsonElement("source")]
[BsonIgnoreIfNull]
public string? Source { get; set; }
= null;
[BsonElement("resolved")]
[BsonIgnoreIfNull]
public string? Resolved { get; set; }
= null;
[BsonElement("integrity")]
[BsonIgnoreIfNull]
public string? Integrity { get; set; }
= null;
[BsonElement("isDev")]
[BsonIgnoreIfNull]
public bool? IsDev { get; set; }
= null;
[BsonElement("isDirect")]
[BsonIgnoreIfNull]
public bool? IsDirect { get; set; }
= null;
[BsonElement("isPatched")]
[BsonIgnoreIfNull]
public bool? IsPatched { get; set; }
= null;
[BsonElement("provenance")]
[BsonIgnoreIfNull]
public BunPackageProvenance? Provenance { get; set; }
= null;
[BsonElement("metadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? Metadata { get; set; }
= null;
}

View File

@@ -1,16 +1,16 @@
using System;
using System.Net.Http;
using Amazon;
using Amazon.S3;
using Amazon.Runtime;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using System;
using System.Net.Http;
using Amazon;
using Amazon.S3;
using Amazon.Runtime;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Storage.Migrations;
using StellaOps.Scanner.Storage.Mongo;
using StellaOps.Scanner.Storage.ObjectStore;
@@ -62,65 +62,67 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<MongoBootstrapper>();
services.TryAddSingleton<ArtifactRepository>();
services.TryAddSingleton<ImageRepository>();
services.TryAddSingleton<LayerRepository>();
services.TryAddSingleton<LinkRepository>();
services.TryAddSingleton<JobRepository>();
services.TryAddSingleton<LifecycleRuleRepository>();
services.TryAddSingleton<RuntimeEventRepository>();
services.TryAddSingleton<EntryTraceRepository>();
services.TryAddSingleton<RubyPackageInventoryRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
.ConfigureHttpClient((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
if (!options.IsRustFsDriver())
{
return;
}
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
}
client.BaseAddress = baseUri;
client.Timeout = options.RustFs.Timeout;
foreach (var header in options.Headers)
{
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
{
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
}
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
if (!options.IsRustFsDriver())
{
return new HttpClientHandler();
}
var handler = new HttpClientHandler();
if (options.RustFs.AllowInsecureTls)
{
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}
return handler;
});
services.TryAddSingleton(CreateAmazonS3Client);
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
services.TryAddSingleton<ArtifactStorageService>();
}
services.TryAddSingleton<LayerRepository>();
services.TryAddSingleton<LinkRepository>();
services.TryAddSingleton<JobRepository>();
services.TryAddSingleton<LifecycleRuleRepository>();
services.TryAddSingleton<RuntimeEventRepository>();
services.TryAddSingleton<EntryTraceRepository>();
services.TryAddSingleton<RubyPackageInventoryRepository>();
services.TryAddSingleton<BunPackageInventoryRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
.ConfigureHttpClient((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
if (!options.IsRustFsDriver())
{
return;
}
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
}
client.BaseAddress = baseUri;
client.Timeout = options.RustFs.Timeout;
foreach (var header in options.Headers)
{
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
{
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
}
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
if (!options.IsRustFsDriver())
{
return new HttpClientHandler();
}
var handler = new HttpClientHandler();
if (options.RustFs.AllowInsecureTls)
{
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}
return handler;
});
services.TryAddSingleton(CreateAmazonS3Client);
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
services.TryAddSingleton<ArtifactStorageService>();
}
private static IMongoClient CreateMongoClient(IServiceProvider provider)
{
@@ -149,47 +151,47 @@ public static class ServiceCollectionExtensions
return client.GetDatabase(databaseName);
}
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
var config = new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
ForcePathStyle = options.ForcePathStyle,
};
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
{
config.ServiceURL = options.ServiceUrl;
}
if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
{
AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
: new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
return new AmazonS3Client(credentials, config);
}
return new AmazonS3Client(config);
}
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
var objectStore = options.Value.ObjectStore;
if (objectStore.IsRustFsDriver())
{
return new RustFsArtifactObjectStore(
provider.GetRequiredService<IHttpClientFactory>(),
options,
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
}
return new S3ArtifactObjectStore(
provider.GetRequiredService<IAmazonS3>(),
options,
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
}
}
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
var config = new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
ForcePathStyle = options.ForcePathStyle,
};
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
{
config.ServiceURL = options.ServiceUrl;
}
if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
{
AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
: new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
return new AmazonS3Client(credentials, config);
}
return new AmazonS3Client(config);
}
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
var objectStore = options.Value.ObjectStore;
if (objectStore.IsRustFsDriver())
{
return new RustFsArtifactObjectStore(
provider.GetRequiredService<IHttpClientFactory>(),
options,
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
}
return new S3ArtifactObjectStore(
provider.GetRequiredService<IAmazonS3>(),
options,
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
}
}

View File

@@ -16,14 +16,15 @@ public sealed class MongoCollectionProvider
}
public IMongoCollection<ArtifactDocument> Artifacts => GetCollection<ArtifactDocument>(ScannerStorageDefaults.Collections.Artifacts);
public IMongoCollection<ImageDocument> Images => GetCollection<ImageDocument>(ScannerStorageDefaults.Collections.Images);
public IMongoCollection<LayerDocument> Layers => GetCollection<LayerDocument>(ScannerStorageDefaults.Collections.Layers);
public IMongoCollection<LinkDocument> Links => GetCollection<LinkDocument>(ScannerStorageDefaults.Collections.Links);
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
public IMongoCollection<RubyPackageInventoryDocument> RubyPackages => GetCollection<RubyPackageInventoryDocument>(ScannerStorageDefaults.Collections.RubyPackages);
public IMongoCollection<ImageDocument> Images => GetCollection<ImageDocument>(ScannerStorageDefaults.Collections.Images);
public IMongoCollection<LayerDocument> Layers => GetCollection<LayerDocument>(ScannerStorageDefaults.Collections.Layers);
public IMongoCollection<LinkDocument> Links => GetCollection<LinkDocument>(ScannerStorageDefaults.Collections.Links);
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
public IMongoCollection<RubyPackageInventoryDocument> RubyPackages => GetCollection<RubyPackageInventoryDocument>(ScannerStorageDefaults.Collections.RubyPackages);
public IMongoCollection<BunPackageInventoryDocument> BunPackages => GetCollection<BunPackageInventoryDocument>(ScannerStorageDefaults.Collections.BunPackages);
private IMongoCollection<TDocument> GetCollection<TDocument>(string name)
{

View File

@@ -0,0 +1,33 @@
using MongoDB.Driver;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Mongo;
namespace StellaOps.Scanner.Storage.Repositories;
public sealed class BunPackageInventoryRepository
{
private readonly MongoCollectionProvider _collections;
public BunPackageInventoryRepository(MongoCollectionProvider collections)
{
_collections = collections ?? throw new ArgumentNullException(nameof(collections));
}
public async Task<BunPackageInventoryDocument?> GetAsync(string scanId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
return await _collections.BunPackages
.Find(x => x.ScanId == scanId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task UpsertAsync(BunPackageInventoryDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
var options = new ReplaceOptions { IsUpsert = true };
await _collections.BunPackages
.ReplaceOneAsync(x => x.ScanId == document.ScanId, document, options, cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -1,8 +1,8 @@
namespace StellaOps.Scanner.Storage;
public static class ScannerStorageDefaults
{
public const string DefaultDatabaseName = "scanner";
namespace StellaOps.Scanner.Storage;
public static class ScannerStorageDefaults
{
public const string DefaultDatabaseName = "scanner";
public const string DefaultBucketName = "stellaops";
public const string DefaultRootPrefix = "scanner";
@@ -24,9 +24,10 @@ public static class ScannerStorageDefaults
public const string RuntimeEvents = "runtime.events";
public const string EntryTrace = "entrytrace";
public const string RubyPackages = "ruby.packages";
public const string BunPackages = "bun.packages";
public const string Migrations = "schema_migrations";
}
public static class ObjectPrefixes
{
public const string Layers = "layers";

View File

@@ -0,0 +1,90 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Services;
public sealed class BunPackageInventoryStore : IBunPackageInventoryStore
{
private readonly BunPackageInventoryRepository _repository;
public BunPackageInventoryStore(BunPackageInventoryRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(inventory);
var document = new BunPackageInventoryDocument
{
ScanId = inventory.ScanId,
ImageDigest = inventory.ImageDigest,
GeneratedAtUtc = inventory.GeneratedAtUtc.UtcDateTime,
Packages = inventory.Packages.Select(ToDocument).ToList()
};
await _repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
}
public async Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var document = await _repository.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return null;
}
var generatedAt = DateTime.SpecifyKind(document.GeneratedAtUtc, DateTimeKind.Utc);
var packages = document.Packages?.Select(FromDocument).ToImmutableArray()
?? ImmutableArray<BunPackageArtifact>.Empty;
return new BunPackageInventory(
document.ScanId,
document.ImageDigest ?? string.Empty,
new DateTimeOffset(generatedAt),
packages);
}
private static BunPackageDocument ToDocument(BunPackageArtifact artifact)
{
var doc = new BunPackageDocument
{
Id = artifact.Id,
Name = artifact.Name,
Version = artifact.Version,
Source = artifact.Source,
Resolved = artifact.Resolved,
Integrity = artifact.Integrity,
IsDev = artifact.IsDev,
IsDirect = artifact.IsDirect,
IsPatched = artifact.IsPatched,
Provenance = artifact.Provenance,
Metadata = artifact.Metadata is null ? null : new Dictionary<string, string?>(artifact.Metadata, StringComparer.OrdinalIgnoreCase)
};
return doc;
}
private static BunPackageArtifact FromDocument(BunPackageDocument document)
{
IReadOnlyDictionary<string, string?>? metadata = document.Metadata;
return new BunPackageArtifact(
document.Id,
document.Name,
document.Version,
document.Source,
document.Resolved,
document.Integrity,
document.IsDev,
document.IsDirect,
document.IsPatched,
document.Provenance,
metadata);
}
}