11using System . Security . Cryptography ;
2+ using System . Text ;
23using System . Text . Json ;
34using System . Text . Json . Nodes ;
45using GsdOrchestrator . Workflows . Models ;
@@ -18,24 +19,35 @@ namespace GsdOrchestrator.Tests;
1819/// check self-contained and offline (no JSON-Schema validator NuGet dependency).
1920///
2021/// Cross-repo reference note: cas-contracts is a separate repo. In isolated CI only
21- /// this repo is checked out, so the pinned v1.1.0 release is vendored under
22- /// GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.0 / (same convention as the
22+ /// this repo is checked out, so the pinned v1.1.1 release is vendored under
23+ /// GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.1 / (same convention as the
2324/// Python consumers). When the sibling ../cas-contracts checkout is present
2425/// (local dev), an extra test asserts the vendored copy has not drifted upstream.
2526/// </summary>
2627public class ContractCompatibilityTests
2728{
2829 // The cas-contracts version this consumer targets. Kept in lockstep with the
2930 // vendored release and with SdlcProfile.CasSdlcV1.ProfileVersion.
31+ private const string PinnedReleaseVersion = "1.1.1" ;
3032 private const string PinnedSchemaVersion = "1.1.0" ;
3133 private const string PinnedProfileVersion = "v1.1" ;
3234
3335 private static string ContractRoot { get ; } = Path . Combine (
34- AppContext . BaseDirectory , "Contracts" , "cas-contracts" , $ "v{ PinnedSchemaVersion } ") ;
36+ AppContext . BaseDirectory , "Contracts" , "cas-contracts" , $ "v{ PinnedReleaseVersion } ") ;
3537
3638 private static JsonNode LoadSchema ( string name ) =>
3739 JsonNode . Parse ( File . ReadAllText ( Path . Combine ( ContractRoot , name ) ) ) ! ;
3840
41+ private static byte [ ] ReadCanonicalUtf8Bytes ( string path )
42+ {
43+ var text = File . ReadAllText ( path ) ;
44+ var normalized = text . ReplaceLineEndings ( "\n " ) ;
45+ return Encoding . UTF8 . GetBytes ( normalized ) ;
46+ }
47+
48+ private static string Sha256Hex ( byte [ ] bytes ) =>
49+ Convert . ToHexString ( SHA256 . HashData ( bytes ) ) . ToLowerInvariant ( ) ;
50+
3951 /// <summary>Walk up from the test output directory to find a sibling
4052 /// cas-contracts/registry/releases/v{version} checkout, or null if absent.</summary>
4153 private static string ? ResolveUpstreamRoot ( )
@@ -44,7 +56,7 @@ private static JsonNode LoadSchema(string name) =>
4456 while ( dir is not null )
4557 {
4658 var candidate = Path . Combine (
47- dir . FullName , "cas-contracts" , "registry" , "releases" , $ "v{ PinnedSchemaVersion } ") ;
59+ dir . FullName , "cas-contracts" , "registry" , "releases" , $ "v{ PinnedReleaseVersion } ") ;
4860 if ( Directory . Exists ( candidate ) )
4961 {
5062 return candidate ;
@@ -115,18 +127,41 @@ private static JsonObject BuildProfileWirePayload()
115127 public void VendoredRelease_MatchesManifestHashes ( )
116128 {
117129 var manifest = LoadSchema ( "manifest.json" ) ;
118- Assert . Equal ( PinnedSchemaVersion , ( string ? ) manifest [ "version" ] ) ;
130+ Assert . Equal ( PinnedReleaseVersion , ( string ? ) manifest [ "version" ] ) ;
119131
120132 foreach ( var entry in manifest [ "schemas" ] ! . AsArray ( ) )
121133 {
122134 var path = ( string ) entry ! [ "path" ] ! ;
123135 var expected = ( string ) entry [ "sha256" ] ! ;
124- var bytes = File . ReadAllBytes ( Path . Combine ( ContractRoot , path ) ) ;
125- var actual = Convert . ToHexString ( SHA256 . HashData ( bytes ) ) . ToLowerInvariant ( ) ;
136+ var bytes = ReadCanonicalUtf8Bytes ( Path . Combine ( ContractRoot , path ) ) ;
137+ var actual = Sha256Hex ( bytes ) ;
126138 Assert . Equal ( expected , actual ) ;
127139 }
128140 }
129141
142+ [ Fact ]
143+ public void CanonicalSchemaDigest_IgnoresLineEndings ( )
144+ {
145+ var crlfPath = Path . GetTempFileName ( ) ;
146+ var lfPath = Path . GetTempFileName ( ) ;
147+
148+ try
149+ {
150+ File . WriteAllText ( crlfPath , "{\r \n \" name\" : \" schema\" \r \n }\r \n " , new UTF8Encoding ( false ) ) ;
151+ File . WriteAllText ( lfPath , "{\n \" name\" : \" schema\" \n }\n " , new UTF8Encoding ( false ) ) ;
152+
153+ var crlf = Sha256Hex ( ReadCanonicalUtf8Bytes ( crlfPath ) ) ;
154+ var lf = Sha256Hex ( ReadCanonicalUtf8Bytes ( lfPath ) ) ;
155+
156+ Assert . Equal ( crlf , lf ) ;
157+ }
158+ finally
159+ {
160+ File . Delete ( crlfPath ) ;
161+ File . Delete ( lfPath ) ;
162+ }
163+ }
164+
130165 [ Fact ]
131166 public void PinnedVersion_IsTheVersionThisConsumerEmits ( )
132167 {
@@ -214,8 +249,8 @@ public void VendoredRelease_MatchesUpstreamSourceOfTruth()
214249 {
215250 var upstream = Path . Combine ( upstreamRoot , Path . GetFileName ( file ) ) ;
216251 Assert . True ( File . Exists ( upstream ) , $ "upstream missing { Path . GetFileName ( file ) } ") ;
217- var local = Convert . ToHexString ( SHA256 . HashData ( File . ReadAllBytes ( file ) ) ) ;
218- var remote = Convert . ToHexString ( SHA256 . HashData ( File . ReadAllBytes ( upstream ) ) ) ;
252+ var local = Sha256Hex ( ReadCanonicalUtf8Bytes ( file ) ) ;
253+ var remote = Sha256Hex ( ReadCanonicalUtf8Bytes ( upstream ) ) ;
219254 Assert . Equal ( remote , local ) ;
220255 }
221256 }
0 commit comments