Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user