@@ -228,6 +228,31 @@ function parseMultipartHeaders(rawHeaders: string): Readonly<Record<string, stri
228228 return headers ;
229229}
230230
231+ function findNextMultipartBoundary (
232+ payload : Uint8Array ,
233+ boundaryPrefix : Uint8Array ,
234+ from = 0 ,
235+ ) : number {
236+ let offset = from ;
237+ while ( offset < payload . length ) {
238+ const index = findBytes ( payload , boundaryPrefix , offset ) ;
239+ if ( index < 0 ) {
240+ return - 1 ;
241+ }
242+
243+ const suffixIndex = index + boundaryPrefix . length ;
244+ const isClosingBoundary = payload [ suffixIndex ] === 45 && payload [ suffixIndex + 1 ] === 45 ;
245+ const isPartBoundary = payload [ suffixIndex ] === 13 && payload [ suffixIndex + 1 ] === 10 ;
246+ if ( isClosingBoundary || isPartBoundary ) {
247+ return index ;
248+ }
249+
250+ offset = index + 1 ;
251+ }
252+
253+ return - 1 ;
254+ }
255+
231256function decodeMultipartParts (
232257 payload : Uint8Array ,
233258 boundary : string ,
@@ -259,7 +284,7 @@ function decodeMultipartParts(
259284 throw new Error ( "multipart part is missing its header separator" ) ;
260285 }
261286 const bodyStart = separatorIndex + headerSeparator . length ;
262- const nextPartIndex = findBytes ( payload , nextPartPrefix , bodyStart ) ;
287+ const nextPartIndex = findNextMultipartBoundary ( payload , nextPartPrefix , bodyStart ) ;
263288 if ( nextPartIndex < 0 ) {
264289 throw new Error ( "multipart response is missing its closing boundary" ) ;
265290 }
@@ -282,16 +307,19 @@ function decodeMultipartParts(
282307
283308function readContentDispositionParam (
284309 contentDisposition : string ,
285- param : "name" | "filename" ,
310+ param : "name" | "filename" | "filename*" ,
286311) : Effect . Effect < string | undefined , InvalidFunctionDownloadResponseError > {
312+ const paramPattern = param . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
287313 const quotedMatch = contentDisposition . match (
288- new RegExp ( `(?:^|;)\\s*${ param } ="((?:[^"\\\\]|\\\\.)*)"` , "i" ) ,
314+ new RegExp ( `(?:^|;)\\s*${ paramPattern } ="((?:[^"\\\\]|\\\\.)*)"` , "i" ) ,
289315 ) ;
290316 if ( quotedMatch !== null ) {
291317 return Effect . succeed ( quotedMatch [ 1 ] ?. replaceAll ( '\\"' , '"' ) ) ;
292318 }
293319
294- const assignmentMatch = contentDisposition . match ( new RegExp ( `(?:^|;)\\s*${ param } =([^;]*)` , "i" ) ) ;
320+ const assignmentMatch = contentDisposition . match (
321+ new RegExp ( `(?:^|;)\\s*${ paramPattern } =([^;]*)` , "i" ) ,
322+ ) ;
295323 if ( assignmentMatch === null ) {
296324 return Effect . succeed ( undefined ) ;
297325 }
@@ -307,6 +335,37 @@ function readContentDispositionParam(
307335 ) ;
308336}
309337
338+ function decodeRfc5987Param (
339+ value : string ,
340+ ) : Effect . Effect < string , InvalidFunctionDownloadResponseError > {
341+ const firstQuote = value . indexOf ( "'" ) ;
342+ const secondQuote = firstQuote < 0 ? - 1 : value . indexOf ( "'" , firstQuote + 1 ) ;
343+ if ( firstQuote < 0 || secondQuote < 0 ) {
344+ return Effect . fail (
345+ new InvalidFunctionDownloadResponseError ( {
346+ message : "failed to parse content disposition: malformed filename*" ,
347+ } ) ,
348+ ) ;
349+ }
350+
351+ const charset = value . slice ( 0 , firstQuote ) . toLowerCase ( ) ;
352+ if ( charset !== "utf-8" && charset !== "us-ascii" ) {
353+ return Effect . fail (
354+ new InvalidFunctionDownloadResponseError ( {
355+ message : `failed to parse content disposition: unsupported filename* charset ${ charset } ` ,
356+ } ) ,
357+ ) ;
358+ }
359+
360+ return Effect . try ( {
361+ try : ( ) => decodeURIComponent ( value . slice ( secondQuote + 1 ) ) ,
362+ catch : ( cause ) =>
363+ new InvalidFunctionDownloadResponseError ( {
364+ message : `failed to parse content disposition: malformed filename*: ${ cause instanceof Error ? cause . message : String ( cause ) } ` ,
365+ } ) ,
366+ } ) ;
367+ }
368+
310369function readFormFieldName (
311370 headers : Readonly < Record < string , string > > ,
312371) : Effect . Effect < string | undefined , InvalidFunctionDownloadResponseError > {
@@ -317,6 +376,19 @@ function readFormFieldName(
317376 return readContentDispositionParam ( contentDisposition , "name" ) ;
318377}
319378
379+ function readContentDispositionFilename (
380+ contentDisposition : string ,
381+ ) : Effect . Effect < string | undefined , InvalidFunctionDownloadResponseError > {
382+ return Effect . gen ( function * ( ) {
383+ const encodedFilename = yield * readContentDispositionParam ( contentDisposition , "filename*" ) ;
384+ if ( encodedFilename !== undefined ) {
385+ return yield * decodeRfc5987Param ( encodedFilename ) ;
386+ }
387+
388+ return yield * readContentDispositionParam ( contentDisposition , "filename" ) ;
389+ } ) ;
390+ }
391+
320392function getPartPath (
321393 headers : Readonly < Record < string , string > > ,
322394) : Effect . Effect < string , InvalidFunctionDownloadResponseError > {
@@ -330,7 +402,7 @@ function getPartPath(
330402 return Effect . succeed ( "" ) ;
331403 }
332404
333- return readContentDispositionParam ( contentDisposition , "filename" ) . pipe (
405+ return readContentDispositionFilename ( contentDisposition ) . pipe (
334406 Effect . map ( ( filename ) => filename ?? "" ) ,
335407 ) ;
336408}
@@ -357,6 +429,12 @@ function decodeMultipartForm(
357429 const files : DownloadFilePart [ ] = [ ] ;
358430
359431 for ( const part of parts ) {
432+ const filePath = yield * getPartPath ( part . headers ) ;
433+ if ( filePath . length > 0 ) {
434+ files . push ( { path : filePath , body : part . body } ) ;
435+ continue ;
436+ }
437+
360438 const fieldName = yield * readFormFieldName ( part . headers ) ;
361439 if ( fieldName === "metadata" ) {
362440 const rawMetadata = new TextDecoder ( ) . decode ( part . body ) ;
@@ -367,12 +445,6 @@ function decodeMultipartForm(
367445 message : `failed to unmarshal metadata: ${ cause instanceof Error ? cause . message : String ( cause ) } ` ,
368446 } ) ,
369447 } ) ;
370- continue ;
371- }
372-
373- const filePath = yield * getPartPath ( part . headers ) ;
374- if ( filePath . length > 0 ) {
375- files . push ( { path : filePath , body : part . body } ) ;
376448 }
377449 }
378450
@@ -459,7 +531,7 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro
459531 try : ( ) => {
460532 const parsed = JSON . parse ( body ) ;
461533 if ( ! Array . isArray ( parsed ) ) {
462- return [ ] ;
534+ throw new Error ( "expected functions list response to be an array" ) ;
463535 }
464536 return parsed . flatMap ( ( value ) => {
465537 const slug = getObjectProperty ( value , "slug" ) ;
@@ -468,7 +540,7 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro
468540 } ,
469541 catch : ( cause ) =>
470542 new InvalidFunctionDownloadResponseError ( {
471- message : `failed to read form : ${ cause instanceof Error ? cause . message : String ( cause ) } ` ,
543+ message : `failed to read functions list : ${ cause instanceof Error ? cause . message : String ( cause ) } ` ,
472544 } ) ,
473545 } ) ;
474546} ) ;
@@ -554,10 +626,9 @@ const downloadSingle = Effect.fnUntraced(function* (
554626
555627 const response = yield * downloadBody ( dependencies . api , projectRef , slug ) ;
556628 const { metadata, files } = yield * decodeMultipartForm ( response ) ;
557- const remoteFunction =
558- metadata ?. entrypoint_path !== undefined
559- ? undefined
560- : yield * getRemoteFunction ( dependencies . api , projectRef , slug ) ;
629+ const remoteFunction = hasEntrypointPath ( metadata )
630+ ? undefined
631+ : yield * getRemoteFunction ( dependencies . api , projectRef , slug ) ;
561632 const entrypointPath = resolveEntrypointPath ( metadata , remoteFunction ) ;
562633 const projectRoot = dependencies . projectRoot ;
563634 const functionsRoot = join ( projectRoot , "supabase" , "functions" ) ;
0 commit comments