Add unit tests for VexLens normalizer, CPE parser, product mapper, and PURL parser

- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios.
- Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation.
- Created tests for ProductMapper to validate parsing and matching logic across different strictness levels.
- Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers.
- Introduced stubs for Monaco editor and worker to facilitate testing in the web application.
- Updated project file for the test project to include necessary dependencies.
This commit is contained in:
StellaOps Bot
2025-12-06 16:28:12 +02:00
parent 2b892ad1b2
commit efd6850c38
132 changed files with 16675 additions and 5428 deletions

View File

@@ -0,0 +1,373 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses go.mod files to extract module dependencies.
/// Supports module declarations, require blocks, replace directives, and indirect markers.
/// </summary>
internal static partial class GoModParser
{
/// <summary>
/// Parsed go.mod file data.
/// </summary>
public sealed record GoModData
{
public static readonly GoModData Empty = new(
null,
null,
ImmutableArray<GoModRequire>.Empty,
ImmutableArray<GoModReplace>.Empty,
ImmutableArray<GoModExclude>.Empty,
ImmutableArray<string>.Empty);
public GoModData(
string? modulePath,
string? goVersion,
ImmutableArray<GoModRequire> requires,
ImmutableArray<GoModReplace> replaces,
ImmutableArray<GoModExclude> excludes,
ImmutableArray<string> retracts)
{
ModulePath = modulePath;
GoVersion = goVersion;
Requires = requires;
Replaces = replaces;
Excludes = excludes;
Retracts = retracts;
}
public string? ModulePath { get; }
public string? GoVersion { get; }
public ImmutableArray<GoModRequire> Requires { get; }
public ImmutableArray<GoModReplace> Replaces { get; }
public ImmutableArray<GoModExclude> Excludes { get; }
public ImmutableArray<string> Retracts { get; }
public bool IsEmpty => string.IsNullOrEmpty(ModulePath);
}
/// <summary>
/// A required dependency from go.mod.
/// </summary>
public sealed record GoModRequire(
string Path,
string Version,
bool IsIndirect);
/// <summary>
/// A replace directive from go.mod.
/// </summary>
public sealed record GoModReplace(
string OldPath,
string? OldVersion,
string NewPath,
string? NewVersion);
/// <summary>
/// An exclude directive from go.mod.
/// </summary>
public sealed record GoModExclude(
string Path,
string Version);
/// <summary>
/// Parses a go.mod file from the given path.
/// </summary>
public static GoModData Parse(string goModPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(goModPath);
if (!File.Exists(goModPath))
{
return GoModData.Empty;
}
try
{
var content = File.ReadAllText(goModPath);
return ParseContent(content);
}
catch (IOException)
{
return GoModData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoModData.Empty;
}
}
/// <summary>
/// Parses go.mod content string.
/// </summary>
public static GoModData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoModData.Empty;
}
string? modulePath = null;
string? goVersion = null;
var requires = new List<GoModRequire>();
var replaces = new List<GoModReplace>();
var excludes = new List<GoModExclude>();
var retracts = new List<string>();
// Remove comments (but preserve // indirect markers)
var lines = content.Split('\n');
var inRequireBlock = false;
var inReplaceBlock = false;
var inExcludeBlock = false;
var inRetractBlock = false;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
// Skip empty lines and full-line comments
if (string.IsNullOrEmpty(line) || line.StartsWith("//"))
{
continue;
}
// Handle block endings
if (line == ")")
{
inRequireBlock = false;
inReplaceBlock = false;
inExcludeBlock = false;
inRetractBlock = false;
continue;
}
// Handle block starts
if (line == "require (")
{
inRequireBlock = true;
continue;
}
if (line == "replace (")
{
inReplaceBlock = true;
continue;
}
if (line == "exclude (")
{
inExcludeBlock = true;
continue;
}
if (line == "retract (")
{
inRetractBlock = true;
continue;
}
// Parse module directive
if (line.StartsWith("module ", StringComparison.Ordinal))
{
modulePath = ExtractQuotedOrUnquoted(line["module ".Length..]);
continue;
}
// Parse go directive
if (line.StartsWith("go ", StringComparison.Ordinal))
{
goVersion = line["go ".Length..].Trim();
continue;
}
// Parse single-line require
if (line.StartsWith("require ", StringComparison.Ordinal) && !line.Contains('('))
{
var req = ParseRequireLine(line["require ".Length..]);
if (req is not null)
{
requires.Add(req);
}
continue;
}
// Parse single-line replace
if (line.StartsWith("replace ", StringComparison.Ordinal) && !line.Contains('('))
{
var rep = ParseReplaceLine(line["replace ".Length..]);
if (rep is not null)
{
replaces.Add(rep);
}
continue;
}
// Parse single-line exclude
if (line.StartsWith("exclude ", StringComparison.Ordinal) && !line.Contains('('))
{
var exc = ParseExcludeLine(line["exclude ".Length..]);
if (exc is not null)
{
excludes.Add(exc);
}
continue;
}
// Parse single-line retract
if (line.StartsWith("retract ", StringComparison.Ordinal) && !line.Contains('('))
{
var version = line["retract ".Length..].Trim();
if (!string.IsNullOrEmpty(version))
{
retracts.Add(version);
}
continue;
}
// Handle block contents
if (inRequireBlock)
{
var req = ParseRequireLine(line);
if (req is not null)
{
requires.Add(req);
}
}
else if (inReplaceBlock)
{
var rep = ParseReplaceLine(line);
if (rep is not null)
{
replaces.Add(rep);
}
}
else if (inExcludeBlock)
{
var exc = ParseExcludeLine(line);
if (exc is not null)
{
excludes.Add(exc);
}
}
else if (inRetractBlock)
{
var version = StripComment(line).Trim();
if (!string.IsNullOrEmpty(version))
{
retracts.Add(version);
}
}
}
if (string.IsNullOrEmpty(modulePath))
{
return GoModData.Empty;
}
return new GoModData(
modulePath,
goVersion,
requires.ToImmutableArray(),
replaces.ToImmutableArray(),
excludes.ToImmutableArray(),
retracts.ToImmutableArray());
}
private static GoModRequire? ParseRequireLine(string line)
{
// Format: path version [// indirect]
var isIndirect = line.Contains("// indirect", StringComparison.OrdinalIgnoreCase);
line = StripComment(line);
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
var path = parts[0].Trim();
var version = parts[1].Trim();
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(version))
{
return null;
}
return new GoModRequire(path, version, isIndirect);
}
private static GoModReplace? ParseReplaceLine(string line)
{
// Format: old [version] => new [version]
line = StripComment(line);
var arrowIndex = line.IndexOf("=>", StringComparison.Ordinal);
if (arrowIndex < 0)
{
return null;
}
var leftPart = line[..arrowIndex].Trim();
var rightPart = line[(arrowIndex + 2)..].Trim();
var leftParts = leftPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var rightParts = rightPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (leftParts.Length == 0 || rightParts.Length == 0)
{
return null;
}
var oldPath = leftParts[0];
var oldVersion = leftParts.Length > 1 ? leftParts[1] : null;
var newPath = rightParts[0];
var newVersion = rightParts.Length > 1 ? rightParts[1] : null;
return new GoModReplace(oldPath, oldVersion, newPath, newVersion);
}
private static GoModExclude? ParseExcludeLine(string line)
{
line = StripComment(line);
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
return new GoModExclude(parts[0], parts[1]);
}
private static string StripComment(string line)
{
var commentIndex = line.IndexOf("//", StringComparison.Ordinal);
return commentIndex >= 0 ? line[..commentIndex].Trim() : line.Trim();
}
private static string ExtractQuotedOrUnquoted(string value)
{
value = value.Trim();
// Remove quotes if present
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
return value[1..^1];
}
// Remove backticks if present
if (value.Length >= 2 && value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
// Strip any trailing comment
return StripComment(value);
}
}

View File

@@ -0,0 +1,199 @@
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Detects private Go modules based on common patterns and heuristics.
/// Uses patterns similar to GOPRIVATE environment variable matching.
/// </summary>
internal static partial class GoPrivateModuleDetector
{
// Common private hosting patterns
private static readonly string[] PrivateHostPatterns =
[
// GitLab self-hosted (common pattern)
@"^gitlab\.[^/]+/",
// Gitea/Gogs self-hosted
@"^git\.[^/]+/",
@"^gitea\.[^/]+/",
@"^gogs\.[^/]+/",
// Bitbucket Server
@"^bitbucket\.[^/]+/",
@"^stash\.[^/]+/",
// Azure DevOps (not github.com, gitlab.com, etc.)
@"^dev\.azure\.com/",
@"^[^/]+\.visualstudio\.com/",
// AWS CodeCommit
@"^git-codecommit\.[^/]+\.amazonaws\.com/",
// Internal/corporate patterns
@"^internal\.[^/]+/",
@"^private\.[^/]+/",
@"^corp\.[^/]+/",
@"^code\.[^/]+/",
// IP addresses (likely internal)
@"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}[:/]",
// Localhost
@"^localhost[:/]",
@"^127\.0\.0\.1[:/]",
];
// Known public hosting services
private static readonly string[] PublicHosts =
[
"github.com",
"gitlab.com",
"bitbucket.org",
"golang.org",
"google.golang.org",
"gopkg.in",
"go.uber.org",
"go.etcd.io",
"k8s.io",
"sigs.k8s.io",
"cloud.google.com",
"google.cloud.go",
];
private static readonly Regex[] CompiledPatterns;
static GoPrivateModuleDetector()
{
CompiledPatterns = PrivateHostPatterns
.Select(pattern => new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase))
.ToArray();
}
/// <summary>
/// Determines if a module path appears to be from a private source.
/// </summary>
public static bool IsLikelyPrivate(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return false;
}
// Check if it's a known public host first
foreach (var publicHost in PublicHosts)
{
if (modulePath.StartsWith(publicHost, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
// Check against private patterns
foreach (var pattern in CompiledPatterns)
{
if (pattern.IsMatch(modulePath))
{
return true;
}
}
// Check for internal TLDs
var host = ExtractHost(modulePath);
if (IsInternalTld(host))
{
return true;
}
return false;
}
/// <summary>
/// Gets the category of a module (public, private, local).
/// </summary>
public static string GetModuleCategory(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return "unknown";
}
// Local replacements start with . or /
if (modulePath.StartsWith('.') || modulePath.StartsWith('/') || modulePath.StartsWith('\\'))
{
return "local";
}
// Windows absolute paths
if (modulePath.Length >= 2 && char.IsLetter(modulePath[0]) && modulePath[1] == ':')
{
return "local";
}
if (IsLikelyPrivate(modulePath))
{
return "private";
}
return "public";
}
/// <summary>
/// Extracts the registry/host from a module path.
/// </summary>
public static string? GetRegistry(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return null;
}
// Local paths don't have a registry
if (modulePath.StartsWith('.') || modulePath.StartsWith('/') || modulePath.StartsWith('\\'))
{
return null;
}
var host = ExtractHost(modulePath);
if (string.IsNullOrEmpty(host))
{
return null;
}
// Standard Go proxy for public modules
if (!IsLikelyPrivate(modulePath))
{
return "proxy.golang.org";
}
// Private modules use direct access
return host;
}
private static string ExtractHost(string modulePath)
{
// Module path format: host/path
var slashIndex = modulePath.IndexOf('/');
return slashIndex > 0 ? modulePath[..slashIndex] : modulePath;
}
private static bool IsInternalTld(string host)
{
if (string.IsNullOrEmpty(host))
{
return false;
}
// Internal/non-public TLDs
string[] internalTlds = [".local", ".internal", ".corp", ".lan", ".intranet", ".private"];
foreach (var tld in internalTlds)
{
if (host.EndsWith(tld, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
// No TLD at all (single-word hostname)
if (!host.Contains('.'))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Discovers Go project roots by looking for go.mod, go.work, and vendor directories.
/// </summary>
internal static class GoProjectDiscoverer
{
/// <summary>
/// Discovered Go project information.
/// </summary>
public sealed record GoProject
{
public GoProject(
string rootPath,
string? goModPath,
string? goSumPath,
string? goWorkPath,
string? vendorModulesPath,
ImmutableArray<string> workspaceMembers)
{
RootPath = rootPath;
GoModPath = goModPath;
GoSumPath = goSumPath;
GoWorkPath = goWorkPath;
VendorModulesPath = vendorModulesPath;
WorkspaceMembers = workspaceMembers;
}
public string RootPath { get; }
public string? GoModPath { get; }
public string? GoSumPath { get; }
public string? GoWorkPath { get; }
public string? VendorModulesPath { get; }
public ImmutableArray<string> WorkspaceMembers { get; }
public bool HasGoMod => GoModPath is not null;
public bool HasGoSum => GoSumPath is not null;
public bool HasGoWork => GoWorkPath is not null;
public bool HasVendor => VendorModulesPath is not null;
public bool IsWorkspace => HasGoWork && WorkspaceMembers.Length > 0;
}
/// <summary>
/// Discovers all Go projects under the given root path.
/// </summary>
public static IReadOnlyList<GoProject> Discover(string rootPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
if (!Directory.Exists(rootPath))
{
return Array.Empty<GoProject>();
}
var projects = new List<GoProject>();
var visitedRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// First, check for go.work (workspace) at root
var goWorkPath = Path.Combine(rootPath, "go.work");
if (File.Exists(goWorkPath))
{
var workspaceProject = DiscoverWorkspace(rootPath, goWorkPath, cancellationToken);
if (workspaceProject is not null)
{
projects.Add(workspaceProject);
visitedRoots.Add(rootPath);
// Mark all workspace members as visited
foreach (var member in workspaceProject.WorkspaceMembers)
{
var memberFullPath = Path.GetFullPath(Path.Combine(rootPath, member));
visitedRoots.Add(memberFullPath);
}
}
}
// Then scan for standalone go.mod files
try
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 10
};
foreach (var goModFile in Directory.EnumerateFiles(rootPath, "go.mod", enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
var projectDir = Path.GetDirectoryName(goModFile);
if (string.IsNullOrEmpty(projectDir))
{
continue;
}
// Skip if already part of a workspace
var normalizedDir = Path.GetFullPath(projectDir);
if (visitedRoots.Contains(normalizedDir))
{
continue;
}
// Skip vendor directories
if (projectDir.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) ||
projectDir.EndsWith($"{Path.DirectorySeparatorChar}vendor", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var project = DiscoverStandaloneProject(projectDir);
if (project is not null)
{
projects.Add(project);
visitedRoots.Add(normalizedDir);
}
}
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible directories
}
return projects;
}
private static GoProject? DiscoverWorkspace(string rootPath, string goWorkPath, CancellationToken cancellationToken)
{
var workData = GoWorkParser.Parse(goWorkPath);
if (workData.IsEmpty)
{
return null;
}
var workspaceMembers = new List<string>();
foreach (var usePath in workData.UsePaths)
{
cancellationToken.ThrowIfCancellationRequested();
var memberPath = Path.Combine(rootPath, usePath);
var memberGoMod = Path.Combine(memberPath, "go.mod");
if (Directory.Exists(memberPath) && File.Exists(memberGoMod))
{
workspaceMembers.Add(usePath);
}
}
// The workspace itself may have a go.mod or not
var rootGoMod = Path.Combine(rootPath, "go.mod");
var rootGoSum = Path.Combine(rootPath, "go.sum");
var vendorModules = Path.Combine(rootPath, "vendor", "modules.txt");
return new GoProject(
rootPath,
File.Exists(rootGoMod) ? rootGoMod : null,
File.Exists(rootGoSum) ? rootGoSum : null,
goWorkPath,
File.Exists(vendorModules) ? vendorModules : null,
workspaceMembers.ToImmutableArray());
}
private static GoProject? DiscoverStandaloneProject(string projectDir)
{
var goModPath = Path.Combine(projectDir, "go.mod");
if (!File.Exists(goModPath))
{
return null;
}
var goSumPath = Path.Combine(projectDir, "go.sum");
var vendorModulesPath = Path.Combine(projectDir, "vendor", "modules.txt");
return new GoProject(
projectDir,
goModPath,
File.Exists(goSumPath) ? goSumPath : null,
null,
File.Exists(vendorModulesPath) ? vendorModulesPath : null,
ImmutableArray<string>.Empty);
}
}

View File

@@ -0,0 +1,129 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses go.sum files to extract module checksums.
/// Format: module version hash
/// Example: github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
/// </summary>
internal static class GoSumParser
{
/// <summary>
/// A single entry from go.sum.
/// </summary>
public sealed record GoSumEntry(
string Path,
string Version,
string Hash,
bool IsGoMod);
/// <summary>
/// Parsed go.sum data.
/// </summary>
public sealed record GoSumData
{
public static readonly GoSumData Empty = new(ImmutableDictionary<string, GoSumEntry>.Empty);
public GoSumData(ImmutableDictionary<string, GoSumEntry> entries)
{
Entries = entries;
}
/// <summary>
/// Entries keyed by "path@version" for quick lookup.
/// </summary>
public ImmutableDictionary<string, GoSumEntry> Entries { get; }
public bool IsEmpty => Entries.Count == 0;
/// <summary>
/// Tries to find the checksum for a module.
/// </summary>
public string? GetHash(string path, string version)
{
var key = $"{path}@{version}";
return Entries.TryGetValue(key, out var entry) ? entry.Hash : null;
}
}
/// <summary>
/// Parses a go.sum file from the given path.
/// </summary>
public static GoSumData Parse(string goSumPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(goSumPath);
if (!File.Exists(goSumPath))
{
return GoSumData.Empty;
}
try
{
var content = File.ReadAllText(goSumPath);
return ParseContent(content);
}
catch (IOException)
{
return GoSumData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoSumData.Empty;
}
}
/// <summary>
/// Parses go.sum content string.
/// </summary>
public static GoSumData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoSumData.Empty;
}
var entries = new Dictionary<string, GoSumEntry>(StringComparer.Ordinal);
var lines = content.Split('\n');
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line))
{
continue;
}
// Format: module version[/go.mod] hash
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 3)
{
continue;
}
var path = parts[0];
var versionPart = parts[1];
var hash = parts[2];
// Check if this is a go.mod checksum (version ends with /go.mod)
var isGoMod = versionPart.EndsWith("/go.mod", StringComparison.Ordinal);
var version = isGoMod ? versionPart[..^"/go.mod".Length] : versionPart;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(version) || string.IsNullOrEmpty(hash))
{
continue;
}
// Prefer the module hash over the go.mod hash
var key = $"{path}@{version}";
if (!isGoMod || !entries.ContainsKey(key))
{
entries[key] = new GoSumEntry(path, version, hash, isGoMod);
}
}
return new GoSumData(entries.ToImmutableDictionary(StringComparer.Ordinal));
}
}

View File

@@ -0,0 +1,178 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses vendor/modules.txt files to extract vendored dependencies.
/// Format:
/// # github.com/pkg/errors v0.9.1
/// ## explicit
/// github.com/pkg/errors
/// # golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a
/// ## explicit; go 1.17
/// golang.org/x/sys/unix
/// </summary>
internal static class GoVendorParser
{
/// <summary>
/// A vendored module entry.
/// </summary>
public sealed record GoVendorModule(
string Path,
string Version,
bool IsExplicit,
string? GoVersion,
ImmutableArray<string> Packages);
/// <summary>
/// Parsed vendor/modules.txt data.
/// </summary>
public sealed record GoVendorData
{
public static readonly GoVendorData Empty = new(ImmutableArray<GoVendorModule>.Empty);
public GoVendorData(ImmutableArray<GoVendorModule> modules)
{
Modules = modules;
}
public ImmutableArray<GoVendorModule> Modules { get; }
public bool IsEmpty => Modules.IsEmpty;
/// <summary>
/// Checks if a module path is vendored.
/// </summary>
public bool IsVendored(string path)
{
return Modules.Any(m => string.Equals(m.Path, path, StringComparison.Ordinal));
}
}
/// <summary>
/// Parses a vendor/modules.txt file from the given path.
/// </summary>
public static GoVendorData Parse(string modulesPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(modulesPath);
if (!File.Exists(modulesPath))
{
return GoVendorData.Empty;
}
try
{
var content = File.ReadAllText(modulesPath);
return ParseContent(content);
}
catch (IOException)
{
return GoVendorData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoVendorData.Empty;
}
}
/// <summary>
/// Parses vendor/modules.txt content string.
/// </summary>
public static GoVendorData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoVendorData.Empty;
}
var modules = new List<GoVendorModule>();
var lines = content.Split('\n');
string? currentPath = null;
string? currentVersion = null;
var currentPackages = new List<string>();
var isExplicit = false;
string? goVersion = null;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line))
{
continue;
}
// Module header: # module/path version
if (line.StartsWith("# ", StringComparison.Ordinal) && !line.StartsWith("## ", StringComparison.Ordinal))
{
// Save previous module if any
if (!string.IsNullOrEmpty(currentPath) && !string.IsNullOrEmpty(currentVersion))
{
modules.Add(new GoVendorModule(
currentPath,
currentVersion,
isExplicit,
goVersion,
currentPackages.ToImmutableArray()));
}
// Parse new module header
var parts = line[2..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
currentPath = parts[0];
currentVersion = parts[1];
currentPackages.Clear();
isExplicit = false;
goVersion = null;
}
else
{
currentPath = null;
currentVersion = null;
}
continue;
}
// Metadata line: ## explicit or ## explicit; go 1.17
if (line.StartsWith("## ", StringComparison.Ordinal))
{
var metadata = line[3..];
isExplicit = metadata.Contains("explicit", StringComparison.OrdinalIgnoreCase);
// Extract go version if present
var goIndex = metadata.IndexOf("go ", StringComparison.Ordinal);
if (goIndex >= 0)
{
var goVersionPart = metadata[(goIndex + 3)..].Trim();
var semicolonIndex = goVersionPart.IndexOf(';');
goVersion = semicolonIndex >= 0 ? goVersionPart[..semicolonIndex].Trim() : goVersionPart;
}
continue;
}
// Package path (not starting with #)
if (!line.StartsWith('#') && !string.IsNullOrEmpty(currentPath))
{
currentPackages.Add(line);
}
}
// Save last module
if (!string.IsNullOrEmpty(currentPath) && !string.IsNullOrEmpty(currentVersion))
{
modules.Add(new GoVendorModule(
currentPath,
currentVersion,
isExplicit,
goVersion,
currentPackages.ToImmutableArray()));
}
return new GoVendorData(modules.ToImmutableArray());
}
}

View File

@@ -0,0 +1,239 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses go.work files for Go workspace support (Go 1.18+).
/// Format:
/// go 1.21
/// use (
/// ./app
/// ./lib
/// )
/// replace example.com/old => example.com/new v1.0.0
/// </summary>
internal static class GoWorkParser
{
/// <summary>
/// Parsed go.work file data.
/// </summary>
public sealed record GoWorkData
{
public static readonly GoWorkData Empty = new(
null,
ImmutableArray<string>.Empty,
ImmutableArray<GoModParser.GoModReplace>.Empty);
public GoWorkData(
string? goVersion,
ImmutableArray<string> usePaths,
ImmutableArray<GoModParser.GoModReplace> replaces)
{
GoVersion = goVersion;
UsePaths = usePaths;
Replaces = replaces;
}
/// <summary>
/// Go version from the go directive.
/// </summary>
public string? GoVersion { get; }
/// <summary>
/// Relative paths to workspace member modules (from use directives).
/// </summary>
public ImmutableArray<string> UsePaths { get; }
/// <summary>
/// Replace directives that apply to all workspace modules.
/// </summary>
public ImmutableArray<GoModParser.GoModReplace> Replaces { get; }
public bool IsEmpty => UsePaths.IsEmpty;
}
/// <summary>
/// Parses a go.work file from the given path.
/// </summary>
public static GoWorkData Parse(string goWorkPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(goWorkPath);
if (!File.Exists(goWorkPath))
{
return GoWorkData.Empty;
}
try
{
var content = File.ReadAllText(goWorkPath);
return ParseContent(content);
}
catch (IOException)
{
return GoWorkData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoWorkData.Empty;
}
}
/// <summary>
/// Parses go.work content string.
/// </summary>
public static GoWorkData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoWorkData.Empty;
}
string? goVersion = null;
var usePaths = new List<string>();
var replaces = new List<GoModParser.GoModReplace>();
var lines = content.Split('\n');
var inUseBlock = false;
var inReplaceBlock = false;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
// Skip empty lines and comments
if (string.IsNullOrEmpty(line) || line.StartsWith("//"))
{
continue;
}
// Handle block endings
if (line == ")")
{
inUseBlock = false;
inReplaceBlock = false;
continue;
}
// Handle block starts
if (line == "use (")
{
inUseBlock = true;
continue;
}
if (line == "replace (")
{
inReplaceBlock = true;
continue;
}
// Parse go directive
if (line.StartsWith("go ", StringComparison.Ordinal))
{
goVersion = line["go ".Length..].Trim();
continue;
}
// Parse single-line use
if (line.StartsWith("use ", StringComparison.Ordinal) && !line.Contains('('))
{
var path = ExtractPath(line["use ".Length..]);
if (!string.IsNullOrEmpty(path))
{
usePaths.Add(path);
}
continue;
}
// Parse single-line replace
if (line.StartsWith("replace ", StringComparison.Ordinal) && !line.Contains('('))
{
var rep = ParseReplaceLine(line["replace ".Length..]);
if (rep is not null)
{
replaces.Add(rep);
}
continue;
}
// Handle block contents
if (inUseBlock)
{
var path = ExtractPath(line);
if (!string.IsNullOrEmpty(path))
{
usePaths.Add(path);
}
}
else if (inReplaceBlock)
{
var rep = ParseReplaceLine(line);
if (rep is not null)
{
replaces.Add(rep);
}
}
}
return new GoWorkData(
goVersion,
usePaths.ToImmutableArray(),
replaces.ToImmutableArray());
}
private static string ExtractPath(string value)
{
value = StripComment(value).Trim();
// Remove quotes if present
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
return value[1..^1];
}
if (value.Length >= 2 && value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
return value;
}
private static GoModParser.GoModReplace? ParseReplaceLine(string line)
{
line = StripComment(line);
var arrowIndex = line.IndexOf("=>", StringComparison.Ordinal);
if (arrowIndex < 0)
{
return null;
}
var leftPart = line[..arrowIndex].Trim();
var rightPart = line[(arrowIndex + 2)..].Trim();
var leftParts = leftPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var rightParts = rightPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (leftParts.Length == 0 || rightParts.Length == 0)
{
return null;
}
var oldPath = leftParts[0];
var oldVersion = leftParts.Length > 1 ? leftParts[1] : null;
var newPath = rightParts[0];
var newVersion = rightParts.Length > 1 ? rightParts[1] : null;
return new GoModParser.GoModReplace(oldPath, oldVersion, newPath, newVersion);
}
private static string StripComment(string line)
{
var commentIndex = line.IndexOf("//", StringComparison.Ordinal);
return commentIndex >= 0 ? line[..commentIndex].Trim() : line.Trim();
}
}