feat(ruby): Implement RubyManifestParser for parsing gem groups and dependencies
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat(ruby): Add RubyVendorArtifactCollector to collect vendor artifacts test(deno): Add golden tests for Deno analyzer with various fixtures test(deno): Create Deno module and package files for testing test(deno): Implement Deno lock and import map for dependency management test(deno): Add FFI and worker scripts for Deno testing feat(ruby): Set up Ruby workspace with Gemfile and dependencies feat(ruby): Add expected output for Ruby workspace tests feat(signals): Introduce CallgraphManifest model for signal processing
This commit is contained in:
@@ -68,7 +68,7 @@ internal static class DenoContainerAdapter
|
||||
builder.Add(new DenoContainerInput(
|
||||
DenoContainerSourceKind.Bundle,
|
||||
bundle.SourcePath,
|
||||
layerDigest: null,
|
||||
LayerDigest: null,
|
||||
metadata,
|
||||
bundle));
|
||||
}
|
||||
|
||||
@@ -463,9 +463,17 @@ internal static class DenoNpmCompatibilityAdapter
|
||||
}
|
||||
|
||||
var trimmed = value.Replace('\\', '/');
|
||||
return trimmed.StartsWith("./", StringComparison.Ordinal) || trimmed.StartsWith("/", StringComparison.Ordinal)
|
||||
? trimmed.TrimStart('.')
|
||||
: $"./{trimmed}";
|
||||
|
||||
if (trimmed.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = trimmed[2..];
|
||||
}
|
||||
else if (trimmed.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = trimmed.TrimStart('/');
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private readonly record struct NpmPackageKey(string Name, string Version);
|
||||
|
||||
@@ -12,7 +12,7 @@ internal static class DenoObservationSerializer
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
using var buffer = new ArrayBufferWriter<byte>();
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false }))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal sealed class RubyBundlerConfig
|
||||
{
|
||||
private RubyBundlerConfig(IReadOnlyList<string> gemfiles, IReadOnlyList<string> bundlePaths)
|
||||
{
|
||||
Gemfiles = gemfiles;
|
||||
BundlePaths = bundlePaths;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Gemfiles { get; }
|
||||
|
||||
public IReadOnlyList<string> BundlePaths { get; }
|
||||
|
||||
public static RubyBundlerConfig Empty { get; } = new(Array.Empty<string>(), Array.Empty<string>());
|
||||
|
||||
public static RubyBundlerConfig Load(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var configPath = Path.Combine(rootPath, \".bundle\", \"config\");
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var gemfiles = new List<string>();
|
||||
var bundlePaths = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var rawLine in File.ReadAllLines(configPath))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith(\"#\", StringComparison.Ordinal) || line.StartsWith(\"---\", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separatorIndex = line.IndexOf(':');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separatorIndex].Trim();
|
||||
var value = line[(separatorIndex + 1)..].Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
value = value.Trim('\"', '\'');
|
||||
|
||||
if (key.Equals(\"BUNDLE_GEMFILE\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddPath(gemfiles, rootPath, value);
|
||||
}
|
||||
else if (key.Equals(\"BUNDLE_PATH\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddPath(bundlePaths, rootPath, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
return new RubyBundlerConfig(
|
||||
DistinctNormalized(gemfiles),
|
||||
DistinctNormalized(bundlePaths));
|
||||
}
|
||||
|
||||
private static void AddPath(List<string> target, string rootPath, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = Path.IsPathRooted(value)
|
||||
? value
|
||||
: Path.Combine(rootPath, value);
|
||||
|
||||
target.Add(Path.GetFullPath(path));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DistinctNormalized(IEnumerable<string> values)
|
||||
{
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => Path.GetFullPath(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal static class RubyLockCollector
|
||||
{
|
||||
private static readonly string[] LockFileNames = { "Gemfile.lock", "gems.locked" };
|
||||
private static readonly string[] ManifestFileNames = { "Gemfile", "gems.rb" };
|
||||
private static readonly string[] IgnoredDirectories =
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".bundle",
|
||||
"node_modules",
|
||||
"vendor/bundle",
|
||||
"vendor/cache",
|
||||
"tmp",
|
||||
"log",
|
||||
"coverage"
|
||||
};
|
||||
|
||||
private const int MaxDiscoveryDepth = 3;
|
||||
|
||||
private static readonly IReadOnlyCollection<string> DefaultGroups = new[] { "default" };
|
||||
|
||||
public static async ValueTask<RubyLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var rootPath = context.RootPath;
|
||||
var bundlerConfig = RubyBundlerConfig.Load(rootPath);
|
||||
var lockFiles = DiscoverLockFiles(rootPath, bundlerConfig);
|
||||
if (lockFiles.Count == 0)
|
||||
{
|
||||
return RubyLockData.Empty;
|
||||
}
|
||||
|
||||
var manifestCache = new Dictionary<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>>(StringComparer.OrdinalIgnoreCase);
|
||||
var entries = new List<RubyLockEntry>();
|
||||
var bundlerVersions = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var lockFile in lockFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var parserResult = await ParseLockFileAsync(lockFile, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(parserResult.BundledWith))
|
||||
{
|
||||
bundlerVersions.Add(parserResult.BundledWith);
|
||||
}
|
||||
|
||||
var manifestGroups = ResolveManifestGroups(lockFile, bundlerConfig, manifestCache);
|
||||
var relativeLockPath = context.GetRelativePath(lockFile);
|
||||
|
||||
foreach (var spec in parserResult.Entries)
|
||||
{
|
||||
var groups = ResolveGroups(spec.Name, manifestGroups);
|
||||
entries.Add(new RubyLockEntry(
|
||||
spec.Name,
|
||||
spec.Version,
|
||||
spec.Source,
|
||||
spec.Platform,
|
||||
groups,
|
||||
relativeLockPath));
|
||||
}
|
||||
}
|
||||
|
||||
var bundledWith = bundlerVersions.Count == 0 ? string.Empty : bundlerVersions.First();
|
||||
return RubyLockData.Create(entries, bundledWith);
|
||||
}
|
||||
|
||||
private static async ValueTask<RubyLockParserResult> ParseLockFileAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return RubyLockParser.Parse(content);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ResolveManifestGroups(
|
||||
string lockFilePath,
|
||||
RubyBundlerConfig bundlerConfig,
|
||||
Dictionary<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>> manifestCache)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(lockFilePath) ?? string.Empty;
|
||||
foreach (var manifestName in ManifestFileNames)
|
||||
{
|
||||
var candidate = Path.Combine(directory, manifestName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return GetManifestGroups(candidate, manifestCache);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var overridePath in bundlerConfig.Gemfiles)
|
||||
{
|
||||
if (!IsSameDirectory(directory, overridePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return GetManifestGroups(overridePath, manifestCache);
|
||||
}
|
||||
|
||||
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> GetManifestGroups(
|
||||
string manifestPath,
|
||||
Dictionary<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>> manifestCache)
|
||||
{
|
||||
if (manifestCache.TryGetValue(manifestPath, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var groups = RubyManifestParser.ParseGroups(manifestPath);
|
||||
manifestCache[manifestPath] = groups;
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> ResolveGroups(
|
||||
string gemName,
|
||||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> manifestGroups)
|
||||
{
|
||||
if (manifestGroups.TryGetValue(gemName, out var groups) && groups.Count > 0)
|
||||
{
|
||||
return groups;
|
||||
}
|
||||
|
||||
return DefaultGroups;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> DiscoverLockFiles(string rootPath, RubyBundlerConfig bundlerConfig)
|
||||
{
|
||||
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootPath));
|
||||
|
||||
void TryAdd(string candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryNormalizeUnderRoot(normalizedRoot, candidate, out var normalized))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(normalized))
|
||||
{
|
||||
discovered.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var name in LockFileNames)
|
||||
{
|
||||
TryAdd(Path.Combine(rootPath, name));
|
||||
}
|
||||
|
||||
foreach (var gemfile in bundlerConfig.Gemfiles)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(gemfile);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var name in LockFileNames)
|
||||
{
|
||||
TryAdd(Path.Combine(directory, name));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in EnumerateLockFiles(rootPath))
|
||||
{
|
||||
TryAdd(candidate);
|
||||
}
|
||||
|
||||
return discovered
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLockFiles(string rootPath)
|
||||
{
|
||||
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootPath));
|
||||
var pending = new Stack<(string Path, int Depth)>();
|
||||
pending.Push((normalizedRoot, 0));
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var (current, depth) = pending.Pop();
|
||||
IEnumerable<string>? directories = null;
|
||||
|
||||
foreach (var name in LockFileNames)
|
||||
{
|
||||
var candidate = Path.Combine(current, name);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
yield return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth >= MaxDiscoveryDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(current);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (directories is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
if (!TryNormalizeUnderRoot(normalizedRoot, directory, out var normalizedDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldSkipDirectory(normalizedRoot, normalizedDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.Push((normalizedDirectory, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkipDirectory(string rootPath, string normalizedDirectory)
|
||||
{
|
||||
var relative = Path.GetRelativePath(rootPath, normalizedDirectory)
|
||||
.Replace('\\', '/');
|
||||
|
||||
var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
return segments.Any(segment => IgnoredDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool TryNormalizeUnderRoot(string normalizedRoot, string path, out string normalizedPath)
|
||||
{
|
||||
normalizedPath = Path.GetFullPath(path);
|
||||
if (!normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
if (path.EndsWith(Path.DirectorySeparatorChar))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static bool IsSameDirectory(string lockDirectory, string manifestPath)
|
||||
{
|
||||
var manifestDirectory = Path.GetDirectoryName(manifestPath);
|
||||
if (string.IsNullOrWhiteSpace(manifestDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
Path.GetFullPath(lockDirectory),
|
||||
Path.GetFullPath(manifestDirectory),
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -2,38 +2,33 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal sealed class RubyLockData
|
||||
{
|
||||
private RubyLockData(string? lockFilePath, IReadOnlyList<RubyLockEntry> entries, string bundledWith)
|
||||
private RubyLockData(IReadOnlyList<RubyLockEntry> entries, string bundledWith)
|
||||
{
|
||||
LockFilePath = lockFilePath;
|
||||
Entries = entries;
|
||||
BundledWith = bundledWith;
|
||||
BundledWith = bundledWith ?? string.Empty;
|
||||
}
|
||||
|
||||
public string? LockFilePath { get; }
|
||||
|
||||
public string BundledWith { get; }
|
||||
|
||||
public IReadOnlyList<RubyLockEntry> Entries { get; }
|
||||
|
||||
public string BundledWith { get; }
|
||||
|
||||
public bool IsEmpty => Entries.Count == 0;
|
||||
|
||||
public static async ValueTask<RubyLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
|
||||
public static ValueTask<RubyLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rootPath);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return RubyLockCollector.LoadAsync(context, cancellationToken);
|
||||
}
|
||||
|
||||
var lockPath = Path.Combine(rootPath, "Gemfile.lock");
|
||||
if (!File.Exists(lockPath))
|
||||
public static RubyLockData Create(IReadOnlyList<RubyLockEntry> entries, string bundledWith)
|
||||
{
|
||||
if (entries.Count == 0 && string.IsNullOrWhiteSpace(bundledWith))
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var parser = RubyLockParser.Parse(content);
|
||||
return new RubyLockData(lockPath, parser.Entries, parser.BundledWith);
|
||||
return new RubyLockData(entries, bundledWith);
|
||||
}
|
||||
|
||||
public static RubyLockData Empty { get; } = new(lockFilePath: null, Array.Empty<RubyLockEntry>(), bundledWith: string.Empty);
|
||||
public static RubyLockData Empty { get; } = new(Array.Empty<RubyLockEntry>(), string.Empty);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ internal sealed record RubyLockEntry(
|
||||
string Version,
|
||||
string Source,
|
||||
string? Platform,
|
||||
IReadOnlyCollection<string> Groups);
|
||||
IReadOnlyCollection<string> Groups,
|
||||
string LockFileRelativePath);
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal static class RubyLockParser
|
||||
{
|
||||
private static readonly Regex SpecLineRegex = new(@"^\s{4}([^\s]+)\s\(([^)]+)\)", RegexOptions.Compiled);
|
||||
private enum RubyLockSection
|
||||
{
|
||||
None,
|
||||
Gem,
|
||||
Git,
|
||||
Path,
|
||||
BundledWith
|
||||
}
|
||||
|
||||
private static readonly Regex SpecLineRegex = new(@"^\s{4}(?<name>[^\s]+)\s\((?<version>[^)]+)\)", RegexOptions.Compiled);
|
||||
|
||||
public static RubyLockParserResult Parse(string contents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contents))
|
||||
{
|
||||
return new RubyLockParserResult(Array.Empty<RubyLockEntry>(), string.Empty);
|
||||
return new RubyLockParserResult(Array.Empty<RubyLockParserEntry>(), string.Empty);
|
||||
}
|
||||
|
||||
var entries = new List<RubyLockEntry>();
|
||||
var entries = new List<RubyLockParserEntry>();
|
||||
var section = RubyLockSection.None;
|
||||
var bundledWith = string.Empty;
|
||||
var inSpecs = false;
|
||||
string? currentRemote = null;
|
||||
string? currentRevision = null;
|
||||
string? currentPath = null;
|
||||
|
||||
using var reader = new StringReader(contents);
|
||||
string? line;
|
||||
string currentSection = string.Empty;
|
||||
string? currentSource = null;
|
||||
bool inSpecs = false;
|
||||
var bundledWith = string.Empty;
|
||||
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!char.IsWhiteSpace(line[0]))
|
||||
{
|
||||
currentSection = line.Trim();
|
||||
section = ParseSection(line.Trim());
|
||||
inSpecs = false;
|
||||
currentRemote = null;
|
||||
currentRevision = null;
|
||||
currentPath = null;
|
||||
|
||||
if (string.Equals(currentSection, "GEM", StringComparison.OrdinalIgnoreCase))
|
||||
if (section == RubyLockSection.Gem)
|
||||
{
|
||||
currentSource = "rubygems";
|
||||
currentRemote = "https://rubygems.org/";
|
||||
}
|
||||
else if (string.Equals(currentSection, "GIT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentSource = null;
|
||||
}
|
||||
else if (string.Equals(currentSection, "PATH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentSource = null;
|
||||
}
|
||||
else if (string.Equals(currentSection, "BUNDLED WITH", StringComparison.OrdinalIgnoreCase))
|
||||
else if (section == RubyLockSection.BundledWith)
|
||||
{
|
||||
var versionLine = reader.ReadLine();
|
||||
if (!string.IsNullOrWhiteSpace(versionLine))
|
||||
@@ -58,72 +64,144 @@ internal static class RubyLockParser
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
|
||||
switch (section)
|
||||
{
|
||||
currentSource = line[9..].Trim();
|
||||
continue;
|
||||
case RubyLockSection.Gem:
|
||||
case RubyLockSection.Git:
|
||||
case RubyLockSection.Path:
|
||||
ProcessSectionLine(
|
||||
line,
|
||||
section,
|
||||
ref inSpecs,
|
||||
ref currentRemote,
|
||||
ref currentRevision,
|
||||
ref currentPath,
|
||||
entries);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase)
|
||||
&& currentSection.Equals("GIT", StringComparison.OrdinalIgnoreCase)
|
||||
&& currentSource is not null)
|
||||
{
|
||||
currentSource = $"{currentSource}@{line[10..].Trim()}";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase)
|
||||
&& currentSection.Equals("PATH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentSource = $"path:{line[6..].Trim()}";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" specs:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inSpecs = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inSpecs)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = SpecLineRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = match.Groups[1].Value.Trim();
|
||||
var versionToken = match.Groups[2].Value.Trim();
|
||||
|
||||
string version;
|
||||
string? platform = null;
|
||||
|
||||
var tokens = versionToken.Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length > 1)
|
||||
{
|
||||
version = tokens[0];
|
||||
platform = string.Join(" ", tokens.Skip(1));
|
||||
}
|
||||
else
|
||||
{
|
||||
version = versionToken;
|
||||
}
|
||||
|
||||
var source = currentSource ?? "unknown";
|
||||
entries.Add(new RubyLockEntry(name, version, source, platform, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
return new RubyLockParserResult(entries, bundledWith);
|
||||
}
|
||||
|
||||
private static void ProcessSectionLine(
|
||||
string line,
|
||||
RubyLockSection section,
|
||||
ref bool inSpecs,
|
||||
ref string? currentRemote,
|
||||
ref string? currentRevision,
|
||||
ref string? currentPath,
|
||||
List<RubyLockParserEntry> entries)
|
||||
{
|
||||
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentRemote = line[9..].Trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentRevision = line[10..].Trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" ref:", StringComparison.OrdinalIgnoreCase) && currentRevision is null)
|
||||
{
|
||||
currentRevision = line[6..].Trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentPath = line[6..].Trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" specs:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inSpecs = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inSpecs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var match = SpecLineRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
|
||||
{
|
||||
// Nested dependency entry under a spec.
|
||||
return;
|
||||
}
|
||||
|
||||
var name = match.Groups["name"].Value.Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (version, platform) = ParseVersion(match.Groups["version"].Value);
|
||||
var source = ResolveSource(section, currentRemote, currentRevision, currentPath);
|
||||
|
||||
entries.Add(new RubyLockParserEntry(name, version, source, platform));
|
||||
}
|
||||
|
||||
private static RubyLockSection ParseSection(string value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
"GEM" => RubyLockSection.Gem,
|
||||
"GIT" => RubyLockSection.Git,
|
||||
"PATH" => RubyLockSection.Path,
|
||||
"BUNDLED WITH" => RubyLockSection.BundledWith,
|
||||
_ => RubyLockSection.None,
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Version, string? Platform) ParseVersion(string raw)
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return (string.Empty, null);
|
||||
}
|
||||
|
||||
var tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length <= 1)
|
||||
{
|
||||
return (trimmed, null);
|
||||
}
|
||||
|
||||
var version = tokens[0];
|
||||
var platform = string.Join(' ', tokens[1..]);
|
||||
return (version, platform);
|
||||
}
|
||||
|
||||
private static string ResolveSource(RubyLockSection section, string? remote, string? revision, string? path)
|
||||
{
|
||||
return section switch
|
||||
{
|
||||
RubyLockSection.Git when !string.IsNullOrWhiteSpace(remote) && !string.IsNullOrWhiteSpace(revision)
|
||||
=> $"git:{remote}@{revision}",
|
||||
RubyLockSection.Git when !string.IsNullOrWhiteSpace(remote)
|
||||
=> $"git:{remote}",
|
||||
RubyLockSection.Path when !string.IsNullOrWhiteSpace(path)
|
||||
=> $"path:{path}",
|
||||
_ when !string.IsNullOrWhiteSpace(remote)
|
||||
=> remote!,
|
||||
_ => "rubygems",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockEntry> Entries, string BundledWith);
|
||||
internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform);
|
||||
|
||||
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockParserEntry> Entries, string BundledWith);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal static class RubyManifestParser
|
||||
{
|
||||
private static readonly Regex GemLineRegex = new(@"^\s*gem\s+(?<quote>['""])(?<name>[^'""]+)\k<quote>(?<args>.*)$", RegexOptions.Compiled);
|
||||
private static readonly Regex GroupStartRegex = new(@"^\s*group\s+(?<args>.+?)\s+do\b", RegexOptions.Compiled);
|
||||
private static readonly Regex InlineGroupRegex = new(@"(group|groups)\s*[:=]>\s*(?<value>\[.*?\]|:[A-Za-z0-9_]+)|(group|groups):\s*(?<typed>\[.*?\]|:[A-Za-z0-9_]+)", RegexOptions.Compiled);
|
||||
private static readonly Regex SymbolRegex = new(@":(?<symbol>[A-Za-z0-9_]+)", RegexOptions.Compiled);
|
||||
private static readonly Regex StringRegex = new(@"['""](?<value>[A-Za-z0-9_\-]+)['""]", RegexOptions.Compiled);
|
||||
|
||||
public static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ParseGroups(string manifestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath))
|
||||
{
|
||||
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ParseInternal(manifestPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return ImmutableDictionary<string, IReadOnlyCollection<string>>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ParseInternal(string manifestPath)
|
||||
{
|
||||
var groupStack = new Stack<HashSet<string>>();
|
||||
var mapping = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var rawLine in File.ReadLines(manifestPath))
|
||||
{
|
||||
var line = StripComment(rawLine);
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryBeginGroup(line, groupStack))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsGroupEnd(line) && groupStack.Count > 0)
|
||||
{
|
||||
groupStack.Pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseGem(line, out var gemName, out var inlineGroups))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mapping.TryGetValue(gemName, out var groups))
|
||||
{
|
||||
groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
mapping[gemName] = groups;
|
||||
}
|
||||
|
||||
if (groupStack.Count > 0)
|
||||
{
|
||||
foreach (var group in groupStack.Peek())
|
||||
{
|
||||
groups.Add(group);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var inline in inlineGroups)
|
||||
{
|
||||
groups.Add(inline);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
groups.Add("default");
|
||||
}
|
||||
}
|
||||
|
||||
return mapping.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => (IReadOnlyCollection<string>)pair.Value
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryBeginGroup(string line, Stack<HashSet<string>> stack)
|
||||
{
|
||||
var match = GroupStartRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedGroups = ParseGroupTokens(match.Groups["args"].Value);
|
||||
var inherited = stack.Count > 0
|
||||
? new HashSet<string>(stack.Peek(), StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (parsedGroups.Count == 0 && inherited.Count == 0)
|
||||
{
|
||||
inherited.Add("default");
|
||||
}
|
||||
|
||||
foreach (var group in parsedGroups)
|
||||
{
|
||||
inherited.Add(group);
|
||||
}
|
||||
|
||||
stack.Push(inherited);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> ParseGroupTokens(string tokens)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokens))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = tokens.Trim().Trim('(', ')');
|
||||
var results = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var segment in normalized.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var token = segment.Trim();
|
||||
if (token.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var symbolMatch = SymbolRegex.Match(token);
|
||||
if (symbolMatch.Success)
|
||||
{
|
||||
results.Add(symbolMatch.Groups["symbol"].Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
var stringMatch = StringRegex.Match(token);
|
||||
if (stringMatch.Success)
|
||||
{
|
||||
results.Add(stringMatch.Groups["value"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool TryParseGem(string line, out string name, out IReadOnlyCollection<string> inlineGroups)
|
||||
{
|
||||
var match = GemLineRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
name = string.Empty;
|
||||
inlineGroups = Array.Empty<string>();
|
||||
return false;
|
||||
}
|
||||
|
||||
name = match.Groups["name"].Value.Trim();
|
||||
inlineGroups = ExtractInlineGroups(match.Groups["args"].Value);
|
||||
return !string.IsNullOrWhiteSpace(name);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> ExtractInlineGroups(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in InlineGroupRegex.Matches(args))
|
||||
{
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var valueGroup = match.Groups["value"].Success ? match.Groups["value"] : match.Groups["typed"];
|
||||
if (!valueGroup.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = valueGroup.Value;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (Match symbol in SymbolRegex.Matches(value))
|
||||
{
|
||||
if (symbol.Success)
|
||||
{
|
||||
groups.Add(symbol.Groups["symbol"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match str in StringRegex.Matches(value))
|
||||
{
|
||||
if (str.Success)
|
||||
{
|
||||
groups.Add(str.Groups["value"].Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static bool IsGroupEnd(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (!trimmed.StartsWith("end", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.Length == 3 || char.IsWhiteSpace(trimmed[3]) || trimmed[3] == '#';
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(line.Length);
|
||||
var inSingle = false;
|
||||
var inDouble = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var ch = line[i];
|
||||
|
||||
if (ch == '\'' && !inDouble)
|
||||
{
|
||||
inSingle = !inSingle;
|
||||
}
|
||||
else if (ch == '"' && !inSingle)
|
||||
{
|
||||
inDouble = !inDouble;
|
||||
}
|
||||
|
||||
if (ch == '#' && !inSingle && !inDouble)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
builder.Append(ch);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal sealed class RubyPackage
|
||||
{
|
||||
private RubyPackage(
|
||||
public static IReadOnlyCollection<string> DefaultGroups { get; } = new[] { "default" };
|
||||
|
||||
internal RubyPackage(
|
||||
string name,
|
||||
string version,
|
||||
string source,
|
||||
string? platform,
|
||||
IReadOnlyCollection<string> groups,
|
||||
string locator,
|
||||
string? lockfileLocator,
|
||||
string? artifactLocator,
|
||||
string evidenceSource,
|
||||
bool declaredOnly)
|
||||
{
|
||||
Name = name;
|
||||
@@ -16,7 +20,9 @@ internal sealed class RubyPackage
|
||||
Source = source;
|
||||
Platform = platform;
|
||||
Groups = groups;
|
||||
Locator = locator;
|
||||
LockfileLocator = string.IsNullOrWhiteSpace(lockfileLocator) ? null : Normalize(lockfileLocator);
|
||||
ArtifactLocator = string.IsNullOrWhiteSpace(artifactLocator) ? null : Normalize(artifactLocator);
|
||||
EvidenceSource = string.IsNullOrWhiteSpace(evidenceSource) ? "Gemfile.lock" : evidenceSource;
|
||||
DeclaredOnly = declaredOnly;
|
||||
}
|
||||
|
||||
@@ -30,7 +36,11 @@ internal sealed class RubyPackage
|
||||
|
||||
public IReadOnlyCollection<string> Groups { get; }
|
||||
|
||||
public string Locator { get; }
|
||||
public string? LockfileLocator { get; }
|
||||
|
||||
public string? ArtifactLocator { get; }
|
||||
|
||||
public string EvidenceSource { get; }
|
||||
|
||||
public bool DeclaredOnly { get; }
|
||||
|
||||
@@ -38,15 +48,31 @@ internal sealed class RubyPackage
|
||||
|
||||
public string ComponentKey => $"purl::{Purl}";
|
||||
|
||||
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata(RubyCapabilities? capabilities = null, RubyRuntimeUsage? runtimeUsage = null)
|
||||
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata(
|
||||
RubyCapabilities? capabilities = null,
|
||||
RubyRuntimeUsage? runtimeUsage = null)
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new("source", Source),
|
||||
new("lockfile", string.IsNullOrWhiteSpace(Locator) ? "Gemfile.lock" : Locator),
|
||||
new("declaredOnly", DeclaredOnly ? "true" : "false")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(LockfileLocator))
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockfile", LockfileLocator));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(ArtifactLocator))
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockfile", ArtifactLocator));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ArtifactLocator)
|
||||
&& !string.Equals(ArtifactLocator, LockfileLocator, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("artifact", ArtifactLocator));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Platform))
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("platform", Platform));
|
||||
@@ -81,8 +107,7 @@ internal sealed class RubyPackage
|
||||
|
||||
foreach (var scheduler in schedulers)
|
||||
{
|
||||
var key = $"capability.scheduler.{scheduler}";
|
||||
metadata.Add(new KeyValuePair<string, string?>(key, "true"));
|
||||
metadata.Add(new KeyValuePair<string, string?>($"capability.scheduler.{scheduler}", "true"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,32 +136,19 @@ internal sealed class RubyPackage
|
||||
|
||||
public IReadOnlyCollection<LanguageComponentEvidence> CreateEvidence()
|
||||
{
|
||||
var locator = string.IsNullOrWhiteSpace(Locator)
|
||||
? "Gemfile.lock"
|
||||
: Locator;
|
||||
var locator = ArtifactLocator ?? LockfileLocator ?? "Gemfile.lock";
|
||||
|
||||
return new[]
|
||||
{
|
||||
new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"Gemfile.lock",
|
||||
EvidenceSource,
|
||||
locator,
|
||||
Value: null,
|
||||
Sha256: null)
|
||||
};
|
||||
}
|
||||
|
||||
public static RubyPackage From(RubyLockEntry entry, string lockFileRelativePath)
|
||||
{
|
||||
var groups = entry.Groups.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: entry.Groups.OrderBy(static g => g, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
return new RubyPackage(entry.Name, entry.Version, entry.Source, entry.Platform, groups, lockFileRelativePath, declaredOnly: true);
|
||||
}
|
||||
|
||||
public static RubyPackage FromVendor(string name, string version, string source, string? platform, string locator)
|
||||
{
|
||||
return new RubyPackage(name, version, source, platform, Array.Empty<string>(), locator, declaredOnly: true);
|
||||
}
|
||||
private static string Normalize(string path)
|
||||
=> path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
@@ -2,104 +2,140 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal static class RubyPackageCollector
|
||||
{
|
||||
public static IReadOnlyList<RubyPackage> CollectPackages(RubyLockData lockData, LanguageAnalyzerContext context)
|
||||
public static IReadOnlyList<RubyPackage> CollectPackages(
|
||||
RubyLockData lockData,
|
||||
LanguageAnalyzerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packages = new List<RubyPackage>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var bundlerConfig = RubyBundlerConfig.Load(context.RootPath);
|
||||
var vendorArtifacts = RubyVendorArtifactCollector.Collect(context, bundlerConfig, cancellationToken);
|
||||
var builders = new Dictionary<string, RubyPackageBuilder>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!lockData.IsEmpty)
|
||||
foreach (var artifact in vendorArtifacts)
|
||||
{
|
||||
var relativeLockPath = lockData.LockFilePath is null
|
||||
? "Gemfile.lock"
|
||||
: context.GetRelativePath(lockData.LockFilePath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativeLockPath))
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var key = RubyPackageBuilder.BuildKey(artifact.Name, artifact.Version, artifact.Platform);
|
||||
if (!builders.TryGetValue(key, out var builder))
|
||||
{
|
||||
relativeLockPath = "Gemfile.lock";
|
||||
builder = new RubyPackageBuilder(artifact.Name, artifact.Version, artifact.Platform);
|
||||
builders[key] = builder;
|
||||
}
|
||||
|
||||
foreach (var entry in lockData.Entries)
|
||||
{
|
||||
var key = $"{entry.Name}@{entry.Version}";
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
packages.Add(RubyPackage.From(entry, relativeLockPath));
|
||||
}
|
||||
builder.ApplyVendorArtifact(artifact);
|
||||
}
|
||||
|
||||
CollectVendorCachePackages(context, packages, seen);
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static void CollectVendorCachePackages(LanguageAnalyzerContext context, List<RubyPackage> packages, HashSet<string> seen)
|
||||
{
|
||||
var vendorCache = Path.Combine(context.RootPath, "vendor", "cache");
|
||||
if (!Directory.Exists(vendorCache))
|
||||
foreach (var entry in lockData.Entries)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var gemPath in Directory.EnumerateFiles(vendorCache, "*.gem", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!TryParseGemArchive(gemPath, out var name, out var version, out var platform))
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var key = RubyPackageBuilder.BuildKey(entry.Name, entry.Version, entry.Platform);
|
||||
if (!builders.TryGetValue(key, out var builder))
|
||||
{
|
||||
continue;
|
||||
builder = new RubyPackageBuilder(entry.Name, entry.Version, entry.Platform);
|
||||
builders[key] = builder;
|
||||
}
|
||||
|
||||
var key = $"{name}@{version}";
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var locator = context.GetRelativePath(gemPath);
|
||||
packages.Add(RubyPackage.FromVendor(name, version, source: "vendor-cache", platform, locator));
|
||||
builder.ApplyLockEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseGemArchive(string gemPath, out string name, out string version, out string? platform)
|
||||
{
|
||||
name = string.Empty;
|
||||
version = string.Empty;
|
||||
platform = null;
|
||||
|
||||
var fileName = Path.GetFileNameWithoutExtension(gemPath);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
if (builders.Count == 0)
|
||||
{
|
||||
return false;
|
||||
return Array.Empty<RubyPackage>();
|
||||
}
|
||||
|
||||
var segments = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var versionIndex = -1;
|
||||
for (var i = 1; i < segments.Length; i++)
|
||||
{
|
||||
if (char.IsDigit(segments[i][0]))
|
||||
{
|
||||
versionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (versionIndex <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
name = string.Join('-', segments[..versionIndex]);
|
||||
version = segments[versionIndex];
|
||||
platform = segments.Length > versionIndex + 1
|
||||
? string.Join('-', segments[(versionIndex + 1)..])
|
||||
: null;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version);
|
||||
return builders.Values
|
||||
.Select(builder => builder.Build())
|
||||
.OrderBy(static package => package.ComponentKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RubyPackageBuilder
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string _version;
|
||||
private readonly string? _platform;
|
||||
|
||||
private readonly HashSet<string> _groups = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private string? _lockSource;
|
||||
private string? _lockLocator;
|
||||
private string? _lockEvidenceSource;
|
||||
|
||||
private string? _artifactSource;
|
||||
private string? _artifactLocator;
|
||||
private string? _artifactEvidenceSource;
|
||||
|
||||
private bool _hasVendor;
|
||||
|
||||
public RubyPackageBuilder(string name, string version, string? platform)
|
||||
{
|
||||
_name = name;
|
||||
_version = version;
|
||||
_platform = platform;
|
||||
}
|
||||
|
||||
public static string BuildKey(string name, string version, string? platform)
|
||||
{
|
||||
return platform is null
|
||||
? $"{name}::{version}"
|
||||
: $"{name}::{version}::{platform}";
|
||||
}
|
||||
|
||||
public void ApplyLockEntry(RubyLockEntry entry)
|
||||
{
|
||||
_lockSource ??= entry.Source;
|
||||
_lockLocator ??= NormalizeLocator(entry.LockFileRelativePath);
|
||||
_lockEvidenceSource ??= Path.GetFileName(entry.LockFileRelativePath);
|
||||
|
||||
foreach (var group in entry.Groups)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(group))
|
||||
{
|
||||
_groups.Add(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyVendorArtifact(RubyVendorArtifact artifact)
|
||||
{
|
||||
_hasVendor = true;
|
||||
_artifactLocator ??= NormalizeLocator(artifact.RelativePath);
|
||||
_artifactSource ??= artifact.SourceLabel;
|
||||
_artifactEvidenceSource ??= Path.GetFileName(artifact.RelativePath);
|
||||
}
|
||||
|
||||
public RubyPackage Build()
|
||||
{
|
||||
var groups = _groups.Count == 0
|
||||
? RubyPackage.DefaultGroups
|
||||
: _groups
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var source = _lockSource ?? _artifactSource ?? "unknown";
|
||||
var evidenceSource = _hasVendor
|
||||
? _artifactEvidenceSource ?? "vendor"
|
||||
: _lockEvidenceSource ?? "Gemfile.lock";
|
||||
|
||||
return new RubyPackage(
|
||||
_name,
|
||||
_version,
|
||||
source,
|
||||
_platform,
|
||||
groups,
|
||||
lockfileLocator: _lockLocator,
|
||||
artifactLocator: _artifactLocator,
|
||||
evidenceSource,
|
||||
declaredOnly: !_hasVendor);
|
||||
}
|
||||
|
||||
private static string NormalizeLocator(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
internal static class RubyVendorArtifactCollector
|
||||
{
|
||||
private static readonly string[] DefaultVendorRoots =
|
||||
{
|
||||
Path.Combine("vendor", "cache"),
|
||||
Path.Combine(".bundle", "cache")
|
||||
};
|
||||
|
||||
private static readonly string[] DirectoryBlockList =
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"node_modules",
|
||||
"tmp",
|
||||
"log",
|
||||
"coverage"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<RubyVendorArtifact> Collect(LanguageAnalyzerContext context, RubyBundlerConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(context.RootPath));
|
||||
|
||||
void TryAdd(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var absolute = Path.IsPathRooted(path) ? path : Path.Combine(context.RootPath, path);
|
||||
try
|
||||
{
|
||||
absolute = Path.GetFullPath(absolute);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!absolute.StartsWith(normalizedRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Directory.Exists(absolute))
|
||||
{
|
||||
roots.Add(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var root in DefaultVendorRoots)
|
||||
{
|
||||
TryAdd(root);
|
||||
}
|
||||
|
||||
TryAdd(Path.Combine(context.RootPath, "vendor", "bundle"));
|
||||
|
||||
foreach (var bundlePath in config.BundlePaths)
|
||||
{
|
||||
TryAdd(bundlePath);
|
||||
TryAdd(Path.Combine(bundlePath, "cache"));
|
||||
}
|
||||
|
||||
var artifacts = new List<RubyVendorArtifact>();
|
||||
foreach (var root in roots.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
TraverseDirectory(context, root, artifacts, cancellationToken);
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private static void TraverseDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
string root,
|
||||
List<RubyVendorArtifact> artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(root);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var current = stack.Pop();
|
||||
|
||||
IEnumerable<string>? files = null;
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(current);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
files = null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
files = null;
|
||||
}
|
||||
|
||||
if (files is not null)
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ProcessFile(context, artifacts, file);
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(current);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
directories = null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
directories = null;
|
||||
}
|
||||
|
||||
if (directories is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (ShouldSkip(directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessDirectoryArtifact(context, artifacts, directory);
|
||||
stack.Push(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessFile(LanguageAnalyzerContext context, List<RubyVendorArtifact> artifacts, string filePath)
|
||||
{
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!extension.Equals(".gem", StringComparison.OrdinalIgnoreCase)
|
||||
&& !extension.Equals(".gemspec", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
if (!RubyPackageNameParser.TryParse(fileName, out var name, out var version, out var platform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetRelativePath(context, filePath, out var relative))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceLabel = DescribeSource(relative);
|
||||
artifacts.Add(new RubyVendorArtifact(name, version, platform, relative, sourceLabel));
|
||||
}
|
||||
|
||||
private static void ProcessDirectoryArtifact(LanguageAnalyzerContext context, List<RubyVendorArtifact> artifacts, string directoryPath)
|
||||
{
|
||||
var parent = Path.GetFileName(Path.GetDirectoryName(directoryPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) ?? string.Empty);
|
||||
if (!parent.Equals("gems", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directoryName = Path.GetFileName(directoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (!RubyPackageNameParser.TryParse(directoryName, out var name, out var version, out var platform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetRelativePath(context, directoryPath, out var relative))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceLabel = DescribeSource(relative);
|
||||
artifacts.Add(new RubyVendorArtifact(name, version, platform, relative, sourceLabel));
|
||||
}
|
||||
|
||||
private static bool TryGetRelativePath(LanguageAnalyzerContext context, string absolutePath, out string relative)
|
||||
{
|
||||
relative = context.GetRelativePath(absolutePath);
|
||||
if (string.IsNullOrWhiteSpace(relative) || relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
relative = relative.Replace('\\', '/');
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(string directoryPath)
|
||||
{
|
||||
var name = Path.GetFileName(directoryPath);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DirectoryBlockList.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string DescribeSource(string relativePath)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/');
|
||||
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return segments[0];
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RubyVendorArtifact(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Platform,
|
||||
string RelativePath,
|
||||
string SourceLabel);
|
||||
|
||||
internal static class RubyPackageNameParser
|
||||
{
|
||||
public static bool TryParse(string value, out string name, out string version, out string? platform)
|
||||
{
|
||||
name = string.Empty;
|
||||
version = string.Empty;
|
||||
platform = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = value.Split('-', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var versionIndex = -1;
|
||||
for (var i = segments.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (char.IsDigit(segments[i][0]))
|
||||
{
|
||||
versionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (versionIndex <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
name = string.Join('-', segments[..versionIndex]);
|
||||
version = segments[versionIndex];
|
||||
platform = versionIndex < segments.Length - 1
|
||||
? string.Join('-', segments[(versionIndex + 1)..])
|
||||
: null;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby;
|
||||
|
||||
@@ -13,14 +15,16 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var lockData = await RubyLockData.LoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
if (lockData.IsEmpty)
|
||||
await EnsureSurfaceValidationAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lockData = await RubyLockData.LoadAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var packages = RubyPackageCollector.CollectPackages(lockData, context, cancellationToken);
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var capabilities = await RubyCapabilityDetector.DetectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var packages = RubyPackageCollector.CollectPackages(lockData, context);
|
||||
var runtimeGraph = await RubyRuntimeGraphBuilder.BuildAsync(context, packages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
|
||||
@@ -40,4 +44,32 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
usedByEntrypoint: runtimeUsage?.UsedByEntrypoint ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask EnsureSurfaceValidationAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.Services is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.TryGetService<ISurfaceValidatorRunner>(out var validatorRunner)
|
||||
|| !context.TryGetService<ISurfaceEnvironment>(out var environment))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["analyzerId"] = \"ruby\",
|
||||
[\"rootPath\"] = context.RootPath
|
||||
};
|
||||
|
||||
var validationContext = SurfaceValidationContext.Create(
|
||||
context.Services,
|
||||
\"StellaOps.Scanner.Analyzers.Lang.Ruby\",
|
||||
environment.Settings,
|
||||
properties);
|
||||
|
||||
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
| Task ID | State | Notes |
|
||||
| --- | --- | --- |
|
||||
| `SCANNER-ENG-0016` | DOING (2025-11-10) | Building RubyLockCollector + multi-source vendor ingestion per design §4.1–4.3 (Codex agent). |
|
||||
| `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. |
|
||||
| `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. |
|
||||
|
||||
Reference in New Issue
Block a user