diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f18c91fd..a14bda37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,14 +193,17 @@ jobs: - name: Smoke test - package quiet run: | - dotnet-inspect package System.Text.Json 10.0.0 -v:q > smoke-package.md + # Pipe through cat to work around a .NET 11 preview SDK regression + # where redirecting an AOT binary's stdout directly to a file + # ('> file') truncates the output to ~256 bytes. Piping is fine. + dotnet-inspect package System.Text.Json 10.0.0 -v:q | cat > smoke-package.md cat smoke-package.md grep -q "System.Text.Json" smoke-package.md grep -q "Library" smoke-package.md - name: Smoke test - package normal run: | - dotnet-inspect package System.Text.Json 10.0.0 -v:n > smoke-package-normal.md + dotnet-inspect package System.Text.Json 10.0.0 -v:n | cat > smoke-package-normal.md cat smoke-package-normal.md grep -q "## Package" smoke-package-normal.md grep -q "## Package Dependencies" smoke-package-normal.md @@ -208,26 +211,26 @@ jobs: - name: Smoke test - type command (oneline default) run: | - dotnet-inspect type --package System.Text.Json@10.0.0 -t 5 > smoke-type.txt + dotnet-inspect type --package System.Text.Json@10.0.0 -t 5 | cat > smoke-type.txt cat smoke-type.txt grep -q "JsonSerializer" smoke-type.txt - name: Smoke test - member command (oneline default) run: | - dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -m 5 > smoke-member.txt + dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -m 5 | cat > smoke-member.txt cat smoke-member.txt grep -q "Serialize" smoke-member.txt grep -q "Deserialize" smoke-member.txt - name: Smoke test - type command (markdown) run: | - dotnet-inspect type --package System.Text.Json@10.0.0 -v:m -t 5 > smoke-type.md + dotnet-inspect type --package System.Text.Json@10.0.0 -v:m -t 5 | cat > smoke-type.md cat smoke-type.md grep -q "JsonSerializer" smoke-type.md - name: Smoke test - member command (markdown) run: | - dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -v:m -m 5 > smoke-member.md + dotnet-inspect member JsonSerializer --package System.Text.Json@10.0.0 -v:m -m 5 | cat > smoke-member.md cat smoke-member.md grep -q "JsonSerializer" smoke-member.md grep -q "Serialize" smoke-member.md diff --git a/docs/lap-around.md b/docs/lap-around.md index 18f87653..c4893128 100644 --- a/docs/lap-around.md +++ b/docs/lap-around.md @@ -489,7 +489,7 @@ Name: System.Text.Json | Version: 10.0.3 | TFM: .NETCoreApp,Version=v10.0 | Arch | Company | Microsoft Corporation | | Copyright | © Microsoft Corporation. All rights reserved. | | Signed | Yes | -| Public Key Token | 1d05d9bed22b38cb | +| Public Key Token | cc7b13ffcd2ddd51 | | Deterministic | ✓ | | Reproducible | ✓ | | File Size | 80.8 KB | @@ -543,7 +543,7 @@ Name: System.Text.Json | Version: 10.0.3 | TFM: .NETCoreApp,Version=v10.0 | Arch | Company | Microsoft Corporation | | Copyright | © Microsoft Corporation. All rights reserved. | | Signed | Yes | -| Public Key Token | 1d05d9bed22b38cb | +| Public Key Token | cc7b13ffcd2ddd51 | | Deterministic | ✓ | | Reproducible | ✓ | | File Size | 80.8 KB | diff --git a/scripts/baseline.txt b/scripts/baseline.txt index 8821f6e7..e55b9a9a 100644 --- a/scripts/baseline.txt +++ b/scripts/baseline.txt @@ -757,7 +757,7 @@ COMMAND: assembly --package System.Text.Json@10.0.2 --tfm net10.0 | Compilation | CoreCLR | | Informational Version | 10.0.2+44525024595742ebe09023abe709df51de65009b | | Signed | Yes | -| Public Key Token | 1d05d9bed22b38cb | +| Public Key Token | cc7b13ffcd2ddd51 | ================================================================================ COMMAND: assembly --package System.Text.Json@10.0.2 --tfm net10.0 --audit @@ -775,7 +775,7 @@ COMMAND: assembly --package System.Text.Json@10.0.2 --tfm net10.0 --audit | Compilation | CoreCLR | | Informational Version | 10.0.2+44525024595742ebe09023abe709df51de65009b | | Signed | Yes | -| Public Key Token | 1d05d9bed22b38cb | +| Public Key Token | cc7b13ffcd2ddd51 | ## Build Audit diff --git a/src/DotnetInspector.Metadata/AssemblyInspector.cs b/src/DotnetInspector.Metadata/AssemblyInspector.cs index 646aded7..3393b4ac 100644 --- a/src/DotnetInspector.Metadata/AssemblyInspector.cs +++ b/src/DotnetInspector.Metadata/AssemblyInspector.cs @@ -1,6 +1,7 @@ using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; +using System.Security.Cryptography; namespace DotnetInspector.Metadata; @@ -83,7 +84,10 @@ public static AssemblyInfo ExtractAssemblyInfo(PEReader peReader, bool includeRe var publicKey = metadataReader.GetBlobBytes(assemblyDef.PublicKey); if (publicKey.Length > 0) { - info.PublicKeyToken = Convert.ToHexString(publicKey.TakeLast(8).ToArray()).ToLowerInvariant(); + var publicKeyHash = SHA1.HashData(publicKey); + var publicKeyToken = publicKeyHash[^8..]; + Array.Reverse(publicKeyToken); + info.PublicKeyToken = Convert.ToHexString(publicKeyToken).ToLowerInvariant(); } } @@ -429,8 +433,7 @@ private static (bool isNormalized, List nonNormalizedPaths) CheckSourceL string url = prop.Value.GetString() ?? ""; if (url.Contains("github.com", StringComparison.OrdinalIgnoreCase)) { - var match = System.Text.RegularExpressions.Regex.Match(url, - @"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/"); + var match = AssemblyInspectorRegex.GitHubRawUrl().Match(url); if (match.Success) { return $"https://github.com/{match.Groups[1].Value}/{match.Groups[2].Value}"; diff --git a/src/DotnetInspector.Metadata/AssemblyInspectorRegex.cs b/src/DotnetInspector.Metadata/AssemblyInspectorRegex.cs new file mode 100644 index 00000000..c64373d0 --- /dev/null +++ b/src/DotnetInspector.Metadata/AssemblyInspectorRegex.cs @@ -0,0 +1,9 @@ +using System.Text.RegularExpressions; + +namespace DotnetInspector.Metadata; + +internal static partial class AssemblyInspectorRegex +{ + [GeneratedRegex(@"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/")] + internal static partial Regex GitHubRawUrl(); +} diff --git a/tests/DotnetInspector.Metadata.Tests/AssemblyInspectorTests.cs b/tests/DotnetInspector.Metadata.Tests/AssemblyInspectorTests.cs new file mode 100644 index 00000000..79728d02 --- /dev/null +++ b/tests/DotnetInspector.Metadata.Tests/AssemblyInspectorTests.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using DotnetInspector.Metadata; + +namespace DotnetInspector.Metadata.Tests; + +public sealed class AssemblyInspectorTests +{ + public static IEnumerable StrongNamedAssemblies() + { + yield return [typeof(System.Text.Json.JsonSerializer).Assembly]; + yield return [typeof(object).Assembly]; + yield return [typeof(System.Linq.Enumerable).Assembly]; + } + + [Theory] + [MemberData(nameof(StrongNamedAssemblies))] + public void ExtractAssemblyInfo_PublicKeyToken_MatchesRuntimeGroundTruth(Assembly assembly) + { + var expectedTokenBytes = Assert.IsType(AssemblyName.GetAssemblyName(assembly.Location).GetPublicKeyToken()); + Assert.NotEmpty(expectedTokenBytes); + + using var stream = File.OpenRead(assembly.Location); + using var peReader = new PEReader(stream); + + var info = AssemblyInspector.ExtractAssemblyInfo(peReader); + + var expectedToken = Convert.ToHexString(expectedTokenBytes).ToLowerInvariant(); + Assert.Equal(expectedToken, info.PublicKeyToken); + } +} diff --git a/tests/DotnetInspector.Metadata.Tests/NullabilityTests.cs b/tests/DotnetInspector.Metadata.Tests/NullabilityTests.cs index cba0fce6..2ea8fe42 100644 --- a/tests/DotnetInspector.Metadata.Tests/NullabilityTests.cs +++ b/tests/DotnetInspector.Metadata.Tests/NullabilityTests.cs @@ -10,7 +10,7 @@ namespace DotnetInspector.Metadata.Tests; /// Tests for nullability annotation reading and rendering in API signatures. /// Uses the test assembly itself (compiled with nullable enabled) as the test subject. /// -public class NullabilityTests +public sealed class NullabilityTests { private static readonly ApiSurface Surface; @@ -39,37 +39,48 @@ private static ApiMember GetField(string typeName, string fieldName) => [Fact] public void GetNullableContext_ReturnsDefault_WhenNotPresent() { - // Value types typically don't have NullableContextAttribute - var reader = GetMetadataReader(); + // NullableObliviousClass is defined below in a #nullable disable region, + // so it has no NullableContextAttribute in this assembly. + // PEReader/stream are inlined (not shared) because MetadataReader borrows memory + // owned by PEReader — sharing a static reader would leak the file handle. + var assemblyPath = typeof(NullabilityTests).Assembly.Location; + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + var reader = peReader.GetMetadataReader(); + foreach (var typeHandle in reader.TypeDefinitions) { var typeDef = reader.GetTypeDefinition(typeHandle); - if (reader.GetString(typeDef.Name) == "Int32") + if (reader.GetString(typeDef.Name) == nameof(NullableObliviousClass)) { - // System.Int32 doesn't have a NullableContext attribute var context = NullabilityReader.GetNullableContext(reader, typeDef.GetCustomAttributes()); Assert.Equal(0, context); return; } } + Assert.Fail($"{nameof(NullableObliviousClass)} type definition not found in test assembly"); } [Fact] public void GetNullableContext_ReadsValue_WhenPresent() { - // This test assembly has nullable=enable, so types should have NullableContextAttribute - var reader = GetMetadataReader(); + // See comment in GetNullableContext_ReturnsDefault_WhenNotPresent for why PEReader is inlined. + var assemblyPath = typeof(NullabilityTests).Assembly.Location; + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + var reader = peReader.GetMetadataReader(); + foreach (var typeHandle in reader.TypeDefinitions) { var typeDef = reader.GetTypeDefinition(typeHandle); if (reader.GetString(typeDef.Name) == nameof(NullableSampleClass)) { var context = NullabilityReader.GetNullableContext(reader, typeDef.GetCustomAttributes()); - // Should be 1 (not annotated) or 2 (annotated) — non-zero for nullable-enabled types Assert.NotEqual(0, context); return; } } + Assert.Fail($"{nameof(NullableSampleClass)} type definition not found in test assembly"); } // --- TypeNode tree + rendering tests --- @@ -250,13 +261,6 @@ public void Method_GenericNullableArg() Assert.Contains("string?", member.Signature); } - private static MetadataReader GetMetadataReader() - { - var path = typeof(NullabilityTests).Assembly.Location; - var stream = File.OpenRead(path); - var peReader = new PEReader(stream); - return peReader.GetMetadataReader(); - } } // ===== Test fixture types compiled with #nullable enable ===== @@ -281,3 +285,11 @@ public void TakesNonNullableList(List items) { } public Dictionary MixedGeneric() => new(); } + +#nullable disable +// Fixture type with no NullableContextAttribute — used to test GetNullableContext returns 0 +public class NullableObliviousClass +{ + public string Value; +} +#nullable restore