mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 07:04:53 +09:00
Migrate the usage of CommonJS specific code and imports to ESM. Roughly summarised this: - Changes all require() calls to import statement or expressions. - Changes usage of module.exports to export statements. - Replaces require.resolve with import.meta.resolve. - Replaces conditional imports with conditional subpath imports. - Replaces module === require.main with import.meta.main. - Adds the full filename with extention to all relative imports. - Changes all usage of __dirname and __filename to their import.meta equivalents. - Sets "type": "module" in package.json files. - Removes usage of "use strict". - Adds .ts an an alias of .js for the webpack build. Due to differences in how require and import resolves files the plugin entrypoints will have to include the filename and extension after this migration in order to work.
454 lines
16 KiB
JavaScript
454 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import util from "node:util";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
describe("lib/file_ops", function() {
|
|
let baseDir = path.join("temp", "test", "file_ops");
|
|
async function setupTestingEnv() {
|
|
await fs.mkdir(path.join(baseDir, "test", "folder"), { recursive: true });
|
|
await fs.mkdir(path.join(baseDir, "test", "another folder"), { recursive: true });
|
|
await fs.rm(path.join(baseDir, "safe"), { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(path.join(baseDir, "safe"), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(baseDir, "test", "file.txt"), "contents");
|
|
await fs.writeFile(path.join(baseDir, "test", "another file.txt"), "more contents");
|
|
|
|
await fs.mkdir(path.join(baseDir, "find"), { recursive: true });
|
|
await fs.writeFile(path.join(baseDir, "find", "file"), "contents");
|
|
await fs.writeFile(path.join(baseDir, "find", "file.txt"), "contents");
|
|
await fs.writeFile(path.join(baseDir, "find", "foo-1"), "contents");
|
|
await fs.writeFile(path.join(baseDir, "find", "foo-2"), "contents");
|
|
await fs.writeFile(path.join(baseDir, "find", "bar-1.txt"), "contents");
|
|
await fs.writeFile(path.join(baseDir, "find", "bar-2.txt"), "contents");
|
|
}
|
|
|
|
before(setupTestingEnv);
|
|
|
|
describe("getNewestFile()", function() {
|
|
it("returns a string in a directory with files", async function() {
|
|
let newest = await lib.getNewestFile(path.join(baseDir, "test"));
|
|
assert.equal(typeof newest, "string");
|
|
});
|
|
it("returns undefined if all entries were filtered out", async function() {
|
|
let newest = await lib.getNewestFile(path.join(baseDir, "test"), (name) => !name.endsWith(".txt"));
|
|
assert.equal(newest, undefined);
|
|
});
|
|
it("returns undefined if directory is empty", async function() {
|
|
let newest = await lib.getNewestFile(path.join(baseDir, "test", "folder"));
|
|
assert.equal(newest, undefined);
|
|
});
|
|
});
|
|
|
|
describe("getNewestFile()", function() {
|
|
it("returns 0 if directory does not exist", async function() {
|
|
let size = await lib.directorySize(path.join(baseDir, "invalid"));
|
|
assert.equal(size, 0);
|
|
});
|
|
it("returns 0 if directory is empty", async function() {
|
|
let size = await lib.directorySize(path.join(baseDir, "test", "folder"));
|
|
assert.equal(size, 0);
|
|
});
|
|
it("returns size of files in directory", async function() {
|
|
let size = await lib.directorySize(path.join(baseDir, "test"));
|
|
assert.equal(size, 21);
|
|
});
|
|
});
|
|
|
|
describe("findUnusedName()", function() {
|
|
it("should return named unchanged if it does not exist", async function() {
|
|
let cases = [
|
|
[["file"], "file"],
|
|
[["file", ".txt"], "file.txt"],
|
|
[["file.txt", ".txt"], "file.txt"],
|
|
];
|
|
for (let [args, expected] of cases) {
|
|
let actual = await lib.findUnusedName(path.join(baseDir, "test", "folder"), ...args);
|
|
assert.equal(actual, expected);
|
|
}
|
|
});
|
|
it("should return changed name if it does exist", async function() {
|
|
let cases = [
|
|
[["file"], "file-2"],
|
|
[["file", ".txt"], "file-2.txt"],
|
|
[["file.txt", ".txt"], "file-2.txt"],
|
|
[["foo-1"], "foo-3"],
|
|
[["bar-1", ".txt"], "bar-3.txt"],
|
|
[["bar-1.txt", ".txt"], "bar-3.txt"],
|
|
];
|
|
for (let [args, expected] of cases) {
|
|
let actual = await lib.findUnusedName(path.join(baseDir, "find"), ...args);
|
|
assert.equal(actual, expected);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("safeOutputFile()", function() {
|
|
it("should write new target file", async function() {
|
|
let target = path.join(baseDir, "safe", "simple.txt");
|
|
await lib.safeOutputFile(target, "a text file", "utf8");
|
|
await assert.rejects(fs.access(target.replace(".txt", ".tmp.txt")), "temporary was left behind");
|
|
assert.equal(await fs.readFile(target, "utf8"), "a text file");
|
|
});
|
|
it("should overwrite existing target file", async function() {
|
|
let target = path.join(baseDir, "safe", "exists.txt");
|
|
await fs.writeFile(target, "previous", "utf8");
|
|
await lib.safeOutputFile(target, "current", "utf8");
|
|
await assert.rejects(fs.access(target.replace(".txt", ".tmp.txt")), "temporary was left behind");
|
|
assert.equal(await fs.readFile(target, "utf8"), "current");
|
|
});
|
|
it("should handle creating file in current working directory", async function() {
|
|
let target = "temporary-file-made-to-test-cwd.txt";
|
|
try {
|
|
await lib.safeOutputFile(target, "a text file", "utf8");
|
|
} finally {
|
|
try {
|
|
await fs.unlink(target);
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("checkFilename()", function() {
|
|
it("should allow a basic name", function() {
|
|
lib.checkFilename("file");
|
|
});
|
|
|
|
function check(item, msg) {
|
|
assert.throws(() => lib.checkFilename(item), new Error(msg));
|
|
}
|
|
|
|
it("should throw on non-string", function() {
|
|
check(undefined, "must be a string");
|
|
check(null, "must be a string");
|
|
check({}, "must be a string");
|
|
check([], "must be a string");
|
|
check(0, "must be a string");
|
|
check(false, "must be a string");
|
|
});
|
|
|
|
it("should throw on empty name", function() {
|
|
check("", "cannot be empty");
|
|
});
|
|
|
|
it("should throw on <>:\"\\/|?* \\x00\\r\\n\\t", function() {
|
|
for (let char of '<>:"\\/|?*\x00\r\n\t') {
|
|
check(char, 'cannot contain <>:"\\/|=* or control characters');
|
|
}
|
|
});
|
|
|
|
it("should throw on CON, PRN, AUX, NUL, COM1, LPT1", function() {
|
|
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
|
|
check(bad, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
|
|
check(`${bad}.zip`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
|
|
check(`${bad}.anything.txt`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
|
|
check(`${bad}....a`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
|
|
check(`${bad}.`, "cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9");
|
|
}
|
|
});
|
|
|
|
it("should throw on . and ..", function() {
|
|
for (let bad of [".", ".."]) {
|
|
check(bad, `cannot be named ${bad}`);
|
|
}
|
|
});
|
|
|
|
it("should throw on names ending with . or space", function() {
|
|
check("a ", "cannot end with . or space");
|
|
check("a.", "cannot end with . or space");
|
|
});
|
|
});
|
|
|
|
describe("cleanFilename()", function() {
|
|
function clean(item, expected) {
|
|
assert.equal(lib.cleanFilename(item), expected);
|
|
}
|
|
|
|
it("should allow a basic name", function() {
|
|
clean("file", "file");
|
|
});
|
|
|
|
function check(item, msg) {
|
|
assert.throws(() => lib.cleanFilename(item), new Error(msg));
|
|
}
|
|
|
|
it("should throw on non-string", function() {
|
|
check(undefined, "name must be a string");
|
|
check(null, "name must be a string");
|
|
check({}, "name must be a string");
|
|
check([], "name must be a string");
|
|
check(0, "name must be a string");
|
|
check(false, "name must be a string");
|
|
});
|
|
|
|
it("should clean empty name", function() {
|
|
clean("", "_");
|
|
});
|
|
|
|
it("should clean <>:\"\\/|?* \\x00\\r\\n\\t", function() {
|
|
for (let char of '<>:"\\/|?*\x00\r\n\t') {
|
|
clean(char, "_");
|
|
}
|
|
});
|
|
|
|
it("should clean CON, PRN, AUX, NUL, COM1, LPT1", function() {
|
|
for (let bad of ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"]) {
|
|
clean(bad, `${bad}_`);
|
|
clean(`${bad}.zip`, `${bad}_.zip`);
|
|
clean(`${bad}.anything.txt`, `${bad}_.anything.txt`);
|
|
clean(`${bad}....a`, `${bad}_....a`);
|
|
clean(`${bad}.`, `${bad}__`);
|
|
}
|
|
});
|
|
|
|
it("should clean . and ..", function() {
|
|
for (let bad of [".", ".."]) {
|
|
clean(bad, `${bad}_`);
|
|
}
|
|
});
|
|
|
|
it("should clean names ending with . or space", function() {
|
|
clean("a ", "a_");
|
|
clean("a.", "a_");
|
|
});
|
|
});
|
|
|
|
describe("downloadFile()", function () {
|
|
const baseUrl = new URL("http://localhost/");
|
|
const downloadDir = path.join("temp", "test", "downloadFile");
|
|
const fileContent = "mock file content";
|
|
let onStream;
|
|
let server;
|
|
|
|
before(async function () {
|
|
await fs.rm(downloadDir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(downloadDir, { recursive: true });
|
|
server = http.createServer();
|
|
server.unref();
|
|
server.on("request", (req, res) => {
|
|
if (req.url === "/simple-file") {
|
|
res.writeHead(200, {
|
|
"Content-Length": Buffer.byteLength(fileContent),
|
|
"Content-Type": "text/plain",
|
|
}).end(fileContent);
|
|
} else if (req.url === "/empty-file") {
|
|
res.writeHead(200, {
|
|
"Content-Length": 0,
|
|
"Content-Type": "text/plain",
|
|
}).end();
|
|
} else if (req.url === "/no-content") {
|
|
res.writeHead(204).end();
|
|
} else if (req.url === "/stream") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/plain",
|
|
});
|
|
res.write("streaming\n");
|
|
onStream(res);
|
|
} else {
|
|
const body = "not found";
|
|
res.writeHead(404, {
|
|
"Content-Length": Buffer.byteLength(body),
|
|
"Content-Type": "text/plain",
|
|
}).end(body);
|
|
}
|
|
});
|
|
await util.promisify(server.listen.bind(server))();
|
|
baseUrl.port = server.address().port;
|
|
});
|
|
after(function() {
|
|
server.close();
|
|
server.closeAllConnections();
|
|
});
|
|
|
|
it("should download content from URL to the specified file path", async function () {
|
|
for (const mode of ["overwrite", "rename", "error"]) {
|
|
const downloadUrl = new URL("/simple-file", baseUrl);
|
|
const downloadPath = path.join(downloadDir, `simple-file-${mode}.txt`);
|
|
const newDownloadPath = await lib.downloadFile(downloadUrl, downloadPath, mode);
|
|
const writtenContent = await fs.readFile(downloadPath, "utf8");
|
|
assert.equal(writtenContent, fileContent);
|
|
assert.equal(downloadPath, newDownloadPath);
|
|
}
|
|
});
|
|
|
|
it("should throw if fetch response is not ok", async function () {
|
|
const downloadPath = path.join(downloadDir, "not-found.txt");
|
|
await assert.rejects(
|
|
lib.downloadFile(new URL("/not-found", baseUrl), downloadPath, "overwrite"),
|
|
/Failed to download .* 404 Not Found/
|
|
);
|
|
await assert.rejects(
|
|
fs.readFile(downloadPath),
|
|
{ code: "ENOENT" },
|
|
);
|
|
});
|
|
|
|
it("should not leak credentials from the url when throwing", async function () {
|
|
// The mod portal takes the Factorio token as a query parameter, see #795.
|
|
const downloadPath = path.join(downloadDir, "credentials.txt");
|
|
const url = new URL("/not-found", baseUrl);
|
|
url.searchParams.set("username", "user");
|
|
url.searchParams.set("token", "secret");
|
|
await assert.rejects(
|
|
lib.downloadFile(url, downloadPath, "overwrite"),
|
|
err => {
|
|
assert.ok(!err.message.includes("secret"), `token leaked in: ${err.message}`);
|
|
assert.match(err.message, /token=REDACTED/);
|
|
assert.match(err.message, /username=user/);
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
it("should throw if fetch response body is missing", async function () {
|
|
const downloadPath = path.join(downloadDir, "not-content.txt");
|
|
await assert.rejects(
|
|
lib.downloadFile(new URL("/no-content", baseUrl), downloadPath, "overwrite"),
|
|
/Failed to download .* 204 No Content/
|
|
);
|
|
await assert.rejects(
|
|
fs.readFile(downloadPath),
|
|
{ code: "ENOENT" },
|
|
);
|
|
});
|
|
|
|
it("should throw if file exists and overwriteMode is error", async function () {
|
|
const downloadPath = path.join(downloadDir, "exists.txt");
|
|
await fs.writeFile(downloadPath, "");
|
|
await assert.rejects(
|
|
lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "error"),
|
|
{ code: "EEXIST" },
|
|
);
|
|
});
|
|
|
|
it("should throw if target directory does not exist", async function () {
|
|
const downloadPath = path.join(downloadDir, "does-not-exist", "simple.txt");
|
|
for (const mode of ["overwrite", "rename", "error"]) {
|
|
await assert.rejects(
|
|
lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, mode),
|
|
{ code: "ENOENT" },
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should write a new file if it exists and overwriteMode is rename", async function () {
|
|
const downloadPath = path.join(downloadDir, "exists.txt");
|
|
await fs.writeFile(downloadPath, "");
|
|
const newDownloadPath = await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "rename");
|
|
assert.equal(newDownloadPath, path.join(downloadDir, "exists-2.txt"));
|
|
const writtenContent = await fs.readFile(newDownloadPath, "utf8");
|
|
assert.equal(writtenContent, fileContent);
|
|
});
|
|
|
|
it("should write a new file if many files exists and overwriteMode is rename", async function () {
|
|
const downloadPath = path.join(downloadDir, "contended.txt");
|
|
const additionalPaths = [
|
|
path.join(downloadDir, "contended-2.txt"),
|
|
path.join(downloadDir, "contended-3.txt"),
|
|
path.join(downloadDir, "contended-4.txt"),
|
|
];
|
|
await fs.writeFile(downloadPath, "");
|
|
for (const additionalPath of additionalPaths) {
|
|
await fs.writeFile(additionalPath, "");
|
|
}
|
|
const newDownloadPath = await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "rename");
|
|
assert.equal(newDownloadPath, path.join(downloadDir, "contended-5.txt"));
|
|
const writtenContent = await fs.readFile(newDownloadPath, "utf8");
|
|
assert.equal(writtenContent, fileContent);
|
|
});
|
|
|
|
it("should overwrite existing file if overwriteMode is overwrite", async function () {
|
|
const downloadPath = path.join(downloadDir, "overwrite.txt");
|
|
await fs.writeFile(downloadPath, "original content");
|
|
const newDownloadPath = await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "overwrite");
|
|
assert.equal(newDownloadPath, downloadPath);
|
|
const writtenContent = await fs.readFile(newDownloadPath, "utf8");
|
|
assert.equal(writtenContent, fileContent);
|
|
});
|
|
|
|
it("should not overwrite existing temp files", async function () {
|
|
const tempPath = path.join(downloadDir, "temp-exists.tmp.txt");
|
|
await fs.writeFile(tempPath, "original content");
|
|
const downloadPath = path.join(downloadDir, "temp-exists.txt");
|
|
await lib.downloadFile(new URL("/simple-file", baseUrl), downloadPath, "overwrite");
|
|
const writtenContent = await fs.readFile(tempPath, "utf8");
|
|
assert.equal(writtenContent, "original content");
|
|
});
|
|
|
|
it("should not leave a temporary file behind if the request fails", async function () {
|
|
// Bind a server only to get a port nothing is listening on, so
|
|
// that the fetch is refused before there is a response at all.
|
|
const deadServer = http.createServer();
|
|
await util.promisify(deadServer.listen.bind(deadServer))();
|
|
const deadUrl = new URL("/simple-file", baseUrl);
|
|
deadUrl.port = deadServer.address().port;
|
|
await util.promisify(deadServer.close.bind(deadServer))();
|
|
|
|
const downloadPath = path.join(downloadDir, "refused.txt");
|
|
await assert.rejects(lib.downloadFile(deadUrl, downloadPath, "overwrite"));
|
|
await assert.rejects(
|
|
fs.readFile(path.join(downloadDir, "refused.tmp.txt")),
|
|
{ code: "ENOENT" },
|
|
);
|
|
await assert.rejects(fs.readFile(downloadPath), { code: "ENOENT" });
|
|
});
|
|
|
|
it("should not leave a temporary file behind if the download is interrupted", async function () {
|
|
const downloadPath = path.join(downloadDir, "interrupted.txt");
|
|
onStream = (res) => {
|
|
// Cut the connection with the body half written.
|
|
res.destroy();
|
|
};
|
|
await assert.rejects(lib.downloadFile(new URL("/stream", baseUrl), downloadPath, "overwrite"));
|
|
await assert.rejects(
|
|
fs.readFile(path.join(downloadDir, "interrupted.tmp.txt")),
|
|
{ code: "ENOENT" },
|
|
);
|
|
await assert.rejects(fs.readFile(downloadPath), { code: "ENOENT" });
|
|
});
|
|
|
|
it("should throw if overwriteMode is rename and directory disappeared", async function () {
|
|
if (process.platform === "win32") {
|
|
// Can't remove the directory while the stream is running on Windows.
|
|
this.skip();
|
|
}
|
|
function stageWaiter() {
|
|
let stage;
|
|
let waitForStage = new Promise(resolve => { stage = resolve; });
|
|
return [stage, waitForStage];
|
|
}
|
|
|
|
const downloadSubdir = path.join(downloadDir, "subdir");
|
|
const downloadPath = path.join(downloadSubdir, "test.txt");
|
|
await fs.mkdir(downloadSubdir, { recursive: true });
|
|
|
|
const [stage1, waitForStage1] = stageWaiter();
|
|
const [stage2, waitForStage2] = stageWaiter();
|
|
|
|
onStream = async (res) => {
|
|
stage1();
|
|
await waitForStage2;
|
|
res.end("done");
|
|
};
|
|
const download = lib.downloadFile(new URL("/stream", baseUrl), downloadPath, "rename");
|
|
download.catch(() => {});
|
|
await waitForStage1;
|
|
|
|
await fs.rm(downloadSubdir, { recursive: true, maxRetries: 10 });
|
|
stage2();
|
|
|
|
await assert.rejects(
|
|
download,
|
|
{ code: "ENOENT" },
|
|
);
|
|
});
|
|
});
|
|
});
|