Align AOC tasks for Excititor and Concelier
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,243 +1,243 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustBinaryClassifier
|
||||
{
|
||||
private static readonly ReadOnlyMemory<byte> ElfMagic = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F' };
|
||||
private static readonly ReadOnlyMemory<byte> SymbolPrefix = new byte[] { (byte)'_', (byte)'Z', (byte)'N' };
|
||||
|
||||
private const int ChunkSize = 64 * 1024;
|
||||
private const int OverlapSize = 48;
|
||||
private const long MaxBinarySize = 128L * 1024L * 1024L;
|
||||
|
||||
private static readonly HashSet<string> StandardCrates = new(StringComparer.Ordinal)
|
||||
{
|
||||
"core",
|
||||
"alloc",
|
||||
"std",
|
||||
"panic_unwind",
|
||||
"panic_abort",
|
||||
};
|
||||
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
MatchCasing = MatchCasing.CaseSensitive,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<string>> CandidateCache = new();
|
||||
|
||||
public static IReadOnlyList<RustBinaryInfo> Scan(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Root path is required", nameof(rootPath));
|
||||
}
|
||||
|
||||
var binaries = new List<RustBinaryInfo>();
|
||||
foreach (var path in Directory.EnumerateFiles(rootPath, "*", Enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!IsEligibleBinary(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidates = CandidateCache.GetOrAdd(
|
||||
key,
|
||||
static (_, state) => ExtractCrateNames(state.Path, state.CancellationToken),
|
||||
(Path: path, CancellationToken: cancellationToken));
|
||||
|
||||
binaries.Add(new RustBinaryInfo(path, candidates));
|
||||
}
|
||||
|
||||
return binaries;
|
||||
}
|
||||
|
||||
private static bool IsEligibleBinary(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Length == 0 || info.Length > MaxBinarySize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = info.OpenRead();
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
var read = stream.Read(buffer);
|
||||
if (read != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return buffer.SequenceEqual(ElfMagic.Span);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractCrateNames(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(ChunkSize + OverlapSize);
|
||||
var overlap = new byte[OverlapSize];
|
||||
var overlapLength = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Copy previous overlap to buffer prefix.
|
||||
if (overlapLength > 0)
|
||||
{
|
||||
Array.Copy(overlap, 0, buffer, 0, overlapLength);
|
||||
}
|
||||
|
||||
var read = stream.Read(buffer, overlapLength, ChunkSize);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var span = new ReadOnlySpan<byte>(buffer, 0, overlapLength + read);
|
||||
ScanForSymbols(span, names);
|
||||
|
||||
overlapLength = Math.Min(OverlapSize, span.Length);
|
||||
if (overlapLength > 0)
|
||||
{
|
||||
span[^overlapLength..].CopyTo(overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var ordered = names
|
||||
.Where(static name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(static name => name.Trim())
|
||||
.Where(static name => name.Length > 1)
|
||||
.Where(name => !StandardCrates.Contains(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static void ScanForSymbols(ReadOnlySpan<byte> span, HashSet<string> names)
|
||||
{
|
||||
var prefix = SymbolPrefix.Span;
|
||||
var index = 0;
|
||||
|
||||
while (index < span.Length)
|
||||
{
|
||||
var slice = span[index..];
|
||||
var offset = slice.IndexOf(prefix);
|
||||
if (offset < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
index += offset + prefix.Length;
|
||||
if (index >= span.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var remaining = span[index..];
|
||||
if (!TryParseCrate(remaining, out var crate, out var consumed))
|
||||
{
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(crate))
|
||||
{
|
||||
names.Add(crate);
|
||||
}
|
||||
|
||||
index += Math.Max(consumed, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseCrate(ReadOnlySpan<byte> span, out string? crate, out int consumed)
|
||||
{
|
||||
crate = null;
|
||||
consumed = 0;
|
||||
|
||||
var i = 0;
|
||||
var length = 0;
|
||||
|
||||
while (i < span.Length && span[i] is >= (byte)'0' and <= (byte)'9')
|
||||
{
|
||||
length = (length * 10) + (span[i] - (byte)'0');
|
||||
i++;
|
||||
|
||||
if (length > 256)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (i == 0 || length <= 0 || i + length > span.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
crate = Encoding.ASCII.GetString(span.Slice(i, length));
|
||||
consumed = i + length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates)
|
||||
{
|
||||
public string ComputeSha256()
|
||||
{
|
||||
if (RustFileHashCache.TryGetSha256(AbsolutePath, out var sha256) && !string.IsNullOrEmpty(sha256))
|
||||
{
|
||||
return sha256;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustBinaryClassifier
|
||||
{
|
||||
private static readonly ReadOnlyMemory<byte> ElfMagic = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F' };
|
||||
private static readonly ReadOnlyMemory<byte> SymbolPrefix = new byte[] { (byte)'_', (byte)'Z', (byte)'N' };
|
||||
|
||||
private const int ChunkSize = 64 * 1024;
|
||||
private const int OverlapSize = 48;
|
||||
private const long MaxBinarySize = 128L * 1024L * 1024L;
|
||||
|
||||
private static readonly HashSet<string> StandardCrates = new(StringComparer.Ordinal)
|
||||
{
|
||||
"core",
|
||||
"alloc",
|
||||
"std",
|
||||
"panic_unwind",
|
||||
"panic_abort",
|
||||
};
|
||||
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
MatchCasing = MatchCasing.CaseSensitive,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<string>> CandidateCache = new();
|
||||
|
||||
public static IReadOnlyList<RustBinaryInfo> Scan(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Root path is required", nameof(rootPath));
|
||||
}
|
||||
|
||||
var binaries = new List<RustBinaryInfo>();
|
||||
foreach (var path in Directory.EnumerateFiles(rootPath, "*", Enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!IsEligibleBinary(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidates = CandidateCache.GetOrAdd(
|
||||
key,
|
||||
static (_, state) => ExtractCrateNames(state.Path, state.CancellationToken),
|
||||
(Path: path, CancellationToken: cancellationToken));
|
||||
|
||||
binaries.Add(new RustBinaryInfo(path, candidates));
|
||||
}
|
||||
|
||||
return binaries;
|
||||
}
|
||||
|
||||
private static bool IsEligibleBinary(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Length == 0 || info.Length > MaxBinarySize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = info.OpenRead();
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
var read = stream.Read(buffer);
|
||||
if (read != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return buffer.SequenceEqual(ElfMagic.Span);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractCrateNames(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(ChunkSize + OverlapSize);
|
||||
var overlap = new byte[OverlapSize];
|
||||
var overlapLength = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Copy previous overlap to buffer prefix.
|
||||
if (overlapLength > 0)
|
||||
{
|
||||
Array.Copy(overlap, 0, buffer, 0, overlapLength);
|
||||
}
|
||||
|
||||
var read = stream.Read(buffer, overlapLength, ChunkSize);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var span = new ReadOnlySpan<byte>(buffer, 0, overlapLength + read);
|
||||
ScanForSymbols(span, names);
|
||||
|
||||
overlapLength = Math.Min(OverlapSize, span.Length);
|
||||
if (overlapLength > 0)
|
||||
{
|
||||
span[^overlapLength..].CopyTo(overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var ordered = names
|
||||
.Where(static name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(static name => name.Trim())
|
||||
.Where(static name => name.Length > 1)
|
||||
.Where(name => !StandardCrates.Contains(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static void ScanForSymbols(ReadOnlySpan<byte> span, HashSet<string> names)
|
||||
{
|
||||
var prefix = SymbolPrefix.Span;
|
||||
var index = 0;
|
||||
|
||||
while (index < span.Length)
|
||||
{
|
||||
var slice = span[index..];
|
||||
var offset = slice.IndexOf(prefix);
|
||||
if (offset < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
index += offset + prefix.Length;
|
||||
if (index >= span.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var remaining = span[index..];
|
||||
if (!TryParseCrate(remaining, out var crate, out var consumed))
|
||||
{
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(crate))
|
||||
{
|
||||
names.Add(crate);
|
||||
}
|
||||
|
||||
index += Math.Max(consumed, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseCrate(ReadOnlySpan<byte> span, out string? crate, out int consumed)
|
||||
{
|
||||
crate = null;
|
||||
consumed = 0;
|
||||
|
||||
var i = 0;
|
||||
var length = 0;
|
||||
|
||||
while (i < span.Length && span[i] is >= (byte)'0' and <= (byte)'9')
|
||||
{
|
||||
length = (length * 10) + (span[i] - (byte)'0');
|
||||
i++;
|
||||
|
||||
if (length > 256)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (i == 0 || length <= 0 || i + length > span.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
crate = Encoding.ASCII.GetString(span.Slice(i, length));
|
||||
consumed = i + length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates)
|
||||
{
|
||||
public string ComputeSha256()
|
||||
{
|
||||
if (RustFileHashCache.TryGetSha256(AbsolutePath, out var sha256) && !string.IsNullOrEmpty(sha256))
|
||||
{
|
||||
return sha256;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,312 +1,312 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustCargoLockParser
|
||||
{
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<RustCargoPackage>> Cache = new();
|
||||
|
||||
public static IReadOnlyList<RustCargoPackage> Parse(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArgumentException("Lock path is required", nameof(path));
|
||||
}
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
return Array.Empty<RustCargoPackage>();
|
||||
}
|
||||
|
||||
var packages = Cache.GetOrAdd(
|
||||
key,
|
||||
static (_, state) => ParseInternal(state.Path, state.CancellationToken),
|
||||
(Path: path, CancellationToken: cancellationToken));
|
||||
|
||||
return packages.IsDefaultOrEmpty ? Array.Empty<RustCargoPackage>() : packages;
|
||||
}
|
||||
|
||||
private static ImmutableArray<RustCargoPackage> ParseInternal(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var resultBuilder = ImmutableArray.CreateBuilder<RustCargoPackage>();
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
RustCargoPackageBuilder? packageBuilder = null;
|
||||
string? currentArrayKey = null;
|
||||
var arrayValues = new List<string>();
|
||||
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = reader.ReadLine();
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var trimmed = TrimComments(line.AsSpan());
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsPackageHeader(trimmed))
|
||||
{
|
||||
FlushCurrent(packageBuilder, resultBuilder);
|
||||
packageBuilder = new RustCargoPackageBuilder();
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (packageBuilder is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentArrayKey is not null)
|
||||
{
|
||||
if (trimmed[0] == ']')
|
||||
{
|
||||
builder.SetArray(currentArrayKey, arrayValues);
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractString(trimmed);
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
arrayValues.Add(value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed[0] == '[')
|
||||
{
|
||||
// Entering a new table; finish any pending package and skip section.
|
||||
FlushCurrent(builder, packages);
|
||||
builder = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = trimmed.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = trimmed[..equalsIndex].Trim();
|
||||
var valuePart = trimmed[(equalsIndex + 1)..].Trim();
|
||||
if (valuePart.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valuePart[0] == '[')
|
||||
{
|
||||
currentArrayKey = key.ToString();
|
||||
arrayValues.Clear();
|
||||
|
||||
if (valuePart.Length > 1 && valuePart[^1] == ']')
|
||||
{
|
||||
var inline = valuePart[1..^1].Trim();
|
||||
if (inline.Length > 0)
|
||||
{
|
||||
foreach (var token in SplitInlineArray(inline.ToString()))
|
||||
{
|
||||
var parsedValue = ExtractString(token.AsSpan());
|
||||
if (!string.IsNullOrEmpty(parsedValue))
|
||||
{
|
||||
arrayValues.Add(parsedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packageBuilder.SetArray(currentArrayKey, arrayValues);
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = ExtractString(valuePart);
|
||||
if (parsed is not null)
|
||||
{
|
||||
packageBuilder.SetField(key, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentArrayKey is not null && arrayValues.Count > 0)
|
||||
{
|
||||
packageBuilder?.SetArray(currentArrayKey, arrayValues);
|
||||
}
|
||||
|
||||
FlushCurrent(packageBuilder, resultBuilder);
|
||||
return resultBuilder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line)
|
||||
{
|
||||
var index = line.IndexOf('#');
|
||||
if (index >= 0)
|
||||
{
|
||||
line = line[..index];
|
||||
}
|
||||
|
||||
return line.Trim();
|
||||
}
|
||||
|
||||
private static bool IsPackageHeader(ReadOnlySpan<char> value)
|
||||
=> value.SequenceEqual("[[package]]".AsSpan());
|
||||
|
||||
private static IEnumerable<string> SplitInlineArray(string value)
|
||||
{
|
||||
var start = 0;
|
||||
var inString = false;
|
||||
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var current = value[i];
|
||||
|
||||
if (current == '"')
|
||||
{
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
if (current == ',' && !inString)
|
||||
{
|
||||
var item = value.AsSpan(start, i - start).Trim();
|
||||
if (item.Length > 0)
|
||||
{
|
||||
yield return item.ToString();
|
||||
}
|
||||
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < value.Length)
|
||||
{
|
||||
var item = value.AsSpan(start).Trim();
|
||||
if (item.Length > 0)
|
||||
{
|
||||
yield return item.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractString(ReadOnlySpan<char> value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value[0] == '"' && value[^1] == '"')
|
||||
{
|
||||
var inner = value[1..^1];
|
||||
return inner.ToString();
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed.ToString();
|
||||
}
|
||||
|
||||
private static void FlushCurrent(RustCargoPackageBuilder? packageBuilder, ImmutableArray<RustCargoPackage>.Builder packages)
|
||||
{
|
||||
if (packageBuilder is null || !packageBuilder.HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (packageBuilder.TryBuild(out var package))
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RustCargoPackageBuilder
|
||||
{
|
||||
private readonly SortedSet<string> _dependencies = new(StringComparer.Ordinal);
|
||||
|
||||
private string? _name;
|
||||
private string? _version;
|
||||
private string? _source;
|
||||
private string? _checksum;
|
||||
|
||||
public bool HasData => !string.IsNullOrWhiteSpace(_name);
|
||||
|
||||
public void SetField(ReadOnlySpan<char> key, string value)
|
||||
{
|
||||
if (key.SequenceEqual("name".AsSpan()))
|
||||
{
|
||||
_name ??= value.Trim();
|
||||
}
|
||||
else if (key.SequenceEqual("version".AsSpan()))
|
||||
{
|
||||
_version ??= value.Trim();
|
||||
}
|
||||
else if (key.SequenceEqual("source".AsSpan()))
|
||||
{
|
||||
_source ??= value.Trim();
|
||||
}
|
||||
else if (key.SequenceEqual("checksum".AsSpan()))
|
||||
{
|
||||
_checksum ??= value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetArray(string key, IEnumerable<string> values)
|
||||
{
|
||||
if (!string.Equals(key, "dependencies", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = entry.Trim();
|
||||
if (normalized.Length > 0)
|
||||
{
|
||||
_dependencies.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryBuild(out RustCargoPackage package)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_name))
|
||||
{
|
||||
package = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
package = new RustCargoPackage(
|
||||
_name!,
|
||||
_version ?? string.Empty,
|
||||
_source,
|
||||
_checksum,
|
||||
_dependencies.ToArray());
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustCargoPackage(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Source,
|
||||
string? Checksum,
|
||||
IReadOnlyList<string> Dependencies);
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustCargoLockParser
|
||||
{
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, ImmutableArray<RustCargoPackage>> Cache = new();
|
||||
|
||||
public static IReadOnlyList<RustCargoPackage> Parse(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArgumentException("Lock path is required", nameof(path));
|
||||
}
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
return Array.Empty<RustCargoPackage>();
|
||||
}
|
||||
|
||||
var packages = Cache.GetOrAdd(
|
||||
key,
|
||||
static (_, state) => ParseInternal(state.Path, state.CancellationToken),
|
||||
(Path: path, CancellationToken: cancellationToken));
|
||||
|
||||
return packages.IsDefaultOrEmpty ? Array.Empty<RustCargoPackage>() : packages;
|
||||
}
|
||||
|
||||
private static ImmutableArray<RustCargoPackage> ParseInternal(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var resultBuilder = ImmutableArray.CreateBuilder<RustCargoPackage>();
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
RustCargoPackageBuilder? packageBuilder = null;
|
||||
string? currentArrayKey = null;
|
||||
var arrayValues = new List<string>();
|
||||
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = reader.ReadLine();
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var trimmed = TrimComments(line.AsSpan());
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsPackageHeader(trimmed))
|
||||
{
|
||||
FlushCurrent(packageBuilder, resultBuilder);
|
||||
packageBuilder = new RustCargoPackageBuilder();
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (packageBuilder is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentArrayKey is not null)
|
||||
{
|
||||
if (trimmed[0] == ']')
|
||||
{
|
||||
builder.SetArray(currentArrayKey, arrayValues);
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractString(trimmed);
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
arrayValues.Add(value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed[0] == '[')
|
||||
{
|
||||
// Entering a new table; finish any pending package and skip section.
|
||||
FlushCurrent(builder, packages);
|
||||
builder = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = trimmed.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = trimmed[..equalsIndex].Trim();
|
||||
var valuePart = trimmed[(equalsIndex + 1)..].Trim();
|
||||
if (valuePart.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valuePart[0] == '[')
|
||||
{
|
||||
currentArrayKey = key.ToString();
|
||||
arrayValues.Clear();
|
||||
|
||||
if (valuePart.Length > 1 && valuePart[^1] == ']')
|
||||
{
|
||||
var inline = valuePart[1..^1].Trim();
|
||||
if (inline.Length > 0)
|
||||
{
|
||||
foreach (var token in SplitInlineArray(inline.ToString()))
|
||||
{
|
||||
var parsedValue = ExtractString(token.AsSpan());
|
||||
if (!string.IsNullOrEmpty(parsedValue))
|
||||
{
|
||||
arrayValues.Add(parsedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packageBuilder.SetArray(currentArrayKey, arrayValues);
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = ExtractString(valuePart);
|
||||
if (parsed is not null)
|
||||
{
|
||||
packageBuilder.SetField(key, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentArrayKey is not null && arrayValues.Count > 0)
|
||||
{
|
||||
packageBuilder?.SetArray(currentArrayKey, arrayValues);
|
||||
}
|
||||
|
||||
FlushCurrent(packageBuilder, resultBuilder);
|
||||
return resultBuilder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line)
|
||||
{
|
||||
var index = line.IndexOf('#');
|
||||
if (index >= 0)
|
||||
{
|
||||
line = line[..index];
|
||||
}
|
||||
|
||||
return line.Trim();
|
||||
}
|
||||
|
||||
private static bool IsPackageHeader(ReadOnlySpan<char> value)
|
||||
=> value.SequenceEqual("[[package]]".AsSpan());
|
||||
|
||||
private static IEnumerable<string> SplitInlineArray(string value)
|
||||
{
|
||||
var start = 0;
|
||||
var inString = false;
|
||||
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var current = value[i];
|
||||
|
||||
if (current == '"')
|
||||
{
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
if (current == ',' && !inString)
|
||||
{
|
||||
var item = value.AsSpan(start, i - start).Trim();
|
||||
if (item.Length > 0)
|
||||
{
|
||||
yield return item.ToString();
|
||||
}
|
||||
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < value.Length)
|
||||
{
|
||||
var item = value.AsSpan(start).Trim();
|
||||
if (item.Length > 0)
|
||||
{
|
||||
yield return item.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractString(ReadOnlySpan<char> value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value[0] == '"' && value[^1] == '"')
|
||||
{
|
||||
var inner = value[1..^1];
|
||||
return inner.ToString();
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed.ToString();
|
||||
}
|
||||
|
||||
private static void FlushCurrent(RustCargoPackageBuilder? packageBuilder, ImmutableArray<RustCargoPackage>.Builder packages)
|
||||
{
|
||||
if (packageBuilder is null || !packageBuilder.HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (packageBuilder.TryBuild(out var package))
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RustCargoPackageBuilder
|
||||
{
|
||||
private readonly SortedSet<string> _dependencies = new(StringComparer.Ordinal);
|
||||
|
||||
private string? _name;
|
||||
private string? _version;
|
||||
private string? _source;
|
||||
private string? _checksum;
|
||||
|
||||
public bool HasData => !string.IsNullOrWhiteSpace(_name);
|
||||
|
||||
public void SetField(ReadOnlySpan<char> key, string value)
|
||||
{
|
||||
if (key.SequenceEqual("name".AsSpan()))
|
||||
{
|
||||
_name ??= value.Trim();
|
||||
}
|
||||
else if (key.SequenceEqual("version".AsSpan()))
|
||||
{
|
||||
_version ??= value.Trim();
|
||||
}
|
||||
else if (key.SequenceEqual("source".AsSpan()))
|
||||
{
|
||||
_source ??= value.Trim();
|
||||
}
|
||||
else if (key.SequenceEqual("checksum".AsSpan()))
|
||||
{
|
||||
_checksum ??= value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetArray(string key, IEnumerable<string> values)
|
||||
{
|
||||
if (!string.Equals(key, "dependencies", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = entry.Trim();
|
||||
if (normalized.Length > 0)
|
||||
{
|
||||
_dependencies.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryBuild(out RustCargoPackage package)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_name))
|
||||
{
|
||||
package = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
package = new RustCargoPackage(
|
||||
_name!,
|
||||
_version ?? string.Empty,
|
||||
_source,
|
||||
_checksum,
|
||||
_dependencies.ToArray());
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustCargoPackage(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Source,
|
||||
string? Checksum,
|
||||
IReadOnlyList<string> Dependencies);
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
using System.Security;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal readonly struct RustFileCacheKey : IEquatable<RustFileCacheKey>
|
||||
{
|
||||
private readonly string _normalizedPath;
|
||||
private readonly long _length;
|
||||
private readonly long _lastWriteTicks;
|
||||
|
||||
private RustFileCacheKey(string normalizedPath, long length, long lastWriteTicks)
|
||||
{
|
||||
_normalizedPath = normalizedPath;
|
||||
_length = length;
|
||||
_lastWriteTicks = lastWriteTicks;
|
||||
}
|
||||
|
||||
public static bool TryCreate(string path, out RustFileCacheKey key)
|
||||
{
|
||||
key = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedPath = OperatingSystem.IsWindows()
|
||||
? info.FullName.ToLowerInvariant()
|
||||
: info.FullName;
|
||||
|
||||
key = new RustFileCacheKey(normalizedPath, info.Length, info.LastWriteTimeUtc.Ticks);
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Equals(RustFileCacheKey other)
|
||||
=> _length == other._length
|
||||
&& _lastWriteTicks == other._lastWriteTicks
|
||||
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is RustFileCacheKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(_normalizedPath, _length, _lastWriteTicks);
|
||||
}
|
||||
using System.Security;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal readonly struct RustFileCacheKey : IEquatable<RustFileCacheKey>
|
||||
{
|
||||
private readonly string _normalizedPath;
|
||||
private readonly long _length;
|
||||
private readonly long _lastWriteTicks;
|
||||
|
||||
private RustFileCacheKey(string normalizedPath, long length, long lastWriteTicks)
|
||||
{
|
||||
_normalizedPath = normalizedPath;
|
||||
_length = length;
|
||||
_lastWriteTicks = lastWriteTicks;
|
||||
}
|
||||
|
||||
public static bool TryCreate(string path, out RustFileCacheKey key)
|
||||
{
|
||||
key = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedPath = OperatingSystem.IsWindows()
|
||||
? info.FullName.ToLowerInvariant()
|
||||
: info.FullName;
|
||||
|
||||
key = new RustFileCacheKey(normalizedPath, info.Length, info.LastWriteTimeUtc.Ticks);
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Equals(RustFileCacheKey other)
|
||||
=> _length == other._length
|
||||
&& _lastWriteTicks == other._lastWriteTicks
|
||||
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is RustFileCacheKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(_normalizedPath, _length, _lastWriteTicks);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustFileHashCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, string> Sha256Cache = new();
|
||||
|
||||
public static bool TryGetSha256(string path, out string? sha256)
|
||||
{
|
||||
sha256 = null;
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
sha256 = Sha256Cache.GetOrAdd(key, static (_, state) => ComputeSha256(state), path);
|
||||
return !string.IsNullOrEmpty(sha256);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustFileHashCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, string> Sha256Cache = new();
|
||||
|
||||
public static bool TryGetSha256(string path, out string? sha256)
|
||||
{
|
||||
sha256 = null;
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
sha256 = Sha256Cache.GetOrAdd(key, static (_, state) => ComputeSha256(state), path);
|
||||
return !string.IsNullOrEmpty(sha256);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +1,186 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustFingerprintScanner
|
||||
{
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
MatchCasing = MatchCasing.CaseSensitive,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
private static readonly string FingerprintSegment = $"{Path.DirectorySeparatorChar}.fingerprint{Path.DirectorySeparatorChar}";
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, RustFingerprintRecord?> Cache = new();
|
||||
|
||||
public static IReadOnlyList<RustFingerprintRecord> Scan(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Root path is required", nameof(rootPath));
|
||||
}
|
||||
|
||||
var results = new List<RustFingerprintRecord>();
|
||||
foreach (var path in Directory.EnumerateFiles(rootPath, "*.json", Enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!path.Contains(FingerprintSegment, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var record = Cache.GetOrAdd(
|
||||
key,
|
||||
static (_, state) => ParseFingerprint(state),
|
||||
path);
|
||||
|
||||
if (record is not null)
|
||||
{
|
||||
results.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static RustFingerprintRecord? ParseFingerprint(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
|
||||
var pkgId = TryGetString(root, "pkgid")
|
||||
?? TryGetString(root, "package_id")
|
||||
?? TryGetString(root, "packageId");
|
||||
|
||||
var (name, version, source) = ParseIdentity(pkgId, path);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var profile = TryGetString(root, "profile");
|
||||
var targetKind = TryGetKind(root);
|
||||
|
||||
return new RustFingerprintRecord(
|
||||
Name: name!,
|
||||
Version: version,
|
||||
Source: source,
|
||||
TargetKind: targetKind,
|
||||
Profile: profile,
|
||||
AbsolutePath: path);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? Name, string? Version, string? Source) ParseIdentity(string? pkgId, string filePath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pkgId))
|
||||
{
|
||||
var span = pkgId.AsSpan().Trim();
|
||||
var firstSpace = span.IndexOf(' ');
|
||||
if (firstSpace > 0 && firstSpace < span.Length - 1)
|
||||
{
|
||||
var name = span[..firstSpace].ToString();
|
||||
var remaining = span[(firstSpace + 1)..].Trim();
|
||||
|
||||
var secondSpace = remaining.IndexOf(' ');
|
||||
if (secondSpace < 0)
|
||||
{
|
||||
return (name, remaining.ToString(), null);
|
||||
}
|
||||
|
||||
var version = remaining[..secondSpace].ToString();
|
||||
var potentialSource = remaining[(secondSpace + 1)..].Trim();
|
||||
|
||||
if (potentialSource.Length > 1 && potentialSource[0] == '(' && potentialSource[^1] == ')')
|
||||
{
|
||||
potentialSource = potentialSource[1..^1].Trim();
|
||||
}
|
||||
|
||||
var source = potentialSource.Length == 0 ? null : potentialSource.ToString();
|
||||
return (name, version, source);
|
||||
}
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var crateDirectory = Path.GetFileName(directory);
|
||||
if (string.IsNullOrWhiteSpace(crateDirectory))
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var dashIndex = crateDirectory.LastIndexOf('-');
|
||||
if (dashIndex <= 0)
|
||||
{
|
||||
return (crateDirectory, null, null);
|
||||
}
|
||||
|
||||
var maybeName = crateDirectory[..dashIndex];
|
||||
return (maybeName, null, null);
|
||||
}
|
||||
|
||||
private static string? TryGetKind(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("target_kind", out var array) && array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0)
|
||||
{
|
||||
var first = array[0];
|
||||
if (first.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return first.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("target", out var target) && target.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return target.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustFingerprintRecord(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Source,
|
||||
string? TargetKind,
|
||||
string? Profile,
|
||||
string AbsolutePath);
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustFingerprintScanner
|
||||
{
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
MatchCasing = MatchCasing.CaseSensitive,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
private static readonly string FingerprintSegment = $"{Path.DirectorySeparatorChar}.fingerprint{Path.DirectorySeparatorChar}";
|
||||
private static readonly ConcurrentDictionary<RustFileCacheKey, RustFingerprintRecord?> Cache = new();
|
||||
|
||||
public static IReadOnlyList<RustFingerprintRecord> Scan(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Root path is required", nameof(rootPath));
|
||||
}
|
||||
|
||||
var results = new List<RustFingerprintRecord>();
|
||||
foreach (var path in Directory.EnumerateFiles(rootPath, "*.json", Enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!path.Contains(FingerprintSegment, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!RustFileCacheKey.TryCreate(path, out var key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var record = Cache.GetOrAdd(
|
||||
key,
|
||||
static (_, state) => ParseFingerprint(state),
|
||||
path);
|
||||
|
||||
if (record is not null)
|
||||
{
|
||||
results.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static RustFingerprintRecord? ParseFingerprint(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
|
||||
var pkgId = TryGetString(root, "pkgid")
|
||||
?? TryGetString(root, "package_id")
|
||||
?? TryGetString(root, "packageId");
|
||||
|
||||
var (name, version, source) = ParseIdentity(pkgId, path);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var profile = TryGetString(root, "profile");
|
||||
var targetKind = TryGetKind(root);
|
||||
|
||||
return new RustFingerprintRecord(
|
||||
Name: name!,
|
||||
Version: version,
|
||||
Source: source,
|
||||
TargetKind: targetKind,
|
||||
Profile: profile,
|
||||
AbsolutePath: path);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? Name, string? Version, string? Source) ParseIdentity(string? pkgId, string filePath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pkgId))
|
||||
{
|
||||
var span = pkgId.AsSpan().Trim();
|
||||
var firstSpace = span.IndexOf(' ');
|
||||
if (firstSpace > 0 && firstSpace < span.Length - 1)
|
||||
{
|
||||
var name = span[..firstSpace].ToString();
|
||||
var remaining = span[(firstSpace + 1)..].Trim();
|
||||
|
||||
var secondSpace = remaining.IndexOf(' ');
|
||||
if (secondSpace < 0)
|
||||
{
|
||||
return (name, remaining.ToString(), null);
|
||||
}
|
||||
|
||||
var version = remaining[..secondSpace].ToString();
|
||||
var potentialSource = remaining[(secondSpace + 1)..].Trim();
|
||||
|
||||
if (potentialSource.Length > 1 && potentialSource[0] == '(' && potentialSource[^1] == ')')
|
||||
{
|
||||
potentialSource = potentialSource[1..^1].Trim();
|
||||
}
|
||||
|
||||
var source = potentialSource.Length == 0 ? null : potentialSource.ToString();
|
||||
return (name, version, source);
|
||||
}
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var crateDirectory = Path.GetFileName(directory);
|
||||
if (string.IsNullOrWhiteSpace(crateDirectory))
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var dashIndex = crateDirectory.LastIndexOf('-');
|
||||
if (dashIndex <= 0)
|
||||
{
|
||||
return (crateDirectory, null, null);
|
||||
}
|
||||
|
||||
var maybeName = crateDirectory[..dashIndex];
|
||||
return (maybeName, null, null);
|
||||
}
|
||||
|
||||
private static string? TryGetKind(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("target_kind", out var array) && array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0)
|
||||
{
|
||||
var first = array[0];
|
||||
if (first.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return first.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("target", out var target) && target.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return target.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustFingerprintRecord(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Source,
|
||||
string? TargetKind,
|
||||
string? Profile,
|
||||
string AbsolutePath);
|
||||
|
||||
@@ -1,298 +1,298 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustLicenseScanner
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, RustLicenseIndex> IndexCache = new(StringComparer.Ordinal);
|
||||
|
||||
public static RustLicenseIndex GetOrCreate(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return RustLicenseIndex.Empty;
|
||||
}
|
||||
|
||||
var normalizedRoot = NormalizeRoot(rootPath);
|
||||
return IndexCache.GetOrAdd(
|
||||
normalizedRoot,
|
||||
static (_, state) => BuildIndex(state.RootPath, state.CancellationToken),
|
||||
(RootPath: rootPath, CancellationToken: cancellationToken));
|
||||
}
|
||||
|
||||
private static RustLicenseIndex BuildIndex(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var byName = new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal);
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
MatchCasing = MatchCasing.CaseSensitive,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (var cargoTomlPath in Directory.EnumerateFiles(rootPath, "Cargo.toml", enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (IsUnderTargetDirectory(cargoTomlPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseCargoToml(rootPath, cargoTomlPath, out var info))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedName = RustCrateBuilder.NormalizeName(info.Name);
|
||||
if (!byName.TryGetValue(normalizedName, out var entries))
|
||||
{
|
||||
entries = new List<RustLicenseInfo>();
|
||||
byName[normalizedName] = entries;
|
||||
}
|
||||
|
||||
entries.Add(info);
|
||||
}
|
||||
|
||||
foreach (var entry in byName.Values)
|
||||
{
|
||||
entry.Sort(static (left, right) =>
|
||||
{
|
||||
var versionCompare = string.Compare(left.Version, right.Version, StringComparison.OrdinalIgnoreCase);
|
||||
if (versionCompare != 0)
|
||||
{
|
||||
return versionCompare;
|
||||
}
|
||||
|
||||
return string.Compare(left.CargoTomlRelativePath, right.CargoTomlRelativePath, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
return new RustLicenseIndex(byName);
|
||||
}
|
||||
|
||||
private static bool TryParseCargoToml(string rootPath, string cargoTomlPath, out RustLicenseInfo info)
|
||||
{
|
||||
info = default!;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(cargoTomlPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream, leaveOpen: false);
|
||||
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
string? licenseExpression = null;
|
||||
string? licenseFile = null;
|
||||
var inPackageSection = false;
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
line = StripComment(line).Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
inPackageSection = string.Equals(line, "[package]", StringComparison.OrdinalIgnoreCase);
|
||||
if (!inPackageSection && line.StartsWith("[dependency", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Exiting package section.
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inPackageSection)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "name", out var parsedName))
|
||||
{
|
||||
name ??= parsedName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "version", out var parsedVersion))
|
||||
{
|
||||
version ??= parsedVersion;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "license", out var parsedLicense))
|
||||
{
|
||||
licenseExpression ??= parsedLicense;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "license-file", out var parsedLicenseFile))
|
||||
{
|
||||
licenseFile ??= parsedLicenseFile;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expressions = ImmutableArray<string>.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(licenseExpression))
|
||||
{
|
||||
expressions = ImmutableArray.Create(licenseExpression!);
|
||||
}
|
||||
|
||||
var files = ImmutableArray<RustLicenseFileReference>.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(licenseFile))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(cargoTomlPath) ?? string.Empty;
|
||||
var absolute = Path.GetFullPath(Path.Combine(directory, licenseFile!));
|
||||
if (File.Exists(absolute))
|
||||
{
|
||||
var relative = NormalizeRelativePath(rootPath, absolute);
|
||||
if (RustFileHashCache.TryGetSha256(absolute, out var sha256))
|
||||
{
|
||||
files = ImmutableArray.Create(new RustLicenseFileReference(relative, sha256));
|
||||
}
|
||||
else
|
||||
{
|
||||
files = ImmutableArray.Create(new RustLicenseFileReference(relative, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cargoRelative = NormalizeRelativePath(rootPath, cargoTomlPath);
|
||||
|
||||
info = new RustLicenseInfo(
|
||||
name!.Trim(),
|
||||
string.IsNullOrWhiteSpace(version) ? null : version!.Trim(),
|
||||
expressions,
|
||||
files,
|
||||
cargoRelative);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRoot(string rootPath)
|
||||
{
|
||||
var full = Path.GetFullPath(rootPath);
|
||||
return OperatingSystem.IsWindows()
|
||||
? full.ToLowerInvariant()
|
||||
: full;
|
||||
}
|
||||
|
||||
private static bool TryParseStringAssignment(string line, string key, out string? value)
|
||||
{
|
||||
value = null;
|
||||
|
||||
if (!line.StartsWith(key, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remaining = line[key.Length..].TrimStart();
|
||||
if (remaining.Length == 0 || remaining[0] != '=')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remaining = remaining[1..].TrimStart();
|
||||
if (remaining.Length < 2 || remaining[0] != '"' || remaining[^1] != '"')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = remaining[1..^1];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
var index = line.IndexOf('#');
|
||||
return index < 0 ? line : line[..index];
|
||||
}
|
||||
|
||||
private static bool IsUnderTargetDirectory(string path)
|
||||
{
|
||||
var segment = $"{Path.DirectorySeparatorChar}target{Path.DirectorySeparatorChar}";
|
||||
return path.Contains(segment, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string rootPath, string absolutePath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(rootPath, absolutePath);
|
||||
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
|
||||
{
|
||||
return ".";
|
||||
}
|
||||
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RustLicenseIndex
|
||||
{
|
||||
private readonly Dictionary<string, List<RustLicenseInfo>> _byName;
|
||||
|
||||
public static readonly RustLicenseIndex Empty = new(new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal));
|
||||
|
||||
public RustLicenseIndex(Dictionary<string, List<RustLicenseInfo>> byName)
|
||||
{
|
||||
_byName = byName ?? throw new ArgumentNullException(nameof(byName));
|
||||
}
|
||||
|
||||
public RustLicenseInfo? Find(string crateName, string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(crateName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = RustCrateBuilder.NormalizeName(crateName);
|
||||
if (!_byName.TryGetValue(normalized, out var list) || list.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
var match = list.FirstOrDefault(entry => string.Equals(entry.Version, version, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return list[0];
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustLicenseInfo(
|
||||
string Name,
|
||||
string? Version,
|
||||
ImmutableArray<string> Expressions,
|
||||
ImmutableArray<RustLicenseFileReference> Files,
|
||||
string CargoTomlRelativePath);
|
||||
|
||||
internal sealed record RustLicenseFileReference(string RelativePath, string? Sha256);
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
|
||||
|
||||
internal static class RustLicenseScanner
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, RustLicenseIndex> IndexCache = new(StringComparer.Ordinal);
|
||||
|
||||
public static RustLicenseIndex GetOrCreate(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return RustLicenseIndex.Empty;
|
||||
}
|
||||
|
||||
var normalizedRoot = NormalizeRoot(rootPath);
|
||||
return IndexCache.GetOrAdd(
|
||||
normalizedRoot,
|
||||
static (_, state) => BuildIndex(state.RootPath, state.CancellationToken),
|
||||
(RootPath: rootPath, CancellationToken: cancellationToken));
|
||||
}
|
||||
|
||||
private static RustLicenseIndex BuildIndex(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var byName = new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal);
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
MatchCasing = MatchCasing.CaseSensitive,
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
|
||||
};
|
||||
|
||||
foreach (var cargoTomlPath in Directory.EnumerateFiles(rootPath, "Cargo.toml", enumeration))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (IsUnderTargetDirectory(cargoTomlPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseCargoToml(rootPath, cargoTomlPath, out var info))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedName = RustCrateBuilder.NormalizeName(info.Name);
|
||||
if (!byName.TryGetValue(normalizedName, out var entries))
|
||||
{
|
||||
entries = new List<RustLicenseInfo>();
|
||||
byName[normalizedName] = entries;
|
||||
}
|
||||
|
||||
entries.Add(info);
|
||||
}
|
||||
|
||||
foreach (var entry in byName.Values)
|
||||
{
|
||||
entry.Sort(static (left, right) =>
|
||||
{
|
||||
var versionCompare = string.Compare(left.Version, right.Version, StringComparison.OrdinalIgnoreCase);
|
||||
if (versionCompare != 0)
|
||||
{
|
||||
return versionCompare;
|
||||
}
|
||||
|
||||
return string.Compare(left.CargoTomlRelativePath, right.CargoTomlRelativePath, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
return new RustLicenseIndex(byName);
|
||||
}
|
||||
|
||||
private static bool TryParseCargoToml(string rootPath, string cargoTomlPath, out RustLicenseInfo info)
|
||||
{
|
||||
info = default!;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(cargoTomlPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream, leaveOpen: false);
|
||||
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
string? licenseExpression = null;
|
||||
string? licenseFile = null;
|
||||
var inPackageSection = false;
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
line = StripComment(line).Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
inPackageSection = string.Equals(line, "[package]", StringComparison.OrdinalIgnoreCase);
|
||||
if (!inPackageSection && line.StartsWith("[dependency", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Exiting package section.
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inPackageSection)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "name", out var parsedName))
|
||||
{
|
||||
name ??= parsedName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "version", out var parsedVersion))
|
||||
{
|
||||
version ??= parsedVersion;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "license", out var parsedLicense))
|
||||
{
|
||||
licenseExpression ??= parsedLicense;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStringAssignment(line, "license-file", out var parsedLicenseFile))
|
||||
{
|
||||
licenseFile ??= parsedLicenseFile;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expressions = ImmutableArray<string>.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(licenseExpression))
|
||||
{
|
||||
expressions = ImmutableArray.Create(licenseExpression!);
|
||||
}
|
||||
|
||||
var files = ImmutableArray<RustLicenseFileReference>.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(licenseFile))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(cargoTomlPath) ?? string.Empty;
|
||||
var absolute = Path.GetFullPath(Path.Combine(directory, licenseFile!));
|
||||
if (File.Exists(absolute))
|
||||
{
|
||||
var relative = NormalizeRelativePath(rootPath, absolute);
|
||||
if (RustFileHashCache.TryGetSha256(absolute, out var sha256))
|
||||
{
|
||||
files = ImmutableArray.Create(new RustLicenseFileReference(relative, sha256));
|
||||
}
|
||||
else
|
||||
{
|
||||
files = ImmutableArray.Create(new RustLicenseFileReference(relative, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cargoRelative = NormalizeRelativePath(rootPath, cargoTomlPath);
|
||||
|
||||
info = new RustLicenseInfo(
|
||||
name!.Trim(),
|
||||
string.IsNullOrWhiteSpace(version) ? null : version!.Trim(),
|
||||
expressions,
|
||||
files,
|
||||
cargoRelative);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRoot(string rootPath)
|
||||
{
|
||||
var full = Path.GetFullPath(rootPath);
|
||||
return OperatingSystem.IsWindows()
|
||||
? full.ToLowerInvariant()
|
||||
: full;
|
||||
}
|
||||
|
||||
private static bool TryParseStringAssignment(string line, string key, out string? value)
|
||||
{
|
||||
value = null;
|
||||
|
||||
if (!line.StartsWith(key, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remaining = line[key.Length..].TrimStart();
|
||||
if (remaining.Length == 0 || remaining[0] != '=')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
remaining = remaining[1..].TrimStart();
|
||||
if (remaining.Length < 2 || remaining[0] != '"' || remaining[^1] != '"')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = remaining[1..^1];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
var index = line.IndexOf('#');
|
||||
return index < 0 ? line : line[..index];
|
||||
}
|
||||
|
||||
private static bool IsUnderTargetDirectory(string path)
|
||||
{
|
||||
var segment = $"{Path.DirectorySeparatorChar}target{Path.DirectorySeparatorChar}";
|
||||
return path.Contains(segment, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string rootPath, string absolutePath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(rootPath, absolutePath);
|
||||
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
|
||||
{
|
||||
return ".";
|
||||
}
|
||||
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RustLicenseIndex
|
||||
{
|
||||
private readonly Dictionary<string, List<RustLicenseInfo>> _byName;
|
||||
|
||||
public static readonly RustLicenseIndex Empty = new(new Dictionary<string, List<RustLicenseInfo>>(StringComparer.Ordinal));
|
||||
|
||||
public RustLicenseIndex(Dictionary<string, List<RustLicenseInfo>> byName)
|
||||
{
|
||||
_byName = byName ?? throw new ArgumentNullException(nameof(byName));
|
||||
}
|
||||
|
||||
public RustLicenseInfo? Find(string crateName, string? version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(crateName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = RustCrateBuilder.NormalizeName(crateName);
|
||||
if (!_byName.TryGetValue(normalized, out var list) || list.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
var match = list.FirstOrDefault(entry => string.Equals(entry.Version, version, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return list[0];
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RustLicenseInfo(
|
||||
string Name,
|
||||
string? Version,
|
||||
ImmutableArray<string> Expressions,
|
||||
ImmutableArray<RustLicenseFileReference> Files,
|
||||
string CargoTomlRelativePath);
|
||||
|
||||
internal sealed record RustLicenseFileReference(string RelativePath, string? Sha256);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# StellaOps.Scanner.Queue — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver the scanner job queue backbone defined in `docs/modules/scanner/ARCHITECTURE.md`, providing deterministic, offline-friendly leasing semantics for WebService producers and Worker consumers.
|
||||
|
||||
## Responsibilities
|
||||
- Define queue abstractions with idempotent enqueue tokens, acknowledgement, lease renewal, and claim support.
|
||||
- Ship first-party adapters for Redis Streams and NATS JetStream, respecting offline deployments and allow-listed hosts.
|
||||
- Surface health probes, structured diagnostics, and metrics needed by Scanner WebService/Worker.
|
||||
- Document operational expectations and configuration binding hooks.
|
||||
|
||||
## Interfaces & Dependencies
|
||||
- Consumes shared configuration primitives from `StellaOps.Configuration`.
|
||||
- Exposes dependency injection extensions for `StellaOps.DependencyInjection`.
|
||||
- Targets `net10.0` (preview) and aligns with scanner DTOs once `StellaOps.Scanner.Core` lands.
|
||||
# StellaOps.Scanner.Queue — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver the scanner job queue backbone defined in `docs/modules/scanner/ARCHITECTURE.md`, providing deterministic, offline-friendly leasing semantics for WebService producers and Worker consumers.
|
||||
|
||||
## Responsibilities
|
||||
- Define queue abstractions with idempotent enqueue tokens, acknowledgement, lease renewal, and claim support.
|
||||
- Ship first-party adapters for Redis Streams and NATS JetStream, respecting offline deployments and allow-listed hosts.
|
||||
- Surface health probes, structured diagnostics, and metrics needed by Scanner WebService/Worker.
|
||||
- Document operational expectations and configuration binding hooks.
|
||||
|
||||
## Interfaces & Dependencies
|
||||
- Consumes shared configuration primitives from `StellaOps.Configuration`.
|
||||
- Exposes dependency injection extensions for `StellaOps.DependencyInjection`.
|
||||
- Targets `net10.0` (preview) and aligns with scanner DTOs once `StellaOps.Scanner.Core` lands.
|
||||
|
||||
Reference in New Issue
Block a user