mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-16 07:24:53 +09:00
"safe" suggested the data being written survives a crash, it only guarantees the old file is either kept or fully replaced. The new name says what it does. No alias, plugin API breaks are allowed in minor versions and the 2.1 migration guide lists the rename. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
337 lines
9.8 KiB
TypeScript
337 lines
9.8 KiB
TypeScript
/**
|
|
* @module lib/file_ops
|
|
*/
|
|
import crypto from "crypto";
|
|
import events from "events";
|
|
import fs from "node:fs/promises";
|
|
import path from "path";
|
|
import stream from "stream";
|
|
|
|
import { redactUrl } from "./helpers.js";
|
|
|
|
|
|
/**
|
|
* Returns the newest file in a directory
|
|
*
|
|
* @param directory - The directory to look for the newest file in.
|
|
* @param filter -
|
|
* Optional function to filter out files to ignore. Should return true
|
|
* if the file is to be considered.
|
|
* @returns
|
|
* Name of the file with the newest timestamp or null if the
|
|
* directory contains no files.
|
|
*/
|
|
export async function getNewestFile(directory: string, filter = (_name: string) => true) {
|
|
let newestTime = new Date(0);
|
|
let newestFile: string | undefined;
|
|
for (let entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
if (entry.isFile()) {
|
|
let stat = await fs.stat(path.join(directory, entry.name));
|
|
if (filter(entry.name) && stat.mtime > newestTime) {
|
|
newestTime = stat.mtime;
|
|
newestFile = entry.name;
|
|
}
|
|
}
|
|
}
|
|
|
|
return newestFile;
|
|
}
|
|
|
|
/**
|
|
* Returns the total size of all files in a directory
|
|
*
|
|
* Sums up the file size of all files in the given directory if it exists.
|
|
* Error reading the size of files are ignored, and error reading the
|
|
* directory will result in 0 being returned.
|
|
*
|
|
* @param directory - The directory to sum files in.
|
|
* @returns The size in bytes of all files in the directory.
|
|
*/
|
|
export async function directorySize(directory: string) {
|
|
let dirEntries;
|
|
try {
|
|
dirEntries = await fs.readdir(directory, { withFileTypes: true });
|
|
} catch (err: any) {
|
|
if (err.code === "ENOENT") {
|
|
return 0;
|
|
}
|
|
throw err;
|
|
}
|
|
let statTasks: Promise<number>[] = [];
|
|
for (let entry of dirEntries) {
|
|
if (entry.isFile()) {
|
|
statTasks.push(fs
|
|
.stat(path.join(directory, entry.name))
|
|
.then(stat => stat.size, _ => 0)
|
|
);
|
|
}
|
|
}
|
|
return (await Promise.all(statTasks)).reduce((a, v) => a + v, 0);
|
|
}
|
|
|
|
/**
|
|
* Generate collision resistant temporary file name
|
|
*
|
|
* Warning: this function should not be relied upon to be accurate for
|
|
* security sensitive applications. The selection process is inherently
|
|
* racy and a file or folder may have been created in the folder by the time
|
|
* this function returns.
|
|
*
|
|
* @param prefix - Prefix for file
|
|
* @param suffix - Suffix for file
|
|
* @param tmpdir - Directory for temp file
|
|
* @returns Temporary file name
|
|
*/
|
|
export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
|
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
|
let fullPath = path.join(tmpdir, fileName);
|
|
return fullPath;
|
|
}
|
|
|
|
/**
|
|
* Atomically overwrite file data
|
|
*
|
|
* Writes the passed data to a temporary file and rename it over the target
|
|
* file. If the operation fails because the parent directory does not exist, it
|
|
* attempts to create the directory, including any ancestors and then retries
|
|
* the operation. The name of the temporary file is the same as the target file
|
|
* with a random tag and the suffix `.tmp` added before the extension, so
|
|
* concurrent writes to the same file do not interfere with each other.
|
|
*
|
|
* If the operation fails the temporary file is removed. Should the process
|
|
* die in the middle of writing the temporary file it is left behind.
|
|
*
|
|
* @param file - Path to file to write.
|
|
* @param data - Content to write.
|
|
* @param options - see fs.writeFile, `flag` and `flush` may not be used.
|
|
*/
|
|
export async function atomicOverwriteFile(
|
|
file: string,
|
|
data: string | Buffer,
|
|
options?: Omit<
|
|
Extract<Parameters<typeof fs.writeFile>[2], { flush?: boolean }>,
|
|
"flag" | "flush"
|
|
>,
|
|
) {
|
|
let { dir, name, ext } = path.parse(file);
|
|
let temporary = path.join(dir, `${name}.${crypto.randomBytes(8).toString("hex")}.tmp${ext}`);
|
|
try {
|
|
await fs.writeFile(temporary, data, { ...options, flag: "wx", flush: true });
|
|
} catch (err: any) {
|
|
if (err.code === "ENOENT") {
|
|
// Try creating the folder and then retry the operation.
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(temporary, data, { ...options, flag: "wx", flush: true });
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
try {
|
|
await fs.rename(temporary, file);
|
|
} catch (err: any) {
|
|
await fs.unlink(temporary).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Reserved names by almost all filesystems
|
|
const badNames = [".", ".."];
|
|
|
|
// Reserved namespaces in Windows
|
|
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
const badWinNamespaces = [
|
|
"CON", "PRN", "AUX", "NUL",
|
|
...oneToNine.map(n => `COM${n}`),
|
|
...oneToNine.map(n => `LPT${n}`),
|
|
];
|
|
|
|
/**
|
|
* Check if a string is a valid file name
|
|
*
|
|
* @param name - Name to check
|
|
* @throws Error if the name is unsuitable.
|
|
*/
|
|
export function checkFilename(name: string) {
|
|
// All of these are bad in Windows only, except for /, . and ..
|
|
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
|
|
const badEnd = /[. ]$/;
|
|
|
|
if (typeof name !== "string") {
|
|
throw new Error("must be a string");
|
|
}
|
|
|
|
if (name === "") {
|
|
throw new Error("cannot be empty");
|
|
}
|
|
|
|
if (badChars.test(name)) {
|
|
throw new Error('cannot contain <>:"\\/|=* or control characters');
|
|
}
|
|
|
|
if (badNames.includes(name)) {
|
|
throw new Error(
|
|
`cannot be named ${name}`
|
|
);
|
|
}
|
|
|
|
if (badWinNamespaces.includes(name.toUpperCase().split(".")[0])) {
|
|
throw new Error(
|
|
"cannot be named any of CON PRN AUX NUL COM1-9 and LPT1-9"
|
|
);
|
|
}
|
|
|
|
if (badEnd.test(name)) {
|
|
throw new Error("cannot end with . or space");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up string to be suitable for use as filename
|
|
*
|
|
* @param name - Arbitrary name string
|
|
* @returns Filename suitable to use in the filesystem
|
|
*/
|
|
export function cleanFilename(name: string) {
|
|
// copied from checkFilename due to RegExp with global flag containing state.
|
|
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
|
|
const badEnd = /[. ]$/;
|
|
|
|
if (typeof name !== "string") {
|
|
throw new Error("name must be a string");
|
|
}
|
|
|
|
if (name === "" || badNames.includes(name.toUpperCase())) {
|
|
name += "_";
|
|
}
|
|
|
|
if (badWinNamespaces.includes(name.toUpperCase().split(".")[0])) {
|
|
name = [`${name.split(".")[0]}_`, ...name.split(".").slice(1)].join(".");
|
|
}
|
|
|
|
name = name.replace(badChars, "_");
|
|
name = name.replace(badEnd, "_");
|
|
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* Creates a write stream to a guaranteed newly created temporary file in the
|
|
* same folder as the target `filePath`.
|
|
*
|
|
* @param filePath - The target file to create a temporary stream for.
|
|
* @returns The path to the temporary file that was opened and the write stream to it.
|
|
*/
|
|
export async function createTempWriteStream(filePath: string) {
|
|
const { dir, name: fileName, ext } = path.parse(filePath);
|
|
let increment = 1;
|
|
let writeStream;
|
|
let tempFilePath = path.format({ dir, name: `${fileName}.tmp`, ext });
|
|
while (true) {
|
|
try {
|
|
writeStream = (await fs.open(tempFilePath, "wx")).createWriteStream({ flush: true });
|
|
break;
|
|
} catch (err: any) {
|
|
if (err.code === "EEXIST") {
|
|
increment += 1;
|
|
tempFilePath = path.format({ dir, name: `${fileName}-${increment}.tmp`, ext });
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [tempFilePath, writeStream] as const;
|
|
}
|
|
|
|
/**
|
|
* Discard a partially written download
|
|
*
|
|
* Destroys the write stream and removes the file it was writing to, so a
|
|
* download which failed part way through does not leave a stray temporary
|
|
* file and an open file handle behind. Safe to call whether or not the
|
|
* stream has already closed.
|
|
*
|
|
* @param writeStream - stream the download was written to.
|
|
* @param tempFilePath - path the stream was writing to.
|
|
*/
|
|
async function discardPartialDownload(writeStream: stream.Writable, tempFilePath: string) {
|
|
if (!writeStream.closed) {
|
|
writeStream.destroy();
|
|
await events.once(writeStream, "close");
|
|
}
|
|
try {
|
|
await fs.unlink(tempFilePath);
|
|
} catch (err: any) {
|
|
// Nothing to discard if the file never made it to disk.
|
|
if (err.code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a file by fetching the given url and streaming the body content to it.
|
|
*
|
|
* @param url - The resource to download
|
|
* @param filePath - Path to the file store the resource data in.
|
|
* @param overwriteMode - What to do if the target filePath already exists. Use
|
|
* overwrite to clobber it, use rename to write to a differently named file,
|
|
* and error to throw an EEXIST error.
|
|
* @returns file path that was written to. May be different from `filePath` if
|
|
* overwriteMode is set to rename.
|
|
*/
|
|
export async function downloadFile(
|
|
url: URL | string,
|
|
filePath: string,
|
|
overwriteMode: "overwrite" | "rename" | "error",
|
|
) {
|
|
// Prepare file to stream content to.
|
|
let writeStream;
|
|
let tempFilePath;
|
|
if (overwriteMode === "error") {
|
|
tempFilePath = filePath;
|
|
writeStream = (await fs.open(tempFilePath, "wx")).createWriteStream({ flush: true });
|
|
} else {
|
|
[tempFilePath, writeStream] = await createTempWriteStream(filePath);
|
|
}
|
|
|
|
// Fetch stream bytes. Anything that goes wrong from here until the
|
|
// body has been written leaves a temporary file that needs to be removed
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Failed to download ${redactUrl(url)}: ${response.status} ${response.statusText}`);
|
|
}
|
|
const writer = stream.Writable.toWeb(writeStream);
|
|
await response.body.pipeTo(writer);
|
|
|
|
} catch (err) {
|
|
await discardPartialDownload(writeStream, tempFilePath);
|
|
throw err;
|
|
}
|
|
|
|
let targetFilePath = filePath;
|
|
if (overwriteMode === "overwrite") {
|
|
await fs.rename(tempFilePath, targetFilePath);
|
|
} else if (overwriteMode === "rename") {
|
|
let increment = 1;
|
|
let { dir, name, ext } = path.parse(targetFilePath);
|
|
while (true) {
|
|
try {
|
|
await fs.writeFile(targetFilePath, Buffer.alloc(0), { flag: "wx" });
|
|
break;
|
|
} catch (err: any) {
|
|
if (err.code === "EEXIST") {
|
|
increment += 1;
|
|
targetFilePath = path.format({ dir, name: `${name}-${increment}`, ext });
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
await fs.rename(tempFilePath, targetFilePath);
|
|
}
|
|
return targetFilePath;
|
|
}
|