diff --git a/V2/Cose.Abstractions/README.md b/V2/Cose.Abstractions/README.md
index aeaec63c8..dce9d12a5 100644
--- a/V2/Cose.Abstractions/README.md
+++ b/V2/Cose.Abstractions/README.md
@@ -5,7 +5,7 @@ Generic COSE abstractions that are independent of any specific COSE message type
## Contents
- `CoseHeaderLocation` — Flags for searching protected/unprotected headers
- `IndirectSignatureHeaderLabels` — RFC 9054 header label constants
-- `SignatureFormat` — Signature format enumeration
+- `ContentDigestFormat` — Signature format enumeration
## Polyfills
- `Guard` — Cross-framework argument validation (ThrowIfNull, ThrowIfDisposed, etc.)
diff --git a/V2/CoseSign1.Abstractions.Tests/Extensions/ContentDigestFormatTests.cs b/V2/CoseSign1.Abstractions.Tests/Extensions/ContentDigestFormatTests.cs
new file mode 100644
index 000000000..7c4a6fca5
--- /dev/null
+++ b/V2/CoseSign1.Abstractions.Tests/Extensions/ContentDigestFormatTests.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Abstractions.Tests.Extensions;
+
+using System.Security.Cryptography.Cose;
+
+///
+/// Tests for enum.
+///
+[TestFixture]
+public class ContentDigestFormatTests
+{
+ [Test]
+ public void Direct_HasValue0()
+ {
+ Assert.That((int)ContentDigestFormat.Direct, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void IndirectHashLegacy_HasValue1()
+ {
+ Assert.That((int)ContentDigestFormat.IndirectHashLegacy, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void IndirectCoseHashV_HasValue2()
+ {
+ Assert.That((int)ContentDigestFormat.IndirectCoseHashV, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void IndirectCoseHashEnvelope_HasValue3()
+ {
+ Assert.That((int)ContentDigestFormat.IndirectCoseHashEnvelope, Is.EqualTo(3));
+ }
+
+ [Test]
+ public void AllValues_AreUnique()
+ {
+ var values = Enum.GetValues();
+ Assert.That(values.Distinct().Count(), Is.EqualTo(values.Length));
+ }
+
+ [Test]
+ public void AllValues_AreDefined()
+ {
+ Assert.That(Enum.IsDefined(ContentDigestFormat.Direct), Is.True);
+ Assert.That(Enum.IsDefined(ContentDigestFormat.IndirectHashLegacy), Is.True);
+ Assert.That(Enum.IsDefined(ContentDigestFormat.IndirectCoseHashV), Is.True);
+ Assert.That(Enum.IsDefined(ContentDigestFormat.IndirectCoseHashEnvelope), Is.True);
+ }
+}
\ No newline at end of file
diff --git a/V2/CoseSign1.Abstractions.Tests/Extensions/CoseSign1MessageExtensionsTests.cs b/V2/CoseSign1.Abstractions.Tests/Extensions/CoseSign1MessageExtensionsTests.cs
index 83d254d83..1c9fa28ef 100644
--- a/V2/CoseSign1.Abstractions.Tests/Extensions/CoseSign1MessageExtensionsTests.cs
+++ b/V2/CoseSign1.Abstractions.Tests/Extensions/CoseSign1MessageExtensionsTests.cs
@@ -91,10 +91,10 @@ public void IsIndirectSignature_WithPayloadHashAlgHeader_ReturnsTrue()
#endregion
- #region GetSignatureFormat Tests
+ #region GetContentDigestFormat Tests
[Test]
- public void GetSignatureFormat_WithNoIndirectMarkers_ReturnsDirect()
+ public void GetContentDigestFormat_WithNoIndirectMarkers_ReturnsDirect()
{
var headers = new CoseHeaderMap
{
@@ -102,11 +102,11 @@ public void GetSignatureFormat_WithNoIndirectMarkers_ReturnsDirect()
};
var message = CreateMessageWithHeaders(headers);
- Assert.That(message.GetSignatureFormat(), Is.EqualTo(SignatureFormat.Direct));
+ Assert.That(message.GetContentDigestFormat(), Is.EqualTo(ContentDigestFormat.Direct));
}
[Test]
- public void GetSignatureFormat_WithPayloadHashAlgHeader_ReturnsCoseHashEnvelope()
+ public void GetContentDigestFormat_WithPayloadHashAlgHeader_ReturnsCoseHashEnvelope()
{
var headers = new CoseHeaderMap
{
@@ -114,11 +114,11 @@ public void GetSignatureFormat_WithPayloadHashAlgHeader_ReturnsCoseHashEnvelope(
};
var message = CreateMessageWithHeaders(headers);
- Assert.That(message.GetSignatureFormat(), Is.EqualTo(SignatureFormat.IndirectCoseHashEnvelope));
+ Assert.That(message.GetContentDigestFormat(), Is.EqualTo(ContentDigestFormat.IndirectCoseHashEnvelope));
}
[Test]
- public void GetSignatureFormat_WithCoseHashVContentType_ReturnsCoseHashV()
+ public void GetContentDigestFormat_WithCoseHashVContentType_ReturnsCoseHashV()
{
var headers = new CoseHeaderMap
{
@@ -126,11 +126,11 @@ public void GetSignatureFormat_WithCoseHashVContentType_ReturnsCoseHashV()
};
var message = CreateMessageWithHeaders(headers);
- Assert.That(message.GetSignatureFormat(), Is.EqualTo(SignatureFormat.IndirectCoseHashV));
+ Assert.That(message.GetContentDigestFormat(), Is.EqualTo(ContentDigestFormat.IndirectCoseHashV));
}
[Test]
- public void GetSignatureFormat_WithHashLegacyContentType_ReturnsHashLegacy()
+ public void GetContentDigestFormat_WithHashLegacyContentType_ReturnsHashLegacy()
{
var headers = new CoseHeaderMap
{
@@ -138,21 +138,21 @@ public void GetSignatureFormat_WithHashLegacyContentType_ReturnsHashLegacy()
};
var message = CreateMessageWithHeaders(headers);
- Assert.That(message.GetSignatureFormat(), Is.EqualTo(SignatureFormat.IndirectHashLegacy));
+ Assert.That(message.GetContentDigestFormat(), Is.EqualTo(ContentDigestFormat.IndirectHashLegacy));
}
[Test]
- public void GetSignatureFormat_WithNullMessage_ReturnsDirect()
+ public void GetContentDigestFormat_WithNullMessage_ReturnsDirect()
{
CoseSign1Message? message = null;
- Assert.That(message!.GetSignatureFormat(), Is.EqualTo(SignatureFormat.Direct));
+ Assert.That(message!.GetContentDigestFormat(), Is.EqualTo(ContentDigestFormat.Direct));
}
[TestCase("application/test+hash-sha384")]
[TestCase("text/plain+hash-SHA512")]
[TestCase("application/octet-stream+hash-sha_256")]
- public void GetSignatureFormat_WithVariousHashLegacyFormats_ReturnsHashLegacy(string contentType)
+ public void GetContentDigestFormat_WithVariousHashLegacyFormats_ReturnsHashLegacy(string contentType)
{
var headers = new CoseHeaderMap
{
@@ -160,7 +160,7 @@ public void GetSignatureFormat_WithVariousHashLegacyFormats_ReturnsHashLegacy(st
};
var message = CreateMessageWithHeaders(headers);
- Assert.That(message.GetSignatureFormat(), Is.EqualTo(SignatureFormat.IndirectHashLegacy));
+ Assert.That(message.GetContentDigestFormat(), Is.EqualTo(ContentDigestFormat.IndirectHashLegacy));
}
#endregion
diff --git a/V2/CoseSign1.Abstractions.Tests/Extensions/SignatureFormatTests.cs b/V2/CoseSign1.Abstractions.Tests/Extensions/SignatureFormatTests.cs
deleted file mode 100644
index 07e2da093..000000000
--- a/V2/CoseSign1.Abstractions.Tests/Extensions/SignatureFormatTests.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-namespace CoseSign1.Abstractions.Tests.Extensions;
-
-using System.Security.Cryptography.Cose;
-
-///
-/// Tests for enum.
-///
-[TestFixture]
-public class SignatureFormatTests
-{
- [Test]
- public void Direct_HasValue0()
- {
- Assert.That((int)SignatureFormat.Direct, Is.EqualTo(0));
- }
-
- [Test]
- public void IndirectHashLegacy_HasValue1()
- {
- Assert.That((int)SignatureFormat.IndirectHashLegacy, Is.EqualTo(1));
- }
-
- [Test]
- public void IndirectCoseHashV_HasValue2()
- {
- Assert.That((int)SignatureFormat.IndirectCoseHashV, Is.EqualTo(2));
- }
-
- [Test]
- public void IndirectCoseHashEnvelope_HasValue3()
- {
- Assert.That((int)SignatureFormat.IndirectCoseHashEnvelope, Is.EqualTo(3));
- }
-
- [Test]
- public void AllValues_AreUnique()
- {
- var values = Enum.GetValues();
- Assert.That(values.Distinct().Count(), Is.EqualTo(values.Length));
- }
-
- [Test]
- public void AllValues_AreDefined()
- {
- Assert.That(Enum.IsDefined(SignatureFormat.Direct), Is.True);
- Assert.That(Enum.IsDefined(SignatureFormat.IndirectHashLegacy), Is.True);
- Assert.That(Enum.IsDefined(SignatureFormat.IndirectCoseHashV), Is.True);
- Assert.That(Enum.IsDefined(SignatureFormat.IndirectCoseHashEnvelope), Is.True);
- }
-}
\ No newline at end of file
diff --git a/V2/CoseSign1.Abstractions/Extensions/SignatureFormat.cs b/V2/CoseSign1.Abstractions/Extensions/ContentDigestFormat.cs
similarity index 87%
rename from V2/CoseSign1.Abstractions/Extensions/SignatureFormat.cs
rename to V2/CoseSign1.Abstractions/Extensions/ContentDigestFormat.cs
index 656929482..b240a18ed 100644
--- a/V2/CoseSign1.Abstractions/Extensions/SignatureFormat.cs
+++ b/V2/CoseSign1.Abstractions/Extensions/ContentDigestFormat.cs
@@ -4,9 +4,9 @@
namespace System.Security.Cryptography.Cose;
///
-/// Specifies the signature format used by a COSE Sign1 message.
+/// Specifies the content-digest format used by a COSE Sign1 message.
///
-public enum SignatureFormat
+public enum ContentDigestFormat
{
///
/// Standard embedded or detached signature where the payload is signed directly.
diff --git a/V2/CoseSign1.Abstractions/Extensions/CoseSign1MessageExtensions.cs b/V2/CoseSign1.Abstractions/Extensions/CoseSign1MessageExtensions.cs
index 0077adfaf..a7a4d8e6b 100644
--- a/V2/CoseSign1.Abstractions/Extensions/CoseSign1MessageExtensions.cs
+++ b/V2/CoseSign1.Abstractions/Extensions/CoseSign1MessageExtensions.cs
@@ -31,24 +31,24 @@ internal static class ClassStrings
/// The COSE Sign1 message to inspect.
/// if the message uses any indirect signature format; otherwise, .
public static bool IsIndirectSignature(this CoseSign1Message message)
- => message.GetSignatureFormat() != SignatureFormat.Direct;
+ => message.GetContentDigestFormat() != ContentDigestFormat.Direct;
///
/// Determines the signature format type.
///
/// The COSE Sign1 message to inspect.
- /// The for the provided message.
- public static SignatureFormat GetSignatureFormat(this CoseSign1Message message)
+ /// The for the provided message.
+ public static ContentDigestFormat GetContentDigestFormat(this CoseSign1Message message)
{
if (message == null)
{
- return SignatureFormat.Direct;
+ return ContentDigestFormat.Direct;
}
// Check for CoseHashEnvelope (has header 258 - payload hash algorithm)
if (message.ProtectedHeaders.ContainsKey(IndirectSignatureHeaderLabels.PayloadHashAlg))
{
- return SignatureFormat.IndirectCoseHashEnvelope;
+ return ContentDigestFormat.IndirectCoseHashEnvelope;
}
// Check content-type header for indirect signature markers
@@ -57,16 +57,16 @@ public static SignatureFormat GetSignatureFormat(this CoseSign1Message message)
{
if (CoseHashVRegex.IsMatch(contentType))
{
- return SignatureFormat.IndirectCoseHashV;
+ return ContentDigestFormat.IndirectCoseHashV;
}
if (HashLegacyRegex.IsMatch(contentType))
{
- return SignatureFormat.IndirectHashLegacy;
+ return ContentDigestFormat.IndirectHashLegacy;
}
}
- return SignatureFormat.Direct;
+ return ContentDigestFormat.Direct;
}
#endregion
@@ -91,10 +91,10 @@ public static bool TryGetContentType(
return false;
}
- var format = message.GetSignatureFormat();
+ var format = message.GetContentDigestFormat();
// For indirect signatures, get the pre-image content type
- if (format != SignatureFormat.Direct)
+ if (format != ContentDigestFormat.Direct)
{
return message.TryGetIndirectContentType(format, out contentType);
}
@@ -111,16 +111,16 @@ public static bool TryGetContentType(
///
private static bool TryGetIndirectContentType(
this CoseSign1Message message,
- SignatureFormat format,
+ ContentDigestFormat format,
out string? contentType)
{
switch (format)
{
- case SignatureFormat.IndirectCoseHashEnvelope:
+ case ContentDigestFormat.IndirectCoseHashEnvelope:
// For CoseHashEnvelope, content type is in header 259 (preimage content type)
return message.TryGetPreImageContentType(out contentType);
- case SignatureFormat.IndirectCoseHashV:
+ case ContentDigestFormat.IndirectCoseHashV:
// For CoseHashV, strip the "+cose-hash-v" extension
if (message.TryGetHeader(CoseHeaderLabel.ContentType, out string? rawContentType))
{
@@ -130,7 +130,7 @@ private static bool TryGetIndirectContentType(
contentType = null;
return false;
- case SignatureFormat.IndirectHashLegacy:
+ case ContentDigestFormat.IndirectHashLegacy:
// For legacy indirect, strip the "+hash-*" extension
if (message.TryGetHeader(CoseHeaderLabel.ContentType, out rawContentType))
{
@@ -194,7 +194,7 @@ public static bool TryGetPayloadLocation(
}
// Payload location is only meaningful for CoseHashEnvelope format
- if (message.GetSignatureFormat() != SignatureFormat.IndirectCoseHashEnvelope)
+ if (message.GetContentDigestFormat() != ContentDigestFormat.IndirectCoseHashEnvelope)
{
payloadLocation = null;
return false;
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/AssemblyStrings.cs b/V2/CoseSign1.Certificates/Trust/Facts/AssemblyStrings.cs
new file mode 100644
index 000000000..f3ef86af7
--- /dev/null
+++ b/V2/CoseSign1.Certificates/Trust/Facts/AssemblyStrings.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Certificates.Trust.Facts;
+
+using System.Diagnostics.CodeAnalysis;
+
+///
+/// String-literal pool for facts shipped from the certificate trust pack. The repo's
+/// StringLiteralAnalyzer requires user-visible literals (including [TrustFactId]
+/// values) to be sourced from a central ClassStrings static so id renames are diff-able
+/// in one place.
+///
+[ExcludeFromCodeCoverage]
+internal static class AssemblyStrings
+{
+ internal const string FactIdCertificateSigningKeyTrust = "certificate-signing-key-trust/v1";
+ internal const string FactIdX509ChainElementIdentity = "x509-chain-element-identity/v1";
+ internal const string FactIdX509ChainTrusted = "x509-chain-trusted/v1";
+ internal const string FactIdX509SigningCertificateBasicConstraints = "x509-cert-basic-constraints/v1";
+ internal const string FactIdX509SigningCertificateEku = "x509-cert-eku/v1";
+ internal const string FactIdX509SigningCertificateIdentityAllowed = "x509-cert-identity-allowed/v1";
+ internal const string FactIdX509SigningCertificateIdentity = "x509-cert-identity/v1";
+ internal const string FactIdX509SigningCertificateKeyUsage = "x509-cert-key-usage/v1";
+ internal const string FactIdX509X5ChainCertificateIdentity = "x509-x5chain-cert-identity/v1";
+}
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/CertificateSigningKeyTrustFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/CertificateSigningKeyTrustFact.cs
index 3348a66b2..e5801d0c1 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/CertificateSigningKeyTrustFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/CertificateSigningKeyTrustFact.cs
@@ -10,6 +10,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact summarizing certificate identity and trust evaluation for a message's signing key.
///
+[TrustFactId(AssemblyStrings.FactIdCertificateSigningKeyTrust)]
public sealed class CertificateSigningKeyTrustFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509ChainElementIdentityFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509ChainElementIdentityFact.cs
index 5190d3b41..068ba52d8 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509ChainElementIdentityFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509ChainElementIdentityFact.cs
@@ -12,6 +12,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Depth 0 is the leaf (signing) certificate. Depth increases toward the root.
///
+[TrustFactId(AssemblyStrings.FactIdX509ChainElementIdentity)]
public sealed class X509ChainElementIdentityFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509ChainTrustedFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509ChainTrustedFact.cs
index 8dff68751..035429087 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509ChainTrustedFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509ChainTrustedFact.cs
@@ -9,6 +9,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact summarizing X.509 chain trust evaluation for the primary signing key certificate.
///
+[TrustFactId(AssemblyStrings.FactIdX509ChainTrusted)]
public sealed class X509ChainTrustedFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateBasicConstraintsFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateBasicConstraintsFact.cs
index 58696c267..36373cd9c 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateBasicConstraintsFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateBasicConstraintsFact.cs
@@ -9,6 +9,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact describing the basic constraints of the signing certificate.
///
+[TrustFactId(AssemblyStrings.FactIdX509SigningCertificateBasicConstraints)]
public sealed class X509SigningCertificateBasicConstraintsFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateEkuFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateEkuFact.cs
index df48d5003..834c79801 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateEkuFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateEkuFact.cs
@@ -9,6 +9,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact representing an EKU OID on the signing certificate.
///
+[TrustFactId(AssemblyStrings.FactIdX509SigningCertificateEku)]
public sealed class X509SigningCertificateEkuFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityAllowedFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityAllowedFact.cs
index df93b0636..5473e897f 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityAllowedFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityAllowedFact.cs
@@ -9,6 +9,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact indicating whether the signing certificate identity satisfies the configured allow-list.
///
+[TrustFactId(AssemblyStrings.FactIdX509SigningCertificateIdentityAllowed)]
public sealed class X509SigningCertificateIdentityAllowedFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityFact.cs
index aa16e0e33..68bfceb86 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateIdentityFact.cs
@@ -9,6 +9,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact describing the signing certificate used for a message's signing key.
///
+[TrustFactId(AssemblyStrings.FactIdX509SigningCertificateIdentity)]
public sealed class X509SigningCertificateIdentityFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateKeyUsageFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateKeyUsageFact.cs
index 44c5fda03..63ce216e1 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateKeyUsageFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509SigningCertificateKeyUsageFact.cs
@@ -10,6 +10,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact representing the key usage flags present on the signing certificate.
///
+[TrustFactId(AssemblyStrings.FactIdX509SigningCertificateKeyUsage)]
public sealed class X509SigningCertificateKeyUsageFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Certificates/Trust/Facts/X509X5ChainCertificateIdentityFact.cs b/V2/CoseSign1.Certificates/Trust/Facts/X509X5ChainCertificateIdentityFact.cs
index fbdd9340d..f74d6bf34 100644
--- a/V2/CoseSign1.Certificates/Trust/Facts/X509X5ChainCertificateIdentityFact.cs
+++ b/V2/CoseSign1.Certificates/Trust/Facts/X509X5ChainCertificateIdentityFact.cs
@@ -9,6 +9,7 @@ namespace CoseSign1.Certificates.Trust.Facts;
///
/// Fact describing a certificate present in the message's x5chain header.
///
+[TrustFactId(AssemblyStrings.FactIdX509X5ChainCertificateIdentity)]
public sealed class X509X5ChainCertificateIdentityFact : ISigningKeyFact
{
///
diff --git a/V2/CoseSign1.Transparent.MST/Trust/AssemblyStrings.cs b/V2/CoseSign1.Transparent.MST/Trust/AssemblyStrings.cs
new file mode 100644
index 000000000..0c335b1ec
--- /dev/null
+++ b/V2/CoseSign1.Transparent.MST/Trust/AssemblyStrings.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Transparent.MST.Trust;
+
+using System.Diagnostics.CodeAnalysis;
+
+///
+/// String-literal pool for facts shipped from the MST transparent-statement trust pack.
+///
+[ExcludeFromCodeCoverage]
+internal static class AssemblyStrings
+{
+ internal const string FactIdMstReceiptIssuerHost = "mst-receipt-issuer-host/v1";
+ internal const string FactIdMstReceiptPresent = "mst-receipt-present/v1";
+ internal const string FactIdMstReceiptTrusted = "mst-receipt-trusted/v1";
+}
diff --git a/V2/CoseSign1.Transparent.MST/Trust/MstReceiptIssuerHostFact.cs b/V2/CoseSign1.Transparent.MST/Trust/MstReceiptIssuerHostFact.cs
index 3ddbc6fb3..85ff639a7 100644
--- a/V2/CoseSign1.Transparent.MST/Trust/MstReceiptIssuerHostFact.cs
+++ b/V2/CoseSign1.Transparent.MST/Trust/MstReceiptIssuerHostFact.cs
@@ -8,6 +8,7 @@ namespace CoseSign1.Transparent.MST.Trust;
///
/// Counter-signature-scoped fact exposing candidate issuer hosts found in an MST receipt.
///
+[TrustFactId(AssemblyStrings.FactIdMstReceiptIssuerHost)]
public sealed record MstReceiptIssuerHostFact(IReadOnlyList Hosts) : ICounterSignatureFact
{
///
diff --git a/V2/CoseSign1.Transparent.MST/Trust/MstReceiptPresentFact.cs b/V2/CoseSign1.Transparent.MST/Trust/MstReceiptPresentFact.cs
index 62d5b9c02..ce750d830 100644
--- a/V2/CoseSign1.Transparent.MST/Trust/MstReceiptPresentFact.cs
+++ b/V2/CoseSign1.Transparent.MST/Trust/MstReceiptPresentFact.cs
@@ -8,6 +8,7 @@ namespace CoseSign1.Transparent.MST.Trust;
///
/// Counter-signature-scoped fact indicating whether an MST receipt header is present.
///
+[TrustFactId(AssemblyStrings.FactIdMstReceiptPresent)]
public sealed record MstReceiptPresentFact(bool IsPresent) : ICounterSignatureFact
{
///
diff --git a/V2/CoseSign1.Transparent.MST/Trust/MstReceiptTrustedFact.cs b/V2/CoseSign1.Transparent.MST/Trust/MstReceiptTrustedFact.cs
index 35e3c5455..800a93768 100644
--- a/V2/CoseSign1.Transparent.MST/Trust/MstReceiptTrustedFact.cs
+++ b/V2/CoseSign1.Transparent.MST/Trust/MstReceiptTrustedFact.cs
@@ -12,6 +12,7 @@ namespace CoseSign1.Transparent.MST.Trust;
/// When receipt verification is not enabled, this fact may be produced as "unavailable" (no values).
/// Policies should not require this fact unless verification is explicitly enabled.
///
+[TrustFactId(AssemblyStrings.FactIdMstReceiptTrusted)]
public sealed record MstReceiptTrustedFact(bool IsTrusted, string? Details = null) : ICounterSignatureFact
{
///
diff --git a/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/ForbiddenRegoConstructTests.cs b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/ForbiddenRegoConstructTests.cs
new file mode 100644
index 000000000..38101391c
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/ForbiddenRegoConstructTests.cs
@@ -0,0 +1,148 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.AntiPatterns;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Anti-pattern matrix for forbidden Rego constructs. The Rego frontend accepts a constrained
+/// subset; every forbidden construct surfaces a TPX300-band sub-code. These tests pin one
+/// case per sub-code so future relaxations of the dialect can't slip through unnoticed.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class ForbiddenRegoConstructTests
+{
+ private const string RevocationModeNone = "none";
+
+ [Test]
+ public void Verify_RegoWithHttpSendBuiltin_AbortsWithForbiddenBuiltinDiagnostic()
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_RegoWithHttpSendBuiltin_AbortsWithForbiddenBuiltinDiagnostic));
+ const string body = """
+ package cose_trust_policy
+
+ # http.send is on the closed reject-list — translation MUST surface TPX301.
+ policy := {
+ "primary_signing_key": {
+ "fact": "x509-chain-trusted/v1",
+ "predicate": {
+ "operator": "Equals",
+ "path": "$.is_trusted",
+ "value": http.send({"url": "https://example.com/allow", "method": "GET"})
+ }
+ }
+ }
+ """;
+ string policyPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, body, "anti-rego-http");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: "anti / rego http.send / rego",
+ expectedDiagnosticCode: "TPX301");
+ }
+
+ [Test]
+ public void Verify_RegoWithRegexMatchBuiltin_AbortsWithForbiddenBuiltinDiagnostic()
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_RegoWithRegexMatchBuiltin_AbortsWithForbiddenBuiltinDiagnostic));
+ const string body = """
+ package cose_trust_policy
+
+ # regex.* is on the reject-list. Surfaces TPX301.
+ policy := {
+ "primary_signing_key": {
+ "fact": "x509-cert-identity/v1",
+ "predicate": {
+ "operator": "Equals",
+ "path": "$.subject",
+ "value": regex.match("secret search phrase", "$.subject")
+ }
+ }
+ }
+ """;
+ string policyPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, body, "anti-rego-regex");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: "anti / rego regex.match / rego",
+ expectedDiagnosticCode: "TPX301");
+ }
+
+ [Test]
+ public void Verify_RegoWithSomeIteration_AbortsWithUnconstrainedIterationDiagnostic()
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_RegoWithSomeIteration_AbortsWithUnconstrainedIterationDiagnostic));
+ const string body = """
+ package cose_trust_policy
+
+ import future.keywords.in
+
+ # 'some x in coll' is unconstrained iteration. Surfaces TPX302.
+ some host in input.trusted_log_hosts
+
+ policy := {
+ "any_counter_signature": {
+ "on_empty": "deny",
+ "fact": "mst-receipt-issuer-host/v1",
+ "predicate": {"operator": "Equals", "path": "$.host", "value": host}
+ }
+ }
+ """;
+ string policyPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, body, "anti-rego-some");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: "anti / rego some-iter / rego",
+ expectedDiagnosticCode: "TPX302");
+ }
+
+ [Test]
+ public void Verify_RegoWithMultipleRulesPerPackage_AbortsWithMultipleRulesDiagnostic()
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_RegoWithMultipleRulesPerPackage_AbortsWithMultipleRulesDiagnostic));
+ const string body = """
+ package cose_trust_policy
+
+ # The constrained dialect requires exactly one rule per package. Surfaces TPX005.
+ policy := {
+ "primary_signing_key": {"fact": "x509-chain-trusted/v1", "predicate": {"is_trusted": true}}
+ }
+
+ other := {
+ "primary_signing_key": {"fact": "x509-chain-trusted/v1", "predicate": {"is_trusted": false}}
+ }
+ """;
+ string policyPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, body, "anti-rego-multirule");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: "anti / rego multiple-rules / rego",
+ expectedDiagnosticCode: "TPX005");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/MalformedDocumentTests.cs b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/MalformedDocumentTests.cs
new file mode 100644
index 000000000..a654a74b5
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/MalformedDocumentTests.cs
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.AntiPatterns;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Anti-pattern matrix for malformed and schema-violating documents. Each test confirms the
+/// CLI fails fast (non-zero exit) BEFORE invoking the verify pipeline, with the appropriate
+/// TPX-band diagnostic on stderr.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class MalformedDocumentTests
+{
+ private const string RevocationModeNone = "none";
+
+ [TestCase(PolicyFormat.Json, "TPX001")]
+ [TestCase(PolicyFormat.Rego, "TPX001")]
+ public void Verify_MalformedDocument_AbortsWithParserDiagnostic(PolicyFormat format, string expectedCode)
+ {
+ // Build a document the parser cannot consume. For JSON this is unbalanced braces; for
+ // Rego it is an unterminated string literal that trips the tokenizer. Either way the
+ // translator surfaces TPX001 (malformed) before walking the document.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_MalformedDocument_AbortsWithParserDiagnostic));
+
+ string body = format switch
+ {
+ PolicyFormat.Json => "{ this is not json }",
+ PolicyFormat.Rego => "package cose_trust_policy\n\npolicy := { \"unterminated string",
+ _ => throw new System.ArgumentOutOfRangeException(nameof(format))
+ };
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "anti-malformed");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"anti / malformed-document / {format}",
+ expectedDiagnosticCode: expectedCode);
+ }
+
+ [Test]
+ public void Verify_JsonSchemaViolation_UnknownTopLevelField_AbortsWithSchemaDiagnostic()
+ {
+ // The schema declares additionalProperties:false at the document root. A bogus
+ // top-level key triggers a TPX100 schema diagnostic before any IR walking happens.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_JsonSchemaViolation_UnknownTopLevelField_AbortsWithSchemaDiagnostic));
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "x509-chain-trusted/v1",
+ "predicate": {"is_trusted": true}
+ },
+ "totally_unknown_top_level": "boom"
+ }
+ """;
+ string policyPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, body, "anti-schema");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: "anti / schema-violation / json",
+ expectedDiagnosticCode: "TPX100");
+ }
+
+ [Test]
+ public void Verify_JsonSchemaViolation_PredicateValueWrongType_AbortsWithSchemaDiagnostic()
+ {
+ // `is_trusted` must be a JSON value (boolean for this fact's underlying property). An
+ // explicit object that doesn't match $param shape, on a property assertion, fails the
+ // property_assertion_predicate schema → TPX100. This is the matrix's "wrong type" cell.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_JsonSchemaViolation_PredicateValueWrongType_AbortsWithSchemaDiagnostic));
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "x509-chain-trusted/v1",
+ "predicate": {"operator": 12345, "path": "$.is_trusted"}
+ }
+ }
+ """;
+ string policyPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, body, "anti-schema-type");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: "anti / schema-violation-wrong-type / json",
+ expectedDiagnosticCode: "TPX100");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/UnboundParameterTests.cs b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/UnboundParameterTests.cs
new file mode 100644
index 000000000..df3d6ae9f
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/UnboundParameterTests.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.AntiPatterns;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Anti-pattern: a document references $param with no in-document default and the
+/// caller forgot to supply --trust-policy-param. The translator's Bind pass must
+/// surface TPX400 before the verify pipeline runs.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class UnboundParameterTests
+{
+ private const string RevocationModeNone = "none";
+ private const string ExpectedCode = "TPX400";
+ private const string ParamName = "should_have_been_bound";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_DocumentWithUnboundParam_AbortsWithUnboundParamDiagnostic(PolicyFormat format)
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_DocumentWithUnboundParam_AbortsWithUnboundParamDiagnostic));
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptIssuerHostUnboundParam(ParamName);
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "anti-unbound-param");
+
+ // Deliberately omit --trust-policy-param so the binder can't resolve the placeholder.
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"anti / unbound-param / {format}",
+ expectedDiagnosticCode: ExpectedCode);
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/UnknownFactIdTests.cs b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/UnknownFactIdTests.cs
new file mode 100644
index 000000000..b004a1278
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/AntiPatterns/UnknownFactIdTests.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.AntiPatterns;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Anti-pattern: documents that reference a fact id absent from the registry surface TPX200
+/// before the verify pipeline runs. Both frontends share the same capability-aware
+/// translation pass so the diagnostic is identical across formats.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class UnknownFactIdTests
+{
+ private const string RevocationModeNone = "none";
+ private const string ExpectedCode = "TPX200";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_UnknownFactId_AbortsWithRegistryDiagnostic(PolicyFormat format)
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_UnknownFactId_AbortsWithRegistryDiagnostic));
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "totally-not-a-real-fact-id/v1",
+ "predicate": {"operator": "Equals", "path": "$.something", "value": true}
+ }
+ }
+ """;
+ string emitted = format == PolicyFormat.Json
+ ? body
+ : PolicyDocumentBuilder.WrapAsRego(body);
+ string policyPath = PolicyDocumentBuilder.Write(format, emitted, "anti-unknown-fact");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"anti / unknown-fact-id / {format}",
+ expectedDiagnosticCode: ExpectedCode);
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/CoseSign1.Trust.Integration.Tests.csproj b/V2/CoseSign1.Trust.Integration.Tests/CoseSign1.Trust.Integration.Tests.csproj
new file mode 100644
index 000000000..da50481e1
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/CoseSign1.Trust.Integration.Tests.csproj
@@ -0,0 +1,114 @@
+
+
+
+ net10.0
+ enable
+ latest
+ false
+ true
+ true
+ $(NoWarn);CA2252;SYSLIB5006
+
+ true
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(OutputPath)plugins
+ $(PluginsDir)\$(PluginName)
+ $(MSBuildProjectDirectory)\..\$(PluginName)\bin\$(Configuration)\net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/V2/CoseSign1.Trust.Integration.Tests/CrossFormatEquivalenceTests.cs b/V2/CoseSign1.Trust.Integration.Tests/CrossFormatEquivalenceTests.cs
new file mode 100644
index 000000000..c98b4713b
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/CrossFormatEquivalenceTests.cs
@@ -0,0 +1,136 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Cross-format equivalence regression. For every logical scenario in the matrix that has
+/// both a JSON and a Rego variant, the CLI MUST produce the same exit code and the same
+/// observable diagnostic surface (TPX-band codes only, line numbers stripped) regardless of
+/// which frontend authored the document. This is the integration-test analog of the Phase 4
+/// canonical-IR equivalence assertion: it locks the contract that switching formats does not
+/// change verifier behaviour.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class CrossFormatEquivalenceTests
+{
+ private const string RevocationModeNone = "none";
+
+ private static CliResult RunVerifyX509(SignedFixture fixture, string policyPath, params string[] extraArgs)
+ {
+ var args = new System.Collections.Generic.List
+ {
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath,
+ };
+ args.AddRange(extraArgs);
+ return CliRunner.Run([.. args]);
+ }
+
+ private static CliResult RunVerifyScitt(string sigPath, string jwksPath, string policyPath, params string[] extraArgs)
+ {
+ var args = new System.Collections.Generic.List
+ {
+ "verify", "scitt", sigPath,
+ "--issuer-offline-keys", $"{SignedFixtureBuilder.MstIssuerHost}={jwksPath}",
+ "--trust-policy", policyPath,
+ };
+ args.AddRange(extraArgs);
+ return CliRunner.Run([.. args]);
+ }
+
+ [Test]
+ public void X509ChainTrusted_HappyPath_IsCrossFormatEquivalent()
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(X509ChainTrusted_HappyPath_IsCrossFormatEquivalent));
+ (string json, string rego) = PolicyDocumentBuilder.X509ChainTrusted();
+ string jsonPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, json, "cross-x509-happy");
+ string regoPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, rego, "cross-x509-happy");
+
+ CliResult jsonRun = RunVerifyX509(fixture, jsonPath);
+ CliResult regoRun = RunVerifyX509(fixture, regoPath);
+
+ CliAssertions.AssertCrossFormatEquivalent(jsonRun, regoRun, "cross / x509 chain-trusted happy");
+ }
+
+ [Test]
+ public void X509ChainUntrusted_DenyPath_IsCrossFormatEquivalent()
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(X509ChainUntrusted_DenyPath_IsCrossFormatEquivalent));
+ (string json, string rego) = PolicyDocumentBuilder.X509ChainTrusted();
+ string jsonPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, json, "cross-x509-untrusted");
+ string regoPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, rego, "cross-x509-untrusted");
+
+ // Same fixture, no --trust-roots, --trust-system-roots false → chain not trusted.
+ var args = new[] { "--trust-system-roots", "false" };
+ CliResult jsonRun = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-system-roots", "false",
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", jsonPath);
+ CliResult regoRun = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-system-roots", "false",
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", regoPath);
+
+ CliAssertions.AssertCrossFormatEquivalent(jsonRun, regoRun, "cross / x509 chain untrusted deny");
+ }
+
+ [Test]
+ public void MstReceiptPresentAndTrusted_HappyPath_IsCrossFormatEquivalent()
+ {
+ string sigPath = SignedFixtureBuilder.GetMstReceiptFixturePath();
+ string jwksPath = SignedFixtureBuilder.GetMstIssuerJwksPath();
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptPresentAndTrusted();
+ string jsonPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, json, "cross-mst-happy");
+ string regoPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, rego, "cross-mst-happy");
+
+ CliResult jsonRun = RunVerifyScitt(sigPath, jwksPath, jsonPath);
+ CliResult regoRun = RunVerifyScitt(sigPath, jwksPath, regoPath);
+
+ CliAssertions.AssertCrossFormatEquivalent(jsonRun, regoRun, "cross / mst present+trusted happy");
+ }
+
+ [Test]
+ public void UnknownFactId_TranslationDeny_IsCrossFormatEquivalent()
+ {
+ // Translation-time failure: both frontends must surface the same TPX200 code.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(UnknownFactId_TranslationDeny_IsCrossFormatEquivalent));
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "totally-not-a-real-fact-id/v1",
+ "predicate": {"operator": "Equals", "path": "$.something", "value": true}
+ }
+ }
+ """;
+ string jsonPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, body, "cross-unknown-fact");
+ string regoPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, PolicyDocumentBuilder.WrapAsRego(body), "cross-unknown-fact");
+
+ CliResult jsonRun = RunVerifyX509(fixture, jsonPath);
+ CliResult regoRun = RunVerifyX509(fixture, regoPath);
+
+ CliAssertions.AssertCrossFormatEquivalent(jsonRun, regoRun, "cross / unknown-fact-id translation");
+ }
+
+ [Test]
+ public void UnboundParameter_TranslationDeny_IsCrossFormatEquivalent()
+ {
+ // Both frontends share the binder pass: TPX400 must surface identically across formats.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(UnboundParameter_TranslationDeny_IsCrossFormatEquivalent));
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptIssuerHostUnboundParam("missing_binding");
+ string jsonPath = PolicyDocumentBuilder.Write(PolicyFormat.Json, json, "cross-unbound");
+ string regoPath = PolicyDocumentBuilder.Write(PolicyFormat.Rego, rego, "cross-unbound");
+
+ CliResult jsonRun = RunVerifyX509(fixture, jsonPath);
+ CliResult regoRun = RunVerifyX509(fixture, regoPath);
+
+ CliAssertions.AssertCrossFormatEquivalent(jsonRun, regoRun, "cross / unbound-param translation");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/D8Override/PackDefaultsBypassedTests.cs b/V2/CoseSign1.Trust.Integration.Tests/D8Override/PackDefaultsBypassedTests.cs
new file mode 100644
index 000000000..5f116be1f
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/D8Override/PackDefaultsBypassedTests.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.D8Override;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// D8 override semantics. The shipped contract: when --trust-policy <doc> is
+/// supplied, pack-default trust requirements are bypassed entirely; the document is the sole
+/// source of trust requirements (pack fact PRODUCERS stay registered so the document's
+/// RequireFact references resolve at evaluation time). These tests pin the override
+/// behaviour by invoking the SAME signature twice — once without the override, once with it
+/// — and asserting the verdict flips in both directions.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class PackDefaultsBypassedTests
+{
+ private const string RevocationModeNone = "none";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void OverrideWithStricterDoc_FlipsPassToDeny(PolicyFormat format)
+ {
+ // Build a trusted chain. Without --trust-policy, verify x509 succeeds because the
+ // X509VerificationProvider's default trust plan (require chain trusted) is satisfied.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(OverrideWithStricterDoc_FlipsPassToDeny));
+
+ CliResult baseline = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone);
+
+ CliAssertions.AssertVerifySucceeded(baseline, $"d8 stricter / baseline (no override) / {format}");
+
+ // Now layer a STRICTER policy: demand identity is_allowed=false, which the produced
+ // fact never satisfies. If pack defaults were AND-merged with the doc the test would
+ // still pass (chain trusted is fine + doc inverted = ambiguous). The matrix locks D8:
+ // the doc fully replaces the pack policy → the inverted predicate denies.
+ (string json, string rego) = PolicyDocumentBuilder.X509IdentityIsAllowedFalse();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "d8-stricter");
+
+ CliResult overridden = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ overridden,
+ scenario: $"d8 stricter / with override / {format}",
+ expectedStderrSubstring: "TRUST_PLAN_NOT_SATISFIED");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void OverrideWithLooserDoc_FlipsDenyToPass(PolicyFormat format)
+ {
+ // Build a real chain but DO NOT register its root. Without --trust-policy, the X509
+ // pack default policy demands chain-trusted=true and denies because the chain's root
+ // is not in the trust set.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(OverrideWithLooserDoc_FlipsDenyToPass));
+
+ CliResult baseline = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-system-roots", "false",
+ "--revocation-mode", RevocationModeNone);
+
+ CliAssertions.AssertVerifyDenied(
+ baseline,
+ scenario: $"d8 looser / baseline (no override) / {format}",
+ expectedStderrSubstring: "TRUST_PLAN_NOT_SATISFIED");
+
+ // Layer a LOOSER policy: message scope allow_all. If pack defaults survived, the
+ // chain-trusted rule would still deny. The matrix locks D8: doc REPLACES pack policy
+ // → allow_all wins → exit 0.
+ (string json, string rego) = PolicyDocumentBuilder.MessageAllowAll();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "d8-looser");
+
+ CliResult overridden = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-system-roots", "false",
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifySucceeded(overridden, $"d8 looser / with override / {format}");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/Infrastructure/InfrastructureUnitTests.cs b/V2/CoseSign1.Trust.Integration.Tests/Infrastructure/InfrastructureUnitTests.cs
new file mode 100644
index 000000000..f29d8042f
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/Infrastructure/InfrastructureUnitTests.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.Infrastructure;
+
+using System;
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Targeted unit tests for the integration-test infrastructure helpers. The bulk of the
+/// helpers' behaviour is exercised transitively by the matrix tests; this fixture pins the
+/// edge cases (empty input, unknown enum values, repeated-dispose contracts) so future
+/// refactors of the infrastructure don't silently regress without a directly-attributable
+/// red test.
+///
+[TestFixture]
+public sealed class InfrastructureUnitTests
+{
+ [Test]
+ public void ExtractDiagnosticCodes_NullOrEmpty_ReturnsEmptyArray()
+ {
+ Assert.That(CliAssertions.ExtractDiagnosticCodes(null!), Is.Empty);
+ Assert.That(CliAssertions.ExtractDiagnosticCodes(string.Empty), Is.Empty);
+ }
+
+ [Test]
+ public void ExtractDiagnosticCodes_StripsNonTpxBracketedTokens()
+ {
+ const string stderr = """
+ [INFO] starting
+ [TPX300] forbidden builtin
+ [Trust.E007] unrelated
+ [TPX001] malformed
+ plain line without brackets
+ """;
+
+ string[] codes = CliAssertions.ExtractDiagnosticCodes(stderr);
+
+ Assert.That(codes, Is.EquivalentTo(new[] { "TPX300", "TPX001" }));
+ }
+
+ [Test]
+ public void Write_UnknownPolicyFormat_ThrowsArgumentOutOfRange()
+ {
+ Assert.Throws(() =>
+ PolicyDocumentBuilder.Write((PolicyFormat)999, "{}", "bogus-format"));
+ }
+
+ [Test]
+ public void Write_NullBody_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => PolicyDocumentBuilder.Write(PolicyFormat.Json, null!, "stem"));
+ }
+
+ [Test]
+ public void Write_EmptyStem_ThrowsArgumentException()
+ {
+ Assert.Throws(() => PolicyDocumentBuilder.Write(PolicyFormat.Json, "{}", string.Empty));
+ }
+
+ [Test]
+ public void WrapAsRego_NullJson_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => PolicyDocumentBuilder.WrapAsRego(null!));
+ }
+
+ [Test]
+ public void SignedFixture_DoubleDispose_IsIdempotent()
+ {
+ // Reach the early-return path on the second Dispose call.
+ var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(SignedFixture_DoubleDispose_IsIdempotent));
+ fixture.Dispose();
+ Assert.DoesNotThrow(() => fixture.Dispose());
+ }
+
+ [Test]
+ public void InMemoryCliConsole_DoubleDispose_IsIdempotent()
+ {
+ var console = new InMemoryCliConsole();
+ console.Dispose();
+ Assert.DoesNotThrow(() => console.Dispose());
+ }
+
+ [Test]
+ public void InMemoryCliConsole_StdoutAndStderr_StartEmptyAndCaptureWrites()
+ {
+ using var console = new InMemoryCliConsole();
+ Assert.That(console.GetStdout(), Is.Empty);
+ Assert.That(console.GetStderr(), Is.Empty);
+
+ console.StandardOutput.Write("hello-stdout");
+ console.StandardError.Write("hello-stderr");
+
+ Assert.That(console.GetStdout(), Is.EqualTo("hello-stdout"));
+ Assert.That(console.GetStderr(), Is.EqualTo("hello-stderr"));
+ }
+
+ [Test]
+ public void CliResult_RejectsNullStreams_GracefullyByCoercingToEmpty()
+ {
+ // The constructor coerces null stdout/stderr to empty strings so test assertions
+ // never NPE when a caller forgets to capture one of the streams.
+ var result = new CliResult(exitCode: 42, stdout: null!, stderr: null!);
+ Assert.That(result.ExitCode, Is.EqualTo(42));
+ Assert.That(result.Stdout, Is.Empty);
+ Assert.That(result.Stderr, Is.Empty);
+ }
+
+ [Test]
+ public void CliRunner_NullArgs_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => CliRunner.Run(null!));
+ }
+
+ [Test]
+ public void AssertVerifySucceeded_NullResult_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => CliAssertions.AssertVerifySucceeded(null!, "any"));
+ }
+
+ [Test]
+ public void AssertVerifyDenied_NullResult_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => CliAssertions.AssertVerifyDenied(null!, "any"));
+ }
+
+ [Test]
+ public void AssertCrossFormatEquivalent_NullEither_ThrowsArgumentNullException()
+ {
+ var ok = new CliResult(0, string.Empty, string.Empty);
+ Assert.Throws(() => CliAssertions.AssertCrossFormatEquivalent(null!, ok, "any"));
+ Assert.Throws(() => CliAssertions.AssertCrossFormatEquivalent(ok, null!, "any"));
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/Mst/MstParameterTests.cs b/V2/CoseSign1.Trust.Integration.Tests/Mst/MstParameterTests.cs
new file mode 100644
index 000000000..f5fc5070b
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/Mst/MstParameterTests.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.Mst;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// MST parametrised matrix: $param-bound issuer host matching against the bundled
+/// receipt. Matching binding succeeds, mismatched binding denies; both invocations exercise
+/// the post-translate Bind pass + the Contains predicate against the
+/// mst-receipt-issuer-host/v1 fact.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class MstParameterTests
+{
+ private const string ParamName = "trusted_issuer_host";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_RealReceipt_ParamMatchesActualIssuer_Succeeds(PolicyFormat format)
+ {
+ string sigPath = SignedFixtureBuilder.GetMstReceiptFixturePath();
+ string jwksPath = SignedFixtureBuilder.GetMstIssuerJwksPath();
+
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptIssuerHostParam(ParamName, defaultHost: "PLACEHOLDER");
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "mst-param-match");
+
+ CliResult result = CliRunner.Run(
+ "verify", "scitt", sigPath,
+ "--issuer-offline-keys", $"{SignedFixtureBuilder.MstIssuerHost}={jwksPath}",
+ "--trust-policy", policyPath,
+ "--trust-policy-param", $"{ParamName}=\"{SignedFixtureBuilder.MstIssuerHost}\"");
+
+ CliAssertions.AssertVerifySucceeded(result, $"mst param / matching / {format}");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_RealReceipt_ParamDoesNotMatchActualIssuer_Denies(PolicyFormat format)
+ {
+ string sigPath = SignedFixtureBuilder.GetMstReceiptFixturePath();
+ string jwksPath = SignedFixtureBuilder.GetMstIssuerJwksPath();
+
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptIssuerHostParam(ParamName, defaultHost: "PLACEHOLDER");
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "mst-param-miss");
+
+ CliResult result = CliRunner.Run(
+ "verify", "scitt", sigPath,
+ "--issuer-offline-keys", $"{SignedFixtureBuilder.MstIssuerHost}={jwksPath}",
+ "--trust-policy", policyPath,
+ "--trust-policy-param", $"{ParamName}=\"some-other-host.example.org\"");
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"mst param / mismatching / {format}",
+ expectedStderrSubstring: "TRUST_PLAN_NOT_SATISFIED");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/Mst/MstTrustDenyTests.cs b/V2/CoseSign1.Trust.Integration.Tests/Mst/MstTrustDenyTests.cs
new file mode 100644
index 000000000..07bc58cf2
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/Mst/MstTrustDenyTests.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.Mst;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// MST deny matrix: missing receipt against an MST-required policy, and a wrong-issuer
+/// allow-list. Each cell drives a different fact-evaluation failure path.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class MstTrustDenyTests
+{
+ private const string RevocationModeNone = "none";
+ private const string TrustFailureFactRequirement = "TRUST_PLAN_NOT_SATISFIED";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_NoReceipt_PolicyRequiresMstReceiptOnEmptyDeny_Denies(PolicyFormat format)
+ {
+ // Build a plain X509-signed message — no MST receipt present. Use verify x509 (the
+ // root we trust) and layer a trust-policy demanding mst-receipt-present/v1. The
+ // any_counter_signature scope's on_empty:deny rule fires because there are no
+ // counter-signatures to evaluate, surfacing a non-zero exit + trust-failure diagnostic.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_NoReceipt_PolicyRequiresMstReceiptOnEmptyDeny_Denies));
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptPresent();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "mst-deny-missing");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"mst deny / no-receipt / {format}",
+ expectedStderrSubstring: TrustFailureFactRequirement);
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_RealReceipt_PolicyRequiresWrongIssuerHost_Denies(PolicyFormat format)
+ {
+ // The bundled receipt was issued by esrp-cts-cp.confidential-ledger.azure.com. The
+ // policy demands a Contains predicate against an unrelated host; no fact in the
+ // produced set carries that value, so trust evaluation denies.
+ const string UnrelatedHost = "issuer-not-in-this-receipt.example.com";
+
+ string sigPath = SignedFixtureBuilder.GetMstReceiptFixturePath();
+ string jwksPath = SignedFixtureBuilder.GetMstIssuerJwksPath();
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptIssuerHost(UnrelatedHost);
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "mst-deny-wrong-issuer");
+
+ CliResult result = CliRunner.Run(
+ "verify", "scitt", sigPath,
+ "--issuer-offline-keys", $"{SignedFixtureBuilder.MstIssuerHost}={jwksPath}",
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"mst deny / wrong-issuer-host / {format}",
+ expectedStderrSubstring: TrustFailureFactRequirement);
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/Mst/MstTrustHappyPathTests.cs b/V2/CoseSign1.Trust.Integration.Tests/Mst/MstTrustHappyPathTests.cs
new file mode 100644
index 000000000..b4f5e61d2
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/Mst/MstTrustHappyPathTests.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.Mst;
+
+using System.IO;
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// MST happy-path matrix: a real signed SCITT receipt + a trust-policy demanding the receipt
+/// be present, cryptographically trusted, and bound to an authorised issuer host. The
+/// fixtures (1ts-statement.scitt + the offline JWKS) are reused from
+/// CoseSign1.Transparent.MST.Tests via project links so the bytes are canonical and
+/// the test exercises the production verify-pipeline end to end.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class MstTrustHappyPathTests
+{
+ [SetUp]
+ public void EnsureFixturesPresent()
+ {
+ // The fixture deployment is part of the project file. If a future train rearranges the
+ // layout we want the failure to be loud + descriptive instead of a misleading
+ // "verify fails" symptom. NUnit's Ignore is appropriate here only when the bundled
+ // assets genuinely cannot be located; for this project they MUST be present.
+ Assert.That(File.Exists(SignedFixtureBuilder.GetMstReceiptFixturePath()),
+ "Bundled SCITT receipt fixture must be deployed alongside the test assembly.");
+ Assert.That(File.Exists(SignedFixtureBuilder.GetMstIssuerJwksPath()),
+ "Bundled MST issuer JWKS must be deployed alongside the test assembly.");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_RealReceipt_PolicyRequiresPresentAndTrusted_Succeeds(PolicyFormat format)
+ {
+ string sigPath = SignedFixtureBuilder.GetMstReceiptFixturePath();
+ string jwksPath = SignedFixtureBuilder.GetMstIssuerJwksPath();
+
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptPresentAndTrusted();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "mst-present-trusted");
+
+ CliResult result = CliRunner.Run(
+ "verify", "scitt", sigPath,
+ "--issuer-offline-keys", $"{SignedFixtureBuilder.MstIssuerHost}={jwksPath}",
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifySucceeded(result, $"mst happy / present+trusted / {format}");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_RealReceipt_PolicyFiltersIssuerHost_Match_Succeeds(PolicyFormat format)
+ {
+ string sigPath = SignedFixtureBuilder.GetMstReceiptFixturePath();
+ string jwksPath = SignedFixtureBuilder.GetMstIssuerJwksPath();
+
+ (string json, string rego) = PolicyDocumentBuilder.MstReceiptIssuerHost(SignedFixtureBuilder.MstIssuerHost);
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "mst-issuer-match");
+
+ CliResult result = CliRunner.Run(
+ "verify", "scitt", sigPath,
+ "--issuer-offline-keys", $"{SignedFixtureBuilder.MstIssuerHost}={jwksPath}",
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifySucceeded(result, $"mst happy / issuer-host-match / {format}");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/Usings.cs b/V2/CoseSign1.Trust.Integration.Tests/Usings.cs
new file mode 100644
index 000000000..298fabc81
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/Usings.cs
@@ -0,0 +1,4 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+global using NUnit.Framework;
diff --git a/V2/CoseSign1.Trust.Integration.Tests/X509/X509ParameterTests.cs b/V2/CoseSign1.Trust.Integration.Tests/X509/X509ParameterTests.cs
new file mode 100644
index 000000000..d5110b14e
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/X509/X509ParameterTests.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.X509;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// Parametrised X.509 allow-list. The trust-policy document references a $param
+/// placeholder for the expected leaf subject. The CLI's --trust-policy-param
+/// name=jsonValue flag binds that placeholder; the matching binding succeeds, the
+/// non-matching binding denies, and the unbound case is exercised by the anti-pattern suite.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class X509ParameterTests
+{
+ private const string RevocationModeNone = "none";
+ private const string ParamName = "expected_subject";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_ParametrisedSubject_MatchingBinding_Succeeds(PolicyFormat format)
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_ParametrisedSubject_MatchingBinding_Succeeds));
+ // Leaf subject is "CN=Test Leaf: "; we bind to the full DN string.
+ string fullSubject = "CN=Test Leaf: " + nameof(Verify_ParametrisedSubject_MatchingBinding_Succeeds);
+ (string json, string rego) = PolicyDocumentBuilder.X509SubjectEqualsParam(ParamName, defaultCn: "PLACEHOLDER");
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-param-match");
+
+ // --trust-policy-param expects name=jsonValue, so the value side is JSON-quoted.
+ string paramArg = $"{ParamName}=\"{fullSubject}\"";
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath,
+ "--trust-policy-param", paramArg);
+
+ CliAssertions.AssertVerifySucceeded(result, $"x509 param / matching / {format}");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_ParametrisedSubject_MismatchingBinding_Denies(PolicyFormat format)
+ {
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_ParametrisedSubject_MismatchingBinding_Denies));
+ (string json, string rego) = PolicyDocumentBuilder.X509SubjectEqualsParam(ParamName, defaultCn: "PLACEHOLDER");
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-param-miss");
+
+ // Bind to a value that cannot match any real cert subject.
+ string paramArg = $"{ParamName}=\"CN=NotInChain\"";
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath,
+ "--trust-policy-param", paramArg);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"x509 param / mismatching / {format}",
+ expectedStderrSubstring: "TRUST_PLAN_NOT_SATISFIED");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/X509/X509TrustDenyTests.cs b/V2/CoseSign1.Trust.Integration.Tests/X509/X509TrustDenyTests.cs
new file mode 100644
index 000000000..88bfe835a
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/X509/X509TrustDenyTests.cs
@@ -0,0 +1,99 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.X509;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// X.509 deny matrix: trusted-vs-untrusted chain pairings, identity allow-list deny, and EKU
+/// requirement that the cert cannot satisfy. Each cell verifies the CLI reports a non-zero
+/// exit and that the trust-failure surface points at the right fact.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class X509TrustDenyTests
+{
+ private const string RevocationModeNone = "none";
+ private const string TrustFailureFactRequirement = "TRUST_PLAN_NOT_SATISFIED";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_UntrustedChain_PolicyRequiresChainTrusted_Denies(PolicyFormat format)
+ {
+ // Arrange: build a real chain but DO NOT register its root with the verifier.
+ // The chain validates cryptographically but x509-chain-trusted/v1 produces is_trusted=false
+ // because the leaf doesn't chain up to a known anchor.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_UntrustedChain_PolicyRequiresChainTrusted_Denies));
+ (string json, string rego) = PolicyDocumentBuilder.X509ChainTrusted();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-deny-untrusted");
+
+ // Act: omit --trust-roots; force --trust-system-roots false so the test root is
+ // genuinely not trusted. Revocation off because the chain has no revocation
+ // distribution points.
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-system-roots", "false",
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"x509 deny / untrusted-chain / {format}",
+ expectedStderrSubstring: TrustFailureFactRequirement);
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_TrustedChain_PolicyAddsIdentityDenyList_Denies(PolicyFormat format)
+ {
+ // The X509 trust pack has identity pinning disabled by default, so the produced
+ // X509SigningCertificateIdentityAllowedFact carries IsAllowed=true. A policy that
+ // requires is_allowed=false therefore fails — the predicate inversion stands in for a
+ // configured deny-list match.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_TrustedChain_PolicyAddsIdentityDenyList_Denies));
+ (string json, string rego) = PolicyDocumentBuilder.X509IdentityIsAllowedFalse();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-deny-identity");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"x509 deny / identity-not-allowed / {format}",
+ expectedStderrSubstring: TrustFailureFactRequirement);
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_TrustedChain_PolicyRequiresEkuLeafLacks_Denies(PolicyFormat format)
+ {
+ // Leaf is built with TLS auth EKUs only. Demand code-signing (1.3.6.1.5.5.7.3.3) which
+ // is absent — the resulting EKU fact set has no fact whose oid_value matches.
+ const string LeafTlsAuthOid = "1.3.6.1.5.5.7.3.1";
+ const string MissingCodeSigningOid = "1.3.6.1.5.5.7.3.3";
+
+ using var fixture = SignedFixtureBuilder.CreateX509SignedWithLeafEkus(
+ nameof(Verify_TrustedChain_PolicyRequiresEkuLeafLacks_Denies),
+ LeafTlsAuthOid);
+ (string json, string rego) = PolicyDocumentBuilder.X509EkuOid(MissingCodeSigningOid);
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-deny-eku");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifyDenied(
+ result,
+ scenario: $"x509 deny / eku-mismatch / {format}",
+ expectedStderrSubstring: TrustFailureFactRequirement);
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration.Tests/X509/X509TrustHappyPathTests.cs b/V2/CoseSign1.Trust.Integration.Tests/X509/X509TrustHappyPathTests.cs
new file mode 100644
index 000000000..2284ea62a
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration.Tests/X509/X509TrustHappyPathTests.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Tests.X509;
+
+using CoseSign1.Trust.Integration.Infrastructure;
+
+///
+/// X.509 happy-path matrix: a trusted chain + a trust-policy document whose predicates the
+/// produced facts satisfy. Every test runs the real cosesigntool verify x509 ...
+/// pipeline in-process and asserts on the captured exit code and stderr surface. JSON and
+/// Rego variants share the same logical policy, so cross-format equivalence is asserted at
+/// the end of each test.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class X509TrustHappyPathTests
+{
+ private const string RevocationModeNone = "none";
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_TrustedChain_PolicyRequiresChainTrusted_Succeeds(PolicyFormat format)
+ {
+ // Arrange: a real chain + signed message + a policy file demanding the chain be trusted.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_TrustedChain_PolicyRequiresChainTrusted_Succeeds));
+ (string json, string rego) = PolicyDocumentBuilder.X509ChainTrusted();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-trusted");
+
+ // Act: verify with the test root added as a custom trust anchor + revocation disabled
+ // (test certs are not published; the online OCSP/CRL fetch would otherwise stall).
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ // Assert: clean success on both frontends.
+ CliAssertions.AssertVerifySucceeded(result, $"x509 happy / chain-trusted / {format}");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_TrustedChain_PolicyAddsIdentityAllowList_Succeeds(PolicyFormat format)
+ {
+ // The CLI does not surface identity-pinning today, so the produced
+ // X509SigningCertificateIdentityAllowedFact carries IsAllowed=true unconditionally.
+ // A policy that requires is_allowed=true therefore validates against any cert that
+ // came through the trust-pack producer — which is exactly the integration-level
+ // contract this test pins.
+ using var fixture = SignedFixtureBuilder.CreateX509Signed(nameof(Verify_TrustedChain_PolicyAddsIdentityAllowList_Succeeds));
+ (string json, string rego) = PolicyDocumentBuilder.X509IdentityIsAllowedTrue();
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-id-allowed");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifySucceeded(result, $"x509 happy / identity-allowed / {format}");
+ }
+
+ [TestCase(PolicyFormat.Json)]
+ [TestCase(PolicyFormat.Rego)]
+ public void Verify_TrustedChain_PolicyRequiresEkuOnLeaf_Succeeds(PolicyFormat format)
+ {
+ // The default chain factory does NOT attach EKUs to the leaf, so this fixture pins the
+ // leaf's EKU set explicitly via the chain factory's WithLeafEkus. The matrix cell
+ // demands the EKU be satisfied by the cert; we choose TLS server auth.
+ const string TlsServerAuthOid = "1.3.6.1.5.5.7.3.1";
+ using var fixture = SignedFixtureBuilder.CreateX509SignedWithLeafEkus(
+ nameof(Verify_TrustedChain_PolicyRequiresEkuOnLeaf_Succeeds),
+ TlsServerAuthOid);
+ (string json, string rego) = PolicyDocumentBuilder.X509EkuOid(TlsServerAuthOid);
+ string body = format == PolicyFormat.Json ? json : rego;
+ string policyPath = PolicyDocumentBuilder.Write(format, body, "x509-eku");
+
+ CliResult result = CliRunner.Run(
+ "verify", "x509", fixture.SignaturePath,
+ "--trust-roots", fixture.RootPemPath!,
+ "--revocation-mode", RevocationModeNone,
+ "--trust-policy", policyPath);
+
+ CliAssertions.AssertVerifySucceeded(result, $"x509 happy / eku-server-auth / {format}");
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration/CoseSign1.Trust.Integration.csproj b/V2/CoseSign1.Trust.Integration/CoseSign1.Trust.Integration.csproj
new file mode 100644
index 000000000..0d9585980
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration/CoseSign1.Trust.Integration.csproj
@@ -0,0 +1,48 @@
+
+
+
+ net10.0
+ enable
+ latest
+ false
+ true
+
+ $(NoWarn);CSTDOC001;CSTSTR001;CSTGUARD001;CA2252;SYSLIB5006;CS1591;SA1600;SA1611;SA1615;SA1623;SA1633
+ CoseSign1.Trust.Integration
+ CoseSign1.Trust.Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/V2/CoseSign1.Trust.Integration/Infrastructure/CliAssertions.cs b/V2/CoseSign1.Trust.Integration/Infrastructure/CliAssertions.cs
new file mode 100644
index 000000000..9cc84c25a
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration/Infrastructure/CliAssertions.cs
@@ -0,0 +1,127 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Infrastructure;
+
+using System;
+using System.IO;
+
+///
+/// Common matrix-cell assertions reused by every fixture in the suite. Centralising the exit
+/// code + diagnostic-substring expectations keeps individual tests focused on the policy /
+/// signature configuration under test instead of re-stating boilerplate.
+///
+public static class CliAssertions
+{
+ ///
+ /// Asserts that the verify CLI produced an exit code of zero (Success) and that no
+ /// translation error or trust-failure diagnostic leaked to stderr.
+ ///
+ public static void AssertVerifySucceeded(CliResult result, string scenario)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ Assert.That(result.ExitCode, Is.Zero,
+ $"[{scenario}] expected verify to succeed (exit 0)\nSTDOUT:\n{result.Stdout}\nSTDERR:\n{result.Stderr}");
+
+ // A successful run must not leak TPX* diagnostic codes onto stderr — those indicate
+ // translation or fact-evaluation failure, which is incompatible with a 0 exit code.
+ Assert.That(result.Stderr, Does.Not.Contain("[TPX"),
+ $"[{scenario}] verify exit was 0 but stderr carried a TPX diagnostic\nSTDERR:\n{result.Stderr}");
+ }
+
+ ///
+ /// Asserts that the verify CLI rejected the request with a non-zero exit code. Optionally
+ /// asserts that stderr carries a specific TPX code (translation phase) or trust-error
+ /// substring (runtime phase). The combination keeps fixtures terse while still pinning
+ /// each failure mode to a real diagnostic.
+ ///
+ public static void AssertVerifyDenied(
+ CliResult result,
+ string scenario,
+ string? expectedDiagnosticCode = null,
+ string? expectedStderrSubstring = null)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ Assert.That(result.ExitCode, Is.Not.Zero,
+ $"[{scenario}] expected verify to deny (non-zero exit)\nSTDOUT:\n{result.Stdout}\nSTDERR:\n{result.Stderr}");
+
+ if (expectedDiagnosticCode is not null)
+ {
+ Assert.That(result.Stderr, Does.Contain(expectedDiagnosticCode),
+ $"[{scenario}] expected diagnostic code '{expectedDiagnosticCode}' on stderr\nSTDERR:\n{result.Stderr}");
+ }
+
+ if (expectedStderrSubstring is not null)
+ {
+ Assert.That(result.Stderr, Does.Contain(expectedStderrSubstring),
+ $"[{scenario}] expected substring '{expectedStderrSubstring}' on stderr\nSTDERR:\n{result.Stderr}");
+ }
+ }
+
+ ///
+ /// Cross-format equivalence regression. Asserts that two CLI invocations differing ONLY in
+ /// trust-policy frontend produce the same exit code and the same observable diagnostic
+ /// surface (TPX codes + trust-failure error codes).
+ ///
+ ///
+ /// Stderr line numbers and SourceLocation suffixes are noisy across formats (the JSON
+ /// frontend emits JSON-pointer paths; the Rego frontend emits line/column). This assertion
+ /// extracts only the leading [TPX###]/[ErrorCode] tokens so the regression
+ /// holds across both frontends without coupling tests to formatter incidentals.
+ ///
+ public static void AssertCrossFormatEquivalent(CliResult json, CliResult rego, string scenario)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+ ArgumentNullException.ThrowIfNull(rego);
+
+ Assert.That(rego.ExitCode, Is.EqualTo(json.ExitCode),
+ $"[{scenario}] cross-format exit-code mismatch: json={json.ExitCode} rego={rego.ExitCode}\n" +
+ $"JSON STDERR:\n{json.Stderr}\nREGO STDERR:\n{rego.Stderr}");
+
+ string[] jsonCodes = ExtractDiagnosticCodes(json.Stderr);
+ string[] regoCodes = ExtractDiagnosticCodes(rego.Stderr);
+
+ Assert.That(regoCodes, Is.EquivalentTo(jsonCodes),
+ $"[{scenario}] cross-format diagnostic-code set mismatch:\n" +
+ $"JSON codes: [{string.Join(",", jsonCodes)}]\nREGO codes: [{string.Join(",", regoCodes)}]\n" +
+ $"JSON STDERR:\n{json.Stderr}\nREGO STDERR:\n{rego.Stderr}");
+ }
+
+ ///
+ /// Extracts every bracketed diagnostic code (e.g. [TPX300], [Trust.E001])
+ /// from the supplied stderr text, in document order. Whitespace and surrounding text are
+ /// ignored so the result is a normalised, line-number-free fingerprint of the stderr
+ /// surface.
+ ///
+ public static string[] ExtractDiagnosticCodes(string stderr)
+ {
+ if (string.IsNullOrEmpty(stderr))
+ {
+ return Array.Empty();
+ }
+
+ var codes = new System.Collections.Generic.List();
+ using var reader = new StringReader(stderr);
+ string? line;
+ while ((line = reader.ReadLine()) is not null)
+ {
+ // Capture the FIRST [TOKEN] of each line where TOKEN starts with TPX. We deliberately
+ // ignore non-TPX bracketed prefixes (e.g. log-level markers) so cross-format equality
+ // is anchored on translation-error codes only — runtime trust failures share the
+ // same fact identifier across frontends, exit code parity covers them.
+ int open = line.IndexOf('[');
+ int close = open >= 0 ? line.IndexOf(']', open + 1) : -1;
+ if (open >= 0 && close > open)
+ {
+ string token = line.Substring(open + 1, close - open - 1).Trim();
+ if (token.StartsWith("TPX", StringComparison.Ordinal))
+ {
+ codes.Add(token);
+ }
+ }
+ }
+
+ return codes.ToArray();
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration/Infrastructure/CliRunner.cs b/V2/CoseSign1.Trust.Integration/Infrastructure/CliRunner.cs
new file mode 100644
index 000000000..084ef3b30
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration/Infrastructure/CliRunner.cs
@@ -0,0 +1,146 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Infrastructure;
+
+using System;
+using System.IO;
+using System.Threading;
+using CoseSignTool;
+using CoseSignTool.Abstractions.IO;
+
+///
+/// Result of an in-process CLI invocation. Captures the exit code plus the textual stdout and
+/// stderr streams so tests can assert on diagnostic codes (TPX*) and trust-failure reasons.
+///
+public sealed class CliResult
+{
+ public CliResult(int exitCode, string stdout, string stderr)
+ {
+ ExitCode = exitCode;
+ Stdout = stdout ?? string.Empty;
+ Stderr = stderr ?? string.Empty;
+ }
+
+ ///
+ /// Process exit code returned by CoseSignTool.Program.Run. Non-zero indicates failure.
+ ///
+ public int ExitCode { get; }
+
+ ///
+ /// All bytes captured on the test console's stdout TextWriter.
+ ///
+ public string Stdout { get; }
+
+ ///
+ /// All bytes captured on the test console's stderr TextWriter.
+ ///
+ public string Stderr { get; }
+}
+
+///
+/// Invokes the real entry point in-process so each integration test
+/// exercises the full plugin-loaded production verify pipeline (option parsing, plugin
+/// discovery, trust-policy translation, fact production, plan evaluation). The runner captures
+/// stdout, stderr, and the exit code into a for inspection.
+///
+/// The runner serializes invocations because mutates ambient state
+/// (System.CommandLine help registration, plugin AssemblyLoadContext); concurrent runs from
+/// different test fixtures could otherwise collide.
+///
+public static class CliRunner
+{
+ private static readonly SemaphoreSlim Gate = new(initialCount: 1, maxCount: 1);
+
+ ///
+ /// Runs cosesigntool verify ... with the supplied argument vector and returns the
+ /// captured exit code + console output.
+ ///
+ /// Argument vector (excluding the executable name).
+ /// The captured result. Never .
+ public static CliResult Run(params string[] args)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+
+ Gate.Wait();
+ try
+ {
+ using var console = new InMemoryCliConsole();
+ int exit = Program.Run(args, console);
+ return new CliResult(exit, console.GetStdout(), console.GetStderr());
+ }
+ finally
+ {
+ Gate.Release();
+ }
+ }
+}
+
+///
+/// Minimal implementation backed by instances.
+/// Provides empty stdin (this phase's tests pass signature paths positionally, never via stdin)
+/// and captures all writes so the test can assert on the exact diagnostic surface that ships
+/// to a real terminal user.
+///
+public sealed class InMemoryCliConsole : IConsole, IDisposable
+{
+ private readonly MemoryStream _stdin = new(Array.Empty());
+ private readonly StringWriter _stdout = new();
+ private readonly StringWriter _stderr = new();
+ private readonly MemoryStream _stdoutBinary = new();
+ private readonly MemoryStream _stderrBinary = new();
+ private bool _disposed;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — required member, no behaviour to validate.")]
+ public Stream StandardInput => _stdin;
+
+ public TextWriter StandardOutput => _stdout;
+
+ public TextWriter StandardError => _stderr;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — required member, exposed for binary stream consumers we do not exercise from these tests.")]
+ public Func StandardOutputStreamProvider => () => _stdoutBinary;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — required member, exposed for binary stream consumers we do not exercise from these tests.")]
+ public Func StandardErrorStreamProvider => () => _stderrBinary;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — required member, no behaviour to validate.")]
+ public bool IsInputRedirected => true;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — required member, no behaviour to validate.")]
+ public bool IsUserInteractive => false;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — interactive-mode only; not reachable from non-interactive CLI invocations.")]
+ public ConsoleKeyInfo ReadKey(bool intercept) => default;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — interactive-mode only; not reachable from non-interactive CLI invocations.")]
+ public string? ReadLine() => null;
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — the verify pipeline writes via StandardOutput / StandardError TextWriters, not these convenience methods.")]
+ public void Write(string? value) => _stdout.Write(value);
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — the verify pipeline writes via StandardOutput / StandardError TextWriters, not these convenience methods.")]
+ public void WriteLine() => _stdout.WriteLine();
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "IConsole interface plumbing — the verify pipeline writes via StandardOutput / StandardError TextWriters, not these convenience methods.")]
+ public void WriteLine(string? value) => _stdout.WriteLine(value);
+
+ public string GetStdout() => _stdout.ToString();
+
+ public string GetStderr() => _stderr.ToString();
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _stdin.Dispose();
+ _stdout.Dispose();
+ _stderr.Dispose();
+ _stdoutBinary.Dispose();
+ _stderrBinary.Dispose();
+ _disposed = true;
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration/Infrastructure/PolicyDocumentBuilder.cs b/V2/CoseSign1.Trust.Integration/Infrastructure/PolicyDocumentBuilder.cs
new file mode 100644
index 000000000..b959daf60
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration/Infrastructure/PolicyDocumentBuilder.cs
@@ -0,0 +1,302 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Infrastructure;
+
+using System;
+using System.IO;
+using System.Text;
+
+///
+/// Format-agnostic enumeration of the trust-policy frontends exercised by this suite. Tests
+/// declare a matrix cell once per scenario and parameterise on this enum; the
+/// emits the appropriate file for each format.
+///
+public enum PolicyFormat
+{
+ ///
+ /// cose-tp-json/v1 frontend (.coseTrustPolicy.json).
+ ///
+ Json = 0,
+
+ ///
+ /// cose-tp-rego/v1 frontend (.coseTrustPolicy.rego). The Rego file lowers to the
+ /// same canonical IR shape as the JSON form, so the same body strings serve both.
+ ///
+ Rego = 1,
+}
+
+///
+/// Authors a trust-policy document on disk in either the JSON or Rego frontend format. The
+/// builder targets one canonical scenario per test cell — instead of a generic AST builder the
+/// helper exposes scenario-specific factories that produce equivalent JSON + Rego variants.
+/// This keeps the matrix cells readable and guarantees the cross-format equivalence assertion
+/// has byte-comparable inputs (only frontend wrapper changes between formats).
+///
+public static class PolicyDocumentBuilder
+{
+ private const string RegoPackageHeader = "package cose_trust_policy\n\n";
+ private const string RegoPolicyOpener = "policy := ";
+
+ ///
+ /// Writes the supplied document body to a fresh temp file with the appropriate extension
+ /// and returns its absolute path. Caller is responsible for cleanup (most tests rely on
+ /// to clean its directory; standalone documents go under the
+ /// NUnit test directory so test failures keep the artefact for triage).
+ ///
+ public static string Write(PolicyFormat format, string body, string filenameStem)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ ArgumentException.ThrowIfNullOrEmpty(filenameStem);
+
+ string ext = format switch
+ {
+ PolicyFormat.Json => ".coseTrustPolicy.json",
+ PolicyFormat.Rego => ".coseTrustPolicy.rego",
+ _ => throw new ArgumentOutOfRangeException(nameof(format))
+ };
+
+ string root = Path.Combine(TestContext.CurrentContext.TestDirectory, "policy-output");
+ Directory.CreateDirectory(root);
+ string path = Path.Combine(root, filenameStem + "-" + Guid.NewGuid().ToString("N") + ext);
+ File.WriteAllText(path, body, Encoding.UTF8);
+ return path;
+ }
+
+ ///
+ /// Wraps a JSON object literal as a Rego policy rule. Rego policies in this dialect have a
+ /// fixed shape: a package declaration plus a single policy := { ... } rule whose body
+ /// is JSON-equivalent (no Rego comprehensions or expressions, by design).
+ ///
+ public static string WrapAsRego(string jsonObjectBody)
+ {
+ ArgumentNullException.ThrowIfNull(jsonObjectBody);
+ return string.Concat(RegoPackageHeader, RegoPolicyOpener, jsonObjectBody, "\n");
+ }
+
+ // ---------------- canonical scenario factories ----------------
+ // Each method returns a (json, rego) pair so the caller can persist either or both to disk.
+ // The Rego body lifts the same JSON object literal so cross-format equivalence holds at the
+ // IR level (Phase 4's contract). Where the JSON form uses an outer "frontend" discriminator
+ // it is omitted — the loader treats the discriminator as optional but its presence would
+ // still translate identically.
+
+ ///
+ /// Single requirement: x509-chain-trusted/v1 -> is_trusted=true.
+ ///
+ public static (string Json, string Rego) X509ChainTrusted()
+ {
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "x509-chain-trusted/v1",
+ "predicate": {"is_trusted": true}
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// Single requirement: x509-cert-identity-allowed/v1 -> is_allowed=true. Exercises
+ /// the always-pass case where the X509 trust pack has no identity pinning configured.
+ ///
+ public static (string Json, string Rego) X509IdentityIsAllowedTrue()
+ {
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "x509-cert-identity-allowed/v1",
+ "predicate": {"is_allowed": true}
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// Inverted requirement: x509-cert-identity-allowed/v1 -> is_allowed=false. Without
+ /// CLI-side pinning the produced fact always asserts is_allowed=true, so the
+ /// predicate fails and the trust plan denies — the deny-list match scenario.
+ ///
+ public static (string Json, string Rego) X509IdentityIsAllowedFalse()
+ {
+ const string body = """
+ {
+ "primary_signing_key": {
+ "fact": "x509-cert-identity-allowed/v1",
+ "predicate": {"is_allowed": false}
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// EKU OID requirement. The fact set surfaces every EKU on the leaf cert; the policy passes
+ /// when at least one fact carries the expected OID.
+ ///
+ public static (string Json, string Rego) X509EkuOid(string oid)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(oid);
+ string body = $$"""
+ {
+ "primary_signing_key": {
+ "fact": "x509-cert-eku/v1",
+ "predicate": {"oid_value": "{{oid}}"}
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// Parametrised CN allow-list. Produces an x509-cert-identity/v1 predicate that matches
+ /// when the bound parameter equals the leaf's subject CN. The policy succeeds for the
+ /// matching binding and denies otherwise.
+ ///
+ public static (string Json, string Rego) X509SubjectEqualsParam(string paramName, string defaultCn)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(paramName);
+ ArgumentException.ThrowIfNullOrEmpty(defaultCn);
+
+ // Use the path-operator predicate with operator=Equals against $.subject. The fact's
+ // Subject property is the full DN, so the param value supplied by the test must match
+ // the cert's Subject string verbatim (e.g. "CN=Test Leaf: foo").
+ string body = $$"""
+ {
+ "primary_signing_key": {
+ "fact": "x509-cert-identity/v1",
+ "predicate": {
+ "operator": "Equals",
+ "path": "$.subject",
+ "value": {"$param": "{{paramName}}", "default": "{{defaultCn}}"}
+ }
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// MST scope: receipt must be present (boolean property assertion).
+ ///
+ public static (string Json, string Rego) MstReceiptPresent()
+ {
+ const string body = """
+ {
+ "any_counter_signature": {
+ "on_empty": "deny",
+ "fact": "mst-receipt-present/v1",
+ "predicate": {"is_present": true}
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// MST scope: receipt must be cryptographically trusted. AND-combines with present.
+ ///
+ public static (string Json, string Rego) MstReceiptPresentAndTrusted()
+ {
+ const string body = """
+ {
+ "any_counter_signature": {
+ "on_empty": "deny",
+ "all_of": [
+ {"fact": "mst-receipt-present/v1", "predicate": {"is_present": true}},
+ {"fact": "mst-receipt-trusted/v1", "predicate": {"is_trusted": true}}
+ ]
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// MST scope: receipt issuer host must equal the supplied literal (no parameterisation).
+ /// Uses the path-operator predicate with operator=Contains against the fact's Hosts array.
+ ///
+ public static (string Json, string Rego) MstReceiptIssuerHost(string host)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(host);
+ string body = $$"""
+ {
+ "any_counter_signature": {
+ "on_empty": "deny",
+ "fact": "mst-receipt-issuer-host/v1",
+ "predicate": {
+ "operator": "Contains",
+ "path": "$.hosts",
+ "value": "{{host}}"
+ }
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// MST scope: parametrised issuer-host match. The bound parameter supplies the expected
+ /// host name; mismatched bindings cause a deny via the Contains predicate failing.
+ ///
+ public static (string Json, string Rego) MstReceiptIssuerHostParam(string paramName, string defaultHost)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(paramName);
+ ArgumentException.ThrowIfNullOrEmpty(defaultHost);
+
+ string body = $$"""
+ {
+ "any_counter_signature": {
+ "on_empty": "deny",
+ "fact": "mst-receipt-issuer-host/v1",
+ "predicate": {
+ "operator": "Contains",
+ "path": "$.hosts",
+ "value": {"$param": "{{paramName}}", "default": "{{defaultHost}}"}
+ }
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// MST scope: parametrised issuer-host match WITHOUT a default. Drives the unbound-parameter
+ /// path (TPX400) when the caller forgets to supply a binding.
+ ///
+ public static (string Json, string Rego) MstReceiptIssuerHostUnboundParam(string paramName)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(paramName);
+
+ string body = $$"""
+ {
+ "any_counter_signature": {
+ "on_empty": "deny",
+ "fact": "mst-receipt-issuer-host/v1",
+ "predicate": {
+ "operator": "Contains",
+ "path": "$.hosts",
+ "value": {"$param": "{{paramName}}"}
+ }
+ }
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+
+ ///
+ /// Trivial allow-all message scope. Used by D8 override looser-doc tests where the policy
+ /// must accept everything that survived signature verification.
+ ///
+ public static (string Json, string Rego) MessageAllowAll()
+ {
+ const string body = """
+ {
+ "message": {"allow_all": true}
+ }
+ """;
+ return (body, WrapAsRego(body));
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration/Infrastructure/SignedFixtureBuilder.cs b/V2/CoseSign1.Trust.Integration/Infrastructure/SignedFixtureBuilder.cs
new file mode 100644
index 000000000..7105dcf24
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration/Infrastructure/SignedFixtureBuilder.cs
@@ -0,0 +1,304 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Trust.Integration.Infrastructure;
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using CoseSign1.Certificates;
+using CoseSign1.Certificates.Local;
+using CoseSign1.Factories.Direct;
+using CoseSign1.Tests.Common;
+
+///
+/// On-disk artefacts produced by . Owns a temp directory
+/// that contains the COSE signature and (for X509 chains) a PEM-encoded root certificate the
+/// CLI can pick up via --trust-roots. Disposing the fixture removes the directory.
+///
+public sealed class SignedFixture : IDisposable
+{
+ private readonly string _directory;
+ private bool _disposed;
+
+ internal SignedFixture(string directory, string signaturePath, string? rootPemPath, string? leafSubjectCn, string? leafThumbprint)
+ {
+ _directory = directory;
+ SignaturePath = signaturePath;
+ RootPemPath = rootPemPath;
+ LeafSubjectCn = leafSubjectCn;
+ LeafThumbprint = leafThumbprint;
+ }
+
+ ///
+ /// Absolute path of the produced .cose file with embedded payload + x5chain.
+ ///
+ public string SignaturePath { get; }
+
+ ///
+ /// Absolute path of the PEM-encoded root certificate, or when the
+ /// fixture was built without a chain (e.g., MST receipt fixtures imported from disk).
+ ///
+ public string? RootPemPath { get; }
+
+ ///
+ /// Common name of the leaf certificate, or for non-X509 fixtures.
+ /// Tests use this to author x509-cert-identity-allowed/v1 + parameter-binding cases.
+ ///
+ public string? LeafSubjectCn { get; }
+
+ ///
+ /// Hex thumbprint of the leaf certificate, or for non-X509 fixtures.
+ ///
+ public string? LeafThumbprint { get; }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ TryDeleteDirectory(_directory);
+ _disposed = true;
+ }
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "Best-effort cleanup; the catch arm only fires when the OS denies the directory-delete (e.g., another process holds a file handle), which the test suite does not synthesise.")]
+ private static void TryDeleteDirectory(string directory)
+ {
+ try
+ {
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup; failures here would mask real test failures.
+ }
+ }
+}
+
+///
+/// Builds end-to-end test fixtures: a signed COSE message + (optionally) a PEM-encoded trust
+/// root that lets the CLI's verify x509 command treat the chain as trusted. The factory
+/// reuses so chains match every other V2 test surface.
+///
+public static class SignedFixtureBuilder
+{
+ private const string DefaultPayload = "Trust-policy integration test payload";
+ private const string ContentType = "application/cose-trust-policy-itest";
+
+ ///
+ /// Creates a signed COSE_Sign1 message under a fresh ECDSA test chain. Root + intermediate +
+ /// leaf are produced via the canonical chain factory so the leaf's CN and EKU set match the
+ /// Phase 2/3 expectations. The leaf signs the supplied payload (or a default one) and the
+ /// resulting message embeds the full x5chain so the verifier resolves the signing key
+ /// without out-of-band material.
+ ///
+ /// Used to disambiguate chain CNs across parallel tests.
+ /// Optional payload bytes; a default UTF-8 marker is used otherwise.
+ /// Disposable fixture containing the .cose file and a PEM trust-root file.
+ public static SignedFixture CreateX509Signed(string testName, byte[]? payload = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(testName);
+
+ string dir = CreateTempDirectory(testName);
+
+ // Use ECDSA P-256 so the signing path matches the production default + tests are fast.
+ // leafFirst:true lets the producer pull the leaf as chain[0] to match X509-test conventions.
+ var chain = TestCertificateUtils.CreateTestChain(testName, useEcc: true, keySize: 256, leafFirst: true);
+ try
+ {
+ using var leaf = chain[0];
+ var chainArray = chain.Cast().ToArray();
+ X509Certificate2 root = chainArray[chainArray.Length - 1];
+
+ using var signingService = CertificateSigningService.Create(leaf, chainArray);
+ using var factory = new DirectSignatureFactory(signingService);
+
+ byte[] payloadBytes = payload ?? Encoding.UTF8.GetBytes(DefaultPayload);
+ byte[] cose = factory.CreateCoseSign1MessageBytes(payloadBytes, ContentType);
+
+ string signaturePath = Path.Combine(dir, "signed.cose");
+ File.WriteAllBytes(signaturePath, cose);
+
+ string rootPemPath = Path.Combine(dir, "root.pem");
+ File.WriteAllText(rootPemPath, root.ExportCertificatePem());
+
+ // ExtractCommonName isn't worth a separate helper — the chain factory is documented
+ // to produce CN=, but we read the cert to be defensive
+ // about future changes to the factory.
+ string subjectCn = ExtractCommonName(leaf.Subject);
+
+ return new SignedFixture(dir, signaturePath, rootPemPath, subjectCn, leaf.Thumbprint);
+ }
+ catch
+ {
+ CleanupOnConstructionFailure(dir, chain);
+ throw;
+ }
+ finally
+ {
+ // The signing service holds its own clones; release the originals after the message
+ // has been encoded so we don't keep duplicate handles open.
+ for (int i = 1; i < chain.Count; i++)
+ {
+ chain[i].Dispose();
+ }
+ }
+ }
+
+ ///
+ /// Builds a signed X509 chain whose leaf carries the supplied custom EKU OIDs. Used by EKU
+ /// matrix tests that need a specific OID present (or absent) on the certificate.
+ ///
+ /// Disambiguator for the leaf CN.
+ /// Closed list of OIDs to attach as the leaf's enhanced key usages.
+ /// A disposable fixture with the signed message + trust-root PEM.
+ public static SignedFixture CreateX509SignedWithLeafEkus(string testName, params string[] ekuOids)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(testName);
+ ArgumentNullException.ThrowIfNull(ekuOids);
+
+ string dir = CreateTempDirectory(testName);
+
+ // Use the canonical chain factory but pin the leaf's EKU set explicitly. The factory
+ // produces a non-CA leaf with KeyUsage=DigitalSignature, which both keeps chain
+ // validation honest and means the produced X509SigningCertificateEkuFact set carries
+ // exactly the OIDs we requested.
+ var chain = TestCertificateUtils.Chain.CreateChain(o =>
+ {
+ o.WithRootName("CN=ItestRoot-" + testName)
+ .WithIntermediateName("CN=ItestIntermediate-" + testName)
+ .WithLeafName("CN=ItestLeaf-" + testName)
+ .WithKeyAlgorithm(KeyAlgorithm.ECDSA)
+ .WithKeySize(256)
+ .WithLeafEkus(ekuOids)
+ .LeafFirstOrder();
+ });
+
+ try
+ {
+ using var leaf = chain[0];
+ var chainArray = chain.Cast().ToArray();
+ X509Certificate2 root = chainArray[chainArray.Length - 1];
+
+ using var signingService = CertificateSigningService.Create(leaf, chainArray);
+ using var factory = new DirectSignatureFactory(signingService);
+
+ byte[] payloadBytes = Encoding.UTF8.GetBytes(DefaultPayload);
+ byte[] cose = factory.CreateCoseSign1MessageBytes(payloadBytes, ContentType);
+
+ string signaturePath = Path.Combine(dir, "signed.cose");
+ File.WriteAllBytes(signaturePath, cose);
+
+ string rootPemPath = Path.Combine(dir, "root.pem");
+ File.WriteAllText(rootPemPath, root.ExportCertificatePem());
+
+ return new SignedFixture(dir, signaturePath, rootPemPath, ExtractCommonName(leaf.Subject), leaf.Thumbprint);
+ }
+ catch
+ {
+ CleanupOnConstructionFailure(dir, chain);
+ throw;
+ }
+ finally
+ {
+ // Release every cert except chain[0] (already disposed via the using block).
+ for (int i = 1; i < chain.Count; i++)
+ {
+ chain[i].Dispose();
+ }
+ }
+ }
+
+ ///
+ /// Releases certificate handles and removes the temp directory created for a fixture whose
+ /// construction subsequently failed. Excluded from coverage because the only paths that
+ /// reach it are exceptional (cert factory failure, COSE encoding failure, file IO failure)
+ /// and synthesising those reliably from a unit test would brittle-couple to internal
+ /// behaviour of certificate construction in net10.0.
+ ///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "Defensive cleanup for fixture-construction failures; reaching this path requires synthesising a failure inside the .NET cert factory or COSE encoder, neither of which the integration suite drives.")]
+ private static void CleanupOnConstructionFailure(string dir, X509Certificate2Collection chain)
+ {
+ foreach (X509Certificate2 cert in chain)
+ {
+ cert.Dispose();
+ }
+
+ try
+ {
+ if (Directory.Exists(dir))
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Cleanup is best-effort — let the original exception propagate.
+ }
+ }
+
+ ///
+ /// Returns the absolute path of the bundled SCITT receipt fixture (deployed via the test
+ /// project's TestData\Scitt link). The fixture is read-only and shared across tests.
+ ///
+ /// File-name stem (e.g., 1ts-statement).
+ /// Absolute path of the .scitt file.
+ public static string GetMstReceiptFixturePath(string receiptName = "1ts-statement")
+ {
+ string baseDir = TestContext.CurrentContext.TestDirectory;
+ return Path.Combine(baseDir, "TestData", "Scitt", receiptName + ".scitt");
+ }
+
+ ///
+ /// Returns the absolute path of the bundled MST issuer JWKS file used to verify the SCITT
+ /// fixture's receipt offline.
+ ///
+ public static string GetMstIssuerJwksPath()
+ {
+ string baseDir = TestContext.CurrentContext.TestDirectory;
+ return Path.Combine(baseDir, "TestData", "Mst", "esrp-cts-cp.confidential-ledger.azure.com.jwks.json");
+ }
+
+ ///
+ /// Canonical issuer host bound by the bundled JWKS. Tests use this both as the
+ /// verify scitt --issuer-offline-keys <host>=<path> input and as the
+ /// expected value inside the trust-policy document predicates.
+ ///
+ public const string MstIssuerHost = "esrp-cts-cp.confidential-ledger.azure.com";
+
+ private static string CreateTempDirectory(string testName)
+ {
+ // Place under the test directory so artefacts are co-located with the build output —
+ // makes failure triage straightforward and avoids polluting the user's TEMP root.
+ string root = Path.Combine(TestContext.CurrentContext.TestDirectory, "fixture-output", testName + "-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ return root;
+ }
+
+ private static string ExtractCommonName(string distinguishedName)
+ {
+ if (string.IsNullOrEmpty(distinguishedName))
+ {
+ return string.Empty;
+ }
+
+ // Distinguished names are emitted as "CN=, OU=..., ..."; we want only the CN.
+ foreach (string part in distinguishedName.Split(','))
+ {
+ string trimmed = part.Trim();
+ if (trimmed.StartsWith("CN=", StringComparison.Ordinal))
+ {
+ return trimmed[3..];
+ }
+ }
+
+ return distinguishedName;
+ }
+}
diff --git a/V2/CoseSign1.Trust.Integration/Usings.cs b/V2/CoseSign1.Trust.Integration/Usings.cs
new file mode 100644
index 000000000..298fabc81
--- /dev/null
+++ b/V2/CoseSign1.Trust.Integration/Usings.cs
@@ -0,0 +1,4 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+global using NUnit.Framework;
diff --git a/V2/CoseSign1.Validation.Tests/IndirectSignatureValidatorTests.cs b/V2/CoseSign1.Validation.Tests/IndirectContentDigestValidatorTests.cs
similarity index 87%
rename from V2/CoseSign1.Validation.Tests/IndirectSignatureValidatorTests.cs
rename to V2/CoseSign1.Validation.Tests/IndirectContentDigestValidatorTests.cs
index 1b7eb2fc8..5dc2681f3 100644
--- a/V2/CoseSign1.Validation.Tests/IndirectSignatureValidatorTests.cs
+++ b/V2/CoseSign1.Validation.Tests/IndirectContentDigestValidatorTests.cs
@@ -13,11 +13,11 @@ namespace CoseSign1.Validation.Tests;
using Moq;
///
-/// Tests for .
+/// Tests for .
///
[TestFixture]
[Category("Validation")]
-public class IndirectSignatureValidatorTests
+public class IndirectContentDigestValidatorTests
{
private static readonly byte[] TestPayload = Encoding.UTF8.GetBytes("test payload for validation");
@@ -26,7 +26,7 @@ public class IndirectSignatureValidatorTests
[Test]
public void Constructor_NoLogger_CreatesInstance()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
Assert.That(validator, Is.Not.Null);
}
@@ -34,8 +34,8 @@ public void Constructor_NoLogger_CreatesInstance()
[Test]
public void Constructor_WithLogger_CreatesInstance()
{
- var mockLogger = new Mock>();
- var validator = new IndirectSignatureValidator(mockLogger.Object);
+ var mockLogger = new Mock>();
+ var validator = new IndirectContentDigestValidator(mockLogger.Object);
Assert.That(validator, Is.Not.Null);
}
@@ -49,7 +49,7 @@ public void Constructor_WithLogger_CreatesInstance()
[Test]
public void Validate_DirectSignature_ReturnsNotApplicable()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateDirectSignatureMessage();
var context = CreateContext(message, null);
@@ -58,7 +58,7 @@ public void Validate_DirectSignature_ReturnsNotApplicable()
Assert.Multiple(() =>
{
Assert.That(result.IsNotApplicable, Is.True);
- Assert.That(result.ValidatorName, Is.EqualTo("IndirectSignatureValidator"));
+ Assert.That(result.ValidatorName, Is.EqualTo("IndirectContentDigestValidator"));
});
}
@@ -69,7 +69,7 @@ public void Validate_DirectSignature_ReturnsNotApplicable()
[Test]
public void Validate_NullContext_ThrowsArgumentNullException()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
Assert.Throws(() => validator.Validate(null!));
}
@@ -81,7 +81,7 @@ public void Validate_NullContext_ThrowsArgumentNullException()
[Test]
public void Validate_CoseHashEnvelope_NoPayload_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload);
var context = CreateContext(message, null);
@@ -97,7 +97,7 @@ public void Validate_CoseHashEnvelope_NoPayload_ReturnsFailure()
[Test]
public void Validate_CoseHashEnvelope_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload);
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -106,14 +106,14 @@ public void Validate_CoseHashEnvelope_ValidHash_ReturnsSuccess()
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True);
- Assert.That(result.Metadata, Contains.Key("IndirectSignatureType"));
+ Assert.That(result.Metadata, Contains.Key("ContentDigestType"));
});
}
[Test]
public void Validate_CoseHashEnvelope_InvalidHash_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload);
var wrongPayload = Encoding.UTF8.GetBytes("wrong payload");
var context = CreateContext(message, new MemoryStream(wrongPayload));
@@ -130,7 +130,7 @@ public void Validate_CoseHashEnvelope_InvalidHash_ReturnsFailure()
[Test]
public void Validate_CoseHashEnvelope_StreamPositionResets()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload);
var stream = new MemoryStream(TestPayload);
stream.Position = 5; // Set position somewhere in the middle
@@ -148,7 +148,7 @@ public void Validate_CoseHashEnvelope_StreamPositionResets()
[Test]
public void Validate_CoseHashV_NoPayload_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessage(TestPayload);
var context = CreateContext(message, null);
@@ -164,7 +164,7 @@ public void Validate_CoseHashV_NoPayload_ReturnsFailure()
[Test]
public void Validate_CoseHashV_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessage(TestPayload);
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -176,7 +176,7 @@ public void Validate_CoseHashV_ValidHash_ReturnsSuccess()
[Test]
public void Validate_CoseHashV_InvalidHash_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessage(TestPayload);
var wrongPayload = Encoding.UTF8.GetBytes("wrong payload");
var context = CreateContext(message, new MemoryStream(wrongPayload));
@@ -197,7 +197,7 @@ public void Validate_CoseHashV_InvalidHash_ReturnsFailure()
[Test]
public void Validate_HashLegacy_NoPayload_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateHashLegacyMessage(TestPayload);
var context = CreateContext(message, null);
@@ -213,7 +213,7 @@ public void Validate_HashLegacy_NoPayload_ReturnsFailure()
[Test]
public void Validate_HashLegacy_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateHashLegacyMessage(TestPayload);
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -225,7 +225,7 @@ public void Validate_HashLegacy_ValidHash_ReturnsSuccess()
[Test]
public void Validate_HashLegacy_InvalidHash_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateHashLegacyMessage(TestPayload);
var wrongPayload = Encoding.UTF8.GetBytes("wrong payload");
var context = CreateContext(message, new MemoryStream(wrongPayload));
@@ -242,7 +242,7 @@ public void Validate_HashLegacy_InvalidHash_ReturnsFailure()
[Test]
public void Validate_HashLegacy_SHA384_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateHashLegacyMessage(TestPayload, "sha384");
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -254,7 +254,7 @@ public void Validate_HashLegacy_SHA384_ValidHash_ReturnsSuccess()
[Test]
public void Validate_HashLegacy_SHA512_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateHashLegacyMessage(TestPayload, "sha512");
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -266,7 +266,7 @@ public void Validate_HashLegacy_SHA512_ValidHash_ReturnsSuccess()
[Test]
public void Validate_HashLegacy_SHA1_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateHashLegacyMessage(TestPayload, "sha1");
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -278,7 +278,7 @@ public void Validate_HashLegacy_SHA1_ValidHash_ReturnsSuccess()
[Test]
public void Validate_HashLegacy_UnsupportedAlgorithm_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
// Create a message with an unsupported algorithm suffix
var message = CreateHashLegacyMessageWithCustomAlgo(TestPayload, "md5");
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -291,7 +291,7 @@ public void Validate_HashLegacy_UnsupportedAlgorithm_ReturnsFailure()
[Test]
public void Validate_HashLegacy_WithDashes_ExtractsOnlyAlgoBeforeDash()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
// Test algorithm name with dashes like "sha-256"
// The regex \+hash-(?[\w_]+) will only capture "sha" before the dash
// which is not a supported algorithm, so validation fails
@@ -307,7 +307,7 @@ public void Validate_HashLegacy_WithDashes_ExtractsOnlyAlgoBeforeDash()
[Test]
public void Validate_HashLegacy_WithUnderscores_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
// Test algorithm name with underscores like "sha_256"
// Underscores ARE included in \w character class, so "sha_256" is captured
var message = CreateHashLegacyMessage(TestPayload, "sha_256");
@@ -325,7 +325,7 @@ public void Validate_HashLegacy_WithUnderscores_ValidHash_ReturnsSuccess()
[Test]
public void Validate_CoseHashEnvelope_SHA384_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload, -43); // SHA-384
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -337,7 +337,7 @@ public void Validate_CoseHashEnvelope_SHA384_ValidHash_ReturnsSuccess()
[Test]
public void Validate_CoseHashEnvelope_SHA512_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload, -44); // SHA-512
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -349,7 +349,7 @@ public void Validate_CoseHashEnvelope_SHA512_ValidHash_ReturnsSuccess()
[Test]
public void Validate_CoseHashEnvelope_UnsupportedAlgorithm_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload, -99); // Unsupported
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -365,7 +365,7 @@ public void Validate_CoseHashEnvelope_UnsupportedAlgorithm_ReturnsFailure()
[Test]
public void Validate_CoseHashV_SHA384_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessage(TestPayload, -43); // SHA-384
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -377,7 +377,7 @@ public void Validate_CoseHashV_SHA384_ValidHash_ReturnsSuccess()
[Test]
public void Validate_CoseHashV_SHA512_ValidHash_ReturnsSuccess()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessage(TestPayload, -44); // SHA-512
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -389,7 +389,7 @@ public void Validate_CoseHashV_SHA512_ValidHash_ReturnsSuccess()
[Test]
public void Validate_CoseHashV_UnsupportedAlgorithm_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessage(TestPayload, -99); // Unsupported
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -401,7 +401,7 @@ public void Validate_CoseHashV_UnsupportedAlgorithm_ReturnsFailure()
[Test]
public void Validate_CoseHashV_InvalidStructure_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessageWithInvalidStructure();
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -413,7 +413,7 @@ public void Validate_CoseHashV_InvalidStructure_ReturnsFailure()
[Test]
public void Validate_CoseHashV_ArrayTooShort_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashVMessageWithShortArray();
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -425,7 +425,7 @@ public void Validate_CoseHashV_ArrayTooShort_ReturnsFailure()
[Test]
public void Validate_HashLegacy_NoContentType_ReturnsFailure()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateMessageWithPayloadHashAlgButNoContent();
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -441,7 +441,7 @@ public void Validate_HashLegacy_NoContentType_ReturnsFailure()
[Test]
public async Task ValidateAsync_ReturnsResultFromValidate()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
var message = CreateCoseHashEnvelopeMessage(TestPayload);
var context = CreateContext(message, new MemoryStream(TestPayload));
@@ -453,7 +453,7 @@ public async Task ValidateAsync_ReturnsResultFromValidate()
[Test]
public async Task ValidateAsync_NullContext_ThrowsArgumentNullException()
{
- var validator = new IndirectSignatureValidator();
+ var validator = new IndirectContentDigestValidator();
await Task.Run(() => Assert.ThrowsAsync(() => validator.ValidateAsync(null!)));
}
diff --git a/V2/CoseSign1.Validation.Tests/ServiceCollectionExtensionsTests.cs b/V2/CoseSign1.Validation.Tests/ServiceCollectionExtensionsTests.cs
index 44c5c516e..cc9ee51b6 100644
--- a/V2/CoseSign1.Validation.Tests/ServiceCollectionExtensionsTests.cs
+++ b/V2/CoseSign1.Validation.Tests/ServiceCollectionExtensionsTests.cs
@@ -29,7 +29,7 @@ public void ConfigureCoseValidation_IsIdempotent_ForCoreRegistrations()
&& sd.ImplementationType == typeof(CoreMessageFactsProducer));
var postValidatorCount = services.Count(sd => sd.ServiceType == typeof(CoseSign1.Validation.Interfaces.IPostSignatureValidator)
- && sd.ImplementationType == typeof(IndirectSignatureValidator));
+ && sd.ImplementationType == typeof(IndirectContentDigestValidator));
var validatorFactoryCount = services.Count(sd => sd.ServiceType == typeof(CoseSign1.Validation.DependencyInjection.ICoseSign1ValidatorFactory));
diff --git a/V2/CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests/AttributeDrivenFactRegistryConformanceTests.cs b/V2/CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests/AttributeDrivenFactRegistryConformanceTests.cs
new file mode 100644
index 000000000..6ca147b97
--- /dev/null
+++ b/V2/CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests/AttributeDrivenFactRegistryConformanceTests.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CoseSign1.Validation.Trust.PlanPolicy.Spec.Registry;
+
+///
+/// Immutable contract test for Phase 3: the attribute-driven registry MUST report exactly
+/// the same (id, fullTypeName) tuples as the Phase 1 hand-rolled
+/// baseline.
+///
+///
+///
+/// Renaming any v1 fact id is a v2 breaking change (Phase 1 ship contract). This test is the
+/// guard rail: a divergence between the two registries fails the build before a rename can ship.
+/// Updating the test to permit a rename requires going through the v2 migration process (and
+/// updating both registries in lock-step plus the v2 changelog).
+///
+///
+/// The test compares s of "id|fullTypeName" tuples so any
+/// addition, removal, or rename surfaces as a clear diff in the assertion message.
+///
+///
+[TestFixture]
+[Category("TrustPolicySpec")]
+[Category("Conformance")]
+public sealed class AttributeDrivenFactRegistryConformanceTests
+{
+ private const string Separator = "|";
+
+ [Test]
+ public void AttributeDriven_Equals_StaticBaseline()
+ {
+ SortedSet baselineTuples = BuildTupleSet(BuildBaselineMappings());
+ SortedSet attributeTuples = BuildTupleSet(BuildAttributeDrivenMappings());
+
+ // SetEquals gives correct semantics; produce a useful failure message ourselves so a
+ // diff is visible without re-running.
+ bool equal = baselineTuples.SetEquals(attributeTuples);
+ if (!equal)
+ {
+ string missingFromAttribute = string.Join(", ", baselineTuples.Except(attributeTuples));
+ string extraInAttribute = string.Join(", ", attributeTuples.Except(baselineTuples));
+ Assert.Fail(
+ "AttributeDrivenFactRegistry diverges from StaticFactRegistry baseline.\n"
+ + " Missing from attribute-driven (present in baseline): " + missingFromAttribute + "\n"
+ + " Extra in attribute-driven (absent from baseline): " + extraInAttribute);
+ }
+
+ Assert.That(attributeTuples, Has.Count.EqualTo(baselineTuples.Count));
+ }
+
+ [Test]
+ public void AttributeDriven_HasSameCardinality_AsBaseline()
+ {
+ Assert.That(
+ BuildAttributeDrivenMappings().Count,
+ Is.EqualTo(BuildBaselineMappings().Count));
+ }
+
+ [Test]
+ public void AttributeDriven_AllFactIds_AreLexicographicallyOrdered()
+ {
+ var registry = AttributeDrivenFactRegistry.FromLoadedAssemblies();
+ var ids = registry.AllFactIds.ToList();
+ for (int i = 1; i < ids.Count; i++)
+ {
+ Assert.That(
+ StringComparer.Ordinal.Compare(ids[i - 1], ids[i]) < 0,
+ $"AllFactIds must be sorted; '{ids[i - 1]}' should come before '{ids[i]}'.");
+ }
+ }
+
+ private static IReadOnlyDictionary BuildBaselineMappings()
+ {
+#pragma warning disable CS0618 // StaticFactRegistry is the conformance baseline; obsolescence is intentional.
+ IReadOnlyList> baseline = StaticFactRegistry.BuildDefaultMappings();
+#pragma warning restore CS0618
+ var dict = new Dictionary(StringComparer.Ordinal);
+ foreach (var kvp in baseline)
+ {
+ dict[kvp.Key] = kvp.Value;
+ }
+
+ return dict;
+ }
+
+ private static IReadOnlyDictionary BuildAttributeDrivenMappings()
+ {
+ var registry = AttributeDrivenFactRegistry.FromLoadedAssemblies();
+ var dict = new Dictionary(StringComparer.Ordinal);
+ foreach (string id in registry.AllFactIds)
+ {
+ Assert.That(registry.TryGetFactType(id, out var type), Is.True, $"Registry id '{id}' did not resolve to a type.");
+ dict[id] = type!;
+ }
+
+ return dict;
+ }
+
+ private static SortedSet BuildTupleSet(IReadOnlyDictionary map)
+ {
+ var set = new SortedSet(StringComparer.Ordinal);
+ foreach (var kvp in map)
+ {
+ set.Add(kvp.Key + Separator + (kvp.Value.FullName ?? kvp.Value.Name));
+ }
+
+ return set;
+ }
+}
diff --git a/V2/CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests/AttributeDrivenFactRegistryServiceCollectionExtensionsTests.cs b/V2/CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests/AttributeDrivenFactRegistryServiceCollectionExtensionsTests.cs
new file mode 100644
index 000000000..fb41f7b0a
--- /dev/null
+++ b/V2/CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests/AttributeDrivenFactRegistryServiceCollectionExtensionsTests.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace CoseSign1.Validation.Trust.PlanPolicy.Spec.Tests;
+
+using System;
+using CoseSign1.Validation.Trust.PlanPolicy.Spec.Registry;
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Tests covering the Phase 3 DI extension that registers the attribute-driven fact registry.
+///
+[TestFixture]
+[Category("TrustPolicySpec")]
+public sealed class AttributeDrivenFactRegistryServiceCollectionExtensionsTests
+{
+ [Test]
+ public void AddAttributeDrivenFactRegistry_NullServices_Throws()
+ {
+ Assert.Throws(
+ () => AttributeDrivenFactRegistryServiceCollectionExtensions.AddAttributeDrivenFactRegistry(null!));
+ }
+
+ [Test]
+ public void AddAttributeDrivenFactRegistry_RegistersIFactRegistry_AsSingleton()
+ {
+ var services = new ServiceCollection();
+ services.AddAttributeDrivenFactRegistry();
+ using var sp = services.BuildServiceProvider();
+
+ var first = sp.GetRequiredService();
+ var second = sp.GetRequiredService();
+
+ Assert.That(first, Is.InstanceOf());
+ Assert.That(second, Is.SameAs(first), "Singleton lifetime expected.");
+ }
+
+ [Test]
+ public void AddAttributeDrivenFactRegistry_DiscoversShippedFacts()
+ {
+ var services = new ServiceCollection();
+ services.AddAttributeDrivenFactRegistry();
+ using var sp = services.BuildServiceProvider();
+
+ var registry = sp.GetRequiredService();
+ Assert.That(registry.AllFactIds, Has.Count.EqualTo(16));
+ Assert.That(registry.TryGetFactType("x509-chain-trusted/v1", out _), Is.True);
+ }
+
+ [Test]
+ public void AddAttributeDrivenFactRegistry_IsIdempotent()
+ {
+ var services = new ServiceCollection();
+ services.AddAttributeDrivenFactRegistry();
+ services.AddAttributeDrivenFactRegistry();
+ using var sp = services.BuildServiceProvider();
+
+ var resolved = sp.GetServices();
+ Assert.That(resolved, Has.Exactly(1).InstanceOf());
+ }
+
+ [Test]
+ public void AddAttributeDrivenFactRegistry_DoesNotOverrideExistingRegistration()
+ {
+ var preExisting = new AttributeDrivenFactRegistry(Array.Empty());
+ var services = new ServiceCollection();
+ services.AddSingleton(preExisting);
+ services.AddAttributeDrivenFactRegistry();
+ using var sp = services.BuildServiceProvider();
+
+ var resolved = sp.GetRequiredService();
+ Assert.That(resolved, Is.SameAs(preExisting));
+ Assert.That(resolved.AllFactIds, Is.Empty);
+ }
+
+ [Test]
+ public void AddAttributeDrivenFactRegistry_ReturnsSameServiceCollection()
+ {
+ var services = new ServiceCollection();
+ var ret = services.AddAttributeDrivenFactRegistry();
+ Assert.That(ret, Is.SameAs(services));
+ }
+
+ [Test]
+ public void AddAttributeDrivenFactRegistry_WithUnrelatedRegistrations_StillRegisters()
+ {
+ // Existing registrations that are NOT IFactRegistry must be skipped over by the dedupe
+ // loop without short-circuiting. Exercises both branches of the for-loop predicate.
+ var services = new ServiceCollection();
+ services.AddSingleton("not-a-fact-registry");
+ services.AddSingleton