3434 */
3535
3636import { mkdir , mkdtemp , readdir , rename , rm , stat , unlink , writeFile } from 'node:fs/promises' ;
37- import type { Writable } from 'node:stream' ;
37+ import { Readable , Transform , type TransformCallback , type Writable } from 'node:stream' ;
3838import { pipeline } from 'node:stream/promises' ;
3939import type { ReadableStream as NodeReadableStream } from 'node:stream/web' ;
4040import { createWriteStream } from 'node:fs' ;
4141import { dirname , isAbsolute , join , resolve } from 'node:path' ;
4242import type { CliFailureContext , CliTestStep } from '../commands/test.js' ;
43- import { ApiError , TransportError } from './errors.js' ;
43+ import { ApiError , localValidationError , TransportError } from './errors.js' ;
4444import type { FetchImpl } from './http.js' ;
45+ import { assertNotLocal } from './target-url.js' ;
4546
4647/** Schema version stamped into `meta.json`. Bumps with the contract. */
4748export const BUNDLE_SCHEMA_VERSION = 'cli-v1' as const ;
@@ -50,6 +51,10 @@ export const BUNDLE_SCHEMA_VERSION = 'cli-v1' as const;
5051export const STREAM_URL_MAX_RETRIES = 3 ;
5152/** Delay between retries for transient transport failures (ms). */
5253export const STREAM_URL_RETRY_DELAY_MS = 1000 ;
54+ /** Hard deadline for a single artifact download attempt (ms). */
55+ export const STREAM_URL_TIMEOUT_MS = 30_000 ;
56+ /** Max bytes accepted from one presigned artifact URL. Keeps videos bounded. */
57+ export const STREAM_URL_MAX_BYTES = 200 * 1024 * 1024 ;
5358
5459/** Default radius around the failed step when `--failed-only` is set. */
5560export const FAILED_ONLY_RADIUS = 1 ;
@@ -350,6 +355,14 @@ export function pickCodeExtension(language: string, framework: string): string {
350355 * naturally without breaking existing fixtures.
351356 */
352357export function stepFilenamePrefix ( stepIndex : number ) : string {
358+ if ( ! Number . isSafeInteger ( stepIndex ) || stepIndex < 0 ) {
359+ throw localValidationError (
360+ 'steps[].stepIndex' ,
361+ 'must be a non-negative integer' ,
362+ undefined ,
363+ 'field' ,
364+ ) ;
365+ }
353366 return stepIndex >= 100 ? String ( stepIndex ) . padStart ( 3 , '0' ) : String ( stepIndex ) . padStart ( 2 , '0' ) ;
354367}
355368
@@ -659,8 +672,9 @@ async function writeStepArtifacts(
659672 path : `steps/${ prefix } -snapshot.html` ,
660673 } ;
661674 }
675+ const kindSlug = safeFilenameSegment ( entry . kind ) ;
662676 const ext = sidecarExtension ( entry . kind ) ;
663- const filename = `${ prefix } -${ entry . kind } -${ i } .${ ext } ` ;
677+ const filename = `${ prefix } -${ kindSlug } -${ i } .${ ext } ` ;
664678 await streamUrlToFile ( entry . url , join ( stepsTmpDir , filename ) , fetchImpl ) ;
665679 filesWritten . push ( `steps/${ filename } ` ) ;
666680 return {
@@ -680,7 +694,7 @@ async function writeStepArtifacts(
680694 }
681695}
682696
683- function sidecarExtension ( kind : 'log' | 'network' | 'console' | 'screenshot' | 'snapshot' ) : string {
697+ function sidecarExtension ( kind : unknown ) : string {
684698 if ( kind === 'network' || kind === 'console' ) return 'json' ;
685699 if ( kind === 'screenshot' ) return 'png' ;
686700 if ( kind === 'snapshot' ) return 'html' ;
@@ -690,6 +704,15 @@ function sidecarExtension(kind: 'log' | 'network' | 'console' | 'screenshot' | '
690704 return 'txt' ;
691705}
692706
707+ function safeFilenameSegment ( value : unknown ) : string {
708+ const raw = typeof value === 'string' ? value : '' ;
709+ const cleaned = raw
710+ . replace ( / [ ^ A - Z a - z 0 - 9 _ - ] + / g, '-' )
711+ . replace ( / ^ - + | - + $ / g, '' )
712+ . slice ( 0 , 64 ) ;
713+ return cleaned . length > 0 ? cleaned : 'artifact' ;
714+ }
715+
693716/**
694717 * Stream a presigned URL into a file with bounded retry on transport
695718 * failures. 4xx (presigned URL expired or unauthorized) is NOT
@@ -711,12 +734,18 @@ export async function streamUrlToFile(
711734 fetchImpl : FetchImpl ,
712735 deps ?: { sleep ?: ( ms : number ) => Promise < void > } ,
713736) : Promise < void > {
737+ validateArtifactUrl ( url ) ;
714738 const sleepFn = deps ?. sleep ?? ( ( ms : number ) => new Promise < void > ( r => setTimeout ( r , ms ) ) ) ;
715739 for ( let attempt = 1 ; attempt <= STREAM_URL_MAX_RETRIES ; attempt ++ ) {
740+ const controller = new AbortController ( ) ;
741+ const timeout = setTimeout ( ( ) => controller . abort ( ) , STREAM_URL_TIMEOUT_MS ) ;
716742 let response : Response ;
717743 try {
718- response = await fetchImpl ( url ) ;
744+ response = await fetchImpl ( url , { signal : controller . signal } ) ;
745+ assertContentLengthWithinLimit ( response , url ) ;
719746 } catch ( err ) {
747+ clearTimeout ( timeout ) ;
748+ if ( err instanceof ApiError ) throw err ;
720749 const message = err instanceof Error ? err . message : String ( err ) ;
721750 if ( attempt < STREAM_URL_MAX_RETRIES ) {
722751 await sleepFn ( STREAM_URL_RETRY_DELAY_MS ) ;
@@ -725,6 +754,7 @@ export async function streamUrlToFile(
725754 throw new TransportError ( `Failed to download presigned URL ${ url } : ${ message } ` ) ;
726755 }
727756 if ( ! response . ok ) {
757+ clearTimeout ( timeout ) ;
728758 // Non-2xx: the URL itself is bad (expired, unauthorized, not found).
729759 // Retrying the same URL won't help — surface immediately.
730760 throw ApiError . fromEnvelope ( {
@@ -746,9 +776,13 @@ export async function streamUrlToFile(
746776 // a tolerable amount of memory.
747777 try {
748778 const buffer = Buffer . from ( await response . arrayBuffer ( ) ) ;
779+ assertBytesWithinLimit ( buffer . byteLength , url ) ;
749780 await writeFile ( filePath , buffer ) ;
781+ clearTimeout ( timeout ) ;
750782 return ;
751783 } catch ( err ) {
784+ clearTimeout ( timeout ) ;
785+ if ( err instanceof ApiError ) throw err ;
752786 const message = err instanceof Error ? err . message : String ( err ) ;
753787 if ( attempt < STREAM_URL_MAX_RETRIES ) {
754788 await sleepFn ( STREAM_URL_RETRY_DELAY_MS ) ;
@@ -766,11 +800,13 @@ export async function streamUrlToFile(
766800 const fileSink = createWriteStream ( filePath ) ;
767801 try {
768802 const webBody = response . body as unknown as NodeReadableStream < Uint8Array > ;
769- const { Readable } = await import ( 'node:stream' ) ;
770803 const nodeStream = Readable . fromWeb ( webBody ) ;
771- await pipeline ( nodeStream , fileSink as unknown as Writable ) ;
804+ await pipeline ( nodeStream , artifactByteLimit ( url ) , fileSink as unknown as Writable ) ;
805+ clearTimeout ( timeout ) ;
772806 return ;
773807 } catch ( err ) {
808+ clearTimeout ( timeout ) ;
809+ if ( err instanceof ApiError ) throw err ;
774810 const message = err instanceof Error ? err . message : String ( err ) ;
775811 if ( attempt < STREAM_URL_MAX_RETRIES ) {
776812 await sleepFn ( STREAM_URL_RETRY_DELAY_MS ) ;
@@ -781,6 +817,70 @@ export async function streamUrlToFile(
781817 }
782818}
783819
820+ function validateArtifactUrl ( url : string ) : void {
821+ try {
822+ assertNotLocal ( url ) ;
823+ } catch ( err ) {
824+ if ( err instanceof ApiError ) {
825+ throw ApiError . fromEnvelope ( {
826+ error : {
827+ code : 'VALIDATION_ERROR' ,
828+ message : 'Failure bundle artifact URL is invalid.' ,
829+ nextAction :
830+ 'Re-run `testsprite test failure get`. Report the requestId to support if the bundle keeps returning a private or invalid artifact URL.' ,
831+ requestId : 'local' ,
832+ details : { ...err . details , field : 'artifact-url' , url } ,
833+ } ,
834+ } ) ;
835+ }
836+ throw err ;
837+ }
838+ }
839+
840+ function assertContentLengthWithinLimit ( response : Response , url : string ) : void {
841+ const raw = response . headers . get ( 'content-length' ) ;
842+ if ( ! raw ) return ;
843+ const parsed = Number ( raw ) ;
844+ if ( ! Number . isFinite ( parsed ) || parsed < 0 ) return ;
845+ assertBytesWithinLimit ( parsed , url ) ;
846+ }
847+
848+ function artifactByteLimit ( url : string ) : Transform {
849+ let bytes = 0 ;
850+ return new Transform ( {
851+ transform (
852+ chunk : Buffer | Uint8Array | string ,
853+ encoding : BufferEncoding ,
854+ cb : TransformCallback ,
855+ ) {
856+ const length =
857+ typeof chunk === 'string' ? Buffer . byteLength ( chunk , encoding ) : chunk . byteLength ;
858+ bytes += length ;
859+ try {
860+ assertBytesWithinLimit ( bytes , url ) ;
861+ } catch ( err ) {
862+ cb ( err as Error ) ;
863+ return ;
864+ }
865+ cb ( null , chunk ) ;
866+ } ,
867+ } ) ;
868+ }
869+
870+ function assertBytesWithinLimit ( bytes : number , url : string ) : void {
871+ if ( bytes <= STREAM_URL_MAX_BYTES ) return ;
872+ throw ApiError . fromEnvelope ( {
873+ error : {
874+ code : 'PAYLOAD_TOO_LARGE' ,
875+ message : 'Failure bundle artifact is too large.' ,
876+ nextAction :
877+ 'Re-run `testsprite test failure get`. Report the requestId to support if the bundle keeps returning an oversized artifact.' ,
878+ requestId : 'local' ,
879+ details : { url, bytes, maxBytes : STREAM_URL_MAX_BYTES } ,
880+ } ,
881+ } ) ;
882+ }
883+
784884function isPresignedUrl ( value : string ) : boolean {
785885 return value . startsWith ( 'https://' ) ;
786886}
0 commit comments