mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
The name it returns may already be taken by a concurrently running process by the time the caller uses it. Callers now try the name and retry with a -2, -3, ... suffix on EEXIST instead. Save transfers previously renamed with fs.rename, which silently overwrites a save that appears at the target, and copied without COPYFILE_EXCL. Moves now use link + unlink like save rename does, and copies pass COPYFILE_EXCL, so both fail with EEXIST rather than overwrite. Supersedes #880. Co-authored-by: Hornwitser <code@hornwitser.no> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
452 lines
16 KiB
JavaScript
452 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");
|
|
|
|
}
|
|
|
|
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("safeOutputFile()", function() {
|
|
async function assertNoTemporary(target) {
|
|
let { dir, name } = path.parse(target);
|
|
let entries = (await fs.readdir(dir)).filter(
|
|
entry => entry.startsWith(`${name}.`) && entry !== path.basename(target)
|
|
);
|
|
assert.deepEqual(entries, [], "temporary was left behind");
|
|
}
|
|
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 assertNoTemporary(target);
|
|
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 assertNoTemporary(target);
|
|
assert.equal(await fs.readFile(target, "utf8"), "current");
|
|
});
|
|
it("should apply mode to the target file", async function() {
|
|
if (process.platform === "win32") {
|
|
this.skip();
|
|
}
|
|
let target = path.join(baseDir, "safe", "mode.txt");
|
|
await lib.safeOutputFile(target, "private", { mode: 0o600 });
|
|
assert.equal((await fs.stat(target)).mode & 0o777, 0o600);
|
|
assert.equal(await fs.readFile(target, "utf8"), "private");
|
|
});
|
|
it("should not interfere between concurrent writes to the same file", async function() {
|
|
let target = path.join(baseDir, "safe", "concurrent.txt");
|
|
let writes = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
writes.push(lib.safeOutputFile(target, `write ${i}`, "utf8"));
|
|
}
|
|
// Ignore errors from concurrently renaming onto the same file.
|
|
await Promise.allSettled(writes);
|
|
await assertNoTemporary(target);
|
|
assert.match(await fs.readFile(target, "utf8"), /^write \d+$/);
|
|
});
|
|
it("should remove the temporary file when the rename fails", async function() {
|
|
let target = path.join(baseDir, "safe", "rename-fails");
|
|
await fs.mkdir(target, { recursive: true });
|
|
await assert.rejects(lib.safeOutputFile(target, "data", "utf8"));
|
|
await assertNoTemporary(target);
|
|
});
|
|
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" },
|
|
);
|
|
});
|
|
});
|
|
});
|