igir
Version:
🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.
648 lines (647 loc) • 19.9 kB
JavaScript
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import stream from "node:stream";
import async from "async";
import { isNotJunk } from "junk";
import IgirException from "../exceptions/igirException.js";
import Defaults from "../globals/defaults.js";
import gracefulFs from "../polyfill/gracefulFs.js";
import FsReadTransform from "../streams/fsReadTransform.js";
gracefulFs.gracefulify(fs);
const MoveResult = {
COPIED: 1,
RENAMED: 2
};
const WalkMode = {
FILES: 1,
DIRECTORIES: 2
};
class FsUtil {
static SIZE_READABLE_SUFFIXES = ["", "K", "M", "G", "T", "P", "E", "Z", "Y"];
/**
* @param dirPath the path to a temporary directory
* @returns if the current runtime can create hardlinks
*/
static async canHardlink(dirPath) {
const source = await this.mktemp(path.join(dirPath, "source"));
try {
await this.touch(source);
const target = await this.mktemp(path.join(dirPath, "target"));
try {
await this.hardlink(source, target);
return await this.exists(target);
} finally {
await this.rm(target, { force: true });
}
} catch {
return false;
} finally {
await this.rm(source, { force: true });
}
}
/**
* @param dirPath the path to a temporary directory
* @returns if the current runtime can create symbolic links
*/
static async canSymlink(dirPath) {
const source = await this.mktemp(path.join(dirPath, "source"));
try {
await this.touch(source);
const target = await this.mktemp(path.join(dirPath, "target"));
try {
await this.symlink(source, target);
return await this.exists(target);
} finally {
await this.rm(target, { force: true });
}
} catch {
return false;
} finally {
await this.rm(source, { force: true });
}
}
/**
* Copy the contents of {@link src} to {@link dest}, recursively, respecting subdirectories.
*/
static async copyDir(src, dest) {
await this.mkdir(dest, { recursive: true });
const entries = await fs.promises.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await this.copyDir(srcPath, destPath);
} else {
await this.copyFile(srcPath, destPath);
}
}
}
/**
* Copy {@link src} to {@link dest}, overwriting any existing file, and ensuring {@link dest}
* is writable.
*/
static async copyFile(src, dest, callback) {
if (!await this.exists(src)) {
throw new IgirException(`can't copy nonexistent file '${src}' to '${dest}'`);
}
const destDir = path.dirname(dest);
if (!await this.exists(destDir)) {
throw new IgirException(`can't copy '${src}' to nonexistent directory '${destDir}'`);
}
const didDestPreviouslyExist = await this.exists(dest);
const readStream = fs.createReadStream(src, {
highWaterMark: Defaults.FILE_READING_CHUNK_SIZE
});
const writeStream = fs.createWriteStream(dest);
if (callback) {
await stream.promises.pipeline(readStream, new FsReadTransform(callback), writeStream);
} else {
await stream.promises.pipeline(readStream, writeStream);
}
const stat = await this.stat(dest);
const chmodOwnerWrite = 146;
if (!(stat.mode & chmodOwnerWrite)) {
await fs.promises.chmod(dest, stat.mode | chmodOwnerWrite);
}
if (didDestPreviouslyExist) {
await this.touch(dest);
}
}
/**
* @returns all the directories in {@link dirPath}, non-recursively
*/
static async dirs(dirPath) {
const readDir = (await fs.promises.readdir(dirPath)).filter((filePath) => isNotJunk(path.basename(filePath))).map((filePath) => path.join(dirPath, filePath));
return (await async.mapLimit(
readDir,
Defaults.MAX_FS_THREADS,
async (filePath) => await this.isDirectory(filePath) ? filePath : void 0
)).filter((childDir) => childDir !== void 0);
}
/**
* @returns if {@link pathLike} exists, not following symbolic links
*/
static async exists(pathLike) {
try {
await fs.promises.lstat(pathLike);
return true;
} catch {
return false;
}
}
/**
* @returns if {@link pathLike} exists, not following symbolic links
*/
static existsSync(pathLike) {
try {
fs.lstatSync(pathLike);
return true;
} catch {
return false;
}
}
/**
* Create a hardlink at location {@link link} to the original file {@link target}.
*/
static async hardlink(target, link) {
const targetResolved = path.resolve(target);
if (!await this.exists(targetResolved)) {
throw new IgirException(`can't link nonexistent file '${targetResolved}' to '${link}'`);
}
const linkDir = path.dirname(link);
if (!await this.exists(linkDir)) {
throw new IgirException(
`can't link '${targetResolved}' to nonexistent directory '${linkDir}'`
);
}
await this.rm(link, { force: true });
try {
await fs.promises.link(targetResolved, link);
return;
} catch (error) {
if (error.code === "EXDEV") {
throw new IgirException(`can't hard link files on different drives: ${error}`);
}
throw error;
}
}
/**
* @returns the index node of {@link pathLike}
*/
static async inode(pathLike) {
return (await this.stat(pathLike)).ino;
}
/**
* @returns if {@link pathLike} is a directory, following symbolic links
*/
static async isDirectory(pathLike) {
try {
const lstat = await fs.promises.lstat(pathLike);
if (lstat.isSymbolicLink()) {
const link = await this.readlinkResolved(pathLike);
return await this.isDirectory(link);
}
return lstat.isDirectory();
} catch {
return false;
}
}
/**
* @returns if {@link pathLike} is a directory, following symbolic links
*/
static isDirectorySync(pathLike) {
try {
const lstat = fs.lstatSync(pathLike);
if (lstat.isSymbolicLink()) {
const link = this.readlinkResolvedSync(pathLike);
return this.isDirectorySync(link);
}
return lstat.isDirectory();
} catch {
return false;
}
}
/**
* @returns if {@link pathLike} can be executed
*/
static async isExecutable(pathLike) {
try {
await fs.promises.access(pathLike, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* @returns if {@link pathLike} is a file, following symbolic links
*/
static async isFile(pathLike) {
try {
const lstat = await fs.promises.lstat(pathLike);
if (lstat.isSymbolicLink()) {
const link = await this.readlinkResolved(pathLike);
return await this.isFile(link);
}
return lstat.isFile();
} catch {
return false;
}
}
/**
* @returns if {@link pathLike} has at least one related hardlink
*/
static async isHardlink(pathLike) {
try {
return (await this.stat(pathLike)).nlink > 1;
} catch {
return false;
}
}
/**
* @returns if {@link filePath} is on a samba path
*/
static isSamba(filePath) {
const resolvedPath = path.resolve(filePath);
if (resolvedPath === os.devNull) {
return false;
}
return (
// Standard UNC: \\Server\Share\Path
// Extended UNC: \\?\UNC\Server\Share\Path
filePath.startsWith(`\\\\`) || // smb://[user[:password]@]server/share[/path]
filePath.toLowerCase().startsWith("smb://") || // /mnt/smb/share/folder/
filePath.toLowerCase().startsWith("/mnt/smb/")
);
}
/**
* @returns if {@link pathLike} is a symlink
*/
static async isSymlink(pathLike) {
try {
return (await fs.promises.lstat(pathLike)).isSymbolicLink();
} catch {
return false;
}
}
/**
* @returns if {@link pathLike} is a symlink
*/
static isSymlinkSync(pathLike) {
try {
return fs.lstatSync(pathLike).isSymbolicLink();
} catch {
return false;
}
}
/**
* @returns if the current runtime can write to {@link filePath}
*/
static async isWritable(filePath) {
const didExist = await this.exists(filePath);
try {
await this.touch(filePath);
return true;
} catch {
return false;
} finally {
if (!didExist) {
await this.rm(filePath, { force: true });
}
}
}
/**
* @returns a new filepath with all illegal characters removed
*/
static makeLegal(filePath, pathSep = path.sep) {
return filePath.replaceAll(":", ";").replaceAll(/[<>:"|?*]/g, "_").replaceAll(/[\\/]/g, pathSep).replace(/^([a-z]);\\/i, "$1:\\");
}
/**
* Makes the directory {@link pathLike} with the options {@link options}.
*/
static async mkdir(pathLike, options) {
await fs.promises.mkdir(pathLike, options);
}
/**
* mkdtemp() takes a path "prefix" that's concatenated with random characters. Ignore that
* behavior and instead assume we always want to specify a root temp directory.
*/
static async mkdtemp(rootDir) {
const rootDirProcessed = rootDir.replace(/[\\/]+$/, "") + path.sep;
try {
await this.mkdir(rootDirProcessed, { recursive: true });
return await fs.promises.mkdtemp(rootDirProcessed);
} catch {
const backupDir = path.join(process.cwd(), "tmp") + path.sep;
await this.mkdir(backupDir, { recursive: true });
return await fs.promises.mkdtemp(backupDir);
}
}
/**
* @returns a path to a temporary file that doesn't exist, without creating that file
*/
static async mktemp(prefix) {
for (let i = 0; i < 10; i += 1) {
const randomExtension = crypto.randomBytes(4).readUInt32LE().toString(36);
const filePath = `${prefix.replace(/\.+$/, "")}.${randomExtension}`;
if (!await this.exists(filePath)) {
return filePath;
}
}
throw new IgirException("failed to generate non-existent temp file");
}
/**
* Move the file {@link oldPath} to {@link newPath}, retrying failures.
*/
static async mv(oldPath, newPath, callback) {
try {
await fs.promises.rename(oldPath, newPath);
return MoveResult.RENAMED;
} catch (error) {
if (error.code === "EXDEV") {
await this.copyFile(oldPath, newPath, callback);
await this.rm(oldPath, { force: true });
return MoveResult.COPIED;
}
try {
await this.rm(newPath);
return await this.mv(oldPath, newPath, callback);
} catch {
throw error;
}
}
}
/**
* @returns the contents of the file.
*/
static async readFile(pathLike) {
return await fs.promises.readFile(pathLike);
}
/**
* @returns the target path for the symlink {@link pathLike}
*/
static async readlink(pathLike) {
if (!await this.isSymlink(pathLike)) {
throw new IgirException(`can't readlink of non-symlink: ${pathLike.toString()}`);
}
return await fs.promises.readlink(pathLike);
}
/**
* @returns the target path for the symlink {@link pathLike}
*/
static readlinkSync(pathLike) {
if (!this.isSymlinkSync(pathLike)) {
throw new IgirException(`can't readlink of non-symlink: ${pathLike.toString()}`);
}
return fs.readlinkSync(pathLike);
}
/**
* @returns the absolute target path for the symlink {@link link}
*/
static async readlinkResolved(link) {
const source = await this.readlink(link);
if (path.isAbsolute(source)) {
return source;
}
return path.join(path.dirname(link), source);
}
/**
* @returns the absolute target path for the symlink {@link link}
*/
static readlinkResolvedSync(link) {
const source = this.readlinkSync(link);
if (path.isAbsolute(source)) {
return source;
}
return path.join(path.dirname(link), source);
}
/**
* @returns the fully resolved path to {@link pathLike}
*/
static async realpath(pathLike) {
if (!await this.exists(pathLike)) {
throw new IgirException(`can't get realpath of non-existent path: ${pathLike.toString()}`);
}
return await fs.promises.realpath(pathLike);
}
/**
* Copy {@link src} to {@link dest}, overwriting any existing file, and ensuring {@link dest}
* is writable.
*/
static async reflink(src, dest) {
if (!await this.exists(src)) {
throw new IgirException(`can't copy nonexistent file '${src}' to '${dest}'`);
}
const destDir = path.dirname(dest);
if (!await this.exists(destDir)) {
throw new IgirException(`can't copy '${src}' to nonexistent directory '${destDir}'`);
}
const didDestPreviouslyExist = await this.exists(dest);
try {
await fs.promises.copyFile(src, dest, fs.constants.COPYFILE_FICLONE);
} catch (error) {
if (error.code === "ENOTSUP") {
throw new IgirException("reflinks are not supported on this filesystem");
}
if (error.code === "EXDEV") {
throw new IgirException("reflinks are not supported across filesystems");
}
throw error;
}
const stat = await this.stat(dest);
const chmodOwnerWrite = 146;
if (!(stat.mode & chmodOwnerWrite)) {
await fs.promises.chmod(dest, stat.mode | chmodOwnerWrite);
}
if (didDestPreviouslyExist) {
await this.touch(dest);
}
}
/**
* Deletes the file or directory {@link pathLike} with the options {@link options}, retrying
* failures.
*/
static async rm(pathLike, options = {}) {
if (pathLike === os.devNull) {
return;
}
const optionsWithRetry = {
maxRetries: 2,
...options
};
if (!await this.exists(pathLike)) {
if (optionsWithRetry.force) {
return;
}
throw new IgirException(`can't rm, path doesn't exist: ${pathLike}`);
}
if (await this.isDirectory(pathLike)) {
await fs.promises.rm(pathLike, {
...optionsWithRetry,
recursive: true
});
} else {
await fs.promises.unlink(pathLike);
}
}
/**
* Deletes the file or directory {@link pathLike} with the options {@link options}, retrying
* failures.
*/
static rmSync(pathLike, options = {}) {
if (pathLike === os.devNull) {
return;
}
const optionsWithRetry = {
maxRetries: 2,
...options
};
if (!this.existsSync(pathLike)) {
if (optionsWithRetry.force) {
return;
}
throw new IgirException(`can't rmSync, path doesn't exist: ${pathLike}`);
}
if (this.isDirectorySync(pathLike)) {
fs.rmSync(pathLike, {
...optionsWithRetry,
recursive: true
});
} else {
fs.unlinkSync(pathLike);
}
}
/**
* Note: this will follow symlinks and get the size of the target.
*/
static async size(pathLike) {
try {
return (await this.stat(pathLike)).size;
} catch {
return 0;
}
}
/**
* @see https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c
*/
static sizeReadable(bytes) {
const k = process.platform === "darwin" ? 1e3 : 1024;
const i = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(k));
const bytesDivided = bytes / k ** i;
if (Number.isSafeInteger(bytesDivided)) {
return `${bytesDivided}${this.SIZE_READABLE_SUFFIXES[i]}${i > 0 && k === 1024 ? "i" : ""}B`;
}
let fractionDigits = 1;
if (bytesDivided === 0) {
fractionDigits = 0;
} else if (bytesDivided < 10) {
fractionDigits = 2;
} else if (bytesDivided >= 100 || // Will get rounded up to "100.0" or similar
bytesDivided.toFixed(fractionDigits).length === 3 + (fractionDigits > 0 ? 1 + fractionDigits : 0)) {
fractionDigits = 0;
}
return `${bytesDivided.toFixed(fractionDigits)}${this.SIZE_READABLE_SUFFIXES[i]}${i > 0 && k === 1024 ? "i" : ""}B`;
}
/**
* Creates a relative symbolic link at {@link link} to the original file {@link target}
*
* Note: {@link target} should be processed with `path.resolve()` to create absolute path
* symlinks
*/
static async symlink(target, link) {
const targetAbsolute = path.isAbsolute(target) ? target : path.join(path.dirname(link), target);
if (!await this.exists(targetAbsolute)) {
throw new IgirException(`can't link nonexistent file '${targetAbsolute}' to '${link}'`);
}
const linkDir = path.dirname(link);
if (!await this.exists(linkDir)) {
throw new IgirException(
`can't link '${targetAbsolute}' to nonexistent directory '${linkDir}'`
);
}
await this.rm(link, { force: true });
await fs.promises.symlink(target, link);
}
/**
* Creates a relative symbolic link at {@link link} to the original file {@link target}
*/
static async symlinkRelativePath(target, link) {
const realTarget = await this.realpath(target);
const realLink = path.join(await this.realpath(path.dirname(link)), path.basename(link));
return path.relative(path.dirname(realLink), realTarget);
}
/**
* @returns the stats of {@link pathLike}
*/
static async stat(pathLike) {
const fsStats = await fs.promises.stat(pathLike);
return Object.assign(
Object.create(Object.getPrototypeOf(fsStats)),
fsStats,
{
atimeS: Math.floor(fsStats.atimeMs / 1e3),
mtimeS: Math.floor(fsStats.mtimeMs / 1e3),
ctimeS: Math.floor(fsStats.ctimeMs / 1e3),
birthtimeS: Math.floor(fsStats.birthtimeMs / 1e3)
}
);
}
/**
* Create the file {@link filePath} if it doesn't exist, otherwise update the access and modified
* time to "now".
*/
static async touch(filePath) {
const dirname = path.dirname(filePath);
if (!await this.exists(dirname)) {
await this.mkdir(dirname, { recursive: true });
}
const file = await fs.promises.open(filePath, "a");
try {
await file.sync();
const date = /* @__PURE__ */ new Date();
await file.utimes(date, date);
} finally {
await file.close();
}
}
/**
* Return every file in {@link pathLike}, recursively.
*/
static async walk(pathLike, walkMode, callback) {
let output = [];
let entries;
try {
entries = (await fs.promises.readdir(pathLike, { withFileTypes: true })).filter(
(entry) => isNotJunk(entry.name)
);
} catch {
return [];
}
const entryIsDirectory = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(pathLike.toString(), entry.name);
return entry.isDirectory() || entry.isSymbolicLink() && await this.isDirectory(fullPath);
})
);
const directories = entries.filter((_entry, idx) => entryIsDirectory[idx]).map((entry) => path.join(pathLike.toString(), entry.name));
for (const directory of directories) {
const subPaths = await this.walk(directory, walkMode);
if (callback) {
callback(subPaths.length);
}
if (walkMode === WalkMode.DIRECTORIES) {
output.push(directory);
}
for (const subPath of subPaths) {
output.push(subPath);
}
}
if (walkMode === WalkMode.FILES) {
const files = entries.filter((_entry, idx) => entryIsDirectory.at(idx) === false).map((entry) => path.join(pathLike.toString(), entry.name));
if (callback) {
callback(files.length);
}
output = [...output, ...files];
}
return output;
}
/**
* Write {@link data} to {@link filePath}.
*/
static async writeFile(filePath, data, options) {
const file = await fs.promises.open(filePath, "w");
try {
await file.writeFile(data, options);
await file.sync();
} finally {
await file.close();
}
}
}
export {
MoveResult,
WalkMode,
FsUtil as default
};
//# sourceMappingURL=fsUtil.js.map