diff --git a/README.md b/README.md index 46fdc68..b0a36c4 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,9 @@ curl -H "RFETCH_CONFIG_R2_TYPE: s3" \ With `Accept: application/json`, a `GET` gives the list of `rfetch lsjson`, and `Depth: N` lists N levels. Else a folder is an HTML page, and a file is its -bytes, with ranges. The prefix `RCLONE_` of rclone also works. +bytes, with ranges. The prefix `RCLONE_` of rclone also works. The fields of the +query are options too. A header or a field of the query with an empty value is +not an option: the default of the option stays. The Worker is also a WebDAV server, as `rfetch serve webdav`: a WebDAV client can use the URL of a remote, for example `https://rfetch.pages.dev/r2:bucket/`, diff --git a/_worker.js b/_worker.js index a555574..9524efd 100644 --- a/_worker.js +++ b/_worker.js @@ -8,8 +8,9 @@ * `RFETCH_*`, or `RCLONE_*` for compatibility, for example * `RFETCH_S3_ACCESS_KEY_ID` or the remote `RFETCH_CONFIG_R2_TYPE: s3`. The * fields of the query are options of the remote too, as the parameters of a - * connection string. Do not put a secret in the query: a URL can stay in the - * history of a browser and in logs. + * connection string. A header or a field with an empty value is not an + * option. Do not put a secret in the query: a URL can stay in the history of + * a browser and in logs. * * A `GET` with `Accept: application/json` gives the list of `rfetch lsjson`. * The header `Depth: N` lists N levels, and `Depth: infinity` all of them. @@ -60,6 +61,8 @@ function locationOf(url) { } let params = ""; for (const [key, value] of url.searchParams) { + // A field with an empty value is not an option, as a header. + if (value === "") continue; params += `,${key}='${value.replaceAll("'", "''")}'`; } // The remote ends with ":": the parameters go before it. @@ -146,7 +149,9 @@ export default { /** @type {RequestInit} */ const init = { method, headers }; - if (request.body) { + // workerd gives a body stream to a GET with `Content-Length: 0`, and a + // GET or a HEAD with a body is an error of `fetch()`. + if (request.body && method !== "GET" && method !== "HEAD") { init.body = request.body; init.duplex = "half"; } diff --git a/backend/crypt/aes.js b/backend/crypt/aes.js index 8ea1070..11188e6 100644 --- a/backend/crypt/aes.js +++ b/backend/crypt/aes.js @@ -1,17 +1,32 @@ -import { createCipheriv, createDecipheriv } from "node:crypto"; +import * as nodeCrypto from "node:crypto"; + +const BLOCK_SIZE = 16; +const ROUNDS = 14; /** - * AES-256 of `node:crypto` in ECB mode with no padding, the block cipher of - * EME. Each call takes one or more blocks of 16 bytes. One object does all - * the calls of one direction, because ECB keeps no state between blocks. + * AES-256 in ECB mode with no padding, the block cipher of EME. Each call + * takes one or more blocks of 16 bytes. + * + * It uses AES-256-ECB of `node:crypto`. One object does all the calls of one + * direction, because ECB keeps no state between blocks. A runtime with no AES + * in `node:crypto` gets `aes256js`: in a build of Cloudflare Pages, + * `createCipheriv` is a stub of unenv that throws. * @param {Uint8Array} key 32 bytes. + * @param {Pick} [native] + * The functions of `node:crypto`. A test gives others. * @returns {import("./eme.js").BlockCipher} */ -export function aes256(key) { +export function aes256(key, native = nodeCrypto) { // ECB has no IV. workerd refuses null, so the IV is empty. const iv = new Uint8Array(0); - const encryptor = createCipheriv("aes-256-ecb", key, iv); - const decryptor = createDecipheriv("aes-256-ecb", key, iv); + let encryptor; + let decryptor; + try { + encryptor = native.createCipheriv("aes-256-ecb", key, iv); + decryptor = native.createDecipheriv("aes-256-ecb", key, iv); + } catch { + return aes256js(key); + } encryptor.setAutoPadding(false); decryptor.setAutoPadding(false); return { @@ -19,3 +34,218 @@ export function aes256(key) { decrypt: (block) => decryptor.update(block), }; } + +/** + * AES-256 in ECB mode with no padding, in JavaScript, as FIPS-197. It is + * slower than `node:crypto`, and its tables do not keep the time the same for + * each key, but it runs in each runtime. EME uses it only for the names of + * files. + * @param {Uint8Array} key 32 bytes. + * @returns {import("./eme.js").BlockCipher} + */ +export function aes256js(key) { + if (key.length !== 32) { + throw new RangeError("The key of AES-256 must have 32 bytes"); + } + const { sbox, inverse } = tables(); + const w = expand(key, sbox); + return { + encrypt: (blocks) => each(blocks, (s) => encryptBlock(s, w, sbox)), + decrypt: (blocks) => each(blocks, (s) => decryptBlock(s, w, inverse)), + }; +} + +/** + * Gives a copy of the blocks, with each block changed in place by `fn`. + * @param {Uint8Array} blocks + * @param {(state: Uint8Array) => void} fn + * @returns {Uint8Array} + */ +function each(blocks, fn) { + if (blocks.length % BLOCK_SIZE !== 0) { + throw new RangeError("The data of AES-ECB must be blocks of 16 bytes"); + } + // A copy: slice() of a Buffer gives a view of the same bytes. + const out = new Uint8Array(blocks); + for (let i = 0; i < out.length; i += BLOCK_SIZE) { + fn(out.subarray(i, i + BLOCK_SIZE)); + } + return out; +} + +/** @type {{ sbox: Uint8Array, inverse: Uint8Array } | undefined} */ +let cache; + +/** + * The S-box of AES and its inverse (FIPS-197, section 5.1.1): the inverse in + * GF(2^8), then the affine transform. They are made at the first use, not at + * the load of the module. p goes through each value other than 0 with the + * generator 3, and q is its inverse. + */ +function tables() { + if (cache) return cache; + const sbox = new Uint8Array(256); + const inverse = new Uint8Array(256); + const rotl = (x, n) => ((x << n) | (x >> (8 - n))) & 0xff; + let p = 1; + let q = 1; + do { + // p = p * 3. + p = p ^ xtime(p); + // q = q / 3. + q ^= q << 1; + q ^= q << 2; + q ^= q << 4; + q &= 0xff; + if (q & 0x80) q ^= 0x09; + const s = q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63; + sbox[p] = s; + inverse[s] = p; + } while (p !== 1); + // 0 has no inverse: the affine transform of 0. + sbox[0] = 0x63; + inverse[0x63] = 0; + cache = { sbox, inverse }; + return cache; +} + +/** + * The multiplication by 2 in GF(2^8). + * @param {number} x A byte. + */ +function xtime(x) { + return ((x << 1) ^ (x & 0x80 ? 0x1b : 0)) & 0xff; +} + +/** + * The key schedule of AES-256 (FIPS-197, section 5.2): 15 round keys of 16 + * bytes, one after the other. + * @param {Uint8Array} key 32 bytes. + * @param {Uint8Array} sbox + */ +function expand(key, sbox) { + const w = new Uint8Array((ROUNDS + 1) * BLOCK_SIZE); + w.set(key); + let rcon = 1; + for (let i = 32; i < w.length; i += 4) { + let t0 = w[i - 4]; + let t1 = w[i - 3]; + let t2 = w[i - 2]; + let t3 = w[i - 1]; + if (i % 32 === 0) { + // RotWord, SubWord and Rcon. + [t0, t1, t2, t3] = [sbox[t1] ^ rcon, sbox[t2], sbox[t3], sbox[t0]]; + rcon = xtime(rcon); + } else if (i % 32 === 16) { + // SubWord only, for a key of 8 words. + [t0, t1, t2, t3] = [sbox[t0], sbox[t1], sbox[t2], sbox[t3]]; + } + w[i] = w[i - 32] ^ t0; + w[i + 1] = w[i - 31] ^ t1; + w[i + 2] = w[i - 30] ^ t2; + w[i + 3] = w[i - 29] ^ t3; + } + return w; +} + +/** + * @param {Uint8Array} s The state: 16 bytes, column after column. + * @param {Uint8Array} w The round keys. + * @param {number} round + */ +function addRoundKey(s, w, round) { + const start = round * BLOCK_SIZE; + for (let i = 0; i < BLOCK_SIZE; i++) s[i] ^= w[start + i]; +} + +/** + * Puts each byte of the state through a table. + * @param {Uint8Array} s + * @param {Uint8Array} table + */ +function substitute(s, table) { + for (let i = 0; i < BLOCK_SIZE; i++) s[i] = table[s[i]]; +} + +/** + * Turns the row r of the state left by r bytes, or right with `inverse`. + * @param {Uint8Array} s + * @param {boolean} inverse + */ +function shiftRows(s, inverse) { + const t = s.slice(); + for (let r = 1; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const from = inverse ? (c + 4 - r) % 4 : (c + r) % 4; + s[r + 4 * c] = t[r + 4 * from]; + } + } +} + +/** @param {Uint8Array} s */ +function mixColumns(s) { + for (let c = 0; c < 16; c += 4) { + const [a0, a1, a2, a3] = [s[c], s[c + 1], s[c + 2], s[c + 3]]; + const all = a0 ^ a1 ^ a2 ^ a3; + s[c] = a0 ^ all ^ xtime(a0 ^ a1); + s[c + 1] = a1 ^ all ^ xtime(a1 ^ a2); + s[c + 2] = a2 ^ all ^ xtime(a2 ^ a3); + s[c + 3] = a3 ^ all ^ xtime(a3 ^ a0); + } +} + +/** + * The inverse of `mixColumns`: the multiplications by 9, 11, 13 and 14. + * @param {Uint8Array} s + */ +function inverseMixColumns(s) { + for (let c = 0; c < 16; c += 4) { + const a = [s[c], s[c + 1], s[c + 2], s[c + 3]]; + const times = a.map((x) => { + const x2 = xtime(x); + const x4 = xtime(x2); + const x8 = xtime(x4); + return { 9: x8 ^ x, 11: x8 ^ x2 ^ x, 13: x8 ^ x4 ^ x, 14: x8 ^ x4 ^ x2 }; + }); + for (let r = 0; r < 4; r++) { + s[c + r] = times[r][14] ^ times[(r + 1) % 4][11] ^ + times[(r + 2) % 4][13] ^ times[(r + 3) % 4][9]; + } + } +} + +/** + * @param {Uint8Array} s One block, which changes. + * @param {Uint8Array} w + * @param {Uint8Array} sbox + */ +function encryptBlock(s, w, sbox) { + addRoundKey(s, w, 0); + for (let round = 1; round < ROUNDS; round++) { + substitute(s, sbox); + shiftRows(s, false); + mixColumns(s); + addRoundKey(s, w, round); + } + substitute(s, sbox); + shiftRows(s, false); + addRoundKey(s, w, ROUNDS); +} + +/** + * @param {Uint8Array} s One block, which changes. + * @param {Uint8Array} w + * @param {Uint8Array} inverse The inverse S-box. + */ +function decryptBlock(s, w, inverse) { + addRoundKey(s, w, ROUNDS); + for (let round = ROUNDS - 1; round > 0; round--) { + shiftRows(s, true); + substitute(s, inverse); + addRoundKey(s, w, round); + inverseMixColumns(s); + } + shiftRows(s, true); + substitute(s, inverse); + addRoundKey(s, w, 0); +} diff --git a/backend/crypt/aes_test.js b/backend/crypt/aes_test.js new file mode 100644 index 0000000..fb79b49 --- /dev/null +++ b/backend/crypt/aes_test.js @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fc from "fast-check"; + +import { aes256, aes256js } from "./aes.js"; + +/** @param {string} text */ +const hex = (text) => Uint8Array.from(Buffer.from(text, "hex")); + +test("AES-256 in JavaScript gives the vector of FIPS-197, appendix C.3", () => { + const key = hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + ); + const plain = hex("00112233445566778899aabbccddeeff"); + const cipher = hex("8ea2b7ca516745bfeafc49904b496089"); + const aes = aes256js(key); + assert.deepEqual(aes.encrypt(plain), cipher); + assert.deepEqual(aes.decrypt(cipher), plain); + // The input does not change. + assert.deepEqual(plain, hex("00112233445566778899aabbccddeeff")); +}); + +const KEY = fc.uint8Array({ minLength: 32, maxLength: 32 }); +const BLOCKS = fc.integer({ min: 1, max: 8 }).chain((blocks) => + fc.uint8Array({ minLength: blocks * 16, maxLength: blocks * 16 }) +); + +test("AES-256 in JavaScript gives the blocks of node:crypto", () => { + fc.assert( + fc.property(KEY, BLOCKS, (key, blocks) => { + const native = aes256(key); + const js = aes256js(key); + const encrypted = native.encrypt(blocks); + assert.deepEqual(js.encrypt(blocks), new Uint8Array(encrypted)); + assert.deepEqual(js.decrypt(encrypted), blocks); + }), + ); +}); + +test("aes256 uses JavaScript when node:crypto has no AES", () => { + // As createCipheriv of unenv in a build of Cloudflare Pages. + const stub = { + createCipheriv() { + throw new Error("[unenv] crypto.createCipheriv is not implemented yet!"); + }, + createDecipheriv() { + throw new Error( + "[unenv] crypto.createDecipheriv is not implemented yet!", + ); + }, + }; + const key = new Uint8Array(32).fill(7); + const blocks = new Uint8Array(32).fill(1); + const fallback = aes256(key, stub); + const encrypted = fallback.encrypt(blocks); + assert.deepEqual(encrypted, new Uint8Array(aes256(key).encrypt(blocks))); + assert.deepEqual(fallback.decrypt(encrypted), blocks); +}); + +test("AES-256 in JavaScript refuses a key or data of a wrong size", () => { + assert.throws(() => aes256js(new Uint8Array(16)), RangeError); + assert.throws( + () => aes256js(new Uint8Array(32)).encrypt(new Uint8Array(15)), + RangeError, + ); +}); diff --git a/backend/crypt/eme_test.js b/backend/crypt/eme_test.js index 0cd6546..6778948 100644 --- a/backend/crypt/eme_test.js +++ b/backend/crypt/eme_test.js @@ -1,5 +1,5 @@ import { test } from "node:test"; -import { aes256 } from "./aes.js"; +import { aes256, aes256js } from "./aes.js"; import assert from "node:assert/strict"; import fc from "fast-check"; @@ -96,13 +96,16 @@ const VECTORS = { `), }; -test("the vectors of rfjakob/eme", () => { - const cipher = aes256(new Uint8Array(32)); - const tweak = new Uint8Array(16); - for (const [size, expected] of Object.entries(VECTORS)) { - const zeros = new Uint8Array(Number(size)); - assert.deepEqual(encrypt(cipher, tweak, zeros), expected); - assert.deepEqual(decrypt(cipher, tweak, expected), zeros); +test("the vectors of rfjakob/eme, with AES of node:crypto and of JavaScript", () => { + for ( + const cipher of [aes256(new Uint8Array(32)), aes256js(new Uint8Array(32))] + ) { + const tweak = new Uint8Array(16); + for (const [size, expected] of Object.entries(VECTORS)) { + const zeros = new Uint8Array(Number(size)); + assert.deepEqual(new Uint8Array(encrypt(cipher, tweak, zeros)), expected); + assert.deepEqual(new Uint8Array(decrypt(cipher, tweak, expected)), zeros); + } } }); diff --git a/backend/crypt/main.js b/backend/crypt/main.js index 791607b..30183b5 100644 --- a/backend/crypt/main.js +++ b/backend/crypt/main.js @@ -196,8 +196,8 @@ async function crypt(request, env = {}) { options.get("password"), options.get("password2") ?? "", ); - } catch { - return new Response("The password is not obscured", { status: 400 }); + } catch (error) { + return keyError(error); } const isDirectory = path.endsWith("/"); @@ -674,13 +674,36 @@ function cipherOf(password, salt) { return cipher; } +/** The error of a password or a salt that `reveal` cannot read. */ +class NotObscured extends Error {} + /** * @param {string} obscured * @returns {Promise} */ async function revealBytes(obscured) { - const response = await reveal(obscured); - return new Uint8Array(await response.arrayBuffer()); + try { + const response = await reveal(obscured); + return new Uint8Array(await response.arrayBuffer()); + } catch { + throw new NotObscured("The password is not obscured"); + } +} + +/** + * The answer for a password that gives no keys. A password that `reveal` + * cannot read is not obscured: 400. A different error tells its cause, + * with no part of the password: 500. For example, a runtime can have no + * AES-256 or no scrypt in `node:crypto`. + * @param {unknown} error + * @returns {Response} + */ +export function keyError(error) { + if (error instanceof NotObscured) { + return new Response(error.message, { status: 400 }); + } + const cause = error instanceof Error ? error.message : String(error); + return new Response(`The keys of crypt failed: ${cause}`, { status: 500 }); } export default { diff --git a/backend/crypt/main_test.js b/backend/crypt/main_test.js index 231f871..11d2ea9 100644 --- a/backend/crypt/main_test.js +++ b/backend/crypt/main_test.js @@ -10,7 +10,13 @@ import { remote } from "@rfetch/lib/testing/remote"; import { listen } from "@rfetch/lib/testing/server"; import { fetch } from "../../mod.js"; import { mimeType } from "@rfetch/lib/mime"; -import { decode, default as crypt, deriveKey, encode } from "./main.js"; +import { + decode, + default as crypt, + deriveKey, + encode, + keyError, +} from "./main.js"; import memory from "@rfetch/memory"; // The password "123456" and the salt, obscured. @@ -193,7 +199,18 @@ describe("crypt", () => { const bad = remote(crypt, { params: { ...params, password: "!!" } }); const response = await bad.get(""); assert.equal(response.status, 400); - assert.ok(!(await response.text()).includes("!!")); + const text = await response.text(); + assert.equal(text, "The password is not obscured"); + assert.ok(!text.includes("!!")); + }); + + test("gives 500 with the cause for a different error of the keys", async () => { + const response = keyError(new Error("aes-256-ecb is not supported")); + assert.equal(response.status, 500); + assert.equal( + await response.text(), + "The keys of crypt failed: aes-256-ecb is not supported", + ); }); test("gives an error of the stream for data that is not encrypted", async () => { diff --git a/fetch.js b/fetch.js index 3076766..3172972 100644 --- a/fetch.js +++ b/fetch.js @@ -84,7 +84,9 @@ const globalFetch = globalThis.fetch; * * A header of the request with the name of a variable, for example * `RFETCH_CONFIG_MYREMOTE_SKIP_LINKS`, takes the place of the variable of the - * environment. A remote can come only from variables, as in rclone: + * environment. A header with an empty value is not a variable: the variable + * of the environment, or the default, stays. A remote can come only from + * variables, as in rclone: * `RFETCH_CONFIG_MYREMOTE_TYPE` and its options. Thus a request can give * all its configuration, with no config file. * @@ -221,12 +223,13 @@ async function resolve(input, headers) { // The variables, by their names with no prefix, for example // `CONFIG_R2_TYPE`: the environment of the process, then the headers of // the request, which take their place. In each place, `RFETCH_` takes the - // place of `RCLONE_`. + // place of `RCLONE_`. A header with an empty value is not a variable. /** @type {Record} */ const variables = {}; const places = [ variablesOf(env), - [...headers].map(([key, value]) => [key.toUpperCase(), value]), + [...headers].filter(([, value]) => value !== "") + .map(([key, value]) => [key.toUpperCase(), value]), ]; for (const entries of places) { for (const prefix of ["RCLONE_", "RFETCH_"]) { diff --git a/lib/options.js b/lib/options.js index 17a31f1..210ece4 100644 --- a/lib/options.js +++ b/lib/options.js @@ -7,6 +7,10 @@ * `access_key_id` of s3. The prefix `RCLONE_` of rclone also works, for * compatibility: `RFETCH_` takes its place when both are set. In a request, * a variable is a header. The names of headers are not case sensitive. + * + * A header with an empty value is not an option: the variable of the + * environment, or the default, stays. A proxy can copy a header that its + * client did not send, as an empty header. */ /** The prefixes of the options, the primary one first. */ @@ -46,7 +50,9 @@ export function optionsOf(request, type, env = {}) { const prefix of PREFIXES.map((start) => `${start}${type.toLowerCase()}_`) ) { for (const [name, value] of request.headers) { - if (name.startsWith(prefix)) add(name.slice(prefix.length), value); + if (name.startsWith(prefix) && value !== "") { + add(name.slice(prefix.length), value); + } } } for ( diff --git a/lib/options_test.js b/lib/options_test.js index 1fa022c..1aea34d 100644 --- a/lib/options_test.js +++ b/lib/options_test.js @@ -46,6 +46,25 @@ test("optionsOf takes RFETCH_ in the place of RCLONE_", () => { assert.equal(options.get("provider"), "rfetch-env"); }); +test("optionsOf takes no option from a header with an empty value", () => { + const request = new Request("https://rfetch.test/", { + headers: { + RFETCH_S3_REGION: "", + RCLONE_S3_REGION: "rclone-header", + RFETCH_S3_ENDPOINT: "", + RFETCH_S3_PROVIDER: "", + }, + }); + const options = optionsOf(request, "s3", { + RFETCH_S3_ENDPOINT: "env", + }); + // The next place gives the value: a header of the other prefix, or env. + assert.equal(options.get("region"), "rclone-header"); + assert.equal(options.get("endpoint"), "env"); + // With no other place, the option is not set, and the default stays. + assert.equal(options.has("provider"), false); +}); + test("optionsOf reads the names of headers in each case", () => { for ( const name of ["RCLONE_S3_REGION", "rclone_s3_region", "Rclone_S3_Region"] diff --git a/main_test.js b/main_test.js index d2f111f..19e5d95 100644 --- a/main_test.js +++ b/main_test.js @@ -275,6 +275,27 @@ test("options in the headers of a request", async (t) => { } }); + await t.test("a header with an empty value is not a variable", async () => { + process.env.RCLONE_CONFIG_EMPTY_TYPE = "alias"; + process.env.RCLONE_CONFIG_EMPTY_REMOTE = "/tmp/env"; + try { + const response = await fetch("empty:", { + method: "TRACE", + headers: { RFETCH_CONFIG_EMPTY_REMOTE: "", RFETCH_ALIAS_REMOTE: "" }, + }); + const body = await response.text(); + assert.equal(variableOf(body, "RFETCH_ALIAS_REMOTE"), "/tmp/env"); + } finally { + delete process.env.RCLONE_CONFIG_EMPTY_TYPE; + delete process.env.RCLONE_CONFIG_EMPTY_REMOTE; + } + // An empty type gives no remote. + await assert.rejects( + fetch("nothing:", { headers: { RFETCH_CONFIG_NOTHING_TYPE: "" } }), + /Remote nothing not found/, + ); + }); + await t.test("RFETCH_ takes the place of RCLONE_", async () => { process.env.RCLONE_CONFIG_BOTH_TYPE = "alias"; process.env.RCLONE_CONFIG_BOTH_REMOTE = "/tmp/rclone-env"; diff --git a/worker_test.js b/worker_test.js index e77a58f..cfb8d8e 100644 --- a/worker_test.js +++ b/worker_test.js @@ -173,6 +173,40 @@ test("the fields of the query are options of the remote", async () => { assert.equal(await file.text(), "c"); }); +test("a field or a header with an empty value is not an option", async () => { + await send("/:memory:/empty/d.txt", { method: "PUT", body: "d" }); + const headers = { RFETCH_ALIAS_REMOTE: ":memory:/empty" }; + // The empty field of the query does not take the place of the header. + const file = await send("/:alias:/d.txt?remote=", { headers }); + assert.equal(file.status, 200); + assert.equal(await file.text(), "d"); + // An empty header is no option: the alias has no remote. + const none = await send("/:alias:/d.txt?remote=", { + headers: { RFETCH_ALIAS_REMOTE: "" }, + }); + assert.equal(none.ok, false); + await none.body?.cancel(); +}); + +test("a GET and a HEAD with an empty body read the file", async () => { + await send("/:memory:/get-body/e.txt", { method: "PUT", body: "e" }); + for (const method of ["GET", "HEAD"]) { + // A request with `Content-Length: 0` has a body stream in workerd, also + // for a GET. A Request of Node.js cannot have it, so the test gives the + // method after the body. + const url = "https://rfetch.test/:memory:/get-body/e.txt"; + const request = new Request(url, { + method: "POST", + headers: { "Content-Length": "0" }, + body: "", + }); + Object.defineProperty(request, "method", { value: method }); + const file = await worker.fetch(request, env); + assert.equal(file.status, 200, method); + assert.equal(await file.text(), method === "GET" ? "e" : "", method); + } +}); + test("gives the list of lsjson for Accept: application/json", async () => { await send("/:memory:/json/a.txt", { method: "PUT", body: "a" }); await send("/:memory:/json/sub/b.txt", { method: "PUT", body: "bb" });