UNPKG

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
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