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 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>(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(); 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.Empty; if (!string.IsNullOrWhiteSpace(licenseExpression)) { expressions = ImmutableArray.Create(licenseExpression!); } var files = ImmutableArray.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> _byName; public static readonly RustLicenseIndex Empty = new(new Dictionary>(StringComparer.Ordinal)); public RustLicenseIndex(Dictionary> 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 Expressions, ImmutableArray Files, string CargoTomlRelativePath); internal sealed record RustLicenseFileReference(string RelativePath, string? Sha256);