Add comprehensive tests for Go and Python version conflict detection and licensing normalization

- Implemented GoVersionConflictDetectorTests to validate pseudo-version detection, conflict analysis, and conflict retrieval for Go modules.
- Created VersionConflictDetectorTests for Python to assess conflict detection across various version scenarios, including major, minor, and patch differences.
- Added SpdxLicenseNormalizerTests to ensure accurate normalization of SPDX license strings and classifiers.
- Developed VendoredPackageDetectorTests to identify vendored packages and extract embedded packages from Python packages, including handling of vendor directories and known vendored packages.
This commit is contained in:
StellaOps Bot
2025-12-07 01:51:37 +02:00
parent 98934170ca
commit e0f6efecce
66 changed files with 7591 additions and 451 deletions

View File

@@ -0,0 +1,6 @@
{
"lockfileVersion": 1,
"packages": {
"@company/internal-pkg@1.0.0": ["https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz", "sha512-customhash123=="]
}
}

View File

@@ -0,0 +1,2 @@
[install.scopes]
"@company" = "https://npm.company.com/"

View File

@@ -0,0 +1,39 @@
[
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/%40company/internal-pkg@1.0.0",
"purl": "pkg:npm/%40company/internal-pkg@1.0.0",
"name": "@company/internal-pkg",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"customRegistry": "https://npm.company.com/",
"direct": "true",
"integrity": "sha512-customhash123==",
"packageManager": "bun",
"path": "node_modules/@company/internal-pkg",
"resolved": "https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz",
"source": "node_modules"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/@company/internal-pkg/package.json"
},
{
"kind": "metadata",
"source": "integrity",
"locator": "bun.lock",
"value": "sha512-customhash123=="
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz"
}
]
}
]

View File

@@ -0,0 +1,7 @@
{
"name": "custom-registry-fixture",
"version": "1.0.0",
"dependencies": {
"@company/internal-pkg": "^1.0.0"
}
}

View File

@@ -0,0 +1,7 @@
{
"lockfileVersion": 1,
"packages": {
"debug@4.3.4": ["https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", {"ms": "^2.1.2"}],
"ms@2.1.3": ["https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="]
}
}

View File

@@ -0,0 +1,73 @@
[
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/debug@4.3.4",
"purl": "pkg:npm/debug@4.3.4",
"name": "debug",
"version": "4.3.4",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"direct": "true",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"packageManager": "bun",
"path": "node_modules/debug",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"source": "node_modules"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/debug/package.json"
},
{
"kind": "metadata",
"source": "integrity",
"locator": "bun.lock",
"value": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
}
]
},
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/ms@2.1.3",
"purl": "pkg:npm/ms@2.1.3",
"name": "ms",
"version": "2.1.3",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"packageManager": "bun",
"path": "node_modules/ms",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"source": "node_modules"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/ms/package.json"
},
{
"kind": "metadata",
"source": "integrity",
"locator": "bun.lock",
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
}
]
}
]

View File

@@ -0,0 +1,7 @@
{
"name": "deep-tree-fixture",
"version": "1.0.0",
"dependencies": {
"debug": "^4.3.4"
}
}

View File

@@ -0,0 +1,6 @@
{
"lockfileVersion": 1,
"packages": {
"my-git-pkg@1.0.0": ["git+https://github.com/user/my-git-pkg.git#abc123def456", null]
}
}

View File

@@ -0,0 +1,34 @@
[
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/my-git-pkg@1.0.0",
"purl": "pkg:npm/my-git-pkg@1.0.0",
"name": "my-git-pkg",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"direct": "true",
"gitCommit": "abc123def456",
"packageManager": "bun",
"path": "node_modules/my-git-pkg",
"resolved": "git+https://github.com/user/my-git-pkg.git#abc123def456",
"source": "node_modules",
"sourceType": "git",
"specifier": "git+https://github.com/user/my-git-pkg.git#abc123def456"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/my-git-pkg/package.json"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "git+https://github.com/user/my-git-pkg.git#abc123def456"
}
]
}
]

View File

@@ -0,0 +1,7 @@
{
"name": "git-dependencies-fixture",
"version": "1.0.0",
"dependencies": {
"my-git-pkg": "github:user/my-git-pkg#v1.0.0"
}
}

View File

@@ -0,0 +1,6 @@
{
"lockfileVersion": 1,
"packages": {
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi+8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7+D9bF8Q=="]
}
}

View File

@@ -0,0 +1,40 @@
[
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/lodash@4.17.21",
"purl": "pkg:npm/lodash@4.17.21",
"name": "lodash",
"version": "4.17.21",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"direct": "true",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi+8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7+D9bF8Q==",
"packageManager": "bun",
"patchFile": "patches/lodash@4.17.21.patch",
"patched": "true",
"path": "node_modules/lodash",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"source": "node_modules"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/lodash/package.json"
},
{
"kind": "metadata",
"source": "integrity",
"locator": "bun.lock",
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi+8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7+D9bF8Q=="
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
}
]
}
]

View File

@@ -0,0 +1,10 @@
{
"name": "patched-packages-fixture",
"version": "1.0.0",
"dependencies": {
"lodash": "^4.17.21"
},
"patchedDependencies": {
"lodash@4.17.21": "patches/lodash@4.17.21.patch"
}
}

View File

@@ -0,0 +1,5 @@
--- a/index.js
+++ b/index.js
@@ -1 +1 @@
-module.exports = require('./lodash');
+module.exports = require('./lodash-patched');

View File

@@ -0,0 +1,7 @@
{
"lockfileVersion": 1,
"packages": {
"@babel/core@7.24.0": ["https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw=="],
"@types/node@20.11.0": ["https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e+V1B3eXX0WCw+INcAobvUag=="]
}
}

View File

@@ -0,0 +1,74 @@
[
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/%40babel/core@7.24.0",
"purl": "pkg:npm/%40babel/core@7.24.0",
"name": "@babel/core",
"version": "7.24.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"direct": "true",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"packageManager": "bun",
"path": "node_modules/@babel/core",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"source": "node_modules"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/@babel/core/package.json"
},
{
"kind": "metadata",
"source": "integrity",
"locator": "bun.lock",
"value": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw=="
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz"
}
]
},
{
"analyzerId": "bun",
"componentKey": "purl::pkg:npm/%40types/node@20.11.0",
"purl": "pkg:npm/%40types/node@20.11.0",
"name": "@types/node",
"version": "20.11.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"direct": "true",
"integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e+V1B3eXX0WCw+INcAobvUag==",
"packageManager": "bun",
"path": "node_modules/@types/node",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz",
"source": "node_modules"
},
"evidence": [
{
"kind": "file",
"source": "node_modules",
"locator": "node_modules/@types/node/package.json"
},
{
"kind": "metadata",
"source": "integrity",
"locator": "bun.lock",
"value": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e+V1B3eXX0WCw+INcAobvUag=="
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock",
"value": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz"
}
]
}
]

View File

@@ -0,0 +1,8 @@
{
"name": "scoped-packages-fixture",
"version": "1.0.0",
"dependencies": {
"@babel/core": "^7.24.0",
"@types/node": "^20.11.0"
}
}

View File

@@ -0,0 +1,226 @@
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
public sealed class BunConfigHelperTests
{
private readonly string _tempDir;
public BunConfigHelperTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"bun-config-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
#region ParseConfig Tests
[Fact]
public void ParseConfig_MissingFile_ReturnsEmpty()
{
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Null(result.DefaultRegistry);
Assert.Empty(result.ScopeRegistries);
Assert.False(result.HasCustomRegistry);
}
[Fact]
public void ParseConfig_DefaultRegistry_ReturnsUrl()
{
var bunfig = """
[install]
registry = "https://npm.company.com/"
""";
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Equal("https://npm.company.com/", result.DefaultRegistry);
Assert.True(result.HasCustomRegistry);
}
[Fact]
public void ParseConfig_ScopedRegistries_ReturnsMappings()
{
var bunfig = """
[install.scopes]
"@company" = "https://npm.company.com/"
"@internal" = "https://internal.registry.com/"
""";
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Equal(2, result.ScopeRegistries.Count);
Assert.Equal("https://npm.company.com/", result.ScopeRegistries["@company"]);
Assert.Equal("https://internal.registry.com/", result.ScopeRegistries["@internal"]);
Assert.True(result.HasCustomRegistry);
}
[Fact]
public void ParseConfig_InlineTableFormat_ExtractsUrl()
{
var bunfig = """
[install.scopes]
"@company" = { url = "https://npm.company.com/" }
""";
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Single(result.ScopeRegistries);
Assert.Equal("https://npm.company.com/", result.ScopeRegistries["@company"]);
}
[Fact]
public void ParseConfig_Comments_IgnoresComments()
{
var bunfig = """
# This is a comment
[install]
# registry for npm packages
registry = "https://npm.company.com/"
""";
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Equal("https://npm.company.com/", result.DefaultRegistry);
}
[Fact]
public void ParseConfig_EmptyFile_ReturnsEmpty()
{
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), "");
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Null(result.DefaultRegistry);
Assert.Empty(result.ScopeRegistries);
}
[Fact]
public void ParseConfig_BothDefaultAndScoped_ReturnsBoth()
{
var bunfig = """
[install]
registry = "https://default.registry.com/"
[install.scopes]
"@company" = "https://npm.company.com/"
""";
File.WriteAllText(Path.Combine(_tempDir, "bunfig.toml"), bunfig);
var result = BunConfigHelper.ParseConfig(_tempDir);
Assert.Equal("https://default.registry.com/", result.DefaultRegistry);
Assert.Single(result.ScopeRegistries);
Assert.Equal("https://npm.company.com/", result.ScopeRegistries["@company"]);
}
#endregion
#region StripQuotes Tests
[Fact]
public void StripQuotes_DoubleQuotes_RemovesQuotes()
{
var result = BunConfigHelper.StripQuotes("\"hello world\"");
Assert.Equal("hello world", result);
}
[Fact]
public void StripQuotes_SingleQuotes_RemovesQuotes()
{
var result = BunConfigHelper.StripQuotes("'hello world'");
Assert.Equal("hello world", result);
}
[Fact]
public void StripQuotes_NoQuotes_ReturnsUnchanged()
{
var result = BunConfigHelper.StripQuotes("hello world");
Assert.Equal("hello world", result);
}
[Fact]
public void StripQuotes_MismatchedQuotes_ReturnsUnchanged()
{
var result = BunConfigHelper.StripQuotes("\"hello world'");
Assert.Equal("\"hello world'", result);
}
[Fact]
public void StripQuotes_EmptyString_ReturnsEmpty()
{
var result = BunConfigHelper.StripQuotes("");
Assert.Equal("", result);
}
[Fact]
public void StripQuotes_SingleCharacter_ReturnsUnchanged()
{
var result = BunConfigHelper.StripQuotes("a");
Assert.Equal("a", result);
}
#endregion
#region ExtractRegistryUrl Tests
[Fact]
public void ExtractRegistryUrl_DirectUrl_ReturnsUrl()
{
var result = BunConfigHelper.ExtractRegistryUrl("https://npm.company.com/");
Assert.Equal("https://npm.company.com/", result);
}
[Fact]
public void ExtractRegistryUrl_InlineTable_ExtractsUrl()
{
var result = BunConfigHelper.ExtractRegistryUrl("{ url = \"https://npm.company.com/\" }");
Assert.Equal("https://npm.company.com/", result);
}
[Fact]
public void ExtractRegistryUrl_InlineTableSingleQuotes_ExtractsUrl()
{
var result = BunConfigHelper.ExtractRegistryUrl("{ url = 'https://npm.company.com/' }");
Assert.Equal("https://npm.company.com/", result);
}
[Fact]
public void ExtractRegistryUrl_InvalidFormat_ReturnsNull()
{
var result = BunConfigHelper.ExtractRegistryUrl("not-a-url");
Assert.Null(result);
}
[Fact]
public void ExtractRegistryUrl_HttpUrl_ReturnsUrl()
{
var result = BunConfigHelper.ExtractRegistryUrl("http://internal.registry.local/");
Assert.Equal("http://internal.registry.local/", result);
}
#endregion
}

View File

@@ -0,0 +1,455 @@
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
public sealed class BunLockParserTests
{
#region ParsePackageKey Tests
[Fact]
public void ParsePackageKey_ScopedPackage_ReturnsCorrectNameAndVersion()
{
var (name, version) = BunLockParser.ParsePackageKey("@babel/core@7.24.0");
Assert.Equal("@babel/core", name);
Assert.Equal("7.24.0", version);
}
[Fact]
public void ParsePackageKey_UnscopedPackage_ReturnsCorrectNameAndVersion()
{
var (name, version) = BunLockParser.ParsePackageKey("lodash@4.17.21");
Assert.Equal("lodash", name);
Assert.Equal("4.17.21", version);
}
[Fact]
public void ParsePackageKey_InvalidFormat_NoAtSymbol_ReturnsEmpty()
{
var (name, version) = BunLockParser.ParsePackageKey("lodash");
Assert.Empty(name);
Assert.Empty(version);
}
[Fact]
public void ParsePackageKey_InvalidFormat_OnlyScope_ReturnsEmpty()
{
var (name, version) = BunLockParser.ParsePackageKey("@babel");
Assert.Empty(name);
Assert.Empty(version);
}
[Fact]
public void ParsePackageKey_ScopedPackageWithComplexVersion_ReturnsCorrectParts()
{
var (name, version) = BunLockParser.ParsePackageKey("@types/node@20.11.24");
Assert.Equal("@types/node", name);
Assert.Equal("20.11.24", version);
}
[Fact]
public void ParsePackageKey_PreReleaseVersion_ReturnsCorrectParts()
{
var (name, version) = BunLockParser.ParsePackageKey("typescript@5.4.0-beta");
Assert.Equal("typescript", name);
Assert.Equal("5.4.0-beta", version);
}
#endregion
#region ClassifyResolvedUrl Tests
[Fact]
public void ClassifyResolvedUrl_GitPlusHttps_ReturnsGit()
{
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("git+https://github.com/user/repo.git#abc123");
Assert.Equal("git", sourceType);
Assert.Equal("abc123", gitCommit);
Assert.Equal("git+https://github.com/user/repo.git#abc123", specifier);
}
[Fact]
public void ClassifyResolvedUrl_GitPlusSsh_ReturnsGit()
{
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("git+ssh://git@github.com/user/repo.git#v1.0.0");
Assert.Equal("git", sourceType);
Assert.Equal("v1.0.0", gitCommit);
}
[Fact]
public void ClassifyResolvedUrl_GithubShorthand_ReturnsGit()
{
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("github:user/repo#main");
Assert.Equal("git", sourceType);
Assert.Equal("main", gitCommit);
Assert.Equal("github:user/repo#main", specifier);
}
[Fact]
public void ClassifyResolvedUrl_GitlabShorthand_ReturnsGit()
{
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("gitlab:user/repo#v2.0.0");
Assert.Equal("git", sourceType);
}
[Fact]
public void ClassifyResolvedUrl_BitbucketShorthand_ReturnsGit()
{
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("bitbucket:user/repo#feature");
Assert.Equal("git", sourceType);
}
[Fact]
public void ClassifyResolvedUrl_TarballUrl_ReturnsTarball()
{
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("https://example.com/pkg-1.0.0.tgz");
Assert.Equal("tarball", sourceType);
Assert.Null(gitCommit);
Assert.Equal("https://example.com/pkg-1.0.0.tgz", specifier);
}
[Fact]
public void ClassifyResolvedUrl_TarGzUrl_ReturnsTarball()
{
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("https://example.com/pkg-1.0.0.tar.gz");
Assert.Equal("tarball", sourceType);
}
[Fact]
public void ClassifyResolvedUrl_NpmRegistryTgz_ReturnsNpm()
{
var (sourceType, _, _) = BunLockParser.ClassifyResolvedUrl("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz");
Assert.Equal("npm", sourceType);
}
[Fact]
public void ClassifyResolvedUrl_FileProtocol_ReturnsFile()
{
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("file:./local-pkg");
Assert.Equal("file", sourceType);
Assert.Null(gitCommit);
Assert.Equal("file:./local-pkg", specifier);
}
[Fact]
public void ClassifyResolvedUrl_LinkProtocol_ReturnsLink()
{
var (sourceType, _, specifier) = BunLockParser.ClassifyResolvedUrl("link:../packages/shared");
Assert.Equal("link", sourceType);
Assert.Equal("link:../packages/shared", specifier);
}
[Fact]
public void ClassifyResolvedUrl_WorkspaceProtocol_ReturnsWorkspace()
{
var (sourceType, _, specifier) = BunLockParser.ClassifyResolvedUrl("workspace:*");
Assert.Equal("workspace", sourceType);
Assert.Equal("workspace:*", specifier);
}
[Fact]
public void ClassifyResolvedUrl_NpmRegistry_ReturnsNpm()
{
var (sourceType, gitCommit, specifier) = BunLockParser.ClassifyResolvedUrl("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz");
Assert.Equal("npm", sourceType);
Assert.Null(gitCommit);
Assert.Null(specifier);
}
[Fact]
public void ClassifyResolvedUrl_NullOrEmpty_ReturnsNpm()
{
var (sourceType1, _, _) = BunLockParser.ClassifyResolvedUrl(null);
var (sourceType2, _, _) = BunLockParser.ClassifyResolvedUrl("");
Assert.Equal("npm", sourceType1);
Assert.Equal("npm", sourceType2);
}
#endregion
#region ExtractGitCommit Tests
[Fact]
public void ExtractGitCommit_HashFragment_ReturnsCommit()
{
var commit = BunLockParser.ExtractGitCommit("git+https://github.com/user/repo.git#abc123def");
Assert.Equal("abc123def", commit);
}
[Fact]
public void ExtractGitCommit_NoFragment_ReturnsNull()
{
var commit = BunLockParser.ExtractGitCommit("git+https://github.com/user/repo.git");
Assert.Null(commit);
}
[Fact]
public void ExtractGitCommit_EmptyFragment_ReturnsNull()
{
var commit = BunLockParser.ExtractGitCommit("github:user/repo#");
Assert.Null(commit);
}
[Fact]
public void ExtractGitCommit_TagName_ReturnsTag()
{
var commit = BunLockParser.ExtractGitCommit("github:user/repo#v1.2.3");
Assert.Equal("v1.2.3", commit);
}
#endregion
#region Parse Tests
[Fact]
public void Parse_EmptyContent_ReturnsEmptyData()
{
var result = BunLockParser.Parse("");
Assert.Empty(result.Entries);
}
[Fact]
public void Parse_WhitespaceContent_ReturnsEmptyData()
{
var result = BunLockParser.Parse(" \n\t ");
Assert.Empty(result.Entries);
}
[Fact]
public void Parse_MalformedJson_ReturnsEmptyData()
{
var result = BunLockParser.Parse("{ invalid json }");
Assert.Empty(result.Entries);
}
[Fact]
public void Parse_JsoncComments_IgnoresCommentsAndParses()
{
var content = """
{
// This is a comment
"lockfileVersion": 1,
"packages": {
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-abc"]
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
Assert.Equal("lodash", result.Entries[0].Name);
}
[Fact]
public void Parse_TrailingCommas_ParsesSuccessfully()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-abc"],
},
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
}
[Fact]
public void Parse_ArrayFormat_ExtractsResolvedAndIntegrity()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"ms@2.1.3": ["https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "sha512-6FlzubTLZG3J2a"]
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
var entry = result.Entries[0];
Assert.Equal("ms", entry.Name);
Assert.Equal("2.1.3", entry.Version);
Assert.Equal("https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", entry.Resolved);
Assert.Equal("sha512-6FlzubTLZG3J2a", entry.Integrity);
}
[Fact]
public void Parse_ArrayFormat_ExtractsDependencies()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"debug@4.3.4": ["https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "sha512-abc", {"ms": "^2.1.3"}]
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
var entry = result.Entries[0];
Assert.Single(entry.Dependencies);
Assert.Contains("ms", entry.Dependencies);
}
[Fact]
public void Parse_ObjectFormat_ExtractsDevOptionalPeer()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"typescript@5.4.0": {
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.0.tgz",
"integrity": "sha512-abc",
"dev": true,
"optional": true,
"peer": true
}
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
var entry = result.Entries[0];
Assert.Equal("typescript", entry.Name);
Assert.True(entry.IsDev);
Assert.True(entry.IsOptional);
Assert.True(entry.IsPeer);
}
[Fact]
public void Parse_StringFormat_ExtractsResolved()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"lodash@4.17.21": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
var entry = result.Entries[0];
Assert.Equal("lodash", entry.Name);
Assert.Equal("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", entry.Resolved);
Assert.Null(entry.Integrity);
}
[Fact]
public void Parse_SkipsRootProjectEntry()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"": {},
".": {},
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-abc"]
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
Assert.Equal("lodash", result.Entries[0].Name);
}
[Fact]
public void Parse_MultiplePackages_ReturnsAll()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"lodash@4.17.21": ["https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "sha512-lodash"],
"ms@2.1.3": ["https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "sha512-ms"],
"@babel/core@7.24.0": ["https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "sha512-babel"]
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Equal(3, result.Entries.Count);
Assert.Contains(result.Entries, e => e.Name == "lodash");
Assert.Contains(result.Entries, e => e.Name == "ms");
Assert.Contains(result.Entries, e => e.Name == "@babel/core");
}
[Fact]
public void Parse_GitDependency_ClassifiesCorrectly()
{
var content = """
{
"lockfileVersion": 1,
"packages": {
"my-pkg@1.0.0": ["git+https://github.com/user/my-pkg.git#abc123", null]
}
}
""";
var result = BunLockParser.Parse(content);
Assert.Single(result.Entries);
var entry = result.Entries[0];
Assert.Equal("git", entry.SourceType);
Assert.Equal("abc123", entry.GitCommit);
Assert.Equal("git+https://github.com/user/my-pkg.git#abc123", entry.Specifier);
}
[Fact]
public void Parse_NoPackagesProperty_ReturnsEmpty()
{
var content = """
{
"lockfileVersion": 1
}
""";
var result = BunLockParser.Parse(content);
Assert.Empty(result.Entries);
}
#endregion
}

View File

@@ -0,0 +1,325 @@
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
public sealed class BunPackageTests
{
#region Purl Generation Tests
[Fact]
public void FromPackageJson_UnscopedPackage_GeneratesCorrectPurl()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: null);
Assert.Equal("pkg:npm/lodash@4.17.21", package.Purl);
Assert.Equal("purl::pkg:npm/lodash@4.17.21", package.ComponentKey);
}
[Fact]
public void FromPackageJson_ScopedPackage_EncodesAtSymbol()
{
var package = BunPackage.FromPackageJson(
name: "@babel/core",
version: "7.24.0",
logicalPath: "node_modules/@babel/core",
realPath: null,
isPrivate: false,
lockEntry: null);
Assert.Equal("pkg:npm/%40babel/core@7.24.0", package.Purl);
}
[Fact]
public void FromPackageJson_ScopedPackageWithSlash_EncodesCorrectly()
{
var package = BunPackage.FromPackageJson(
name: "@types/node",
version: "20.11.0",
logicalPath: "node_modules/@types/node",
realPath: null,
isPrivate: false,
lockEntry: null);
Assert.Equal("pkg:npm/%40types/node@20.11.0", package.Purl);
}
#endregion
#region CreateMetadata Tests
[Fact]
public void CreateMetadata_BasicPackage_ReturnsRequiredKeys()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: null);
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
Assert.True(metadata.ContainsKey("packageManager"));
Assert.Equal("bun", metadata["packageManager"]);
Assert.True(metadata.ContainsKey("source"));
Assert.Equal("node_modules", metadata["source"]);
Assert.True(metadata.ContainsKey("path"));
Assert.Equal("node_modules/lodash", metadata["path"]);
}
[Fact]
public void CreateMetadata_AllFieldsSet_ReturnsAllKeys()
{
var lockEntry = new BunLockEntry
{
Name = "lodash",
Version = "4.17.21",
Resolved = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
Integrity = "sha512-abc",
IsDev = true,
IsOptional = true,
IsPeer = true,
SourceType = "git",
GitCommit = "abc123",
Specifier = "github:lodash/lodash#abc123"
};
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: "node_modules/.bun/lodash@4.17.21",
isPrivate: true,
lockEntry: lockEntry);
package.IsDirect = true;
package.IsPatched = true;
package.PatchFile = "patches/lodash.patch";
package.CustomRegistry = "https://npm.company.com/";
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
Assert.Equal("true", metadata["dev"]);
Assert.Equal("true", metadata["direct"]);
Assert.Equal("true", metadata["optional"]);
Assert.Equal("true", metadata["peer"]);
Assert.Equal("true", metadata["private"]);
Assert.Equal("true", metadata["patched"]);
Assert.Equal("patches/lodash.patch", metadata["patchFile"]);
Assert.Equal("https://npm.company.com/", metadata["customRegistry"]);
Assert.Equal("abc123", metadata["gitCommit"]);
Assert.Equal("git", metadata["sourceType"]);
Assert.Equal("github:lodash/lodash#abc123", metadata["specifier"]);
}
[Fact]
public void CreateMetadata_SortedAlphabetically()
{
var lockEntry = new BunLockEntry
{
Name = "lodash",
Version = "4.17.21",
Resolved = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
Integrity = "sha512-abc",
IsDev = true
};
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: lockEntry);
package.IsDirect = true;
var keys = package.CreateMetadata().Select(kvp => kvp.Key).ToList();
// Verify keys are sorted alphabetically
var sortedKeys = keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
Assert.Equal(sortedKeys, keys);
}
[Fact]
public void CreateMetadata_NormalizesPathSeparators()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules\\lodash",
realPath: "node_modules\\.bun\\lodash@4.17.21",
isPrivate: false,
lockEntry: null);
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
Assert.Equal("node_modules/lodash", metadata["path"]);
Assert.Equal("node_modules/.bun/lodash@4.17.21", metadata["realPath"]);
}
[Fact]
public void CreateMetadata_MultipleOccurrences_JoinsWithSemicolon()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: null);
package.AddOccurrence("node_modules/lodash");
package.AddOccurrence("packages/app/node_modules/lodash");
var metadata = package.CreateMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
Assert.True(metadata.ContainsKey("occurrences"));
Assert.Contains(";", metadata["occurrences"]);
}
#endregion
#region CreateEvidence Tests
[Fact]
public void CreateEvidence_WithResolvedAndIntegrity_ReturnsAll()
{
var lockEntry = new BunLockEntry
{
Name = "lodash",
Version = "4.17.21",
Resolved = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
Integrity = "sha512-abc123"
};
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: lockEntry);
var evidence = package.CreateEvidence().ToList();
Assert.Equal(3, evidence.Count);
// File evidence
var fileEvidence = evidence.FirstOrDefault(e => e.Kind == LanguageEvidenceKind.File);
Assert.NotNull(fileEvidence);
Assert.Equal("node_modules", fileEvidence.Source);
Assert.Equal("node_modules/lodash/package.json", fileEvidence.Locator);
// Resolved evidence
var resolvedEvidence = evidence.FirstOrDefault(e => e.Source == "resolved");
Assert.NotNull(resolvedEvidence);
Assert.Equal(LanguageEvidenceKind.Metadata, resolvedEvidence.Kind);
Assert.Equal("bun.lock", resolvedEvidence.Locator);
Assert.Equal("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", resolvedEvidence.Value);
// Integrity evidence
var integrityEvidence = evidence.FirstOrDefault(e => e.Source == "integrity");
Assert.NotNull(integrityEvidence);
Assert.Equal("sha512-abc123", integrityEvidence.Value);
}
[Fact]
public void CreateEvidence_NoLockEntry_ReturnsOnlyFileEvidence()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: null);
var evidence = package.CreateEvidence().ToList();
Assert.Single(evidence);
Assert.Equal(LanguageEvidenceKind.File, evidence[0].Kind);
}
#endregion
#region FromLockEntry Tests
[Fact]
public void FromLockEntry_CreatesPackageWithAllProperties()
{
var lockEntry = new BunLockEntry
{
Name = "ms",
Version = "2.1.3",
Resolved = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
Integrity = "sha512-6FlzubTLZG3J2a",
IsDev = true,
IsOptional = false,
IsPeer = false,
SourceType = "npm",
Dependencies = new List<string> { "debug" }
};
var package = BunPackage.FromLockEntry(lockEntry, "bun.lock");
Assert.Equal("ms", package.Name);
Assert.Equal("2.1.3", package.Version);
Assert.Equal("pkg:npm/ms@2.1.3", package.Purl);
Assert.Equal("bun.lock", package.Source);
Assert.Equal("https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", package.Resolved);
Assert.Equal("sha512-6FlzubTLZG3J2a", package.Integrity);
Assert.True(package.IsDev);
Assert.False(package.IsOptional);
Assert.Equal("npm", package.SourceType);
Assert.Contains("debug", package.Dependencies);
}
#endregion
#region AddOccurrence Tests
[Fact]
public void AddOccurrence_AddsDuplicatePath_DoesNotDuplicate()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: null);
package.AddOccurrence("node_modules/lodash");
package.AddOccurrence("node_modules/lodash");
Assert.Single(package.OccurrencePaths);
}
[Fact]
public void AddOccurrence_AddsMultiplePaths_StoresAll()
{
var package = BunPackage.FromPackageJson(
name: "lodash",
version: "4.17.21",
logicalPath: "node_modules/lodash",
realPath: null,
isPrivate: false,
lockEntry: null);
package.AddOccurrence("node_modules/lodash");
package.AddOccurrence("packages/app/node_modules/lodash");
package.AddOccurrence("packages/lib/node_modules/lodash");
Assert.Equal(3, package.OccurrencePaths.Count);
}
#endregion
}

View File

@@ -0,0 +1,284 @@
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Tests.Parsers;
public sealed class BunWorkspaceHelperTests : IDisposable
{
private readonly string _tempDir;
public BunWorkspaceHelperTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"bun-workspace-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
#region ParseWorkspaceInfo Tests
[Fact]
public void ParseWorkspaceInfo_MissingPackageJson_ReturnsEmpty()
{
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Empty(result.WorkspacePatterns);
Assert.Empty(result.WorkspacePaths);
Assert.Empty(result.DirectDependencies);
}
[Fact]
public void ParseWorkspaceInfo_NoWorkspaces_ReturnsEmptyPatterns()
{
var packageJson = """
{
"name": "my-project",
"dependencies": {
"lodash": "^4.17.21"
}
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Empty(result.WorkspacePatterns);
Assert.Single(result.DirectDependencies);
Assert.True(result.DirectDependencies.ContainsKey("lodash"));
}
[Fact]
public void ParseWorkspaceInfo_ArrayFormatWorkspaces_ReturnsPatterns()
{
var packageJson = """
{
"name": "my-project",
"workspaces": ["packages/*", "apps/*"]
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Equal(2, result.WorkspacePatterns.Count);
Assert.Contains("packages/*", result.WorkspacePatterns);
Assert.Contains("apps/*", result.WorkspacePatterns);
}
[Fact]
public void ParseWorkspaceInfo_ObjectFormatWorkspaces_ReturnsPatterns()
{
var packageJson = """
{
"name": "my-project",
"workspaces": {
"packages": ["packages/*", "apps/*"]
}
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Equal(2, result.WorkspacePatterns.Count);
}
[Fact]
public void ParseWorkspaceInfo_ResolvesWorkspacePaths()
{
var packageJson = """
{
"name": "my-project",
"workspaces": ["packages/*"]
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
// Create workspace packages
var pkgADir = Path.Combine(_tempDir, "packages", "pkg-a");
Directory.CreateDirectory(pkgADir);
File.WriteAllText(Path.Combine(pkgADir, "package.json"), """{"name": "@my/pkg-a"}""");
var pkgBDir = Path.Combine(_tempDir, "packages", "pkg-b");
Directory.CreateDirectory(pkgBDir);
File.WriteAllText(Path.Combine(pkgBDir, "package.json"), """{"name": "@my/pkg-b"}""");
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Equal(2, result.WorkspacePaths.Count);
}
[Fact]
public void ParseWorkspaceInfo_ParsesAllDependencyTypes()
{
var packageJson = """
{
"name": "my-project",
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"optionalDependencies": {
"fsevents": "^2.3.0"
},
"peerDependencies": {
"react": "^18.0.0"
}
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Equal(4, result.DirectDependencies.Count);
Assert.True(result.DirectDependencies.ContainsKey("lodash"));
Assert.Equal(BunWorkspaceHelper.DependencyType.Production, result.DirectDependencies["lodash"]);
Assert.True(result.DirectDependencies.ContainsKey("typescript"));
Assert.Equal(BunWorkspaceHelper.DependencyType.Dev, result.DirectDependencies["typescript"]);
Assert.True(result.DirectDependencies.ContainsKey("fsevents"));
Assert.Equal(BunWorkspaceHelper.DependencyType.Optional, result.DirectDependencies["fsevents"]);
Assert.True(result.DirectDependencies.ContainsKey("react"));
Assert.Equal(BunWorkspaceHelper.DependencyType.Peer, result.DirectDependencies["react"]);
}
[Fact]
public void ParseWorkspaceInfo_MergesDependencyFlags()
{
var packageJson = """
{
"name": "my-project",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"lodash": "^4.17.0"
}
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Single(result.DirectDependencies);
var depType = result.DirectDependencies["lodash"];
Assert.True((depType & BunWorkspaceHelper.DependencyType.Production) != 0);
Assert.True((depType & BunWorkspaceHelper.DependencyType.Peer) != 0);
}
[Fact]
public void ParseWorkspaceInfo_ParsesPatchedDependencies()
{
var packageJson = """
{
"name": "my-project",
"patchedDependencies": {
"lodash@4.17.21": "patches/lodash@4.17.21.patch"
}
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Single(result.PatchedDependencies);
Assert.True(result.PatchedDependencies.ContainsKey("lodash"));
Assert.Equal("patches/lodash@4.17.21.patch", result.PatchedDependencies["lodash"]);
}
[Fact]
public void ParseWorkspaceInfo_ScansPatchesDirectory()
{
var packageJson = """
{
"name": "my-project"
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
// Create patches directory with patch files
var patchesDir = Path.Combine(_tempDir, "patches");
Directory.CreateDirectory(patchesDir);
File.WriteAllText(Path.Combine(patchesDir, "lodash@4.17.21.patch"), "diff content");
File.WriteAllText(Path.Combine(patchesDir, "@babel+core@7.24.0.patch"), "diff content");
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Equal(2, result.PatchedDependencies.Count);
Assert.True(result.PatchedDependencies.ContainsKey("lodash"));
Assert.True(result.PatchedDependencies.ContainsKey("@babel+core"));
}
[Fact]
public void ParseWorkspaceInfo_ScansBunPatchesDirectory()
{
var packageJson = """
{
"name": "my-project"
}
""";
File.WriteAllText(Path.Combine(_tempDir, "package.json"), packageJson);
// Create .patches directory (Bun-specific)
var patchesDir = Path.Combine(_tempDir, ".patches");
Directory.CreateDirectory(patchesDir);
File.WriteAllText(Path.Combine(patchesDir, "ms@2.1.3.patch"), "diff content");
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Single(result.PatchedDependencies);
Assert.True(result.PatchedDependencies.ContainsKey("ms"));
}
[Fact]
public void ParseWorkspaceInfo_MalformedJson_ReturnsEmpty()
{
File.WriteAllText(Path.Combine(_tempDir, "package.json"), "{ invalid json }");
var result = BunWorkspaceHelper.ParseWorkspaceInfo(_tempDir);
Assert.Empty(result.DirectDependencies);
}
#endregion
#region IsDirect Tests
[Fact]
public void IsDirect_DirectDependency_ReturnsTrue()
{
var deps = new Dictionary<string, BunWorkspaceHelper.DependencyType>
{
["lodash"] = BunWorkspaceHelper.DependencyType.Production
};
var result = BunWorkspaceHelper.IsDirect("lodash", deps);
Assert.True(result);
}
[Fact]
public void IsDirect_TransitiveDependency_ReturnsFalse()
{
var deps = new Dictionary<string, BunWorkspaceHelper.DependencyType>
{
["lodash"] = BunWorkspaceHelper.DependencyType.Production
};
var result = BunWorkspaceHelper.IsDirect("ms", deps);
Assert.False(result);
}
#endregion
}

View File

@@ -0,0 +1,205 @@
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests.Internal;
public sealed class GoCgoDetectorTests
{
[Fact]
public void AnalyzeGoFileContent_DetectsCgoImport()
{
var content = @"
package main
/*
#include <stdio.h>
*/
import ""C""
func main() {
C.puts(C.CString(""Hello from C""))
}
";
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
Assert.True(result.HasCgoImport);
}
[Fact]
public void AnalyzeGoFileContent_DetectsCgoDirectives()
{
var content = @"
package main
/*
#cgo CFLAGS: -I/usr/local/include
#cgo LDFLAGS: -L/usr/local/lib -lpng
#cgo pkg-config: gtk+-3.0
#include <png.h>
*/
import ""C""
func main() {}
";
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
Assert.True(result.HasCgoImport);
Assert.Equal(3, result.Directives.Count);
var cflags = result.Directives.FirstOrDefault(d => d.Type == "CFLAGS");
Assert.NotNull(cflags);
Assert.Equal("-I/usr/local/include", cflags.Value);
var ldflags = result.Directives.FirstOrDefault(d => d.Type == "LDFLAGS");
Assert.NotNull(ldflags);
Assert.Equal("-L/usr/local/lib -lpng", ldflags.Value);
var pkgconfig = result.Directives.FirstOrDefault(d => d.Type == "pkg-config");
Assert.NotNull(pkgconfig);
Assert.Equal("gtk+-3.0", pkgconfig.Value);
}
[Fact]
public void AnalyzeGoFileContent_DetectsIncludedHeaders()
{
var content = @"
package main
/*
#include <stdio.h>
#include <stdlib.h>
#include ""custom.h""
*/
import ""C""
func main() {}
";
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
Assert.True(result.HasCgoImport);
Assert.Equal(3, result.Headers.Count);
Assert.Contains("stdio.h", result.Headers);
Assert.Contains("stdlib.h", result.Headers);
Assert.Contains("custom.h", result.Headers);
}
[Fact]
public void AnalyzeGoFileContent_DetectsPlatformConstrainedDirectives()
{
var content = @"
package main
/*
#cgo linux LDFLAGS: -lm
#cgo darwin LDFLAGS: -framework CoreFoundation
#cgo windows LDFLAGS: -lkernel32
*/
import ""C""
func main() {}
";
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
Assert.True(result.HasCgoImport);
Assert.Equal(3, result.Directives.Count);
var linuxLdflags = result.Directives.FirstOrDefault(d => d.Constraint?.Contains("linux") == true);
Assert.NotNull(linuxLdflags);
Assert.Equal("-lm", linuxLdflags.Value);
var darwinLdflags = result.Directives.FirstOrDefault(d => d.Constraint?.Contains("darwin") == true);
Assert.NotNull(darwinLdflags);
Assert.Equal("-framework CoreFoundation", darwinLdflags.Value);
}
[Fact]
public void AnalyzeGoFileContent_NoCgoImport_ReturnsEmpty()
{
var content = @"
package main
import ""fmt""
func main() {
fmt.Println(""Hello"")
}
";
var result = GoCgoDetector.AnalyzeGoFileContent(content, "main.go");
Assert.False(result.HasCgoImport);
Assert.Empty(result.Directives);
Assert.Empty(result.Headers);
}
[Fact]
public void ExtractFromBuildSettings_ExtractsCgoEnabled()
{
var settings = new List<KeyValuePair<string, string?>>
{
new("CGO_ENABLED", "1"),
new("CGO_CFLAGS", "-I/usr/include"),
new("CGO_LDFLAGS", "-L/usr/lib -lssl"),
new("CC", "gcc"),
new("CXX", "g++"),
};
var result = GoCgoDetector.ExtractFromBuildSettings(settings);
Assert.True(result.CgoEnabled);
Assert.Equal("-I/usr/include", result.CgoFlags);
Assert.Equal("-L/usr/lib -lssl", result.CgoLdFlags);
Assert.Equal("gcc", result.CCompiler);
Assert.Equal("g++", result.CxxCompiler);
}
[Fact]
public void ExtractFromBuildSettings_CgoDisabled_ReturnsFalse()
{
var settings = new List<KeyValuePair<string, string?>>
{
new("CGO_ENABLED", "0"),
};
var result = GoCgoDetector.ExtractFromBuildSettings(settings);
Assert.False(result.CgoEnabled);
}
[Fact]
public void ExtractFromBuildSettings_NoSettings_ReturnsEmpty()
{
var settings = new List<KeyValuePair<string, string?>>();
var result = GoCgoDetector.ExtractFromBuildSettings(settings);
Assert.False(result.CgoEnabled);
Assert.True(result.IsEmpty);
}
[Fact]
public void CgoAnalysisResult_GetCFlags_CombinesMultipleDirectives()
{
var directives = new[]
{
new GoCgoDetector.CgoDirective("CFLAGS", "-I/usr/include", null, "a.go"),
new GoCgoDetector.CgoDirective("CFLAGS", "-I/usr/local/include", null, "b.go"),
};
var result = new GoCgoDetector.CgoAnalysisResult(
true,
["a.go", "b.go"],
[.. directives],
[],
[]);
var cflags = result.GetCFlags();
Assert.NotNull(cflags);
Assert.Contains("-I/usr/include", cflags);
Assert.Contains("-I/usr/local/include", cflags);
}
}

View File

@@ -0,0 +1,288 @@
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests.Internal;
public sealed class GoLicenseDetectorTests
{
[Fact]
public void AnalyzeLicenseContent_DetectsMitLicense()
{
var content = @"
MIT License
Copyright (c) 2023 Example Corp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the ""Software""), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software...
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("MIT", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsApache2License()
{
var content = @"
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("Apache-2.0", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsBsd3ClauseLicense()
{
var content = @"
BSD 3-Clause License
Copyright (c) 2023, Example Corp
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("BSD-3-Clause", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsGpl3License()
{
var content = @"
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("GPL-3.0-only", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsIscLicense()
{
var content = @"
ISC License
Copyright (c) 2023, Example Corp
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("ISC", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsUnlicense()
{
var content = @"
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("Unlicense", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsSpdxIdentifier()
{
var content = @"
// SPDX-License-Identifier: Apache-2.0
Some license text here...
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("Apache-2.0", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.High, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsDualLicenseSpdx()
{
var content = @"
// SPDX-License-Identifier: MIT OR Apache-2.0
Dual licensed under MIT and Apache 2.0
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("MIT OR Apache-2.0", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.High, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsMpl2License()
{
var content = @"
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("MPL-2.0", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Medium, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_EmptyContent_ReturnsUnknown()
{
var result = GoLicenseDetector.AnalyzeLicenseContent("");
Assert.False(result.IsDetected);
Assert.Null(result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.None, result.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_UnrecognizedContent_ReturnsUnknown()
{
var content = @"
This is some custom proprietary license text that doesn't match any known patterns.
No redistribution allowed without express written permission.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.False(result.IsDetected);
Assert.Null(result.SpdxIdentifier);
}
[Fact]
public void AnalyzeLicenseContent_KeywordFallback_DetectsMit()
{
var content = @"
Some text mentioning MIT but not in the standard format
This project is licensed under MIT terms
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
// Should detect MIT via keyword fallback with low confidence
Assert.True(result.IsDetected);
Assert.Equal("MIT", result.SpdxIdentifier);
Assert.Equal(GoLicenseDetector.LicenseConfidence.Low, result.Confidence);
}
[Fact]
public void LicenseInfo_Unknown_IsDetectedFalse()
{
var info = GoLicenseDetector.LicenseInfo.Unknown;
Assert.False(info.IsDetected);
Assert.Null(info.SpdxIdentifier);
Assert.Null(info.LicenseFile);
Assert.Equal(GoLicenseDetector.LicenseConfidence.None, info.Confidence);
}
[Fact]
public void AnalyzeLicenseContent_DetectsCC0License()
{
var content = @"
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("CC0-1.0", result.SpdxIdentifier);
}
[Fact]
public void AnalyzeLicenseContent_DetectsZlibLicense()
{
var content = @"
zlib License
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("Zlib", result.SpdxIdentifier);
}
[Fact]
public void AnalyzeLicenseContent_DetectsBoostLicense()
{
var content = @"
Boost Software License - Version 1.0 - August 17th, 2003
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the ""Software"") to use, reproduce, display, distribute,
execute, and transmit the Software...
";
var result = GoLicenseDetector.AnalyzeLicenseContent(content);
Assert.True(result.IsDetected);
Assert.Equal("BSL-1.0", result.SpdxIdentifier);
}
}

View File

@@ -0,0 +1,276 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Tests.Internal;
public sealed class GoVersionConflictDetectorTests
{
[Fact]
public void IsPseudoVersion_DetectsPseudoVersions()
{
// Standard pseudo-version formats
Assert.True(GoVersionConflictDetector.IsPseudoVersion("v0.0.0-20210101120000-abcdef123456"));
Assert.True(GoVersionConflictDetector.IsPseudoVersion("v1.2.3-0.20210101120000-abcdef123456"));
Assert.True(GoVersionConflictDetector.IsPseudoVersion("v0.0.0-20230915143052-deadbeef1234"));
}
[Fact]
public void IsPseudoVersion_RejectsRegularVersions()
{
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v1.0.0"));
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v1.2.3"));
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v0.1.0-alpha"));
Assert.False(GoVersionConflictDetector.IsPseudoVersion("v2.0.0-beta.1"));
}
[Fact]
public void Analyze_DetectsPseudoVersionConflict()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v0.0.0-20210101120000-abcdef123456",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
Assert.True(result.HasConflicts);
Assert.Single(result.Conflicts);
Assert.Equal(GoVersionConflictDetector.GoConflictType.PseudoVersion, result.Conflicts[0].ConflictType);
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.Medium, result.Conflicts[0].Severity);
}
[Fact]
public void Analyze_DetectsReplaceOverrideConflict()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v1.0.0",
IsReplaced = true,
ReplacementPath = "github.com/fork/mod",
ReplacementVersion = "v1.1.0",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
Assert.True(result.HasConflicts);
Assert.Single(result.Conflicts);
Assert.Equal(GoVersionConflictDetector.GoConflictType.ReplaceOverride, result.Conflicts[0].ConflictType);
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.Low, result.Conflicts[0].Severity);
}
[Fact]
public void Analyze_DetectsLocalReplacementAsHighSeverity()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v1.0.0",
IsReplaced = true,
ReplacementPath = "../local/mod",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
Assert.True(result.HasConflicts);
Assert.Single(result.Conflicts);
Assert.Equal(GoVersionConflictDetector.GoConflictType.LocalReplacement, result.Conflicts[0].ConflictType);
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.High, result.Conflicts[0].Severity);
}
[Fact]
public void Analyze_DetectsExcludedVersionConflict()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v1.0.0",
},
};
var excludes = new List<GoModParser.GoModExclude>
{
new("github.com/example/mod", "v1.0.0"),
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
excludes,
ImmutableArray<string>.Empty);
Assert.True(result.HasConflicts);
Assert.Single(result.Conflicts);
Assert.Equal(GoVersionConflictDetector.GoConflictType.ExcludedVersion, result.Conflicts[0].ConflictType);
Assert.Equal(GoVersionConflictDetector.GoConflictSeverity.High, result.Conflicts[0].Severity);
}
[Fact]
public void Analyze_DetectsMajorVersionMismatch()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v1.0.0",
},
new()
{
Path = "github.com/example/mod/v2",
Version = "v2.0.0",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
Assert.True(result.HasConflicts);
Assert.Equal(2, result.Conflicts.Length);
Assert.All(result.Conflicts, c =>
Assert.Equal(GoVersionConflictDetector.GoConflictType.MajorVersionMismatch, c.ConflictType));
}
[Fact]
public void Analyze_NoConflicts_ReturnsEmpty()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v1.0.0",
},
new()
{
Path = "github.com/other/lib",
Version = "v2.1.0",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
Assert.False(result.HasConflicts);
Assert.Empty(result.Conflicts);
}
[Fact]
public void GetConflict_ReturnsConflictForModule()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v0.0.0-20210101120000-abcdef123456",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
var conflict = result.GetConflict("github.com/example/mod");
Assert.NotNull(conflict);
Assert.Equal("github.com/example/mod", conflict.ModulePath);
}
[Fact]
public void GetConflict_ReturnsNullForNonConflictingModule()
{
var modules = new List<GoSourceInventory.GoSourceModule>
{
new()
{
Path = "github.com/example/mod",
Version = "v1.0.0",
},
};
var result = GoVersionConflictDetector.Analyze(
modules,
[],
[],
ImmutableArray<string>.Empty);
var conflict = result.GetConflict("github.com/example/mod");
Assert.Null(conflict);
}
[Fact]
public void AnalyzeWorkspace_DetectsCrossModuleConflicts()
{
var inventory1 = new GoSourceInventory.SourceInventoryResult(
"github.com/workspace/mod1",
"1.21",
[
new GoSourceInventory.GoSourceModule
{
Path = "github.com/shared/dep",
Version = "v1.0.0",
},
],
ImmutableArray<string>.Empty,
GoVersionConflictDetector.GoConflictAnalysis.Empty,
GoCgoDetector.CgoAnalysisResult.Empty,
null);
var inventory2 = new GoSourceInventory.SourceInventoryResult(
"github.com/workspace/mod2",
"1.21",
[
new GoSourceInventory.GoSourceModule
{
Path = "github.com/shared/dep",
Version = "v1.2.0",
},
],
ImmutableArray<string>.Empty,
GoVersionConflictDetector.GoConflictAnalysis.Empty,
GoCgoDetector.CgoAnalysisResult.Empty,
null);
var result = GoVersionConflictDetector.AnalyzeWorkspace([inventory1, inventory2]);
Assert.True(result.HasConflicts);
Assert.Single(result.Conflicts);
Assert.Equal(GoVersionConflictDetector.GoConflictType.WorkspaceConflict, result.Conflicts[0].ConflictType);
Assert.Contains("v1.0.0", result.Conflicts[0].RequestedVersions);
Assert.Contains("v1.2.0", result.Conflicts[0].RequestedVersions);
}
}

View File

@@ -9,7 +9,9 @@
"usedByEntrypoint": false,
"metadata": {
"entrypoint": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/index.js",
"path": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg"
"path": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg",
"riskLevel": "production",
"scope": "production"
},
"evidence": [
{

View File

@@ -0,0 +1,322 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Conflicts;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Conflicts;
public class VersionConflictDetectorTests
{
[Fact]
public void Analyze_EmptyList_ReturnsEmpty()
{
var result = VersionConflictDetector.Analyze([]);
Assert.False(result.HasConflicts);
Assert.Equal(0, result.TotalConflicts);
Assert.Equal(ConflictSeverity.None, result.MaxSeverity);
}
[Fact]
public void Analyze_SinglePackage_NoConflict()
{
var packages = new[]
{
CreatePackage("requests", "2.28.0", "/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.False(result.HasConflicts);
}
[Fact]
public void Analyze_SameVersionMultipleLocations_NoConflict()
{
var packages = new[]
{
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
CreatePackage("requests", "2.28.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.False(result.HasConflicts);
}
[Fact]
public void Analyze_DifferentVersions_DetectsConflict()
{
var packages = new[]
{
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
CreatePackage("requests", "2.31.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(1, result.TotalConflicts);
var conflict = result.Conflicts[0];
Assert.Equal("requests", conflict.NormalizedName);
Assert.Equal(2, conflict.UniqueVersions.Count());
Assert.Contains("2.28.0", conflict.UniqueVersions);
Assert.Contains("2.31.0", conflict.UniqueVersions);
}
[Fact]
public void Analyze_MajorVersionDifference_HighSeverity()
{
var packages = new[]
{
CreatePackage("django", "3.2.0", "/env1/site-packages"),
CreatePackage("django", "4.1.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(ConflictSeverity.High, result.MaxSeverity);
Assert.Equal(ConflictSeverity.High, result.Conflicts[0].Severity);
}
[Fact]
public void Analyze_MinorVersionDifference_MediumSeverity()
{
var packages = new[]
{
CreatePackage("flask", "2.1.0", "/env1/site-packages"),
CreatePackage("flask", "2.3.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(ConflictSeverity.Medium, result.MaxSeverity);
}
[Fact]
public void Analyze_PatchVersionDifference_LowSeverity()
{
var packages = new[]
{
CreatePackage("pytest", "7.4.0", "/env1/site-packages"),
CreatePackage("pytest", "7.4.3", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(ConflictSeverity.Low, result.MaxSeverity);
}
[Fact]
public void Analyze_EpochDifference_HighSeverity()
{
var packages = new[]
{
CreatePackage("pytz", "2023.3", "/env1/site-packages"),
CreatePackage("pytz", "1!2023.3", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(ConflictSeverity.High, result.MaxSeverity);
}
[Fact]
public void Analyze_NormalizesPackageNames()
{
var packages = new[]
{
CreatePackage("My-Package", "1.0.0", "/env1/site-packages"),
CreatePackage("my_package", "2.0.0", "/env2/site-packages"),
CreatePackage("my.package", "3.0.0", "/env3/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(1, result.TotalConflicts);
Assert.Equal(3, result.Conflicts[0].UniqueVersions.Count());
}
[Fact]
public void Analyze_PreReleaseVersions_Handled()
{
var packages = new[]
{
CreatePackage("numpy", "1.24.0", "/env1/site-packages"),
CreatePackage("numpy", "1.25.0rc1", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(ConflictSeverity.Medium, result.MaxSeverity); // Minor difference
}
[Fact]
public void Analyze_LocalVersions_Handled()
{
var packages = new[]
{
CreatePackage("mypackage", "1.0.0", "/env1/site-packages"),
CreatePackage("mypackage", "1.0.0+local.build", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
// Local versions are different but same base version
}
[Fact]
public void Analyze_PostReleaseVersions_Handled()
{
var packages = new[]
{
CreatePackage("setuptools", "68.0.0", "/env1/site-packages"),
CreatePackage("setuptools", "68.0.0.post1", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(ConflictSeverity.Low, result.MaxSeverity); // Same micro, just post release
}
[Fact]
public void Analyze_MultipleConflicts_AllDetected()
{
var packages = new[]
{
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
CreatePackage("requests", "2.31.0", "/env2/site-packages"),
CreatePackage("flask", "2.0.0", "/env1/site-packages"),
CreatePackage("flask", "3.0.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.True(result.HasConflicts);
Assert.Equal(2, result.TotalConflicts);
Assert.Equal(ConflictSeverity.High, result.MaxSeverity); // Flask has major diff
}
[Fact]
public void Analyze_PackagesWithoutVersion_Ignored()
{
var packages = new[]
{
CreatePackage("mypackage", null, "/env1/site-packages"),
CreatePackage("mypackage", "1.0.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
Assert.False(result.HasConflicts);
}
[Fact]
public void GetConflict_ReturnsSpecificConflict()
{
var packages = new[]
{
CreatePackage("requests", "2.28.0", "/env1/site-packages"),
CreatePackage("requests", "2.31.0", "/env2/site-packages"),
CreatePackage("flask", "2.0.0", "/env1/site-packages")
};
var conflict = VersionConflictDetector.GetConflict(packages, "requests");
Assert.NotNull(conflict);
Assert.Equal("requests", conflict.NormalizedName);
}
[Fact]
public void GetConflict_NoConflict_ReturnsNull()
{
var packages = new[]
{
CreatePackage("flask", "2.0.0", "/env1/site-packages")
};
var conflict = VersionConflictDetector.GetConflict(packages, "flask");
Assert.Null(conflict);
}
[Fact]
public void Conflict_PurlGeneration_Correct()
{
var packages = new[]
{
CreatePackage("my_package", "1.0.0", "/env1/site-packages"),
CreatePackage("my_package", "2.0.0", "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
var conflict = result.Conflicts[0];
Assert.Equal("pkg:pypi/my-package", conflict.Purl);
}
[Fact]
public void HighSeverityConflicts_FiltersCorrectly()
{
var packages = new[]
{
CreatePackage("django", "3.0.0", "/env1/site-packages"),
CreatePackage("django", "4.0.0", "/env2/site-packages"), // High
CreatePackage("flask", "2.0.0", "/env1/site-packages"),
CreatePackage("flask", "2.0.1", "/env2/site-packages") // Low
};
var result = VersionConflictDetector.Analyze(packages);
Assert.Equal(2, result.TotalConflicts);
Assert.Single(result.HighSeverityConflicts);
Assert.Equal("django", result.HighSeverityConflicts[0].NormalizedName);
}
[Theory]
[InlineData("1.0.0", "2.0.0", 3)] // ConflictSeverity.High
[InlineData("1.0.0", "1.1.0", 2)] // ConflictSeverity.Medium
[InlineData("1.0.0", "1.0.1", 1)] // ConflictSeverity.Low
[InlineData("1!1.0.0", "2!1.0.0", 3)] // ConflictSeverity.High (epoch diff)
[InlineData("1.0.0a1", "1.0.0", 1)] // ConflictSeverity.Low
public void Analyze_VersionPairs_CorrectSeverity(string v1, string v2, int expectedSeverity)
{
var packages = new[]
{
CreatePackage("testpkg", v1, "/env1/site-packages"),
CreatePackage("testpkg", v2, "/env2/site-packages")
};
var result = VersionConflictDetector.Analyze(packages);
var expected = (ConflictSeverity)expectedSeverity;
Assert.True(result.HasConflicts);
Assert.Equal(expected, result.Conflicts[0].Severity);
}
private static PythonPackageInfo CreatePackage(string name, string? version, string location)
{
return new PythonPackageInfo(
Name: name,
Version: version,
Kind: PythonPackageKind.Wheel,
Location: location,
MetadataPath: $"{location}/{name.ToLowerInvariant()}-{version ?? "0.0.0"}.dist-info",
TopLevelModules: [],
Dependencies: [],
Extras: [],
RecordFiles: [],
InstallerTool: "pip",
EditableTarget: null,
IsDirectDependency: true,
Confidence: PythonPackageConfidence.High);
}
}

View File

@@ -0,0 +1,263 @@
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Licensing;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Licensing;
public class SpdxLicenseNormalizerTests
{
[Theory]
[InlineData("MIT", "MIT")]
[InlineData("MIT License", "MIT")]
[InlineData("The MIT License", "MIT")]
[InlineData("mit", "MIT")]
public void NormalizeFromString_MitVariations(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("Apache", "Apache-2.0")]
[InlineData("Apache 2.0", "Apache-2.0")]
[InlineData("Apache-2.0", "Apache-2.0")]
[InlineData("Apache License 2.0", "Apache-2.0")]
[InlineData("Apache License, Version 2.0", "Apache-2.0")]
[InlineData("Apache Software License", "Apache-2.0")]
[InlineData("ASL 2.0", "Apache-2.0")]
public void NormalizeFromString_ApacheVariations(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("BSD", "BSD-3-Clause")]
[InlineData("BSD License", "BSD-3-Clause")]
[InlineData("BSD-2-Clause", "BSD-2-Clause")]
[InlineData("BSD-3-Clause", "BSD-3-Clause")]
[InlineData("BSD 2-Clause", "BSD-2-Clause")]
[InlineData("BSD 3-Clause", "BSD-3-Clause")]
[InlineData("Simplified BSD", "BSD-2-Clause")]
[InlineData("New BSD", "BSD-3-Clause")]
public void NormalizeFromString_BsdVariations(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("GPL", "GPL-3.0-only")]
[InlineData("GPLv2", "GPL-2.0-only")]
[InlineData("GPLv3", "GPL-3.0-only")]
[InlineData("GPL-2.0", "GPL-2.0-only")]
[InlineData("GPL-3.0", "GPL-3.0-only")]
[InlineData("GPL-2.0-only", "GPL-2.0-only")]
[InlineData("GPL-3.0-only", "GPL-3.0-only")]
[InlineData("GPL-2.0-or-later", "GPL-2.0-or-later")]
[InlineData("GPL-3.0-or-later", "GPL-3.0-or-later")]
public void NormalizeFromString_GplVariations(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("LGPL", "LGPL-3.0-only")]
[InlineData("LGPLv2", "LGPL-2.0-only")]
[InlineData("LGPL-2.0", "LGPL-2.0-only")]
[InlineData("LGPL-2.1", "LGPL-2.1-only")]
[InlineData("LGPLv3", "LGPL-3.0-only")]
[InlineData("LGPL-3.0", "LGPL-3.0-only")]
public void NormalizeFromString_LgplVariations(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("MPL", "MPL-2.0")]
[InlineData("MPL-2.0", "MPL-2.0")]
[InlineData("Mozilla Public License 2.0", "MPL-2.0")]
public void NormalizeFromString_MplVariations(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("ISC", "ISC")]
[InlineData("ISC License", "ISC")]
[InlineData("Unlicense", "Unlicense")]
[InlineData("The Unlicense", "Unlicense")]
[InlineData("CC0", "CC0-1.0")]
[InlineData("CC0-1.0", "CC0-1.0")]
[InlineData("Public Domain", "Unlicense")]
[InlineData("Zlib", "Zlib")]
[InlineData("PSF", "PSF-2.0")]
public void NormalizeFromString_OtherLicenses(string input, string expected)
{
var result = SpdxLicenseNormalizer.NormalizeFromString(input);
Assert.Equal(expected, result);
}
[Fact]
public void NormalizeFromClassifiers_MitClassifier()
{
var classifiers = new[] { "License :: OSI Approved :: MIT License" };
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
Assert.Equal("MIT", result);
}
[Fact]
public void NormalizeFromClassifiers_ApacheClassifier()
{
var classifiers = new[] { "License :: OSI Approved :: Apache Software License" };
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
Assert.Equal("Apache-2.0", result);
}
[Fact]
public void NormalizeFromClassifiers_MultipleLicenses_ReturnsOrExpression()
{
var classifiers = new[]
{
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License"
};
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
// Should return "Apache-2.0 OR MIT" (alphabetically sorted)
Assert.Equal("Apache-2.0 OR MIT", result);
}
[Fact]
public void NormalizeFromClassifiers_NoLicenseClassifiers_ReturnsNull()
{
var classifiers = new[]
{
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3"
};
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
Assert.Null(result);
}
[Fact]
public void Normalize_Pep639Expression_TakesPrecedence()
{
var result = SpdxLicenseNormalizer.Normalize(
license: "MIT",
classifiers: new[] { "License :: OSI Approved :: Apache Software License" },
licenseExpression: "GPL-3.0-only");
Assert.Equal("GPL-3.0-only", result);
}
[Fact]
public void Normalize_ClassifiersOverLicenseString()
{
var result = SpdxLicenseNormalizer.Normalize(
license: "Some custom license",
classifiers: new[] { "License :: OSI Approved :: MIT License" });
Assert.Equal("MIT", result);
}
[Fact]
public void Normalize_FallsBackToLicenseString()
{
var result = SpdxLicenseNormalizer.Normalize(
license: "MIT",
classifiers: new[] { "Programming Language :: Python :: 3" });
Assert.Equal("MIT", result);
}
[Fact]
public void Normalize_AllNull_ReturnsNull()
{
var result = SpdxLicenseNormalizer.Normalize(null, null, null);
Assert.Null(result);
}
[Theory]
[InlineData("License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "GPL-3.0-only")]
[InlineData("License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", "GPL-2.0-or-later")]
[InlineData("License :: OSI Approved :: BSD License", "BSD-3-Clause")]
public void NormalizeFromClassifiers_GplBsdClassifiers(string classifier, string expected)
{
var classifiers = new[] { classifier };
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
Assert.Equal(expected, result);
}
[Fact]
public void NormalizeFromString_UnknownLicense_ReturnsLicenseRef()
{
var result = SpdxLicenseNormalizer.NormalizeFromString("Custom License v1.0");
Assert.StartsWith("LicenseRef-", result);
}
[Fact]
public void NormalizeFromString_Empty_ReturnsNull()
{
Assert.Null(SpdxLicenseNormalizer.NormalizeFromString(""));
Assert.Null(SpdxLicenseNormalizer.NormalizeFromString(" "));
Assert.Null(SpdxLicenseNormalizer.NormalizeFromString(null!));
}
[Fact]
public void NormalizeFromString_VeryLongText_ReturnsNull()
{
var longText = new string('x', 200);
var result = SpdxLicenseNormalizer.NormalizeFromString(longText);
Assert.Null(result);
}
[Fact]
public void NormalizeFromString_Url_ReturnsNull()
{
var result = SpdxLicenseNormalizer.NormalizeFromString("https://opensource.org/licenses/MIT");
Assert.Null(result);
}
[Theory]
[InlineData("GPL-2.0-only AND MIT", true)]
[InlineData("Apache-2.0 OR MIT", true)]
[InlineData("MIT WITH Classpath-exception-2.0", true)]
[InlineData("Apache-2.0", true)]
public void Normalize_ValidSpdxExpression_AcceptedAsPep639(string expression, bool isValid)
{
if (isValid)
{
var result = SpdxLicenseNormalizer.Normalize(null, null, expression);
Assert.Equal(expression, result);
}
}
[Fact]
public void NormalizeFromClassifiers_DuplicateLicenses_Deduplicated()
{
var classifiers = new[]
{
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: MIT License", // duplicate
"Programming Language :: Python :: 3"
};
var result = SpdxLicenseNormalizer.NormalizeFromClassifiers(classifiers);
Assert.Equal("MIT", result);
}
[Fact]
public void NormalizeFromString_PatternMatch_GplWithVersion()
{
var result = SpdxLicenseNormalizer.NormalizeFromString("GNU General Public License v3");
Assert.Equal("GPL-3.0-only", result);
}
[Fact]
public void NormalizeFromString_PatternMatch_BsdWithClauses()
{
var result = SpdxLicenseNormalizer.NormalizeFromString("BSD 2-Clause License");
Assert.NotNull(result);
Assert.Contains("BSD", result);
}
}

View File

@@ -0,0 +1,316 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Vendoring;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Vendoring;
public class VendoredPackageDetectorTests
{
[Fact]
public async Task Analyze_NoVendorDirectory_ReturnsNotVendored()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/module.py");
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.False(result.IsVendored);
Assert.Equal(VendoringConfidence.None, result.Confidence);
Assert.Empty(result.EmbeddedPackages);
}
[Fact]
public async Task Analyze_WithVendorDirectory_DetectsVendoring()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/_vendor/__init__.py",
"/site-packages/mypackage/_vendor/urllib3/__init__.py",
"/site-packages/mypackage/_vendor/urllib3/connection.py");
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.True(result.Confidence >= VendoringConfidence.Low);
Assert.Contains(result.Markers, m => m.StartsWith("vendor-directory:"));
}
[Fact]
public async Task Analyze_ExtractsEmbeddedPackages()
{
var vfs = CreateMockVfs(
"/site-packages/pip/__init__.py",
"/site-packages/pip/_vendor/__init__.py",
"/site-packages/pip/_vendor/certifi/__init__.py",
"/site-packages/pip/_vendor/urllib3/__init__.py",
"/site-packages/pip/_vendor/requests/__init__.py");
var package = CreatePackage("pip", "23.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.True(result.EmbeddedCount >= 3);
var embeddedNames = result.EmbeddedPackages.Select(p => p.Name).ToList();
Assert.Contains("certifi", embeddedNames);
Assert.Contains("urllib3", embeddedNames);
Assert.Contains("requests", embeddedNames);
}
[Fact]
public async Task Analyze_KnownVendoredPackage_HighConfidence()
{
var vfs = CreateMockVfs(
"/site-packages/pip/__init__.py",
"/site-packages/pip/_vendor/__init__.py",
"/site-packages/pip/_vendor/certifi/__init__.py",
"/site-packages/pip/_vendor/urllib3/__init__.py",
"/site-packages/pip/_vendor/packaging/__init__.py");
var package = CreatePackage("pip", "23.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.Contains("known-vendored-package", result.Markers);
}
[Fact]
public async Task Analyze_DetectsThirdPartyPattern()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/third_party/__init__.py",
"/site-packages/mypackage/third_party/six.py");
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.Contains(result.Markers, m => m.Contains("third_party"));
}
[Fact]
public async Task Analyze_DetectsExternPattern()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/extern/__init__.py",
"/site-packages/mypackage/extern/six.py");
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.Contains(result.Markers, m => m.Contains("extern"));
}
[Fact]
public async Task Analyze_SkipsInternalDirectories()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/_vendor/__init__.py",
"/site-packages/mypackage/_vendor/urllib3/__init__.py",
"/site-packages/mypackage/_vendor/__pycache__/cached.pyc",
"/site-packages/mypackage/_vendor/.hidden/__init__.py");
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
var embeddedNames = result.EmbeddedPackages.Select(p => p.Name).ToList();
Assert.DoesNotContain("__pycache__", embeddedNames);
Assert.DoesNotContain(".hidden", embeddedNames);
}
[Fact]
public async Task EmbeddedPackage_GeneratesCorrectPurl()
{
var vfs = CreateMockVfs(
"/site-packages/pip/__init__.py",
"/site-packages/pip/_vendor/__init__.py",
"/site-packages/pip/_vendor/urllib3/__init__.py");
var package = CreatePackage("pip", "23.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
var urllib3 = result.EmbeddedPackages.FirstOrDefault(p => p.Name == "urllib3");
Assert.NotNull(urllib3);
Assert.StartsWith("pkg:pypi/urllib3", urllib3.Purl);
}
[Fact]
public async Task EmbeddedPackage_GeneratesQualifiedName()
{
var vfs = CreateMockVfs(
"/site-packages/pip/__init__.py",
"/site-packages/pip/_vendor/__init__.py",
"/site-packages/pip/_vendor/urllib3/__init__.py");
var package = CreatePackage("pip", "23.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
var urllib3 = result.EmbeddedPackages.FirstOrDefault(p => p.Name == "urllib3");
Assert.NotNull(urllib3);
Assert.Equal("pip._vendor.urllib3", urllib3.QualifiedName);
}
[Fact]
public async Task Analyze_RecordEntriesWithVendor_AddsMarker()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/_vendor/__init__.py",
"/site-packages/mypackage/_vendor/six.py");
var recordFiles = ImmutableArray.Create(
new PythonRecordEntry("mypackage/__init__.py", "sha256=abc", 100),
new PythonRecordEntry("mypackage/_vendor/__init__.py", "sha256=def", 50),
new PythonRecordEntry("mypackage/_vendor/six.py", "sha256=ghi", 500));
var package = CreatePackageWithRecords("mypackage", "1.0.0", "/site-packages", recordFiles);
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.Contains("record-vendor-entries", result.Markers);
}
[Fact]
public async Task Analyze_MultipleVendorDirectories_DetectsAll()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/_vendor/__init__.py",
"/site-packages/mypackage/_vendor/six.py",
"/site-packages/mypackage/extern/__init__.py",
"/site-packages/mypackage/extern/toml.py");
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
Assert.True(result.VendorPaths.Length >= 2);
}
[Fact]
public async Task Analyze_SingleFileModule_Detected()
{
var vfs = CreateMockVfs(
"/site-packages/mypackage/__init__.py",
"/site-packages/mypackage/_vendor/__init__.py",
"/site-packages/mypackage/_vendor/six.py"); // Single file module
var package = CreatePackage("mypackage", "1.0.0", "/site-packages");
var result = await VendoredPackageDetector.AnalyzeAsync(vfs, package, TestContext.Current.CancellationToken);
Assert.True(result.IsVendored);
var embeddedNames = result.EmbeddedPackages.Select(p => p.Name).ToList();
Assert.Contains("six", embeddedNames);
}
[Fact]
public void NotVendored_ReturnsEmptyAnalysis()
{
var analysis = VendoringAnalysis.NotVendored("testpkg");
Assert.Equal("testpkg", analysis.PackageName);
Assert.False(analysis.IsVendored);
Assert.Equal(VendoringConfidence.None, analysis.Confidence);
Assert.Empty(analysis.Markers);
Assert.Empty(analysis.EmbeddedPackages);
Assert.Empty(analysis.VendorPaths);
}
[Fact]
public void GetEmbeddedPackageList_FormatsCorrectly()
{
var analysis = new VendoringAnalysis(
"pip",
true,
VendoringConfidence.High,
["vendor-directory:_vendor"],
[
new EmbeddedPackage("urllib3", "2.0.0", null, "/pip/_vendor/urllib3", "pip"),
new EmbeddedPackage("certifi", "2023.7.22", null, "/pip/_vendor/certifi", "pip")
],
["/pip/_vendor"]);
var list = analysis.GetEmbeddedPackageList();
Assert.Contains("certifi@2023.7.22", list);
Assert.Contains("urllib3@2.0.0", list);
}
private static PythonVirtualFileSystem CreateMockVfs(params string[] filePaths)
{
var builder = PythonVirtualFileSystem.CreateBuilder();
foreach (var path in filePaths)
{
// Normalize path - remove leading slash for the builder
var normalizedPath = path.TrimStart('/');
builder.AddFile(
normalizedPath,
path, // Use original as absolute path for testing
PythonFileSource.SitePackages,
size: 100);
}
return builder.Build();
}
private static PythonPackageInfo CreatePackage(string name, string version, string location)
{
return new PythonPackageInfo(
Name: name,
Version: version,
Kind: PythonPackageKind.Wheel,
Location: location.TrimStart('/'),
MetadataPath: $"{location.TrimStart('/')}/{name.ToLowerInvariant()}-{version}.dist-info",
TopLevelModules: [name.ToLowerInvariant()],
Dependencies: [],
Extras: [],
RecordFiles: [],
InstallerTool: "pip",
EditableTarget: null,
IsDirectDependency: true,
Confidence: PythonPackageConfidence.High);
}
private static PythonPackageInfo CreatePackageWithRecords(
string name,
string version,
string location,
ImmutableArray<PythonRecordEntry> records)
{
return new PythonPackageInfo(
Name: name,
Version: version,
Kind: PythonPackageKind.Wheel,
Location: location.TrimStart('/'),
MetadataPath: $"{location.TrimStart('/')}/{name.ToLowerInvariant()}-{version}.dist-info",
TopLevelModules: [name.ToLowerInvariant()],
Dependencies: [],
Extras: [],
RecordFiles: records,
InstallerTool: "pip",
EditableTarget: null,
IsDirectDependency: true,
Confidence: PythonPackageConfidence.High);
}
}

View File

@@ -321,4 +321,106 @@ public class ElfDynamicSectionParserTests
buffer[offset + bytes.Length] = 0; // null terminator
return bytes.Length;
}
[Fact]
public void ParsesElfWithVersionNeeds()
{
// Test that version needs (GLIBC_2.17, etc.) are properly extracted
var buffer = new byte[4096];
SetupElf64Header(buffer, littleEndian: true);
// String table at offset 0x400
var strtab = 0x400;
var libcOffset = 1; // "libc.so.6"
var glibc217Offset = libcOffset + WriteString(buffer, strtab + libcOffset, "libc.so.6") + 1;
var glibc228Offset = glibc217Offset + WriteString(buffer, strtab + glibc217Offset, "GLIBC_2.17") + 1;
var strtabSize = glibc228Offset + WriteString(buffer, strtab + glibc228Offset, "GLIBC_2.28") + 1;
// Section headers at offset 0x800
var shoff = 0x800;
var shentsize = 64;
var shnum = 3; // null + .dynstr + .gnu.version_r
BitConverter.GetBytes((ulong)shoff).CopyTo(buffer, 40);
BitConverter.GetBytes((ushort)shentsize).CopyTo(buffer, 58);
BitConverter.GetBytes((ushort)shnum).CopyTo(buffer, 60);
// Section header 0: null
// Section header 1: .dynstr
var sh1 = shoff + shentsize;
BitConverter.GetBytes((uint)3).CopyTo(buffer, sh1 + 4); // sh_type = SHT_STRTAB
BitConverter.GetBytes((ulong)0x400).CopyTo(buffer, sh1 + 16); // sh_addr
BitConverter.GetBytes((ulong)strtab).CopyTo(buffer, sh1 + 24); // sh_offset
BitConverter.GetBytes((ulong)strtabSize).CopyTo(buffer, sh1 + 32); // sh_size
// Section header 2: .gnu.version_r (SHT_GNU_verneed = 0x6ffffffe)
var verneedFileOffset = 0x600;
var sh2 = shoff + shentsize * 2;
BitConverter.GetBytes((uint)0x6ffffffe).CopyTo(buffer, sh2 + 4); // sh_type = SHT_GNU_verneed
BitConverter.GetBytes((ulong)0x600).CopyTo(buffer, sh2 + 16); // sh_addr (vaddr)
BitConverter.GetBytes((ulong)verneedFileOffset).CopyTo(buffer, sh2 + 24); // sh_offset
// Version needs section at offset 0x600
// Verneed entry for libc.so.6 with two version requirements
// Elf64_Verneed: vn_version(2), vn_cnt(2), vn_file(4), vn_aux(4), vn_next(4)
var verneedOffset = verneedFileOffset;
BitConverter.GetBytes((ushort)1).CopyTo(buffer, verneedOffset); // vn_version = 1
BitConverter.GetBytes((ushort)2).CopyTo(buffer, verneedOffset + 2); // vn_cnt = 2 aux entries
BitConverter.GetBytes((uint)libcOffset).CopyTo(buffer, verneedOffset + 4); // vn_file -> "libc.so.6"
BitConverter.GetBytes((uint)16).CopyTo(buffer, verneedOffset + 8); // vn_aux = 16 (offset to first aux)
BitConverter.GetBytes((uint)0).CopyTo(buffer, verneedOffset + 12); // vn_next = 0 (last entry)
// Vernaux entries
// Elf64_Vernaux: vna_hash(4), vna_flags(2), vna_other(2), vna_name(4), vna_next(4)
var aux1Offset = verneedOffset + 16;
BitConverter.GetBytes((uint)0x0d696910).CopyTo(buffer, aux1Offset); // vna_hash for GLIBC_2.17
BitConverter.GetBytes((ushort)0).CopyTo(buffer, aux1Offset + 4); // vna_flags
BitConverter.GetBytes((ushort)2).CopyTo(buffer, aux1Offset + 6); // vna_other
BitConverter.GetBytes((uint)glibc217Offset).CopyTo(buffer, aux1Offset + 8); // vna_name -> "GLIBC_2.17"
BitConverter.GetBytes((uint)16).CopyTo(buffer, aux1Offset + 12); // vna_next = 16 (offset to next aux)
var aux2Offset = aux1Offset + 16;
BitConverter.GetBytes((uint)0x09691974).CopyTo(buffer, aux2Offset); // vna_hash for GLIBC_2.28
BitConverter.GetBytes((ushort)0).CopyTo(buffer, aux2Offset + 4);
BitConverter.GetBytes((ushort)3).CopyTo(buffer, aux2Offset + 6);
BitConverter.GetBytes((uint)glibc228Offset).CopyTo(buffer, aux2Offset + 8); // vna_name -> "GLIBC_2.28"
BitConverter.GetBytes((uint)0).CopyTo(buffer, aux2Offset + 12); // vna_next = 0 (last aux)
// Dynamic section at offset 0x200
var dynOffset = 0x200;
var dynEntrySize = 16;
var dynIndex = 0;
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 5, 0x400); // DT_STRTAB
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 10, (ulong)strtabSize); // DT_STRSZ
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 1, (ulong)libcOffset); // DT_NEEDED -> libc.so.6
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 0x6ffffffe, 0x600); // DT_VERNEED (vaddr)
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex++, 0x6fffffff, 1); // DT_VERNEEDNUM = 1
WriteDynEntry64(buffer, dynOffset + dynEntrySize * dynIndex, 0, 0); // DT_NULL
var dynSize = dynEntrySize * (dynIndex + 1);
// Program header
var phoff = 0x40;
var phentsize = 56;
var phnum = 1;
BitConverter.GetBytes((ulong)phoff).CopyTo(buffer, 32);
BitConverter.GetBytes((ushort)phentsize).CopyTo(buffer, 54);
BitConverter.GetBytes((ushort)phnum).CopyTo(buffer, 56);
BitConverter.GetBytes((uint)2).CopyTo(buffer, phoff); // PT_DYNAMIC
BitConverter.GetBytes((ulong)dynOffset).CopyTo(buffer, phoff + 8);
BitConverter.GetBytes((ulong)dynSize).CopyTo(buffer, phoff + 32);
using var stream = new MemoryStream(buffer);
var result = ElfDynamicSectionParser.TryParse(stream, out var info);
result.Should().BeTrue();
info.Dependencies.Should().HaveCount(1);
info.Dependencies[0].Soname.Should().Be("libc.so.6");
info.Dependencies[0].VersionNeeds.Should().HaveCount(2);
info.Dependencies[0].VersionNeeds.Should().Contain(v => v.Version == "GLIBC_2.17");
info.Dependencies[0].VersionNeeds.Should().Contain(v => v.Version == "GLIBC_2.28");
}
}

View File

@@ -275,4 +275,226 @@ public class PeImportParserTests
""";
Encoding.UTF8.GetBytes(manifestXml).CopyTo(buffer, 0x1000);
}
[Fact]
public void ParsesPe32PlusWithImportThunks()
{
// Test that 64-bit PE files correctly parse 8-byte import thunks
var buffer = new byte[8192];
SetupPe32PlusHeaderWithImports(buffer);
using var stream = new MemoryStream(buffer);
var result = PeImportParser.TryParse(stream, out var info);
result.Should().BeTrue();
info.Is64Bit.Should().BeTrue();
info.Dependencies.Should().HaveCount(1);
info.Dependencies[0].DllName.Should().Be("kernel32.dll");
// Verify function names are parsed correctly with 8-byte thunks
info.Dependencies[0].ImportedFunctions.Should().Contain("GetProcAddress");
info.Dependencies[0].ImportedFunctions.Should().Contain("LoadLibraryA");
}
private static void SetupPe32PlusHeaderWithImports(byte[] buffer)
{
// DOS header
buffer[0] = (byte)'M';
buffer[1] = (byte)'Z';
BitConverter.GetBytes(0x80).CopyTo(buffer, 0x3C); // e_lfanew
// PE signature
var peOffset = 0x80;
buffer[peOffset] = (byte)'P';
buffer[peOffset + 1] = (byte)'E';
// COFF header
BitConverter.GetBytes((ushort)0x8664).CopyTo(buffer, peOffset + 4); // Machine = x86_64
BitConverter.GetBytes((ushort)2).CopyTo(buffer, peOffset + 6); // NumberOfSections
BitConverter.GetBytes((ushort)0xF0).CopyTo(buffer, peOffset + 20); // SizeOfOptionalHeader (PE32+)
// Optional header (PE32+)
var optHeaderOffset = peOffset + 24;
BitConverter.GetBytes((ushort)0x20b).CopyTo(buffer, optHeaderOffset); // Magic = PE32+
BitConverter.GetBytes((ushort)PeSubsystem.WindowsConsole).CopyTo(buffer, optHeaderOffset + 68); // Subsystem
BitConverter.GetBytes((uint)16).CopyTo(buffer, optHeaderOffset + 108); // NumberOfRvaAndSizes
// Data directory - Import Directory (entry 1)
var dataDirOffset = optHeaderOffset + 112;
BitConverter.GetBytes((uint)0x2000).CopyTo(buffer, dataDirOffset + 8); // Import Directory RVA
BitConverter.GetBytes((uint)40).CopyTo(buffer, dataDirOffset + 12); // Import Directory Size
// Section headers
var sectionOffset = optHeaderOffset + 0xF0;
// .text section
".text\0\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8); // VirtualSize
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 12); // VirtualAddress
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 16); // SizeOfRawData
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 20); // PointerToRawData
// .idata section
sectionOffset += 40;
".idata\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8); // VirtualSize
BitConverter.GetBytes((uint)0x2000).CopyTo(buffer, sectionOffset + 12); // VirtualAddress
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 16); // SizeOfRawData
BitConverter.GetBytes((uint)0x400).CopyTo(buffer, sectionOffset + 20); // PointerToRawData
// Import descriptor at file offset 0x400 (RVA 0x2000)
var importOffset = 0x400;
BitConverter.GetBytes((uint)0x2080).CopyTo(buffer, importOffset); // OriginalFirstThunk RVA
BitConverter.GetBytes((uint)0).CopyTo(buffer, importOffset + 4); // TimeDateStamp
BitConverter.GetBytes((uint)0).CopyTo(buffer, importOffset + 8); // ForwarderChain
BitConverter.GetBytes((uint)0x2100).CopyTo(buffer, importOffset + 12); // Name RVA
BitConverter.GetBytes((uint)0x2080).CopyTo(buffer, importOffset + 16); // FirstThunk
// Null terminator for import directory
// (already zero at importOffset + 20)
// Import Lookup Table (ILT) / Import Name Table at RVA 0x2080 -> file offset 0x480
// PE32+ uses 8-byte entries!
var iltOffset = 0x480;
// Entry 1: Import by name, hint-name RVA = 0x2120
BitConverter.GetBytes((ulong)0x2120).CopyTo(buffer, iltOffset);
// Entry 2: Import by name, hint-name RVA = 0x2140
BitConverter.GetBytes((ulong)0x2140).CopyTo(buffer, iltOffset + 8);
// Null terminator (8 bytes of zero)
// (already zero)
// DLL name at RVA 0x2100 -> file offset 0x500
"kernel32.dll\0"u8.CopyTo(buffer.AsSpan(0x500));
// Hint-Name table entries
// Entry 1 at RVA 0x2120 -> file offset 0x520
BitConverter.GetBytes((ushort)0).CopyTo(buffer, 0x520); // Hint
"GetProcAddress\0"u8.CopyTo(buffer.AsSpan(0x522));
// Entry 2 at RVA 0x2140 -> file offset 0x540
BitConverter.GetBytes((ushort)0).CopyTo(buffer, 0x540); // Hint
"LoadLibraryA\0"u8.CopyTo(buffer.AsSpan(0x542));
}
[Fact]
public void ParsesPeWithEmbeddedResourceManifest()
{
// Test that manifest is properly extracted from PE resources
var buffer = new byte[16384];
SetupPe32HeaderWithResourceManifest(buffer);
using var stream = new MemoryStream(buffer);
var result = PeImportParser.TryParse(stream, out var info);
result.Should().BeTrue();
info.SxsDependencies.Should().HaveCountGreaterOrEqualTo(1);
info.SxsDependencies.Should().Contain(d => d.Name == "Microsoft.VC90.CRT");
}
private static void SetupPe32HeaderWithResourceManifest(byte[] buffer)
{
// DOS header
buffer[0] = (byte)'M';
buffer[1] = (byte)'Z';
BitConverter.GetBytes(0x80).CopyTo(buffer, 0x3C);
// PE signature
var peOffset = 0x80;
buffer[peOffset] = (byte)'P';
buffer[peOffset + 1] = (byte)'E';
// COFF header
BitConverter.GetBytes((ushort)0x8664).CopyTo(buffer, peOffset + 4);
BitConverter.GetBytes((ushort)2).CopyTo(buffer, peOffset + 6); // 2 sections
BitConverter.GetBytes((ushort)0xE0).CopyTo(buffer, peOffset + 20);
// Optional header (PE32)
var optHeaderOffset = peOffset + 24;
BitConverter.GetBytes((ushort)0x10b).CopyTo(buffer, optHeaderOffset);
BitConverter.GetBytes((ushort)PeSubsystem.WindowsConsole).CopyTo(buffer, optHeaderOffset + 68);
BitConverter.GetBytes((uint)16).CopyTo(buffer, optHeaderOffset + 92);
// Data directory - Resource Directory (entry 2)
var dataDirOffset = optHeaderOffset + 96;
BitConverter.GetBytes((uint)0x3000).CopyTo(buffer, dataDirOffset + 16); // Resource Directory RVA
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, dataDirOffset + 20); // Resource Directory Size
// Section headers
var sectionOffset = optHeaderOffset + 0xE0;
// .text section
".text\0\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8);
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 12);
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 16);
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, sectionOffset + 20);
// .rsrc section
sectionOffset += 40;
".rsrc\0\0\0"u8.CopyTo(buffer.AsSpan(sectionOffset));
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 8);
BitConverter.GetBytes((uint)0x3000).CopyTo(buffer, sectionOffset + 12); // VirtualAddress
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 16);
BitConverter.GetBytes((uint)0x1000).CopyTo(buffer, sectionOffset + 20); // PointerToRawData
// Resource directory at file offset 0x1000 (RVA 0x3000)
var rsrcBase = 0x1000;
// Root directory (Type level)
BitConverter.GetBytes((uint)0).CopyTo(buffer, rsrcBase); // Characteristics
BitConverter.GetBytes((uint)0).CopyTo(buffer, rsrcBase + 4); // TimeDateStamp
BitConverter.GetBytes((ushort)0).CopyTo(buffer, rsrcBase + 8); // MajorVersion
BitConverter.GetBytes((ushort)0).CopyTo(buffer, rsrcBase + 10); // MinorVersion
BitConverter.GetBytes((ushort)0).CopyTo(buffer, rsrcBase + 12); // NumberOfNamedEntries
BitConverter.GetBytes((ushort)1).CopyTo(buffer, rsrcBase + 14); // NumberOfIdEntries
// Entry for RT_MANIFEST (ID=24) at offset 16
BitConverter.GetBytes((uint)24).CopyTo(buffer, rsrcBase + 16); // ID = RT_MANIFEST
BitConverter.GetBytes((uint)(0x80000000 | 0x30)).CopyTo(buffer, rsrcBase + 20); // Offset to subdirectory (high bit set)
// Name/ID subdirectory at offset 0x30
var nameDir = rsrcBase + 0x30;
BitConverter.GetBytes((uint)0).CopyTo(buffer, nameDir);
BitConverter.GetBytes((uint)0).CopyTo(buffer, nameDir + 4);
BitConverter.GetBytes((ushort)0).CopyTo(buffer, nameDir + 8);
BitConverter.GetBytes((ushort)0).CopyTo(buffer, nameDir + 10);
BitConverter.GetBytes((ushort)0).CopyTo(buffer, nameDir + 12);
BitConverter.GetBytes((ushort)1).CopyTo(buffer, nameDir + 14);
// Entry for ID=1 (application manifest)
BitConverter.GetBytes((uint)1).CopyTo(buffer, nameDir + 16);
BitConverter.GetBytes((uint)(0x80000000 | 0x50)).CopyTo(buffer, nameDir + 20); // Offset to language subdirectory
// Language subdirectory at offset 0x50
var langDir = rsrcBase + 0x50;
BitConverter.GetBytes((uint)0).CopyTo(buffer, langDir);
BitConverter.GetBytes((uint)0).CopyTo(buffer, langDir + 4);
BitConverter.GetBytes((ushort)0).CopyTo(buffer, langDir + 8);
BitConverter.GetBytes((ushort)0).CopyTo(buffer, langDir + 10);
BitConverter.GetBytes((ushort)0).CopyTo(buffer, langDir + 12);
BitConverter.GetBytes((ushort)1).CopyTo(buffer, langDir + 14);
// Entry for language (e.g., 0x409 = English US)
BitConverter.GetBytes((uint)0x409).CopyTo(buffer, langDir + 16);
BitConverter.GetBytes((uint)0x70).CopyTo(buffer, langDir + 20); // Offset to data entry (no high bit = data entry)
// Data entry at offset 0x70
var dataEntry = rsrcBase + 0x70;
BitConverter.GetBytes((uint)0x3100).CopyTo(buffer, dataEntry); // Data RVA
BitConverter.GetBytes((uint)0x200).CopyTo(buffer, dataEntry + 4); // Data Size
BitConverter.GetBytes((uint)0).CopyTo(buffer, dataEntry + 8); // CodePage
BitConverter.GetBytes((uint)0).CopyTo(buffer, dataEntry + 12); // Reserved
// Manifest data at RVA 0x3100 -> file offset 0x1100
var manifestXml = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.VC90.CRT" version="9.0.21022.8" processorArchitecture="amd64" publicKeyToken="1fc8b3b9a1e18e3b"/>
</dependentAssembly>
</dependency>
</assembly>
""";
Encoding.UTF8.GetBytes(manifestXml).CopyTo(buffer, 0x1100);
}
}