using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.Scanner.Analyzers.Lang.Python; using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests; public sealed class PythonLanguageAnalyzerTests { [Fact] public async Task SimpleVenvFixtureProducesDeterministicOutputAsync() { var cancellationToken = TestContext.Current.CancellationToken; var fixturePath = TestPaths.ResolveFixture("lang", "python", "simple-venv"); var goldenPath = Path.Combine(fixturePath, "expected.json"); var usageHints = new LanguageUsageHints(new[] { Path.Combine(fixturePath, "bin", "simple-tool") }); var analyzers = new ILanguageAnalyzer[] { new PythonLanguageAnalyzer() }; await LanguageAnalyzerTestHarness.AssertDeterministicAsync( fixturePath, goldenPath, analyzers, cancellationToken, usageHints); } [Fact] public async Task PipCacheFixtureProducesDeterministicOutputAsync() { var cancellationToken = TestContext.Current.CancellationToken; var fixturePath = TestPaths.ResolveFixture("lang", "python", "pip-cache"); var goldenPath = Path.Combine(fixturePath, "expected.json"); var usageHints = new LanguageUsageHints(new[] { Path.Combine(fixturePath, "lib", "python3.11", "site-packages", "cache_pkg-1.2.3.data", "scripts", "cache-tool") }); var analyzers = new ILanguageAnalyzer[] { new PythonLanguageAnalyzer() }; await LanguageAnalyzerTestHarness.AssertDeterministicAsync( fixturePath, goldenPath, analyzers, cancellationToken, usageHints); } [Fact] public async Task LayeredEditableFixtureMergesAcrossLayersAsync() { var cancellationToken = TestContext.Current.CancellationToken; var fixturePath = TestPaths.ResolveFixture("lang", "python", "layered-editable"); var goldenPath = Path.Combine(fixturePath, "expected.json"); var usageHints = new LanguageUsageHints(new[] { Path.Combine(fixturePath, "layer1", "usr", "bin", "layered-cli") }); var analyzers = new ILanguageAnalyzer[] { new PythonLanguageAnalyzer() }; await LanguageAnalyzerTestHarness.AssertDeterministicAsync( fixturePath, goldenPath, analyzers, cancellationToken, usageHints); } [Fact] public async Task LockfileCollectorEmitsDeclaredOnlyComponentsAsync() { var cancellationToken = TestContext.Current.CancellationToken; var fixturePath = CreateTemporaryWorkspace(); try { await CreatePythonPackageAsync(fixturePath, "locked", "1.0.0", cancellationToken); await CreatePythonPackageAsync(fixturePath, "runtime-only", "2.0.0", cancellationToken); var requirementsPath = Path.Combine(fixturePath, "requirements.txt"); var requirements = new StringBuilder() .AppendLine("locked==1.0.0") .AppendLine("declared-only==3.0.0") .ToString(); await File.WriteAllTextAsync(requirementsPath, requirements, cancellationToken); var analyzers = new ILanguageAnalyzer[] { new PythonLanguageAnalyzer() }; var json = await LanguageAnalyzerTestHarness.RunToJsonAsync( fixturePath, analyzers, cancellationToken).ConfigureAwait(false); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.True(ComponentHasMetadata(root, "declared-only", "declaredOnly", "true")); Assert.True(ComponentHasMetadata(root, "declared-only", "lockSource", "requirements.txt")); Assert.True(ComponentHasMetadata(root, "locked", "lockSource", "requirements.txt")); Assert.True(ComponentHasMetadata(root, "runtime-only", "lockMissing", "true")); } finally { Directory.Delete(fixturePath, recursive: true); } } private static async Task CreatePythonPackageAsync(string root, string name, string version, CancellationToken cancellationToken) { var sitePackages = Path.Combine(root, "lib", "python3.11", "site-packages"); Directory.CreateDirectory(sitePackages); var packageDir = Path.Combine(sitePackages, name); Directory.CreateDirectory(packageDir); var modulePath = Path.Combine(packageDir, "__init__.py"); var moduleContent = $"__version__ = \"{version}\"{Environment.NewLine}"; await File.WriteAllTextAsync(modulePath, moduleContent, cancellationToken); var distInfoDir = Path.Combine(sitePackages, $"{name}-{version}.dist-info"); Directory.CreateDirectory(distInfoDir); var metadataPath = Path.Combine(distInfoDir, "METADATA"); var metadataContent = $"Metadata-Version: 2.1{Environment.NewLine}Name: {name}{Environment.NewLine}Version: {version}{Environment.NewLine}"; await File.WriteAllTextAsync(metadataPath, metadataContent, cancellationToken); var wheelPath = Path.Combine(distInfoDir, "WHEEL"); await File.WriteAllTextAsync(wheelPath, "Wheel-Version: 1.0", cancellationToken); var entryPointsPath = Path.Combine(distInfoDir, "entry_points.txt"); await File.WriteAllTextAsync(entryPointsPath, string.Empty, cancellationToken); var recordPath = Path.Combine(distInfoDir, "RECORD"); var recordContent = new StringBuilder() .AppendLine($"{name}/__init__.py,sha256={ComputeSha256Base64(modulePath)},{new FileInfo(modulePath).Length}") .AppendLine($"{name}-{version}.dist-info/METADATA,sha256={ComputeSha256Base64(metadataPath)},{new FileInfo(metadataPath).Length}") .AppendLine($"{name}-{version}.dist-info/RECORD,,") .AppendLine($"{name}-{version}.dist-info/WHEEL,sha256={ComputeSha256Base64(wheelPath)},{new FileInfo(wheelPath).Length}") .AppendLine($"{name}-{version}.dist-info/entry_points.txt,sha256={ComputeSha256Base64(entryPointsPath)},{new FileInfo(entryPointsPath).Length}") .ToString(); await File.WriteAllTextAsync(recordPath, recordContent, cancellationToken); } private static bool ComponentHasMetadata(JsonElement root, string componentName, string key, string? expectedValue) { foreach (var component in root.EnumerateArray()) { if (!component.TryGetProperty("name", out var nameElement) || !string.Equals(nameElement.GetString(), componentName, StringComparison.OrdinalIgnoreCase)) { continue; } if (!component.TryGetProperty("metadata", out var metadataElement) || metadataElement.ValueKind != JsonValueKind.Object) { continue; } if (!metadataElement.TryGetProperty(key, out var valueElement)) { continue; } var actual = valueElement.GetString(); if (string.Equals(actual, expectedValue, StringComparison.Ordinal)) { return true; } } return false; } private static string ComputeSha256Base64(string path) { using var sha = SHA256.Create(); using var stream = File.OpenRead(path); var hash = sha.ComputeHash(stream); return Convert.ToBase64String(hash); } private static string CreateTemporaryWorkspace() { var path = Path.Combine(Path.GetTempPath(), $"stellaops-python-{Guid.NewGuid():N}"); Directory.CreateDirectory(path); return path; } }