@@ -60,6 +60,10 @@ export type CastingDurabilityBoundary =
6060 | 'registry:file-fsync'
6161 | 'registry:rename'
6262 | 'registry:parent-fsync'
63+ | 'normalization:write'
64+ | 'normalization:file-fsync'
65+ | 'normalization:rename'
66+ | 'normalization:parent-fsync'
6367 | 'cleanup:journal-unlink'
6468 | 'cleanup:journal-parent-fsync'
6569 | 'cleanup:payload-remove'
@@ -226,7 +230,7 @@ function writeDurableFile(
226230function durableReplace (
227231 filePath : string ,
228232 content : string ,
229- stage : 'journal' | 'history' | 'manifest' | 'registry' ,
233+ stage : 'journal' | 'history' | 'manifest' | 'registry' | 'normalization' ,
230234 transactionId : string ,
231235) : void {
232236 const tempPath = `${ filePath } .tmp-${ transactionId } -${ randomUUID ( ) } ` ;
@@ -253,6 +257,12 @@ function sha256(content: string | undefined): string | null {
253257 : createHash ( 'sha256' ) . update ( content , 'utf8' ) . digest ( 'hex' ) ;
254258}
255259
260+ /** Treat Git's CRLF worktree conversion as byte-equivalent to canonical LF JSON. */
261+ function matchesSha256 ( content : string | undefined , expected : string | null ) : boolean {
262+ if ( sha256 ( content ) === expected ) return true ;
263+ return content !== undefined && sha256 ( content . replace ( / \r \n / g, '\n' ) ) === expected ;
264+ }
265+
256266function parseObject ( raw : string | undefined , label : string ) : Record < string , unknown > | undefined {
257267 if ( raw === undefined ) return undefined ;
258268 try {
@@ -1127,9 +1137,8 @@ function expectedState(
11271137 nextHash : string ,
11281138 label : string ,
11291139) : 'previous' | 'next' {
1130- const actual = sha256 ( raw ) ;
1131- if ( actual === previousHash ) return 'previous' ;
1132- if ( actual === nextHash ) return 'next' ;
1140+ if ( matchesSha256 ( raw , previousHash ) ) return 'previous' ;
1141+ if ( matchesSha256 ( raw , nextHash ) ) return 'next' ;
11331142 throw new Error ( `Cannot recover casting transaction: ${ label } changed outside the journal` ) ;
11341143}
11351144
@@ -1184,8 +1193,8 @@ export function recoverCastingRegistryTransaction(castingDir: string): void {
11841193 if (
11851194 nextRegistryRaw === undefined
11861195 || nextHistoryRaw === undefined
1187- || sha256 ( nextRegistryRaw ) !== journal . registry . next_sha256
1188- || sha256 ( nextHistoryRaw ) !== journal . history . next_sha256
1196+ || ! matchesSha256 ( nextRegistryRaw , journal . registry . next_sha256 )
1197+ || ! matchesSha256 ( nextHistoryRaw , journal . history . next_sha256 )
11891198 ) {
11901199 throw new Error ( 'Cannot recover casting transaction: durable payload is missing or corrupt' ) ;
11911200 }
@@ -1197,7 +1206,7 @@ export function recoverCastingRegistryTransaction(castingDir: string): void {
11971206 history_sha256 : journal . history . next_sha256 ,
11981207 } ;
11991208 const nextManifestRaw = JSON . stringify ( nextManifest , null , 2 ) + '\n' ;
1200- if ( sha256 ( nextManifestRaw ) !== journal . manifest . next_sha256 ) {
1209+ if ( ! matchesSha256 ( nextManifestRaw , journal . manifest . next_sha256 ) ) {
12011210 throw new Error ( 'Cannot recover casting transaction: manifest hash is corrupt' ) ;
12021211 }
12031212
@@ -1725,6 +1734,40 @@ export function preflightCastingRegistryPair(castingDir: string, dryRun = false)
17251734 }
17261735}
17271736
1737+ /**
1738+ * Normalize casting JSON to LF while holding the shared writer lock.
1739+ * Each live file is replaced atomically after recovery and pair validation.
1740+ */
1741+ export function normalizeCastingJsonLineEndings ( castingDir : string ) : string [ ] {
1742+ if ( ! existsSync ( castingDir ) ) return [ ] ;
1743+ const release = acquireCastingRegistryLock ( castingDir , 'casting JSON line-ending normalization' ) ;
1744+ try {
1745+ recoverCastingRegistryTransaction ( castingDir ) ;
1746+ readCastingRegistryPair ( castingDir ) ;
1747+
1748+ const normalized : string [ ] = [ ] ;
1749+ const transactionId = randomUUID ( ) ;
1750+ const entries = readdirSync ( castingDir , { withFileTypes : true } )
1751+ . filter ( entry => entry . isFile ( ) && entry . name . endsWith ( '.json' ) )
1752+ . sort ( ( left , right ) => left . name . localeCompare ( right . name ) ) ;
1753+ for ( const entry of entries ) {
1754+ const filePath = path . join ( castingDir , entry . name ) ;
1755+ const content = readText ( filePath ) ;
1756+ if ( content === undefined || ! content . includes ( '\r\n' ) ) continue ;
1757+ durableReplace (
1758+ filePath ,
1759+ content . replace ( / \r \n / g, '\n' ) ,
1760+ 'normalization' ,
1761+ transactionId ,
1762+ ) ;
1763+ normalized . push ( entry . name ) ;
1764+ }
1765+ return normalized ;
1766+ } finally {
1767+ release ( ) ;
1768+ }
1769+ }
1770+
17281771/**
17291772 * Create a missing pair or migrate a valid generationless legacy pair while
17301773 * the caller holds the shared casting writer lock.
@@ -1857,8 +1900,8 @@ export function validateCastingRegistryPairRaw(
18571900 || historyTransaction !== manifest . transaction_id
18581901 || historyRevision !== manifest . registry_revision
18591902 || revision !== manifest . registry_revision
1860- || sha256 ( registryRaw ) !== manifest . registry_sha256
1861- || sha256 ( historyRaw ) !== manifest . history_sha256
1903+ || ! matchesSha256 ( registryRaw , manifest . registry_sha256 )
1904+ || ! matchesSha256 ( historyRaw , manifest . history_sha256 )
18621905 ) {
18631906 throw new Error (
18641907 'Cannot read a consistent casting registry/history pair: registry/history bytes do not match the stable commit manifest' ,
@@ -1927,8 +1970,8 @@ export function readCastingRegistryPair(
19271970 && historyTransaction === manifest . transaction_id
19281971 && historyRevision === manifest . registry_revision
19291972 && revision === manifest . registry_revision
1930- && sha256 ( registryRaw ) === manifest . registry_sha256
1931- && sha256 ( historyRaw ) === manifest . history_sha256
1973+ && matchesSha256 ( registryRaw , manifest . registry_sha256 )
1974+ && matchesSha256 ( historyRaw , manifest . history_sha256 )
19321975 ) {
19331976 return {
19341977 registryRaw,
0 commit comments