From 953a6b6a413b265a52b5ff72d4766f36457a015f Mon Sep 17 00:00:00 2001 From: Antoine Date: Wed, 16 Sep 2026 17:11:14 +0200 Subject: [PATCH 1/3] [typescript-fetch] name downloaded files from Content-Disposition, BlobApiResponse returns a File --- .../typescript-fetch/runtime.mustache | 30 +++++++++++++++++-- .../runtime.ts | 30 +++++++++++++++++-- .../infinite-recursion-issue/runtime.ts | 30 +++++++++++++++++-- .../multipart-file-array/runtime.ts | 30 +++++++++++++++++-- .../self-import-issue/runtime.ts | 30 +++++++++++++++++-- .../builds/allOf-nullable/runtime.ts | 30 +++++++++++++++++-- .../builds/allOf-readonly/runtime.ts | 30 +++++++++++++++++-- .../builds/date-library-date/src/runtime.ts | 30 +++++++++++++++++-- .../builds/date-library-string/src/runtime.ts | 30 +++++++++++++++++-- .../builds/default-v3.0/runtime.ts | 30 +++++++++++++++++-- .../builds/default/runtime.ts | 30 +++++++++++++++++-- .../typescript-fetch/builds/enum/runtime.ts | 30 +++++++++++++++++-- .../builds/es6-target/src/runtime.ts | 30 +++++++++++++++++-- .../builds/kebab-case/runtime.ts | 30 +++++++++++++++++-- .../builds/multiple-parameters/runtime.ts | 30 +++++++++++++++++-- .../typescript-fetch/builds/oneOf/runtime.ts | 30 +++++++++++++++++-- .../src/runtime.ts | 30 +++++++++++++++++-- .../builds/sagas-and-records/src/runtime.ts | 30 +++++++++++++++++-- .../builds/snakecase-discriminator/runtime.ts | 30 +++++++++++++++++-- .../split-by-content-type/src/runtime.ts | 30 +++++++++++++++++-- .../builds/validation-attributes/runtime.ts | 30 +++++++++++++++++-- .../builds/with-interfaces/runtime.ts | 30 +++++++++++++++++-- .../builds/with-npm-version/src/runtime.ts | 30 +++++++++++++++++-- .../builds/with-string-enums/runtime.ts | 30 +++++++++++++++++-- .../without-runtime-checks/src/runtime.ts | 30 +++++++++++++++++-- 25 files changed, 700 insertions(+), 50 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache index ec58f037d84e..545cc6e97580 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache @@ -535,11 +535,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts index 1d2ddace09ec..acdad96c0f84 100644 --- a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts index 1d2ddace09ec..acdad96c0f84 100644 --- a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts index e93ee831ab96..ed3a15cd69f1 100644 --- a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts +++ b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts index 0562a357d812..fa2b0858ef63 100644 --- a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts index 21ac6b88d7ff..f4d6818cf790 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts index 21ac6b88d7ff..f4d6818cf790 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts index 59aa9338bf8d..2b50b13c07f0 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts index 2535518eb0ca..82b7fe8e72fd 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts @@ -452,11 +452,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts index 9300aaa0f8d6..df3aa92f6624 100644 --- a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts index 200d6e694abf..019a6cb788f2 100644 --- a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts index 9300aaa0f8d6..df3aa92f6624 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts index 9ad5703db110..0ea76ca688a0 100644 --- a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts index 9300aaa0f8d6..df3aa92f6624 100644 --- a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts index ac813429c9ec..fc2d6b78708c 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts @@ -504,11 +504,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts index b71d0834f8cf..4cb6b98a8a6d 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts index 200d6e694abf..019a6cb788f2 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts @@ -491,11 +491,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts index 56e2e516c01a..816da7f0404f 100644 --- a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts @@ -440,11 +440,37 @@ export class VoidApiResponse { export class BlobApiResponse { constructor(public raw: Response) {} - async value(): Promise { - return await this.raw.blob(); + /** + * The body as a File named after the Content-Disposition header, so that a download keeps the + * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name + * is empty when the server did not send one. + */ + async value(): Promise { + const blob = await this.raw.blob(); + return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } +/** + * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + */ +export function parseContentDispositionFilename(headers: Headers): string | undefined { + const value = headers.get('Content-Disposition'); + if (!value) { + return undefined; + } + const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + if (encoded) { + try { + return decodeURIComponent(encoded[1].trim()); + } catch { + // fall through to the plain form + } + } + const plain = /filename="?([^";]+)"?/.exec(value); + return plain ? plain[1].trim() : undefined; +} + export class TextApiResponse { constructor(public raw: Response) {} From 7537797103a9155a657f9bd4e413237bfde4dc43 Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 17 Sep 2026 08:53:03 +0200 Subject: [PATCH 2/3] [typescript-fetch] parse Content-Disposition per RFC 6266 and keep the bare Blob where File is not a global - filename parameters matched case-insensitively and only at a parameter boundary - quoted-string values keep their semicolons and escaped quotes - runtimes without a global File (Node.js before 20) still receive the Blob - unit tests in the typescript-fetch default test project --- .../typescript-fetch/runtime.mustache | 19 +++-- .../runtime.ts | 19 +++-- .../infinite-recursion-issue/runtime.ts | 19 +++-- .../multipart-file-array/runtime.ts | 19 +++-- .../self-import-issue/runtime.ts | 19 +++-- .../builds/allOf-nullable/runtime.ts | 19 +++-- .../builds/allOf-readonly/runtime.ts | 19 +++-- .../builds/date-library-date/src/runtime.ts | 19 +++-- .../builds/date-library-string/src/runtime.ts | 19 +++-- .../builds/default-v3.0/runtime.ts | 19 +++-- .../builds/default/runtime.ts | 19 +++-- .../typescript-fetch/builds/enum/runtime.ts | 19 +++-- .../builds/es6-target/src/runtime.ts | 19 +++-- .../builds/kebab-case/runtime.ts | 19 +++-- .../builds/multiple-parameters/runtime.ts | 19 +++-- .../typescript-fetch/builds/oneOf/runtime.ts | 19 +++-- .../src/runtime.ts | 19 +++-- .../builds/sagas-and-records/src/runtime.ts | 19 +++-- .../builds/snakecase-discriminator/runtime.ts | 19 +++-- .../split-by-content-type/src/runtime.ts | 19 +++-- .../builds/validation-attributes/runtime.ts | 19 +++-- .../builds/with-interfaces/runtime.ts | 19 +++-- .../builds/with-npm-version/src/runtime.ts | 19 +++-- .../builds/with-string-enums/runtime.ts | 19 +++-- .../without-runtime-checks/src/runtime.ts | 19 +++-- .../tests/default/test/BlobApiResponse.ts | 83 +++++++++++++++++++ .../tests/default/test/index.ts | 1 + 27 files changed, 434 insertions(+), 125 deletions(-) create mode 100644 samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache index 545cc6e97580..160d1394891f 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache @@ -538,23 +538,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -562,8 +568,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts index acdad96c0f84..c40b5851dc94 100644 --- a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts index acdad96c0f84..c40b5851dc94 100644 --- a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts index ed3a15cd69f1..d82d2101d8e9 100644 --- a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts +++ b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts index fa2b0858ef63..9a314a5d4add 100644 --- a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts index f4d6818cf790..28420bd5463a 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts index f4d6818cf790..28420bd5463a 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts index 2b50b13c07f0..8e15d3d451d4 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts index 82b7fe8e72fd..5798f5f1ed69 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts @@ -455,23 +455,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -479,8 +485,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts index df3aa92f6624..e0ad5c786a1d 100644 --- a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts index 019a6cb788f2..1b948a79bc44 100644 --- a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts index df3aa92f6624..e0ad5c786a1d 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts index 0ea76ca688a0..37218096aac1 100644 --- a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts index df3aa92f6624..e0ad5c786a1d 100644 --- a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts index fc2d6b78708c..9528fbfd6e3b 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts @@ -507,23 +507,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -531,8 +537,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts index 4cb6b98a8a6d..394d4c4517eb 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts index 019a6cb788f2..1b948a79bc44 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts @@ -494,23 +494,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -518,8 +524,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts index 816da7f0404f..c7225a03658f 100644 --- a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts @@ -443,23 +443,29 @@ export class BlobApiResponse { /** * The body as a File named after the Content-Disposition header, so that a download keeps the * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name - * is empty when the server did not send one. + * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) + * keep receiving the bare Blob. */ async value(): Promise { const blob = await this.raw.blob(); + if (typeof File === 'undefined') { + return blob as File; + } return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); }; } /** - * The file name advertised by a Content-Disposition header, RFC 5987 encoded form first. + * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter + * names are matched case-insensitively and only at a parameter boundary. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(value); + const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); if (encoded) { try { return decodeURIComponent(encoded[1].trim()); @@ -467,8 +473,11 @@ export function parseContentDispositionFilename(headers: Headers): string | unde // fall through to the plain form } } - const plain = /filename="?([^";]+)"?/.exec(value); - return plain ? plain[1].trim() : undefined; + const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); + if (!plain) { + return undefined; + } + return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts new file mode 100644 index 000000000000..924a909fefb9 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts @@ -0,0 +1,83 @@ +import { expect } from 'chai'; +import { BlobApiResponse, parseContentDispositionFilename } from '@swagger/typescript-fetch-petstore'; + +describe('parseContentDispositionFilename', () => { + + function nameOf(contentDisposition?: string): string | undefined { + const headers = new Headers(); + if (contentDisposition !== undefined) { + headers.set('Content-Disposition', contentDisposition); + } + return parseContentDispositionFilename(headers); + } + + it('should return undefined without a header or without a filename', () => { + expect(nameOf()).to.be.undefined; + expect(nameOf('inline')).to.be.undefined; + expect(nameOf('attachment; name="field"')).to.be.undefined; + }); + + it('should read a token and a quoted-string filename', () => { + expect(nameOf('attachment; filename=report.pdf')).to.equal('report.pdf'); + expect(nameOf('attachment; filename="report 2024.pdf"')).to.equal('report 2024.pdf'); + expect(nameOf('attachment;filename="tight.pdf"')).to.equal('tight.pdf'); + }); + + it('should keep semicolons and escaped quotes inside a quoted-string', () => { + expect(nameOf('attachment; filename="report;2024.pdf"; size=12')).to.equal('report;2024.pdf'); + expect(nameOf('attachment; filename="say \\"hi\\".txt"')).to.equal('say "hi".txt'); + }); + + it('should prefer the RFC 5987 encoded form and decode it', () => { + expect(nameOf("attachment; filename=\"fallback.pdf\"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf")) + .to.equal('résumé.pdf'); + expect(nameOf("attachment; filename*=utf-8'en'plain.pdf")).to.equal('plain.pdf'); + }); + + it('should fall back to the plain form when the encoded one is malformed', () => { + expect(nameOf("attachment; filename*=UTF-8''%E0%A4%A; filename=\"fallback.pdf\"")).to.equal('fallback.pdf'); + }); + + it('should match parameter names case-insensitively', () => { + expect(nameOf('Attachment; Filename="mixed.pdf"')).to.equal('mixed.pdf'); + expect(nameOf("attachment; FILENAME*=utf-8''upper.pdf")).to.equal('upper.pdf'); + }); + + it('should only match filename at a parameter boundary', () => { + expect(nameOf('attachment; xfilename="nope.pdf"')).to.be.undefined; + expect(nameOf('attachment; name="filename*=UTF-8\'\'nope.pdf"; filename="real.pdf"')).to.equal('real.pdf'); + }); +}); + +describe('BlobApiResponse', () => { + + it('should name the file after the Content-Disposition header', async () => { + const response = new Response('content', { + headers: { 'Content-Type': 'text/plain', 'Content-Disposition': 'attachment; filename="named.txt"' }, + }); + const file = await new BlobApiResponse(response).value(); + expect(file).to.be.an.instanceOf(Blob); + expect(file.name).to.equal('named.txt'); + expect(file.type).to.equal('text/plain'); + expect(await file.text()).to.equal('content'); + }); + + it('should return an unnamed file without the header', async () => { + const file = await new BlobApiResponse(new Response('content')).value(); + expect(file.name).to.equal(''); + }); + + it('should keep returning the bare Blob where File is not a global (Node.js before 20)', async () => { + const globals = global as any; + const nativeFile = globals.File; + globals.File = undefined; + try { + const value = await new BlobApiResponse(new Response('content')).value(); + expect(value).to.be.an.instanceOf(Blob); + expect((value as any).name).to.be.undefined; + expect(await value.text()).to.equal('content'); + } finally { + globals.File = nativeFile; + } + }); +}); diff --git a/samples/client/petstore/typescript-fetch/tests/default/test/index.ts b/samples/client/petstore/typescript-fetch/tests/default/test/index.ts index 134fa030435a..2a8136ffc029 100644 --- a/samples/client/petstore/typescript-fetch/tests/default/test/index.ts +++ b/samples/client/petstore/typescript-fetch/tests/default/test/index.ts @@ -1,2 +1,3 @@ import './PetApi'; import './StoreApi'; +import './BlobApiResponse'; From 04d88e9ea277ab26f4fb2223ac9f545ddfc8bfde Mon Sep 17 00:00:00 2001 From: Antoine Date: Fri, 18 Sep 2026 09:23:28 +0200 Subject: [PATCH 3/3] [typescript-fetch] tokenize Content-Disposition parameters and drop any directory part of the file name - parameters split on `;` outside quoted-strings, so an embedded `; filename=` inside another quoted value is no longer taken for a parameter, and a quoted `filename*` value is accepted - the file name is reduced to its last path segment (`/` or `\`), as the Java and Python runtimes already do, so a client writing `file.name` to disk cannot be steered outside its directory; `.`, `..` and an empty name yield no name - unit tests for both --- .../typescript-fetch/runtime.mustache | 67 +++++++++++++++---- .../runtime.ts | 67 +++++++++++++++---- .../infinite-recursion-issue/runtime.ts | 67 +++++++++++++++---- .../multipart-file-array/runtime.ts | 67 +++++++++++++++---- .../self-import-issue/runtime.ts | 67 +++++++++++++++---- .../builds/allOf-nullable/runtime.ts | 67 +++++++++++++++---- .../builds/allOf-readonly/runtime.ts | 67 +++++++++++++++---- .../builds/date-library-date/src/runtime.ts | 67 +++++++++++++++---- .../builds/date-library-string/src/runtime.ts | 67 +++++++++++++++---- .../builds/default-v3.0/runtime.ts | 67 +++++++++++++++---- .../builds/default/runtime.ts | 67 +++++++++++++++---- .../typescript-fetch/builds/enum/runtime.ts | 67 +++++++++++++++---- .../builds/es6-target/src/runtime.ts | 67 +++++++++++++++---- .../builds/kebab-case/runtime.ts | 67 +++++++++++++++---- .../builds/multiple-parameters/runtime.ts | 67 +++++++++++++++---- .../typescript-fetch/builds/oneOf/runtime.ts | 67 +++++++++++++++---- .../src/runtime.ts | 67 +++++++++++++++---- .../builds/sagas-and-records/src/runtime.ts | 67 +++++++++++++++---- .../builds/snakecase-discriminator/runtime.ts | 67 +++++++++++++++---- .../split-by-content-type/src/runtime.ts | 67 +++++++++++++++---- .../builds/validation-attributes/runtime.ts | 67 +++++++++++++++---- .../builds/with-interfaces/runtime.ts | 67 +++++++++++++++---- .../builds/with-npm-version/src/runtime.ts | 67 +++++++++++++++---- .../builds/with-string-enums/runtime.ts | 67 +++++++++++++++---- .../without-runtime-checks/src/runtime.ts | 67 +++++++++++++++---- .../tests/default/test/BlobApiResponse.ts | 25 ++++++- 26 files changed, 1399 insertions(+), 301 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache index 160d1394891f..ea9560de2cdc 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache @@ -552,27 +552,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts index c40b5851dc94..e938f71a92b9 100644 --- a/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/additional-properties-in-multipart-issue/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts index c40b5851dc94..e938f71a92b9 100644 --- a/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/infinite-recursion-issue/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts index d82d2101d8e9..0f2a7cc53e43 100644 --- a/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts +++ b/samples/client/others/typescript-fetch/multipart-file-array/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts index 9a314a5d4add..8fc35f778705 100644 --- a/samples/client/others/typescript-fetch/self-import-issue/runtime.ts +++ b/samples/client/others/typescript-fetch/self-import-issue/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts index 28420bd5463a..21a63f57d6d1 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-nullable/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts index 28420bd5463a..21a63f57d6d1 100644 --- a/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/allOf-readonly/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts index 8e15d3d451d4..5c3911a68825 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-date/src/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts index 5798f5f1ed69..f3b6f5b34a85 100644 --- a/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/date-library-string/src/runtime.ts @@ -469,27 +469,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts index e0ad5c786a1d..b26dcead9923 100644 --- a/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default-v3.0/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/default/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/default/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts index 1b948a79bc44..fde0f53ecd3a 100644 --- a/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/enum/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/es6-target/src/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts index e0ad5c786a1d..b26dcead9923 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/multiple-parameters/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts index 37218096aac1..b433ba535991 100644 --- a/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/oneOf/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/prefix-parameter-interfaces/src/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/sagas-and-records/src/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts index e0ad5c786a1d..b26dcead9923 100644 --- a/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/snakecase-discriminator/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts index 9528fbfd6e3b..14201ca4c56f 100644 --- a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts @@ -521,27 +521,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-interfaces/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts index 394d4c4517eb..a767667e3760 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-npm-version/src/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts index 1b948a79bc44..fde0f53ecd3a 100644 --- a/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/with-string-enums/runtime.ts @@ -508,27 +508,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts index c7225a03658f..f084e16504a1 100644 --- a/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts +++ b/samples/client/petstore/typescript-fetch/builds/without-runtime-checks/src/runtime.ts @@ -457,27 +457,70 @@ export class BlobApiResponse { /** * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded - * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameter - * names are matched case-insensitively and only at a parameter boundary. + * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters + * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory + * part is dropped so that the name is safe to write to disk as is; undefined when no usable name + * is advertised. */ export function parseContentDispositionFilename(headers: Headers): string | undefined { const value = headers.get('Content-Disposition'); if (!value) { return undefined; } - const encoded = /(?:^|;)\s*filename\*\s*=\s*utf-8'[^']*'([^;]*)/i.exec(value); - if (encoded) { - try { - return decodeURIComponent(encoded[1].trim()); - } catch { - // fall through to the plain form + const params = parseHeaderParameters(value); + const encoded = params.get('filename*'); + if (encoded !== undefined) { + const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); + if (extValue) { + try { + return basename(decodeURIComponent(extValue[1])); + } catch { + // malformed percent-encoding: fall through to the plain form + } } } - const plain = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/i.exec(value); - if (!plain) { - return undefined; + const plain = params.get('filename'); + return plain === undefined ? undefined : basename(plain); +} + +/** + * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are + * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the + * first occurrence of a name wins. + */ +function parseHeaderParameters(value: string): Map { + const params = new Map(); + let start = 0; + let quoted = false; + for (let i = 0; i <= value.length; i++) { + const c = value[i]; + if (i === value.length || (c === ';' && !quoted)) { + const part = value.slice(start, i); + const eq = part.indexOf('='); + if (eq !== -1) { + const name = part.slice(0, eq).trim().toLowerCase(); + let raw = part.slice(eq + 1).trim(); + if (raw.startsWith('"')) { + raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); + } + if (name && !params.has(name)) { + params.set(name, raw); + } + } + start = i + 1; + } else if (c === '"') { + quoted = !quoted; + } else if (c === '\\' && quoted) { + i++; + } } - return plain[1] !== undefined ? plain[1].replace(/\\(.)/g, '$1') : plain[2]; + return params; +} + +/** The last path segment of a file name, or undefined when nothing usable is left. */ +function basename(name: string): string | undefined { + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); + return base === '' || base === '.' || base === '..' ? undefined : base; } export class TextApiResponse { diff --git a/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts index 924a909fefb9..5d0ad7af51ff 100644 --- a/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts +++ b/samples/client/petstore/typescript-fetch/tests/default/test/BlobApiResponse.ts @@ -43,10 +43,33 @@ describe('parseContentDispositionFilename', () => { expect(nameOf("attachment; FILENAME*=utf-8''upper.pdf")).to.equal('upper.pdf'); }); - it('should only match filename at a parameter boundary', () => { + it('should only match filename as a parameter name', () => { expect(nameOf('attachment; xfilename="nope.pdf"')).to.be.undefined; expect(nameOf('attachment; name="filename*=UTF-8\'\'nope.pdf"; filename="real.pdf"')).to.equal('real.pdf'); }); + + it('should not split parameters on a semicolon inside a quoted-string', () => { + expect(nameOf('attachment; name="a; filename*=UTF-8\'\'evil.pdf"; filename="real.pdf"')).to.equal('real.pdf'); + expect(nameOf('attachment; name="y; filename=evil"; filename="real.pdf"')).to.equal('real.pdf'); + }); + + it('should accept a quoted encoded form and ignore a charset other than UTF-8', () => { + expect(nameOf("attachment; filename*=\"UTF-8''quoted.pdf\"")).to.equal('quoted.pdf'); + expect(nameOf("attachment; filename*=iso-8859-1''latin.pdf; filename=\"plain.pdf\"")).to.equal('plain.pdf'); + }); + + it('should keep the first occurrence of a repeated parameter', () => { + expect(nameOf('attachment; filename="first.pdf"; filename="second.pdf"')).to.equal('first.pdf'); + }); + + it('should drop any directory part of the name', () => { + expect(nameOf('attachment; filename="../../etc/passwd"')).to.equal('passwd'); + expect(nameOf("attachment; filename*=UTF-8''..%2F..%2Fetc%2Fpasswd")).to.equal('passwd'); + expect(nameOf('attachment; filename=C:\\Users\\me\\report.pdf')).to.equal('report.pdf'); + expect(nameOf('attachment; filename=".."')).to.be.undefined; + expect(nameOf('attachment; filename="/"')).to.be.undefined; + expect(nameOf('attachment; filename=""')).to.be.undefined; + }); }); describe('BlobApiResponse', () => {