@@ -17,7 +17,15 @@ import {
1717import { createHash , randomBytes } from "node:crypto" ;
1818import { join } from "node:path" ;
1919import { getConfigDir } from "../config" ;
20- import { forgetEphemeralSecretPath , forgetHardenedSecretPath , hardenSecretDir , hardenSecretPath } from "../lib/windows-secret-acl" ;
20+ import {
21+ forgetEphemeralSecretPath ,
22+ forgetHardenedSecretPath ,
23+ hardenSecretDir ,
24+ hardenSecretDirAsync ,
25+ hardenSecretPath ,
26+ hardenSecretPathAsync ,
27+ windowsSecretAclApplies ,
28+ } from "../lib/windows-secret-acl" ;
2129import { isValidProviderContinuationOwner } from "./provider-continuation" ;
2230import type { OcxProviderContinuationState } from "../types" ;
2331
@@ -189,6 +197,20 @@ function harden(path: string, mode: number): void {
189197 }
190198}
191199
200+ async function hardenAsync ( path : string , mode : number , retryTimedOutOnce = false ) : Promise < void > {
201+ try {
202+ chmodSync ( path , mode ) ;
203+ } catch {
204+ if ( ! windowsSecretAclApplies ( ) ) throw new Error ( "Response spill permission hardening failed" ) ;
205+ }
206+ if ( windowsSecretAclApplies ( ) ) {
207+ const result = mode === 0o700
208+ ? await hardenSecretDirAsync ( path , { required : true , retryTimedOutOnce } )
209+ : await hardenSecretPathAsync ( path , { required : true , retryTimedOutOnce } ) ;
210+ if ( ! result . ok ) throw new Error ( "Response spill permission hardening failed" ) ;
211+ }
212+ }
213+
192214function writeAll ( fd : number , bytes : Uint8Array ) : void {
193215 if ( spillIoForTest ?. write ) spillIoForTest . write ( fd , bytes ) ;
194216 else {
@@ -261,6 +283,78 @@ function publishNoReplace(tempPath: string, destinationPath: string): void {
261283 record ( "publish" ) ;
262284}
263285
286+ async function publishNoReplaceAsync (
287+ tempPath : string ,
288+ destinationPath : string ,
289+ retryTimedOutOnce : boolean ,
290+ ) : Promise < void > {
291+ try {
292+ if ( spillIoForTest ?. link ) spillIoForTest . link ( tempPath , destinationPath ) ;
293+ else linkSync ( tempPath , destinationPath ) ;
294+ } catch ( error ) {
295+ if ( isErrno ( error , "EEXIST" ) ) throw error ;
296+ if ( ! canUseExclusiveCopyFallback ( error ) ) throw error ;
297+ let copied = false ;
298+ try {
299+ if ( spillIoForTest ?. copyFileExcl ) spillIoForTest . copyFileExcl ( tempPath , destinationPath ) ;
300+ else copyFileSync ( tempPath , destinationPath , constants . COPYFILE_EXCL ) ;
301+ copied = true ;
302+ await hardenAsync ( destinationPath , 0o600 , retryTimedOutOnce ) ;
303+ const copyFd = openSync ( destinationPath , "r" ) ;
304+ try {
305+ if ( spillIoForTest ?. fsync ) spillIoForTest . fsync ( copyFd ) ;
306+ else fsyncSync ( copyFd ) ;
307+ } finally {
308+ closeSync ( copyFd ) ;
309+ }
310+ } catch ( copyError ) {
311+ if ( copied ) {
312+ try { unlink ( destinationPath ) ; } catch { /* startup GC reclaims an incomplete publication */ }
313+ }
314+ throw copyError ;
315+ }
316+ }
317+ record ( "publish" ) ;
318+ }
319+
320+ function serializedSpill (
321+ responseId : string ,
322+ state : Omit < ResponseSpillPayload , "version" | "responseId" > ,
323+ ) : {
324+ bytes : Buffer ;
325+ digest : string ;
326+ idDigest : string ;
327+ contentDigest : string ;
328+ } {
329+ const payload : ResponseSpillPayload = {
330+ version : 1 ,
331+ responseId,
332+ createdAt : state . createdAt ,
333+ ...( state . clientThreadId ? { clientThreadId : state . clientThreadId } : { } ) ,
334+ items : state . items ,
335+ ...( state . providerOutputStart !== undefined ? { providerOutputStart : state . providerOutputStart } : { } ) ,
336+ ...( state . providers ? { providers : state . providers } : { } ) ,
337+ } ;
338+ const serialized = JSON . stringify ( payload ) ;
339+ if ( serialized === undefined ) throw new Error ( "Response spill serialization failed" ) ;
340+ const bytes = Buffer . from ( serialized , "utf8" ) ;
341+ const digest = sha256 ( bytes ) ;
342+ return {
343+ bytes,
344+ digest,
345+ idDigest : sha256 ( responseId ) . slice ( 0 , 12 ) ,
346+ contentDigest : digest . slice ( 0 , 24 ) ,
347+ } ;
348+ }
349+
350+ function responseSpillWriteError ( cause : unknown ) : NodeJS . ErrnoException {
351+ const error = new Error ( "Response spill write failed" , { cause } ) as NodeJS . ErrnoException ;
352+ if ( cause && typeof cause === "object" && "code" in cause ) {
353+ error . code = String ( ( cause as { code ?: unknown } ) . code ) ;
354+ }
355+ return error ;
356+ }
357+
264358function validSpillRef ( ref : ResponseSpillRef ) : boolean {
265359 return ref . version === 1
266360 && OWNED_SPILL_NAME . test ( ref . fileName )
@@ -306,21 +400,7 @@ export function writeResponseSpillDurably(
306400 let tempPath : string | null = null ;
307401 let fd : number | null = null ;
308402 try {
309- const payload : ResponseSpillPayload = {
310- version : 1 ,
311- responseId,
312- createdAt : state . createdAt ,
313- ...( state . clientThreadId ? { clientThreadId : state . clientThreadId } : { } ) ,
314- items : state . items ,
315- ...( state . providerOutputStart !== undefined ? { providerOutputStart : state . providerOutputStart } : { } ) ,
316- ...( state . providers ? { providers : state . providers } : { } ) ,
317- } ;
318- const serialized = JSON . stringify ( payload ) ;
319- if ( serialized === undefined ) throw new Error ( "Response spill serialization failed" ) ;
320- const bytes = Buffer . from ( serialized , "utf8" ) ;
321- const digest = sha256 ( bytes ) ;
322- const idDigest = sha256 ( responseId ) . slice ( 0 , 12 ) ;
323- const contentDigest = digest . slice ( 0 , 24 ) ;
403+ const { bytes, digest, idDigest, contentDigest } = serializedSpill ( responseId , state ) ;
324404 const dir = responseSpillDirectory ( ) ;
325405 mkdirSync ( dir , { recursive : true , mode : 0o700 } ) ;
326406 harden ( dir , 0o700 ) ;
@@ -352,14 +432,77 @@ export function writeResponseSpillDurably(
352432 }
353433 }
354434 throw new Error ( "Response spill publication retries exhausted" ) ;
355- } catch {
435+ } catch ( cause ) {
436+ if ( fd !== null ) {
437+ try { closeSync ( fd ) ; } catch { /* best effort */ }
438+ }
439+ if ( tempPath ) {
440+ try { unlinkEphemeral ( tempPath ) ; } catch { /* best effort */ }
441+ }
442+ throw responseSpillWriteError ( cause ) ;
443+ }
444+ }
445+
446+ /**
447+ * Windows runtime counterpart of `writeResponseSpillDurably`.
448+ *
449+ * The filesystem publication contract stays identical, but required NTFS ACL subprocesses are
450+ * awaited through Bun.spawn instead of Bun.spawnSync. State ownership and serialization remain in
451+ * `state.ts`; callers must compare the resident generation again before installing the returned
452+ * reference because another response can replace it while ACL hardening is pending.
453+ */
454+ export async function writeResponseSpillDurablyAsync (
455+ responseId : string ,
456+ state : Omit < ResponseSpillPayload , "version" | "responseId" > ,
457+ options : { retryTimedOutOnce ?: boolean } = { } ,
458+ ) : Promise < ResponseSpillRef > {
459+ let tempPath : string | null = null ;
460+ let fd : number | null = null ;
461+ try {
462+ const { bytes, digest, idDigest, contentDigest } = serializedSpill ( responseId , state ) ;
463+ const dir = responseSpillDirectory ( ) ;
464+ mkdirSync ( dir , { recursive : true , mode : 0o700 } ) ;
465+ await hardenAsync ( dir , 0o700 , options . retryTimedOutOnce === true ) ;
466+
467+ tempPath = join ( dir , `.response-spill.${ process . pid } .${ randomBytes ( 8 ) . toString ( "hex" ) } .tmp` ) ;
468+ fd = openSync ( tempPath , "wx" , 0o600 ) ;
469+ writeAll ( fd , bytes ) ;
470+ fsyncFile ( fd ) ;
471+ closeFile ( fd ) ;
472+ fd = null ;
473+ await hardenAsync ( tempPath , 0o600 , options . retryTimedOutOnce === true ) ;
474+ record ( "harden" ) ;
475+ const publishTempPath = tempPath ;
476+
477+ for ( let attempt = 0 ; attempt < RESPONSE_SPILL_PUBLISH_RETRIES ; attempt ++ ) {
478+ spillGeneration += 1 ;
479+ const fileName = `${ sanitizeResponseId ( responseId ) } .${ idDigest } .${ contentDigest } .${ spillGeneration } .${ bytes . byteLength } .spill.json` ;
480+ if ( ! OWNED_SPILL_NAME . test ( fileName ) ) throw new Error ( "Response spill name allocation failed" ) ;
481+ const destinationPath = join ( dir , fileName ) ;
482+ try {
483+ await publishNoReplaceAsync (
484+ publishTempPath ,
485+ destinationPath ,
486+ options . retryTimedOutOnce === true ,
487+ ) ;
488+ fsyncDirectoryBestEffort ( dir ) ;
489+ unlinkEphemeral ( publishTempPath ) ;
490+ tempPath = null ;
491+ return { version : 1 , fileName, digest, payloadBytes : bytes . byteLength } ;
492+ } catch ( error ) {
493+ if ( isErrno ( error , "EEXIST" ) ) continue ;
494+ throw error ;
495+ }
496+ }
497+ throw new Error ( "Response spill publication retries exhausted" ) ;
498+ } catch ( cause ) {
356499 if ( fd !== null ) {
357500 try { closeSync ( fd ) ; } catch { /* best effort */ }
358501 }
359502 if ( tempPath ) {
360503 try { unlinkEphemeral ( tempPath ) ; } catch { /* best effort */ }
361504 }
362- throw new Error ( "Response spill write failed" ) ;
505+ throw responseSpillWriteError ( cause ) ;
363506 }
364507}
365508
0 commit comments