// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { assert, assertAlmostEquals, assertEquals, assertFalse, assertMatch, assertStringIncludes, } from "@std/assert"; import { stub } from "@std/testing/mock"; import { serveDir, type ServeDirOptions, serveFile } from "./file_server.ts"; import { eTag } from "./etag.ts"; import { basename, dirname, fromFileUrl, join, resolve, toFileUrl, } from "@std/path"; import denoConfig from "./deno.json" with { type: "json" }; import { MINUTE } from "@std/datetime/constants"; import { getAvailablePort } from "@std/net/get-available-port"; import { concat } from "@std/bytes/concat"; const moduleDir = dirname(fromFileUrl(import.meta.url)); const testdataDir = resolve(moduleDir, "testdata"); const serveDirOptions: ServeDirOptions = { quiet: true, fsRoot: testdataDir, showDirListing: true, showDotfiles: true, enableCors: true, }; const TEST_FILE_PATH = join(testdataDir, "test_file.txt"); const TEST_FILE_STAT = await Deno.stat(TEST_FILE_PATH); const TEST_FILE_SIZE = TEST_FILE_STAT.size; const TEST_FILE_ETAG = await eTag(TEST_FILE_STAT) as string; const TEST_FILE_LAST_MODIFIED = TEST_FILE_STAT.mtime instanceof Date ? new Date(TEST_FILE_STAT.mtime).toUTCString() : ""; const TEST_FILE_TEXT = await Deno.readTextFile(TEST_FILE_PATH); const LOCALHOST = Deno.build.os === "windows" ? "localhost" : "0.0.0.0"; /* HTTP GET request allowing arbitrary paths */ async function fetchExactPath( hostname: string, port: number, path: string, ): Promise { const encoder = new TextEncoder(); const decoder = new TextDecoder(); const conn = await Deno.connect({ hostname, port }); await conn.write(encoder.encode("GET " + path + " HTTP/1.1\r\n\r\n")); let currentResult = ""; let contentLength = -1; let startOfBody = -1; for await (const chunk of conn.readable) { currentResult += decoder.decode(chunk); if (contentLength === -1) { const match = /^content-length: (.*)$/m.exec(currentResult); if (match && match[1]) { contentLength = Number(match[1]); } } if (startOfBody === -1) { const ind = currentResult.indexOf("\r\n\r\n"); if (ind !== -1) { startOfBody = ind + 4; } } if (startOfBody !== -1 && contentLength !== -1) { const byteLen = encoder.encode(currentResult).length; if (byteLen >= contentLength + startOfBody) { break; } } } const status = /^HTTP\/1.1 (...)/.exec(currentResult); let statusCode = 0; if (status && status[1]) { statusCode = Number(status[1]); } const body = currentResult.slice(startOfBody); const headersStr = currentResult.slice(0, startOfBody); const headersReg = /^(.*): (.*)$/mg; const headersObj: { [i: string]: string } = {}; let match = headersReg.exec(headersStr); while (match !== null) { if (match[1] && match[2]) { headersObj[match[1]] = match[2]; } match = headersReg.exec(headersStr); } return new Response(body, { status: statusCode, headers: new Headers(headersObj), }); } Deno.test("serveDir() sets last-modified header", async () => { const req = new Request("http://localhost/test_file.txt"); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); const lastModifiedHeader = res.headers.get("last-modified") as string; const lastModifiedTime = Date.parse(lastModifiedHeader); const expectedTime = TEST_FILE_STAT.mtime instanceof Date ? TEST_FILE_STAT.mtime.getTime() : Number.NaN; assertAlmostEquals(lastModifiedTime, expectedTime, 5 * MINUTE); }); Deno.test("serveDir() sets date header", async () => { const req = new Request("http://localhost/test_file.txt"); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); const dateHeader = res.headers.get("date") as string; const date = Date.parse(dateHeader); const expectedTime = TEST_FILE_STAT.atime && TEST_FILE_STAT.atime instanceof Date ? TEST_FILE_STAT.atime.getTime() : Number.NaN; assertAlmostEquals(date, expectedTime, 5 * MINUTE); }); Deno.test("serveDir()", async () => { const req = new Request("http://localhost/hello.html"); const res = await serveDir(req, serveDirOptions); const downloadedFile = await res.text(); const localFile = await Deno.readTextFile(join(testdataDir, "hello.html")); assertEquals(res.status, 200); assertEquals(downloadedFile, localFile); assertEquals(res.headers.get("content-type"), "text/html; charset=UTF-8"); }); Deno.test("serveDir() with hash symbol in filename", async () => { const filePath = join(testdataDir, "file#2.txt"); const text = "Plain text"; await Deno.writeTextFile(filePath, text); const req = new Request("http://localhost/file%232.txt"); const res = await serveDir(req, serveDirOptions); const downloadedFile = await res.text(); assertEquals(res.status, 200); assertEquals( res.headers.get("content-type"), "text/plain; charset=UTF-8", ); assertEquals(downloadedFile, text); await Deno.remove(filePath); }); Deno.test("serveDir() with space in filename", async () => { const filePath = join(testdataDir, "test file.txt"); const text = "Plain text"; await Deno.writeTextFile(filePath, text); const req = new Request("http://localhost/test%20file.txt"); const res = await serveDir(req, serveDirOptions); const downloadedFile = await res.text(); assertEquals(res.status, 200); assertEquals( res.headers.get("content-type"), "text/plain; charset=UTF-8", ); assertEquals(downloadedFile, text); await Deno.remove(filePath); }); Deno.test("serveDir() serves directory index", async () => { const filePath = join(testdataDir, "%25A.txt"); await Deno.writeTextFile(filePath, "25A"); const req = new Request("http://localhost/"); const res = await serveDir(req, serveDirOptions); const page = await res.text(); assertEquals(res.status, 200); assertStringIncludes(page, 'hello.html'); assertStringIncludes(page, 'tls/'); assertStringIncludes(page, "%2525A.txt"); // `Deno.FileInfo` is not completely compatible with Windows yet // TODO(bartlomieju): `mode` should work correctly in the future. // Correct this test case accordingly. if (Deno.build.os === "windows") { assertMatch(page, /(\s)*\(unknown mode\)(\s)*<\/td>/); } else { assertMatch(page, /(\s)*[a-zA-Z- ]{14}(\s)*<\/td>/); } await Deno.remove(filePath); }); Deno.test("serveDir() serves directory index with file containing space in the filename", async () => { const filePath = join(testdataDir, "test file.txt"); await Deno.writeTextFile(filePath, "25A"); const req = new Request("http://localhost/"); const res = await serveDir(req, serveDirOptions); const page = await res.text(); assertEquals(res.status, 200); assertStringIncludes(page, 'hello.html'); assertStringIncludes(page, 'tls/'); assertStringIncludes(page, "test%20file.txt"); // `Deno.FileInfo` is not completely compatible with Windows yet // TODO(bartlomieju): `mode` should work correctly in the future. // Correct this test case accordingly. if (Deno.build.os === "windows") { assertMatch(page, /(\s)*\(unknown mode\)(\s)*<\/td>/); } else { assertMatch(page, /(\s)*[a-zA-Z- ]{14}(\s)*<\/td>/); } await Deno.remove(filePath); }); Deno.test("serveDir() serves directory index with file's mode is 0", async () => { const stat = Deno.stat; using _stub = stub( Deno, "stat", async (path: string | URL): Promise => ({ ...(await stat(path)), mode: 0, }), ); const res = await serveDir(new Request("http://localhost/"), serveDirOptions); const page = await res.text(); assertMatch(page, /(\s)*- --- --- ---(\s)*<\/td>/); }); Deno.test("serveDir() returns a response even if fileinfo is inaccessible", async () => { // Note: Deno.stat for windows system files may be rejected with os error 32. // Mock Deno.stat to test that the dirlisting page can be generated // even if the fileInfo for a particular file cannot be obtained. // Assuming that fileInfo of `test_file.txt` cannot be accessible using denoStatStub = stub(Deno, "stat", (path): Promise => { if (path.toString().includes("test_file.txt")) { return Promise.reject(new Error("__stubed_error__")); } return denoStatStub.original.call(Deno, path); }); const req = new Request("http://localhost/"); const res = await serveDir(req, serveDirOptions); const page = await res.text(); assertEquals(res.status, 200); assertStringIncludes(page, "/test_file.txt"); }); Deno.test("serveDir() handles not found files", async () => { const req = new Request("http://localhost/badfile.txt"); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.status, 404); }); Deno.test("serveDir() handles incorrect method", async () => { const req = new Request("http://localhost/", { method: "POST" }); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.status, 405); assertEquals(res.statusText, "Method Not Allowed"); }); Deno.test("serveDir() traverses path correctly", async () => { const req = new Request("http://localhost/../../../../../../../.."); const res = await serveDir(req, serveDirOptions); const page = await res.text(); assertEquals(res.status, 200); assertStringIncludes(page, "hello.html"); }); Deno.test("serveDir() traverses path", async () => { const controller = new AbortController(); const port = 4507; const server = Deno.serve( { port, signal: controller.signal }, async (req) => await serveDir(req, serveDirOptions), ); const res1 = await fetchExactPath("127.0.0.1", port, "../../../.."); await res1.body?.cancel(); assertEquals(res1.status, 400); const res2 = await fetchExactPath( "127.0.0.1", port, "http://localhost/../../../..", ); const page = await res2.text(); assertEquals(res2.status, 200); assertStringIncludes(page, "hello.html"); controller.abort(); await server.finished; }); Deno.test("serveDir() traverses encoded URI path", async () => { const req = new Request( "http://localhost/%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..", ); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.status, 301); assertEquals(res.headers.get("location"), "http://localhost/"); }); Deno.test("serveDir() serves unusual filename", async () => { const filePath = join(testdataDir, "%"); using _file = await Deno.create(filePath); const req1 = new Request("http://localhost/%25"); const res1 = await serveDir(req1, serveDirOptions); await res1.body?.cancel(); assertEquals(res1.status, 200); assert(res1.headers.has("access-control-allow-origin")); assert(res1.headers.has("access-control-allow-headers")); const req2 = new Request("http://localhost/test_file.txt"); const res2 = await serveDir(req2, serveDirOptions); await res2.body?.cancel(); assertEquals(res2.status, 200); assert(res2.headers.has("access-control-allow-origin")); assert(res2.headers.has("access-control-allow-headers")); await Deno.remove(filePath); }); Deno.test("serveDir() supports CORS", async () => { const req1 = new Request("http://localhost/"); const res1 = await serveDir(req1, serveDirOptions); await res1.body?.cancel(); assertEquals(res1.status, 200); assert(res1.headers.has("access-control-allow-origin")); assert(res1.headers.has("access-control-allow-headers")); const req2 = new Request("http://localhost/hello.html"); const res2 = await serveDir(req2, serveDirOptions); await res2.body?.cancel(); assertEquals(res2.status, 200); assert(res2.headers.has("access-control-allow-origin")); assert(res2.headers.has("access-control-allow-headers")); }); Deno.test("serveDir() script prints help", async () => { const command = new Deno.Command(Deno.execPath(), { args: [ "run", "--no-check", "--quiet", "--no-lock", "http/file_server.ts", "--help", ], }); const { stdout } = await command.output(); const output = new TextDecoder().decode(stdout); assert(output.includes(`Deno File Server ${denoConfig.version}`)); }); Deno.test("serveDir() script prints version", async () => { const command = new Deno.Command(Deno.execPath(), { args: [ "run", "--no-check", "--quiet", "--no-lock", "http/file_server.ts", "--version", ], }); const { stdout } = await command.output(); const output = new TextDecoder().decode(stdout); assert(output.includes(`Deno File Server ${denoConfig.version}`)); }); Deno.test("serveDir() ignores query params", async () => { const req = new Request("http://localhost/hello.html?key=value"); const res = await serveDir(req, serveDirOptions); const downloadedFile = await res.text(); const localFile = await Deno.readTextFile(join(testdataDir, "hello.html")); assertEquals(res.status, 200); assertEquals(downloadedFile, localFile); }); Deno.test("serveDir() script fails with partial TLS args", async () => { const command = new Deno.Command(Deno.execPath(), { args: [ "run", "--no-check", "--quiet", "--allow-read", "--allow-net", "--no-lock", "http/file_server.ts", ".", "--host", "localhost", "--cert", "./testdata/tls/localhost.crt", "-p", `4578`, ], stderr: "null", }); const { stdout, success } = await command.output(); assertFalse(success); assertStringIncludes( new TextDecoder().decode(stdout), "--key and --cert are required for TLS", ); }); Deno.test("serveDir() doesn't show directory listings", async () => { const req = new Request("http://localhost/"); const res = await serveDir(req, { ...serveDirOptions, showDirListing: false, }); await res.body?.cancel(); assertEquals(res.status, 404); }); Deno.test("serveDir() doesn't show dotfiles", async () => { const req1 = new Request("http://localhost/"); const res1 = await serveDir(req1, { ...serveDirOptions, showDotfiles: false, }); const page1 = await res1.text(); assert(!page1.includes(".dotfile")); const req2 = new Request("http://localhost/.dotfile"); const res2 = await serveDir(req2, { ...serveDirOptions, showDotfiles: false, }); const body = await res2.text(); assertEquals(body, "dotfile"); }); Deno.test("serveDir() shows .. if it makes sense", async () => { const req1 = new Request("http://localhost/"); const res1 = await serveDir(req1, serveDirOptions); const page1 = await res1.text(); assert(!page1.includes("../")); assertStringIncludes(page1, "tls/"); const req2 = new Request("http://localhost/tls/"); const res2 = await serveDir(req2, serveDirOptions); const page2 = await res2.text(); assertStringIncludes(page2, "../"); }); Deno.test("serveDir() handles range request (bytes=0-0)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=0-0" }, }); const res = await serveDir(req, serveDirOptions); const text = await res.text(); assertEquals(text, "L"); }); Deno.test("serveDir() handles range request (bytes=0-100)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=0-100" }, }); const res = await serveDir(req, serveDirOptions); assertEquals( res.headers.get("content-range"), `bytes 0-100/${TEST_FILE_SIZE}`, ); assertEquals(res.status, 206); assertEquals((await res.arrayBuffer()).byteLength, 101); }); Deno.test("serveDir() handles range request (bytes=300-)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=300-" }, }); const res = await serveDir(req, serveDirOptions); const text = await res.text(); assertEquals( res.headers.get("content-range"), `bytes 300-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`, ); assertEquals(text, TEST_FILE_TEXT.substring(300)); }); Deno.test("serveDir() handles range request (bytes=-200)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=-200" }, }); const res = await serveDir(req, serveDirOptions); assertEquals(await res.text(), TEST_FILE_TEXT.slice(-200)); assertEquals( res.headers.get("content-range"), `bytes ${TEST_FILE_SIZE - 200}-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`, ); assertEquals(res.status, 206); assertEquals(res.statusText, "Partial Content"); }); Deno.test("serveDir() clamps ranges that are too large (bytes=0-999999999)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=0-999999999" }, }); const res = await serveDir(req, serveDirOptions); assertEquals(await res.text(), TEST_FILE_TEXT); assertEquals( res.headers.get("content-range"), `bytes 0-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`, ); assertEquals(res.status, 206); assertEquals(res.statusText, "Partial Content"); }); Deno.test("serveDir() clamps ranges that are too large (bytes=-999999999)", async () => { const req = new Request("http://localhost/test_file.txt", { // This means the last 999999999 bytes. It is too big and should be clamped. headers: { range: "bytes=-999999999" }, }); const res = await serveDir(req, serveDirOptions); assertEquals(await res.text(), TEST_FILE_TEXT); assertEquals( res.headers.get("content-range"), `bytes 0-${TEST_FILE_SIZE - 1}/${TEST_FILE_SIZE}`, ); assertEquals(res.status, 206); assertEquals(res.statusText, "Partial Content"); }); Deno.test("serveDir() handles bad range request (bytes=500-200)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=500-200" }, }); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.headers.get("content-range"), `bytes */${TEST_FILE_SIZE}`); assertEquals(res.status, 416); assertEquals(res.statusText, "Range Not Satisfiable"); }); Deno.test("serveDir() handles bad range request (bytes=99999-999999)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=99999-999999" }, }); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.headers.get("content-range"), `bytes */${TEST_FILE_SIZE}`); assertEquals(res.status, 416); assertEquals(res.statusText, "Range Not Satisfiable"); }); Deno.test("serveDir() handles bad range request (bytes=99999)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=99999-" }, }); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.headers.get("content-range"), `bytes */${TEST_FILE_SIZE}`); assertEquals(res.status, 416); assertEquals(res.statusText, "Range Not Satisfiable"); }); Deno.test("serveDir() ignores bad range request (bytes=100)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=100" }, }); const res = await serveDir(req, serveDirOptions); const text = await res.text(); assertEquals(text, TEST_FILE_TEXT); assertEquals(res.status, 200); assertEquals(res.statusText, "OK"); }); Deno.test("serveDir() ignores bad range request (bytes=a-b)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=a-b" }, }); const res = await serveDir(req, serveDirOptions); const text = await res.text(); assertEquals(text, TEST_FILE_TEXT); assertEquals(res.status, 200); assertEquals(res.statusText, "OK"); }); Deno.test("serveDir() ignores bad multi-range request (bytes=0-10, 20-30)", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { range: "bytes=0-10, 20-30" }, }); const res = await serveDir(req, serveDirOptions); const text = await res.text(); assertEquals(text, TEST_FILE_TEXT); assertEquals(res.status, 200); assertEquals(res.statusText, "OK"); }); Deno.test("serveFile() serves ok response for empty file range request", async () => { const req = new Request("http://localhost/test_empty_file.txt", { headers: { range: "bytes=0-10, 20-30" }, }); const res = await serveDir(req, serveDirOptions); const text = await res.text(); assertEquals(text, ""); assertEquals(res.status, 200); assertEquals(res.statusText, "OK"); }); Deno.test("serveDir() sets accept-ranges header to bytes for directory listing", async () => { const req = new Request("http://localhost/"); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.headers.get("accept-ranges"), "bytes"); }); Deno.test("serveDir() sets accept-ranges header to bytes for file response", async () => { const req = new Request("http://localhost/test_file.txt"); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.headers.get("accept-ranges"), "bytes"); }); Deno.test("serveDir() sets headers if provided as arguments", async () => { const req = new Request("http://localhost/test_file.txt"); const res = await serveDir(req, { ...serveDirOptions, headers: ["cache-control:max-age=100", "x-custom-header:hi"], }); await res.body?.cancel(); assertEquals(res.headers.get("cache-control"), "max-age=100"); assertEquals(res.headers.get("x-custom-header"), "hi"); }); Deno.test("serveDir() sets etag header", async () => { const req = new Request("http://localhost/test_file.txt"); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.headers.get("etag"), TEST_FILE_ETAG); }); Deno.test("serveDir() serves empty HTTP 304 response for if-none-match request of unmodified file", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { "if-none-match": TEST_FILE_ETAG }, }); const res = await serveDir(req, serveDirOptions); assertEquals(await res.text(), ""); assertEquals(res.status, 304); assertEquals(res.statusText, "Not Modified"); }); Deno.test("serveDir() serves HTTP 304 response for if-modified-since request of unmodified file", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { "if-modified-since": TEST_FILE_LAST_MODIFIED }, }); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.status, 304); assertEquals(res.statusText, "Not Modified"); }); /** * When used in combination with If-None-Match, If-Modified-Since is ignored. * If etag doesn't match, don't return 304 even if if-modified-since is a valid * value. * * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since} */ Deno.test( "serveDir() only uses if-none-match header if if-non-match and if-modified-since headers are provided", async () => { const req = new Request("http://localhost/test_file.txt", { headers: { "if-none-match": "not match etag", "if-modified-since": TEST_FILE_LAST_MODIFIED, }, }); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.status, 200); assertEquals(res.statusText, "OK"); }, ); Deno.test("serveFile() serves test file", async () => { const req = new Request("http://localhost/testdata/test_file.txt"); const res = await serveFile(req, TEST_FILE_PATH); assertEquals(res.status, 200); assertEquals(await res.text(), TEST_FILE_TEXT); }); Deno.test("serveFile() handles file not found", async () => { const req = new Request("http://localhost/testdata/non_existent.txt"); const testdataPath = join(testdataDir, "non_existent.txt"); const res = await serveFile(req, testdataPath); await res.body?.cancel(); assertEquals(res.status, 404); assertEquals(res.statusText, "Not Found"); }); Deno.test("serveFile() handles method not allowed", async () => { const req = new Request("http://localhost/testdata/test_file.txt", { method: "POST", }); const res = await serveFile(req, TEST_FILE_PATH); await res.body?.cancel(); assertEquals(res.status, 405); assertEquals(res.statusText, "Method Not Allowed"); }); Deno.test("serveFile() serves HTTP 404 when the path is a directory", async () => { const req = new Request("http://localhost/testdata/"); const res = await serveFile(req, testdataDir); await res.body?.cancel(); assertEquals(res.status, 404); assertEquals(res.statusText, "Not Found"); }); Deno.test("serveFile() handles bad range request (bytes=200-500)", async () => { const req = new Request("http://localhost/testdata/test_file.txt", { headers: { range: "bytes=200-500" }, }); const res = await serveFile(req, TEST_FILE_PATH); assertEquals(res.status, 206); assertEquals((await res.arrayBuffer()).byteLength, 301); }); Deno.test("serveFile() handles bad range request (bytes=500-200)", async () => { const req = new Request("http://localhost/testdata/test_file.txt", { headers: { range: "bytes=500-200" }, }); const res = await serveFile(req, TEST_FILE_PATH); await res.body?.cancel(); assertEquals(res.status, 416); }); Deno.test("serveFile() serves HTTP 304 response for if-modified-since request of unmodified file", async () => { const req = new Request("http://localhost/testdata/test_file.txt", { headers: { "if-none-match": TEST_FILE_ETAG }, }); const res = await serveFile(req, TEST_FILE_PATH); assertEquals(res.status, 304); assertEquals(res.statusText, "Not Modified"); }); /** * When used in combination with If-None-Match, If-Modified-Since is ignored. * If etag doesn't match, don't return 304 even if if-modified-since is a valid * value. * * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since} */ Deno.test("serveFile() only uses if-none-match header if if-non-match and if-modified-since headers are provided", async () => { const req = new Request("http://localhost/testdata/test_file.txt", { headers: { "if-none-match": "not match etag", "if-modified-since": TEST_FILE_LAST_MODIFIED, }, }); const res = await serveFile(req, TEST_FILE_PATH); await res.body?.cancel(); assertEquals(res.status, 200); assertEquals(res.statusText, "OK"); }); Deno.test("serveFile() etag value falls back to DENO_DEPLOYMENT_ID if fileInfo.mtime is not available", async () => { const DENO_DEPLOYMENT_ID = "__THIS_IS_DENO_DEPLOYMENT_ID__"; const hashedDenoDeploymentId = await eTag(DENO_DEPLOYMENT_ID, { weak: true, }); // deno-fmt-ignore const code = ` import { serveFile } from "${import.meta.resolve("./file_server.ts")}"; import { fromFileUrl } from "${import.meta.resolve("../path/mod.ts")}"; import { assertEquals } from "${import.meta.resolve("../assert/equals.ts")}"; const testdataPath = "${toFileUrl(join(testdataDir, "test_file.txt"))}"; const fileInfo = await Deno.stat(new URL(testdataPath)); fileInfo.mtime = null; const req = new Request("http://localhost/testdata/test_file.txt"); const res = await serveFile(req, fromFileUrl(testdataPath), { fileInfo }); assertEquals(res.headers.get("etag"), \`${hashedDenoDeploymentId}\`); `; const command = new Deno.Command(Deno.execPath(), { args: ["eval", "--no-lock", code], stdout: "null", stderr: "null", env: { DENO_DEPLOYMENT_ID }, }); const { success } = await command.output(); assert(success); }); Deno.test("serveDir() without options serves files in current directory", async () => { const req = new Request("http://localhost/http/testdata/hello.html"); const res = await serveDir(req); assertEquals(res.status, 200); assertStringIncludes(await res.text(), "Hello World"); }); Deno.test("serveDir() with fsRoot and urlRoot option serves files in given directory", async () => { const req = new Request( "http://localhost/my-static-root/testdata/hello.html", ); const res = await serveDir(req, { fsRoot: "http", urlRoot: "my-static-root", }); assertEquals(res.status, 200); assertStringIncludes(await res.text(), "Hello World"); }); Deno.test("serveDir() serves index.html when showIndex is true", async () => { const url = "http://localhost/http/testdata/subdir-with-index/"; const expectedText = "This is subdir-with-index/index.html"; { const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 200); assertStringIncludes(await res.text(), expectedText); } { // showIndex is true by default const res = await serveDir(new Request(url)); assertEquals(res.status, 200); assertStringIncludes(await res.text(), expectedText); } }); Deno.test("serveDir() doesn't serve index.html when showIndex is false", async () => { const req = new Request( "http://localhost/http/testdata/subdir-with-index/", ); const res = await serveDir(req, { showIndex: false }); assertEquals(res.status, 404); }); Deno.test( "serveDir() redirects a directory URL not ending with a slash if it has an index", async () => { const url = "http://localhost/http/testdata/subdir-with-index"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), "http://localhost/http/testdata/subdir-with-index/", ); }, ); Deno.test("serveDir() redirects a directory URL not ending with a slash correctly even with a query string", async () => { const url = "http://localhost/http/testdata/subdir-with-index?test"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), "http://localhost/http/testdata/subdir-with-index/?test", ); }); Deno.test("serveDir() redirects a file URL ending with a slash correctly even with a query string", async () => { const url = "http://localhost/http/testdata/test_file.txt/?test"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), "http://localhost/http/testdata/test_file.txt?test", ); }); Deno.test("serveDir() redirects non-canonical URLs", async () => { const url = "http://localhost/http/testdata//////test_file.txt/////?test"; const res = await serveDir(new Request(url), { showIndex: true }); assertEquals(res.status, 301); assertEquals( res.headers.get("Location"), "http://localhost/http/testdata/test_file.txt/?test", ); }); Deno.test("serveDir() serves HTTP 304 for if-none-match requests with W/-prefixed etag", async () => { const testurl = "http://localhost/desktop.ini"; const fileurl = new URL("./testdata/desktop.ini", import.meta.url); const req1 = new Request(testurl, { headers: { "accept-encoding": "gzip, deflate, br" }, }); const res1 = await serveDir(req1, serveDirOptions); const etag = res1.headers.get("etag"); assertEquals(res1.status, 200); assertEquals(res1.statusText, "OK"); assertEquals(await Deno.readTextFile(fileurl), await res1.text()); assert(typeof etag === "string"); assert(etag.length > 0); assert(etag.startsWith("W/")); const req2 = new Request(testurl, { headers: { "if-none-match": etag }, }); const res2 = await serveDir(req2, serveDirOptions); assertEquals(res2.status, 304); assertEquals(res2.statusText, "Not Modified"); assertEquals("", await res2.text()); assert( etag === res2.headers.get("etag") || etag === "W/" + res2.headers.get("etag"), ); }); Deno.test("serveDir() resolves path correctly on Windows", { ignore: Deno.build.os !== "windows", }, async () => { const req = new Request("http://localhost/"); const res = await serveDir(req, { ...serveDirOptions, fsRoot: "c:/" }); await res.body?.cancel(); assertEquals(res.status, 200); }); Deno.test( "serveDir() resolves empty sub-directory without asking for current directory read permissions on Windows", { ignore: Deno.build.os !== "windows", permissions: { read: [`${moduleDir}/testdata`], write: true, }, }, async () => { const tempDir = await Deno.makeTempDir({ dir: `${moduleDir}/testdata`, }); const req = new Request(`http://localhost/${basename(tempDir)}/`); const res = await serveDir(req, serveDirOptions); await res.body?.cancel(); assertEquals(res.status, 200); await Deno.remove(tempDir); }, ); Deno.test("file_server prints local and network urls", async () => { const port = await getAvailablePort(); const process = spawnDeno([ "--allow-net", "--allow-read", "--allow-sys=networkInterfaces", "http/file_server.ts", "--port", `${port}`, ]); const output = await readUntilMatch(process.stdout, "Network:"); const networkAdress = Deno.networkInterfaces().find((i) => i.family === "IPv4" && !i.address.startsWith("127") )?.address; assertEquals( output, `Listening on:\n- Local: http://${LOCALHOST}:${port}\n- Network: http://${networkAdress}:${port}\n`, ); process.stdout.cancel(); process.stderr.cancel(); process.kill(); await process.status; }); Deno.test("file_server doesn't print local network url without --allow-sys", async () => { const port = await getAvailablePort(); const process = spawnDeno([ "--allow-net", "--allow-read", "http/file_server.ts", "--port", `${port}`, ]); const output = await readUntilMatch(process.stdout, "Local:"); assertEquals( output, `Listening on:\n- Local: http://${LOCALHOST}:${port}\n`, ); process.stdout.cancel(); process.stderr.cancel(); process.kill(); await process.status; }); Deno.test("file_server prints only local address on Deploy", async () => { const port = await getAvailablePort(); const process = spawnDeno([ "--allow-net", "--allow-read", "--allow-sys=networkInterfaces", "--allow-env=DENO_DEPLOYMENT_ID", "http/file_server.ts", "--port", `${port}`, ], { env: { DENO_DEPLOYMENT_ID: "abcdef", }, }); const output = await readUntilMatch(process.stdout, "Local:"); assertEquals( output, `Listening on:\n- Local: http://${LOCALHOST}:${port}\n`, ); process.stdout.cancel(); process.stderr.cancel(); process.kill(); await process.status; }); /** Spawn deno child process with the options convenient for testing */ function spawnDeno(args: string[], opts?: Deno.CommandOptions) { const cmd = new Deno.Command(Deno.execPath(), { args: [ "run", "--no-lock", "--quiet", ...args, ], stdout: "piped", stderr: "piped", ...opts, }); return cmd.spawn(); } async function readUntilMatch( source: ReadableStream, match: string, ) { const reader = source.getReader(); let buf = new Uint8Array(0); const dec = new TextDecoder(); while (!dec.decode(buf).includes(match)) { const { value } = await reader.read(); if (!value) { break; } buf = concat([buf, value]); } reader.releaseLock(); return dec.decode(buf); }