From e8f7316230b3503b6bb4229463d747ba4d88499e Mon Sep 17 00:00:00 2001 From: Khafra Date: Tue, 5 Mar 2024 13:01:29 -0500 Subject: [PATCH 1/5] add busboy tests add busboy tests --- test/busboy/LICENSE | 19 + test/busboy/test-types-multipart-charsets.js | 59 ++ test/busboy/test-types-multipart.js | 673 +++++++++++++++++++ test/busboy/test.js | 20 + 4 files changed, 771 insertions(+) create mode 100644 test/busboy/LICENSE create mode 100644 test/busboy/test-types-multipart-charsets.js create mode 100644 test/busboy/test-types-multipart.js create mode 100644 test/busboy/test.js diff --git a/test/busboy/LICENSE b/test/busboy/LICENSE new file mode 100644 index 00000000000..da2ac4a2c9c --- /dev/null +++ b/test/busboy/LICENSE @@ -0,0 +1,19 @@ +Copyright Brian White. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/test/busboy/test-types-multipart-charsets.js b/test/busboy/test-types-multipart-charsets.js new file mode 100644 index 00000000000..65f42d4b979 --- /dev/null +++ b/test/busboy/test-types-multipart-charsets.js @@ -0,0 +1,59 @@ +'use strict' + +const assert = require('assert') +const { inspect } = require('util') +const { Response } = require('../..') + +const input = Buffer.from([ + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="テスト.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' +].join('\r\n')) +const boundary = '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k' +const expected = [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('A'.repeat(1023)), + info: { + filename: 'テスト.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } +] + +;(async () => { + const response = new Response(input, { + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } + }) + + const fd = await response.formData() + const results = [] + + for (const [name, value] of fd) { + if (typeof value === 'string') { // field + results.push({ type: 'field', name, val: value }) + } else { // File + results.push({ + type: 'file', + name, + data: Buffer.from(await value.arrayBuffer()) + }) + } + } + + assert.deepStrictEqual( + results, + expected, + 'Results mismatch.\n' + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(expected)}` + ) +})() diff --git a/test/busboy/test-types-multipart.js b/test/busboy/test-types-multipart.js new file mode 100644 index 00000000000..908ccb89ccc --- /dev/null +++ b/test/busboy/test-types-multipart.js @@ -0,0 +1,673 @@ +'use strict' + +const assert = require('assert') +const { inspect } = require('util') +const { Response } = require('../..') + +const active = new Map() + +const tests = [ + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'B'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'file_name_1', + val: 'super beta file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('A'.repeat(1023)), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('B'.repeat(1023)), + info: { + filename: '1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Fields and files' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + '', + 'some random content', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="pass"', + '', + 'some random pass', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name=bit', + '', + '2', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: 'some random content', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'pass', + val: 'some random pass', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'bit', + val: '2', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Fields only' + }, + { + source: [ + '' + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'No fields and no files' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="/tmp/1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="C:\\files\\1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_2"; filename="relative/1k_c.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_c.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Files with filenames containing paths' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="/absolute/1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="C:\\absolute\\1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_2"; filename="relative/1k_c.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '/absolute/1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'C:\\absolute\\1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'relative/1k_c.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Paths to be preserved' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + 'Content-Type: ', + '', + 'some random content', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: ', + '', + 'some random pass', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: 'some random content', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Empty content-type and empty content-disposition' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="file"; filename*=utf-8\'\'n%C3%A4me.txt', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'file', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'näme.txt', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Unicode filenames' + }, + { + source: [ + ['--asdasdasdasd\r\n', + 'Content-Type: text/plain\r\n', + 'Content-Disposition: form-data; name="foo"\r\n', + '\r\n', + 'asd\r\n', + '--asdasdasdasd--' + ].join(':)') + ], + boundary: 'asdasdasdasd', + expected: [ + { error: 'Malformed part header' }, + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-header' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + 'Content-Type: application/json', + '', + '{}', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: '{}', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'application/json' + } + } + ], + what: 'content-type for fields' + }, + { + source: [ + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [], + what: 'empty form' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name=upload_file_0; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + 'Content-Transfer-Encoding: binary', + '', + '' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-file #1' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name=upload_file_0; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'a' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-file #2' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Text file with charset' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: ', + ' text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Folded header value' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Type: text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [], + what: 'No Content-Disposition' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_0"; filename="${'a'.repeat(64 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="notes2.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { error: 'Malformed part header' }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('cd'), + info: { + filename: 'notes2.txt', + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Oversized part header' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'a'.repeat(31) + '\r' + ].join('\r\n'), + 'b'.repeat(40), + '\r\n-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'.repeat(31) + '\r' + 'b'.repeat(40)), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Lookbehind data should not stall file streams' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_0"; filename="${'a'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_1"; filename="${'b'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_2"; filename="${'c'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ef', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ab'), + info: { + filename: `${'a'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('cd'), + info: { + filename: `${'b'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ef'), + info: { + filename: `${'c'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Large filename' + }, + { + source: [ + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee\r\n', + 'Content-Type: application/gzip\r\n' + + 'Content-Encoding: gzip\r\n' + + 'Content-Disposition: form-data; name=batch-1; filename=batch-1' + + '\r\n\r\n', + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee--' + ], + boundary: 'd1bf46b3-aa33-4061-b28d-6c5ced8b08ee', + expected: [ + { + type: 'file', + name: 'batch-1', + data: Buffer.alloc(0), + info: { + filename: 'batch-1', + encoding: '7bit', + mimeType: 'application/gzip' + } + } + ], + what: 'Empty part' + } +] + +;(async () => { + for (const test of tests) { + active.set(test, 1) + + const { what, boundary, source } = test + + const body = source.reduce((a, b) => a + b, '') + const response = new Response(body, { + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } + }) + + let fd + const results = [] + + try { + fd = await response.formData() + } catch (e) { + results.push({ error: e.message }) + } + + for (const [name, value] of fd) { + if (typeof value === 'string') { // field + results.push({ type: 'field', name, val: value }) + } else { // File + results.push({ + type: 'file', + name, + data: Buffer.from(await value.arrayBuffer()) + }) + } + } + + active.delete(test) + + assert.deepStrictEqual( + results, + test.expected, + `[${what}] Results mismatch.\n` + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(test.expected)}` + ) + } +})() + +{ + let exception = false + process.once('uncaughtException', (ex) => { + exception = true + throw ex + }) + process.on('exit', () => { + if (exception || active.size === 0) { return } + process.exitCode = 1 + console.error('==========================') + console.error(`${active.size} test(s) did not finish:`) + console.error('==========================') + console.error(Array.from(active.keys()).map((v) => v.what).join('\n')) + }) +} diff --git a/test/busboy/test.js b/test/busboy/test.js new file mode 100644 index 00000000000..22c42d3fd6b --- /dev/null +++ b/test/busboy/test.js @@ -0,0 +1,20 @@ +'use strict' + +const { spawnSync } = require('child_process') +const { readdirSync } = require('fs') +const { join } = require('path') + +const files = readdirSync(__dirname).sort() +for (const filename of files) { + if (filename.startsWith('test-')) { + const path = join(__dirname, filename) + console.log(`> Running ${filename} ...`) + // Note: nyc changes process.argv0, process.execPath, etc. + const result = spawnSync(`node ${path}`, { + shell: true, + stdio: 'inherit', + windowsHide: true + }) + if (result.status !== 0) { process.exitCode = 1 } + } +} From a5ce46e72198f0499baf19d896775722e99354f7 Mon Sep 17 00:00:00 2001 From: Khafra Date: Tue, 5 Mar 2024 13:44:49 -0500 Subject: [PATCH 2/5] add info, remove *truncated --- test/busboy/test-types-multipart.js | 36 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/test/busboy/test-types-multipart.js b/test/busboy/test-types-multipart.js index 908ccb89ccc..f30561230fc 100644 --- a/test/busboy/test-types-multipart.js +++ b/test/busboy/test-types-multipart.js @@ -39,8 +39,6 @@ const tests = [ name: 'file_name_0', val: 'super alpha file', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'text/plain' } @@ -50,8 +48,6 @@ const tests = [ name: 'file_name_1', val: 'super beta file', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'text/plain' } @@ -103,8 +99,6 @@ const tests = [ name: 'cont', val: 'some random content', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'text/plain' } @@ -114,8 +108,6 @@ const tests = [ name: 'pass', val: 'some random pass', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'text/plain' } @@ -125,8 +117,6 @@ const tests = [ name: 'bit', val: '2', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'text/plain' } @@ -281,8 +271,6 @@ const tests = [ name: 'cont', val: 'some random content', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'text/plain' } @@ -350,8 +338,6 @@ const tests = [ name: 'cont', val: '{}', info: { - nameTruncated: false, - valueTruncated: false, encoding: '7bit', mimeType: 'application/json' } @@ -632,14 +618,32 @@ const tests = [ results.push({ error: e.message }) } + if (!fd[Symbol.iterator]) { + // TODO: + continue + } + for (const [name, value] of fd) { if (typeof value === 'string') { // field - results.push({ type: 'field', name, val: value }) + results.push({ + type: 'field', + name, + val: value, + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }) } else { // File results.push({ type: 'file', name, - data: Buffer.from(await value.arrayBuffer()) + data: Buffer.from(await value.arrayBuffer()), + info: { + filename: value.name, + encoding: '7bit', + mimeType: value.type + } }) } } From a69f4d742c3e87ad5cde53a59a83bc364024179f Mon Sep 17 00:00:00 2001 From: Khafra Date: Tue, 5 Mar 2024 14:02:05 -0500 Subject: [PATCH 3/5] fixup --- test/busboy/test-types-multipart-charsets.js | 17 ++++++- test/busboy/test-types-multipart.js | 47 ++++++++++++-------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/test/busboy/test-types-multipart-charsets.js b/test/busboy/test-types-multipart-charsets.js index 65f42d4b979..3840d5330b0 100644 --- a/test/busboy/test-types-multipart-charsets.js +++ b/test/busboy/test-types-multipart-charsets.js @@ -39,12 +39,25 @@ const expected = [ for (const [name, value] of fd) { if (typeof value === 'string') { // field - results.push({ type: 'field', name, val: value }) + results.push({ + type: 'field', + name, + val: value, + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }) } else { // File results.push({ type: 'file', name, - data: Buffer.from(await value.arrayBuffer()) + data: Buffer.from(await value.arrayBuffer()), + info: { + filename: value.name, + encoding: '7bit', + mimeType: value.type + } }) } } diff --git a/test/busboy/test-types-multipart.js b/test/busboy/test-types-multipart.js index f30561230fc..e821999efb4 100644 --- a/test/busboy/test-types-multipart.js +++ b/test/busboy/test-types-multipart.js @@ -164,7 +164,7 @@ const tests = [ name: 'upload_file_0', data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), info: { - filename: '1k_a.dat', + filename: '/tmp/1k_a.dat', encoding: '7bit', mimeType: 'application/octet-stream' } @@ -174,7 +174,7 @@ const tests = [ name: 'upload_file_1', data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), info: { - filename: '1k_b.dat', + filename: 'C:\\files\\1k_b.dat', encoding: '7bit', mimeType: 'application/octet-stream' } @@ -184,13 +184,13 @@ const tests = [ name: 'upload_file_2', data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), info: { - filename: '1k_c.dat', + filename: 'relative/1k_c.dat', encoding: '7bit', mimeType: 'application/octet-stream' } } ], - what: 'Files with filenames containing paths' + what: 'Files with filenames containing paths preserve path' }, { source: [ @@ -316,8 +316,7 @@ const tests = [ ], boundary: 'asdasdasdasd', expected: [ - { error: 'Malformed part header' }, - { error: 'Unexpected end of form' } + { error: 'Malformed part header' } ], what: 'Stopped mid-header' }, @@ -339,7 +338,8 @@ const tests = [ val: '{}', info: { encoding: '7bit', - mimeType: 'application/json' + // TODO: there's no way to get the content-type of a field + mimeType: 'text/plain' // 'application/json' } } ], @@ -406,7 +406,7 @@ const tests = [ info: { filename: 'notes.txt', encoding: '7bit', - mimeType: 'text/plain' + mimeType: 'text/plain; charset=utf8' } } ], @@ -471,7 +471,17 @@ const tests = [ ], boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', expected: [ - { error: 'Malformed part header' }, + // TODO: the RFC does not mention the max size of a filename? + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ab'), + info: { + filename: `${'a'.repeat(64 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + }, // { error: 'Malformed part header' }, { type: 'file', name: 'upload_file_1', @@ -479,7 +489,7 @@ const tests = [ info: { filename: 'notes2.txt', encoding: '7bit', - mimeType: 'text/plain' + mimeType: 'text/plain; charset=utf8' } } ], @@ -506,7 +516,7 @@ const tests = [ info: { filename: 'notes.txt', encoding: '7bit', - mimeType: 'text/plain' + mimeType: 'text/plain; charset=utf8' } } ], @@ -544,7 +554,7 @@ const tests = [ info: { filename: `${'a'.repeat(8 * 1024)}.txt`, encoding: '7bit', - mimeType: 'text/plain' + mimeType: 'text/plain; charset=utf8' } }, { @@ -554,7 +564,7 @@ const tests = [ info: { filename: `${'b'.repeat(8 * 1024)}.txt`, encoding: '7bit', - mimeType: 'text/plain' + mimeType: 'text/plain; charset=utf8' } }, { @@ -564,7 +574,7 @@ const tests = [ info: { filename: `${'c'.repeat(8 * 1024)}.txt`, encoding: '7bit', - mimeType: 'text/plain' + mimeType: 'text/plain; charset=utf8' } } ], @@ -616,10 +626,11 @@ const tests = [ fd = await response.formData() } catch (e) { results.push({ error: e.message }) - } - if (!fd[Symbol.iterator]) { - // TODO: + if (test.expected.length === 1 && test.expected[0].error) { + active.delete(test) + } + continue } @@ -670,7 +681,7 @@ const tests = [ if (exception || active.size === 0) { return } process.exitCode = 1 console.error('==========================') - console.error(`${active.size} test(s) did not finish:`) + console.error(`${active.size}/${tests.length} test(s) did not finish:`) console.error('==========================') console.error(Array.from(active.keys()).map((v) => v.what).join('\n')) }) From 11a0a9bfe8a6bbdd6bd293466e89d12122c1a7f5 Mon Sep 17 00:00:00 2001 From: Khafra Date: Tue, 5 Mar 2024 20:41:41 -0500 Subject: [PATCH 4/5] fixup --- lib/web/fetch/formdata-parser.js | 19 ++++- package.json | 2 +- test/busboy/test-types-multipart-charsets.js | 9 ++- test/busboy/test-types-multipart.js | 81 +++----------------- test/busboy/test.js | 20 ----- 5 files changed, 35 insertions(+), 96 deletions(-) delete mode 100644 test/busboy/test.js diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js index a338631fc06..190222096b9 100644 --- a/lib/web/fetch/formdata-parser.js +++ b/lib/web/fetch/formdata-parser.js @@ -11,7 +11,7 @@ const { isAscii } = require('node:buffer') const File = globalThis.File ?? UndiciFile const formDataNameBuffer = Buffer.from('form-data; name="') -const filenameBuffer = Buffer.from('; filename="') +const filenameBuffer = Buffer.from('; filename') const dd = Buffer.from('--') const ddcrlf = Buffer.from('--\r\n') @@ -110,6 +110,11 @@ function multipartFormDataParser (input, mimeType) { // the first byte. const position = { position: 0 } + // Note: undici addition, allow \r\n before the body. + if (input[0] === 0x0d && input[1] === 0x0a) { + position.position += 2 + } + // 5. While true: while (true) { // 5.1. If position points to a sequence of bytes starting with 0x2D 0x2D @@ -301,6 +306,18 @@ function parseMultipartFormDataHeaders (input, position) { // 5. If position points to a sequence of bytes starting with `; filename="`: if (bufferStartsWith(input, filenameBuffer, position)) { + // Note: undici also handles filename* + let check = position.position + filenameBuffer.length + + if (input[check] === 0x2a) { + position.position += 1 + check += 1 + } + + if (input[check] !== 0x3d || input[check + 1] !== 0x22) { // =" + return 'failure' + } + // 1. Advance position so it points at the byte after the next 0x22 (") byte // (the one in the sequence of bytes matched above). position.position += 12 diff --git a/package.json b/package.json index 3e2b2ce6bab..739e8527a71 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:cookies": "borp -p \"test/cookie/*.js\"", "test:node-fetch": "borp -p \"test/node-fetch/**/*.js\"", "test:eventsource": "npm run build:node && borp --expose-gc -p \"test/eventsource/*.js\"", - "test:fetch": "npm run build:node && borp --expose-gc -p \"test/fetch/*.js\" && borp -p \"test/webidl/*.js\"", + "test:fetch": "npm run build:node && borp --expose-gc -p \"test/fetch/*.js\" && borp -p \"test/webidl/*.js\" && borp -p \"test/busboy/*.js\"", "test:jest": "cross-env NODE_V8_COVERAGE= jest", "test:unit": "borp --expose-gc -p \"test/*.js\"", "test:node-test": "borp -p \"test/node-test/**/*.js\"", diff --git a/test/busboy/test-types-multipart-charsets.js b/test/busboy/test-types-multipart-charsets.js index 3840d5330b0..b162024438d 100644 --- a/test/busboy/test-types-multipart-charsets.js +++ b/test/busboy/test-types-multipart-charsets.js @@ -1,7 +1,8 @@ 'use strict' -const assert = require('assert') -const { inspect } = require('util') +const assert = require('node:assert') +const { inspect } = require('node:util') +const { test } = require('node:test') const { Response } = require('../..') const input = Buffer.from([ @@ -27,7 +28,7 @@ const expected = [ } ] -;(async () => { +test('unicode filename', async (t) => { const response = new Response(input, { headers: { 'content-type': `multipart/form-data; boundary=${boundary}` @@ -69,4 +70,4 @@ const expected = [ `Parsed: ${inspect(results)}\n` + `Expected: ${inspect(expected)}` ) -})() +}) diff --git a/test/busboy/test-types-multipart.js b/test/busboy/test-types-multipart.js index e821999efb4..73960f2ccac 100644 --- a/test/busboy/test-types-multipart.js +++ b/test/busboy/test-types-multipart.js @@ -1,7 +1,8 @@ 'use strict' -const assert = require('assert') -const { inspect } = require('util') +const assert = require('node:assert') +const { inspect } = require('node:util') +const { describe } = require('node:test') const { Response } = require('../..') const active = new Map() @@ -86,7 +87,7 @@ const tests = [ '', 'some random pass', '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', - 'Content-Disposition: form-data; name=bit', + 'Content-Disposition: form-data; name="bit"', '', '2', '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' @@ -257,10 +258,6 @@ const tests = [ 'Content-Type: ', '', 'some random content', - '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', - 'Content-Disposition: ', - '', - 'some random pass', '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' ].join('\r\n') ], @@ -276,13 +273,13 @@ const tests = [ } } ], - what: 'Empty content-type and empty content-disposition' + what: 'Empty content-type defaults to text/plain' }, { source: [ ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', 'Content-Disposition: form-data; ' + - 'name="file"; filename*=utf-8\'\'n%C3%A4me.txt', + 'name="file"; filename*="utf-8\'\'n%C3%A4me.txt"', 'Content-Type: application/octet-stream', '', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', @@ -296,7 +293,7 @@ const tests = [ name: 'file', data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), info: { - filename: 'näme.txt', + filename: 'utf-8\'\'n%C3%A4me.txt', encoding: '7bit', mimeType: 'application/octet-stream' } @@ -412,46 +409,6 @@ const tests = [ ], what: 'Text file with charset' }, - { - source: [ - ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', - 'Content-Disposition: form-data; ' + - 'name="upload_file_0"; filename="notes.txt"', - 'Content-Type: ', - ' text/plain; charset=utf8', - '', - 'a', - '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' - ].join('\r\n') - ], - boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', - expected: [ - { - type: 'file', - name: 'upload_file_0', - data: Buffer.from('a'), - info: { - filename: 'notes.txt', - encoding: '7bit', - mimeType: 'text/plain' - } - } - ], - what: 'Folded header value' - }, - { - source: [ - ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', - 'Content-Type: text/plain; charset=utf8', - '', - 'a', - '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' - ].join('\r\n') - ], - boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', - expected: [], - what: 'No Content-Disposition' - }, { source: [ ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', @@ -585,8 +542,8 @@ const tests = [ '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee\r\n', 'Content-Type: application/gzip\r\n' + 'Content-Encoding: gzip\r\n' + - 'Content-Disposition: form-data; name=batch-1; filename=batch-1' + - '\r\n\r\n', + 'Content-Disposition: form-data; name="batch-1"; filename="batch-1"' + + '\r\n\r\n' + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee--' ], boundary: 'd1bf46b3-aa33-4061-b28d-6c5ced8b08ee', @@ -606,7 +563,7 @@ const tests = [ } ] -;(async () => { +describe('FormData parsing tests', async (t) => { for (const test of tests) { active.set(test, 1) @@ -669,20 +626,4 @@ const tests = [ `Expected: ${inspect(test.expected)}` ) } -})() - -{ - let exception = false - process.once('uncaughtException', (ex) => { - exception = true - throw ex - }) - process.on('exit', () => { - if (exception || active.size === 0) { return } - process.exitCode = 1 - console.error('==========================') - console.error(`${active.size}/${tests.length} test(s) did not finish:`) - console.error('==========================') - console.error(Array.from(active.keys()).map((v) => v.what).join('\n')) - }) -} +}) diff --git a/test/busboy/test.js b/test/busboy/test.js deleted file mode 100644 index 22c42d3fd6b..00000000000 --- a/test/busboy/test.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict' - -const { spawnSync } = require('child_process') -const { readdirSync } = require('fs') -const { join } = require('path') - -const files = readdirSync(__dirname).sort() -for (const filename of files) { - if (filename.startsWith('test-')) { - const path = join(__dirname, filename) - console.log(`> Running ${filename} ...`) - // Note: nyc changes process.argv0, process.execPath, etc. - const result = spawnSync(`node ${path}`, { - shell: true, - stdio: 'inherit', - windowsHide: true - }) - if (result.status !== 0) { process.exitCode = 1 } - } -} From 56c0080c778d115e09058784db2b382dbf8110c2 Mon Sep 17 00:00:00 2001 From: Khafra Date: Tue, 5 Mar 2024 20:49:42 -0500 Subject: [PATCH 5/5] fixup --- lib/web/fetch/formdata-parser.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js index 190222096b9..8269dce42e9 100644 --- a/lib/web/fetch/formdata-parser.js +++ b/lib/web/fetch/formdata-parser.js @@ -6,9 +6,9 @@ const { HTTP_TOKEN_CODEPOINTS, isomorphicDecode } = require('./data-url') const { isFileLike, File: UndiciFile } = require('./file') const { makeEntry } = require('./formdata') const assert = require('node:assert') -const { isAscii } = require('node:buffer') +const { isAscii, File: NodeFile } = require('node:buffer') -const File = globalThis.File ?? UndiciFile +const File = globalThis.File ?? NodeFile ?? UndiciFile const formDataNameBuffer = Buffer.from('form-data; name="') const filenameBuffer = Buffer.from('; filename')