From 7239484741dfa78de149eba1aa9d6aa8a4bd20e3 Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Tue, 26 Nov 2024 17:07:56 -0800 Subject: [PATCH] feat: implement hidden file utilities and add tests for filesystem functions. Extract functions out of Exports to reduce file size. --- src/__tests__/fs.test.ts | 57 +++++ src/__tests__/fs_metadata.test.ts | 2 + src/__tests__/hidden.test.ts | 165 ++++++++++++++ src/__tests__/hidden_win32.test.ts | 121 ----------- ...{mtab_linux.test.ts => linux_mtab.test.ts} | 89 ++++++-- src/__tests__/memory.test.ts | 25 +-- src/__tests__/remote_info.test.ts | 103 +++++++++ src/exports.ts | 203 ++---------------- src/hidden.ts | 79 +++++++ src/index.cts | 6 +- src/index.ts | 4 +- src/linux/mount_points.ts | 9 +- src/linux/mtab.ts | 47 ++-- src/remote_info.ts | 6 +- src/test-utils/hidden-tests.ts | 25 +++ src/test-utils/platform.ts | 11 +- src/volume_metadata.ts | 125 ++++++++++- 17 files changed, 701 insertions(+), 376 deletions(-) create mode 100644 src/__tests__/fs.test.ts create mode 100644 src/__tests__/hidden.test.ts delete mode 100644 src/__tests__/hidden_win32.test.ts rename src/__tests__/{mtab_linux.test.ts => linux_mtab.test.ts} (85%) create mode 100644 src/__tests__/remote_info.test.ts create mode 100644 src/hidden.ts create mode 100644 src/test-utils/hidden-tests.ts diff --git a/src/__tests__/fs.test.ts b/src/__tests__/fs.test.ts new file mode 100644 index 0000000..9717270 --- /dev/null +++ b/src/__tests__/fs.test.ts @@ -0,0 +1,57 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findAncestorDir } from "../fs.js"; + +describe("fs", () => { + describe("findAncestorDir", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "findAncestorDir-tests-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("should return the directory containing the file", async () => { + const dir1 = join(tempDir, "dir1"); + const dir2 = join(dir1, "dir2"); + const file = join(dir2, "file.txt"); + + await mkdir(dir2, { recursive: true }); + await writeFile(file, "test"); + + const result = await findAncestorDir(dir2, "file.txt"); + expect(result).toBe(dir2); + }); + + it("should return the ancestor directory containing the file", async () => { + const dir1 = join(tempDir, "dir1"); + const dir2 = join(dir1, "dir2"); + const file = join(dir1, "file.txt"); + + await mkdir(dir2, { recursive: true }); + await writeFile(file, "test"); + + const result = await findAncestorDir(dir2, "file.txt"); + expect(result).toBe(dir1); + }); + + it("should return undefined if the file is not found", async () => { + const dir1 = join(tempDir, "dir1"); + const dir2 = join(dir1, "dir2"); + + await mkdir(dir2, { recursive: true }); + + const result = await findAncestorDir(dir2, "file.txt"); + expect(result).toBeUndefined(); + }); + + it("should return undefined if the directory is the root", async () => { + const result = await findAncestorDir(tempDir, "file.txt"); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/fs_metadata.test.ts b/src/__tests__/fs_metadata.test.ts index 025fe57..1c5cbbb 100644 --- a/src/__tests__/fs_metadata.test.ts +++ b/src/__tests__/fs_metadata.test.ts @@ -69,6 +69,8 @@ describe("Filesystem Metadata", () => { const rootPath = isWindows ? "C:\\" : "/"; const metadata = await getVolumeMetadata(rootPath); + console.dir(metadata); + expect(metadata.mountPoint).toBe(rootPath); assertMetadata(metadata); diff --git a/src/__tests__/hidden.test.ts b/src/__tests__/hidden.test.ts new file mode 100644 index 0000000..2c7a746 --- /dev/null +++ b/src/__tests__/hidden.test.ts @@ -0,0 +1,165 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { statAsync } from "../fs_promises.js"; +import { isHidden, isHiddenRecursive, setHidden } from "../index.js"; +import { isWindows } from "../platform.js"; +import { validateHidden } from "../test-utils/hidden-tests.js"; +import { systemDrive, tmpDirNotHidden } from "../test-utils/platform.js"; + +describe("hidden file tests", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(tmpDirNotHidden(), "hidden-tests-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe("isHidden()", () => { + if (isWindows) { + it("should detect hidden files (Windows)", async () => { + const testFile = path.join(tempDir, "hidden.txt"); + await fs.writeFile(testFile, "test"); + execSync(`attrib +h "${testFile}"`); + expect(await isHidden(testFile)).toBe(true); + }); + } else { + it("should detect hidden files by dot (POSIX)", async () => { + expect(await isHidden(path.join(tempDir, ".hidden.txt"))).toBe(true); + }); + } + + it("should not detect normal files as hidden", async () => { + const testFile = path.join(tempDir, "normal.txt"); + await fs.writeFile(testFile, "test"); + expect(await isHidden(testFile)).toBe(false); + }); + + it("should not detect normal directories as hidden", async () => { + const testDir = path.join(tempDir, "normal-dir"); + await fs.mkdir(testDir, { recursive: true }); + expect(await isHidden(testDir)).toBe(false); + }); + + it("should not detect .../. or .../.. directories as hidden", async () => { + expect(await isHidden(path.join(tempDir, "."))).toBe(false); + expect(await isHidden(path.join(tempDir, ".."))).toBe(false); + }); + + if (isWindows) { + it("should detect hidden directories (Windows)", async () => { + const testDir = path.join(tempDir, "hiddenDir"); + await fs.mkdir(testDir); + execSync(`attrib +h "${testDir}"`); + expect(await isHidden(testDir)).toBe(true); + }); + } else { + it("should detect hidden directories by dot (POSIX)", async () => { + const testDir = path.join(tempDir, ".hidden"); + await fs.mkdir(testDir, { recursive: true }); + expect(await isHidden(testDir)).toBe(true); + }); + } + + if (isWindows) { + it("should not treat dot-prefixed files as hidden on Windows", async () => { + const testFile = path.join(tempDir, ".gitignore"); + await fs.writeFile(testFile, "test"); + expect(await isHidden(testFile)).toBe(false); + }); + } + + it("should handle root directory", async () => { + expect(await isHidden(systemDrive())).toBe(false); + }); + + if (isWindows) { + it("should throw on non-existent paths", async () => { + const nonExistentPath = path.join(tempDir, "does-not-exist"); + await expect(isHidden(nonExistentPath)).rejects.toThrow(); + }); + } + }); + + describe("isHiddenRecursive()", () => { + it("should return true for nested hidden structure", async () => { + const level1 = path.join(tempDir, "level1"); + await fs.mkdir(level1); + + let level2 = path.join(level1, "level2"); + await fs.mkdir(level2); + level2 = await setHidden(level2, true); + const level3 = path.join(level2, "level3"); + await fs.mkdir(level3); + + const testFile = path.join(level3, "file.txt"); + await fs.writeFile(testFile, "test"); + const expected = { + testFile: true, + level3: true, + level2: true, + level1: false, + tempDir: false, + }; + expect({ + testFile: await isHiddenRecursive(testFile), + level3: await isHiddenRecursive(level3), + level2: await isHiddenRecursive(level2), + level1: await isHiddenRecursive(level1), + tempDir: await isHiddenRecursive(tempDir), + }).toEqual(expected); + }); + + it("should return false for root path", async () => { + expect(await isHiddenRecursive("C:\\")).toBe(false); + }); + }); + + describe("setHidden", () => { + it("should set file as hidden", async () => { + const testFile = path.join(tempDir, "to-hide.txt"); + await fs.writeFile(testFile, "test"); + const expected = isWindows + ? testFile + : path.join(tempDir, ".to-hide.txt"); + + expect(await setHidden(testFile, true)).toEqual(expected); + expect(await isHidden(expected)).toBe(true); + + expect((await statAsync(expected)).isFile()).toBe(true); + }); + + it("should unhide hidden file", async () => { + const testFile = path.join(tempDir, "to-unhide.txt"); + await fs.writeFile(testFile, "test"); + expect(await isHidden(testFile)).toBe(false); + + const expectedHidden = isWindows + ? testFile + : path.join(tempDir, ".to-unhide.txt"); + const hidden = await setHidden(testFile, true); + + expect(hidden).toEqual(expectedHidden); + expect(await isHidden(hidden)).toBe(true); + + expect(await setHidden(testFile, false)).toEqual(testFile); + expect(await isHidden(testFile)).toBe(false); + }); + + it("should set directory as hidden", async () => { + const testSubDir = path.join(tempDir, "hide-me"); + const expected = isWindows ? testSubDir : path.join(tempDir, ".hide-me"); + await fs.mkdir(testSubDir); + const hidden = await setHidden(testSubDir, true); + expect(hidden).toEqual(expected); + expect(await isHidden(hidden)).toBe(true); + expect((await statAsync(hidden)).isDirectory()).toBe(true); + }); + }); + + it("run hidden-tests", () => + validateHidden(path.join(tempDir, "hidden-tests-"))); +}); diff --git a/src/__tests__/hidden_win32.test.ts b/src/__tests__/hidden_win32.test.ts deleted file mode 100644 index 619d8cf..0000000 --- a/src/__tests__/hidden_win32.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { execSync } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { isHidden, isHiddenRecursive, setHidden } from "../index.js"; -import { isWindows } from "../platform.js"; -import { tmpDirNotHidden } from "../test-utils/platform.js"; - -if (isWindows) - describe("Windows-only hidden file tests", () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(tmpDirNotHidden(), "hidden-tests-")); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - describe("isHidden()", () => { - it("should detect hidden files", async () => { - const testFile = path.join(tempDir, "hidden.txt"); - await fs.writeFile(testFile, "test"); - await execSync(`attrib +h "${testFile}"`); - - expect(await isHidden(testFile)).toBe(true); - }); - - it("should not detect normal files as hidden", async () => { - const testFile = path.join(tempDir, "normal.txt"); - await fs.writeFile(testFile, "test"); - - expect(await isHidden(testFile)).toBe(false); - }); - - it("should detect hidden directories", async () => { - const testDir = path.join(tempDir, "hiddenDir"); - await fs.mkdir(testDir); - await execSync(`attrib +h "${testDir}"`); - - expect(await isHidden(testDir)).toBe(true); - }); - - it("should not treat dot-prefixed files as hidden", async () => { - const testFile = path.join(tempDir, ".gitignore"); - await fs.writeFile(testFile, "test"); - - expect(await isHidden(testFile)).toBe(false); - }); - - it("should handle root directory", async () => { - expect(await isHidden("C:\\")).toBe(false); - }); - - it("should throw on non-existent paths", async () => { - const nonExistentPath = path.join(tempDir, "does-not-exist"); - await expect(isHidden(nonExistentPath)).rejects.toThrow(); - }); - }); - - describe("isHiddenRecursive()", () => { - it("should return true for nested hidden structure", async () => { - const level1 = path.join(tempDir, "level1"); - const level2 = path.join(level1, "level2"); - const level3 = path.join(level2, "level3"); - - await fs.mkdir(level1); - await fs.mkdir(level2); - await fs.mkdir(level3); - await setHidden(level2, true); - - const testFile = path.join(level3, "file.txt"); - await fs.writeFile(testFile, "test"); - const expected = { - testFile: true, - level3: true, - level2: true, - level1: false, - tempDir: false, - }; - expect({ - testFile: await isHiddenRecursive(testFile), - level3: await isHiddenRecursive(level3), - level2: await isHiddenRecursive(level2), - level1: await isHiddenRecursive(level1), - tempDir: await isHiddenRecursive(tempDir), - }).toEqual(expected); - }); - - it("should return false for root path", async () => { - expect(await isHiddenRecursive("C:\\")).toBe(false); - }); - }); - - describe("setHidden", () => { - it("should set file as hidden", async () => { - const testFile = path.join(tempDir, "to-hide.txt"); - await fs.writeFile(testFile, "test"); - - expect(await setHidden(testFile, true)).toEqual(testFile); - expect(await isHidden(testFile)).toBe(true); - }); - - it("should unhide hidden file", async () => { - const testFile = path.join(tempDir, "to-unhide.txt"); - await fs.writeFile(testFile, "test"); - expect(await isHidden(testFile)).toBe(false); - - expect(await setHidden(testFile, true)).toEqual(testFile); - expect(await setHidden(testFile, false)).toEqual(testFile); - expect(await isHidden(testFile)).toBe(false); - }); - - it("should set directory as hidden", async () => { - const testSubDir = path.join(tempDir, "hide-me"); - await fs.mkdir(testSubDir); - expect(await setHidden(testSubDir, true)).toEqual(testSubDir); - expect(await isHidden(testSubDir)).toBe(true); - }); - }); - }); diff --git a/src/__tests__/mtab_linux.test.ts b/src/__tests__/linux_mtab.test.ts similarity index 85% rename from src/__tests__/mtab_linux.test.ts rename to src/__tests__/linux_mtab.test.ts index 2608a9f..fd3cc3c 100644 --- a/src/__tests__/mtab_linux.test.ts +++ b/src/__tests__/linux_mtab.test.ts @@ -1,5 +1,9 @@ // src/__tests__/linux_mtab.test.ts -import { formatMtab, parseMtab } from "../linux/mtab.js"; +import { + formatMtab, + mountEntryToPartialVolumeMetadata, + parseMtab, +} from "../linux/mtab.js"; describe("mtab", () => { describe("parseMtab()", () => { @@ -57,10 +61,6 @@ nfs-server:/export /mnt/nfs nfs rw,vers=4.1 0 0 fs_passno: 0, fs_vfstype: "nfs", fs_spec: "nfs-server:/export", - protocol: "nfs", - remote: true, - remoteHost: "nfs-server", - remoteShare: "export", }, { fs_file: "/media/freenas", @@ -69,10 +69,6 @@ nfs-server:/export /mnt/nfs nfs rw,vers=4.1 0 0 fs_passno: 0, fs_spec: "192.168.0.216:/mnt/HDD1", fs_vfstype: "nfs", - protocol: "nfs", - remote: true, - remoteHost: "192.168.0.216", - remoteShare: "mnt/HDD1", }, { fs_file: "/mnt/cifs", @@ -81,9 +77,6 @@ nfs-server:/export /mnt/nfs nfs rw,vers=4.1 0 0 fs_passno: 0, fs_spec: "//cifs-server/share", fs_vfstype: "cifs", - remote: true, - remoteHost: "cifs-server", - remoteShare: "share", }, { fs_file: "/mnt/cifs2", @@ -92,10 +85,80 @@ nfs-server:/export /mnt/nfs nfs rw,vers=4.1 0 0 fs_passno: 0, fs_spec: "//guest@SERVER._smb._tcp.local/share", fs_vfstype: "smb", + }, + ]); + + const vm_arr = entries.map(mountEntryToPartialVolumeMetadata); + + console.dir({ vm_arr }); + + expect(vm_arr).toEqual([ + { + fileSystem: "ext4", + mountFrom: "/dev/sda1", + mountName: "", + mountPoint: "/", + remote: false, + }, + { + fileSystem: "ext4", + mountFrom: "/dev/sda2", + mountName: "home", + mountPoint: "/home", + remote: false, + }, + { + fileSystem: "proc", + mountFrom: "proc", + mountName: "proc", + mountPoint: "/proc", + remote: false, + }, + { + fileSystem: "tmpfs", + mountFrom: "tmpfs", + mountName: "run", + mountPoint: "/run", + remote: false, + }, + { + fileSystem: "nfs", + mountFrom: "nfs-server:/export", + mountName: "nfs", + mountPoint: "/mnt/nfs", + protocol: "nfs", + remote: true, + remoteHost: "nfs-server", + remoteShare: "export", + }, + { + fileSystem: "nfs", + mountFrom: "192.168.0.216:/mnt/HDD1", + mountName: "freenas", + mountPoint: "/media/freenas", + protocol: "nfs", + remote: true, + remoteHost: "192.168.0.216", + remoteShare: "mnt/HDD1", + }, + { + fileSystem: "cifs", + mountFrom: "//cifs-server/share", + mountName: "cifs", + mountPoint: "/mnt/cifs", + remote: true, + remoteHost: "cifs-server", + remoteShare: "share", + }, + { + fileSystem: "smb", + mountFrom: "//guest@SERVER._smb._tcp.local/share", + mountName: "cifs2", + mountPoint: "/mnt/cifs2", remote: true, - remoteUser: "guest", remoteHost: "SERVER._smb._tcp.local", remoteShare: "share", + remoteUser: "guest", }, ]); }); diff --git a/src/__tests__/memory.test.ts b/src/__tests__/memory.test.ts index 0fb8b52..5a3e969 100644 --- a/src/__tests__/memory.test.ts +++ b/src/__tests__/memory.test.ts @@ -1,7 +1,7 @@ // src/__tests__/memory.test.ts import { jest } from "@jest/globals"; -import { mkdtemp, rmdir, writeFile } from "fs/promises"; +import { mkdtemp, rmdir } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { delay } from "../async.js"; @@ -10,10 +10,10 @@ import { getVolumeMetadata, getVolumeMountPoints, isHidden, - isHiddenRecursive, setHidden, } from "../index.js"; import { randomLetters } from "../random.js"; +import { validateHidden } from "../test-utils/hidden-tests.js"; import { tmpDirNotHidden } from "../test-utils/platform.js"; // Enable garbage collection access @@ -107,30 +107,15 @@ describeMemory("Memory Tests", () => { afterEach(async () => { for (const dir of tmpDirs) { - await rmdir(dir, { recursive: true, maxRetries: 3 }); + await rmdir(dir, { recursive: true, maxRetries: 3 }).catch(() => null); } }); it("should not leak memory under repeated calls", async () => { await checkMemoryUsage(async () => { - const dir = await mkdtemp(join(tmpDirNotHidden(), "memory-test-")); + const dir = await mkdtemp(join(tmpDirNotHidden(), "memory-tests-")); tmpDirs.push(dir); - const file = join(dir, "test.txt"); - await writeFile(file, "test"); - expect(await isHidden(dir)).toBe(false); - expect(await isHidden(file)).toBe(false); - expect(await isHiddenRecursive(dir)).toBe(false); - expect(await isHiddenRecursive(file)).toBe(false); - const hiddenDir = await setHidden(dir, true); - expect(await isHidden(hiddenDir)).toBe(true); - expect(await isHidden(file)).toBe(false); - expect(await isHiddenRecursive(file)).toBe(true); - - // This should be a no-op: - expect(await setHidden(hiddenDir, true)).toEqual(hiddenDir); - const hiddenFile = await setHidden(file, true); - expect(await isHidden(hiddenFile)).toBe(true); - expect(await isHidden(hiddenDir)).toBe(true); + await validateHidden(dir); }); }); diff --git a/src/__tests__/remote_info.test.ts b/src/__tests__/remote_info.test.ts new file mode 100644 index 0000000..5d0bccb --- /dev/null +++ b/src/__tests__/remote_info.test.ts @@ -0,0 +1,103 @@ +import { + extractRemoteInfo, + isRemoteFsType, + normalizeProtocol, + parseURL, +} from "../remote_info.js"; + +describe("remote_info tests", () => { + describe("normalizeProtocol", () => { + it("should normalize protocol by converting to lowercase and removing trailing colon", () => { + expect(normalizeProtocol("HTTP:")).toBe("http"); + expect(normalizeProtocol("ftp:")).toBe("ftp"); + expect(normalizeProtocol("")).toBe(""); + expect(normalizeProtocol(null as unknown as string)).toBe(""); + }); + }); + + describe("isRemoteFsType", () => { + it("should return true for known remote filesystem types", () => { + expect(isRemoteFsType("nfs")).toBe(true); + expect(isRemoteFsType("smb")).toBe(true); + expect(isRemoteFsType("ftp")).toBe(true); + }); + + it("should return false for unknown filesystem types", () => { + expect(isRemoteFsType("ext4")).toBe(false); + expect(isRemoteFsType("btrfs")).toBe(false); + }); + + it("should return false for undefined or blank input", () => { + expect(isRemoteFsType(undefined)).toBe(false); + expect(isRemoteFsType("")).toBe(false); + }); + }); + + describe("parseURL", () => { + it("should return undefined for blank input", () => { + expect(parseURL("")).toBeUndefined(); + expect(parseURL(" ")).toBeUndefined(); + }); + + it("should return URL object for valid URL string", () => { + const url = parseURL("http://example.com"); + expect(url).toBeInstanceOf(URL); + expect(url?.href).toBe("http://example.com/"); + }); + + it("should return undefined for invalid URL string", () => { + expect(parseURL("invalid-url")).toBeUndefined(); + }); + }); + + describe("extractRemoteInfo", () => { + it("should return undefined for undefined or blank input", () => { + expect(extractRemoteInfo(undefined)).toBeUndefined(); + expect(extractRemoteInfo("")).toBeUndefined(); + }); + + it("should return non-remote info for file protocol", () => { + const result = extractRemoteInfo("file:///path/to/file"); + expect(result).toEqual({ + remote: false, + uri: "file:///path/to/file", + }); + }); + + it("should return remote info for SMB/CIFS pattern", () => { + const result = extractRemoteInfo("//user@host/share"); + expect(result).toEqual({ + remote: true, + remoteUser: "user", + remoteHost: "host", + remoteShare: "share", + }); + }); + + it("should return remote info for NFS pattern", () => { + const result = extractRemoteInfo("host:/share"); + expect(result).toEqual({ + remote: true, + remoteHost: "host", + remoteShare: "share", + protocol: "nfs", + }); + }); + + it("should return remote info for valid URL", () => { + const result = extractRemoteInfo("smb://user@host/share"); + expect(result).toEqual({ + remote: true, + uri: "smb://user@host/share", + protocol: "smb", + remoteHost: "host", + remoteShare: "/share", + remoteUser: "user", + }); + }); + + it("should return undefined for invalid URL", () => { + expect(extractRemoteInfo("invalid-url")).toBeUndefined(); + }); + }); +}); diff --git a/src/exports.ts b/src/exports.ts index df515e8..a2f904c 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -1,47 +1,20 @@ // index.ts import NodeGypBuild from "node-gyp-build"; -import { Stats } from "node:fs"; -import { rename, stat } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; import { thenOrTimeout } from "./async.js"; import { filterMountPoints, filterTypedMountPoints } from "./config_filters.js"; import { defer } from "./defer.js"; -import { WrappedError } from "./error.js"; import { findAncestorDir } from "./fs.js"; -import { getLabelFromDevDisk, getUuidFromDevDisk } from "./linux/dev_disk.js"; -import { - getLinuxMountPoints, - getLinuxMtabMetadata, -} from "./linux/mount_points.js"; -import type { - GetVolumeMetadataOptions, - NativeBindings, -} from "./native_bindings.js"; -import { gt0 } from "./number.js"; -import { compactValues } from "./object.js"; -import { type Options, options } from "./options.js"; -import { isRootDirectory, normalizePath } from "./path.js"; -import { isLinux, isMacOS, isWindows } from "./platform.js"; -import { - extractRemoteInfo, - isRemoteFsType, - isRemoteInfo, - RemoteInfo, -} from "./remote_info.js"; -import { isBlank, isNotBlank } from "./string.js"; -import { parseUNCPath } from "./unc.js"; -import { extractUUID } from "./uuid.js"; -import type { VolumeMetadata } from "./volume_metadata.js"; - -export { - ExcludedFileSystemTypesDefault, - ExcludedMountPointGlobsDefault, - options, - TimeoutMsDefault, -} from "./options.js"; -export type { Options as FsOptions } from "./options.js"; -export type { VolumeMetadata } from "./volume_metadata.js"; - +import { isHidden, isHiddenRecursive, setHidden } from "./hidden.js"; +import { getLinuxMountPoints } from "./linux/mount_points.js"; +import type { NativeBindings } from "./native_bindings.js"; +import { type Options, optionsWithDefaults } from "./options.js"; +import { isLinux, isWindows } from "./platform.js"; +import { getVolumeMetadata, type VolumeMetadata } from "./volume_metadata.js"; + +/** + * Glue code between the native bindings and the rest of the library to make + * things simpler for index.ts and index.cts + */ export class ExportsImpl { constructor(readonly _dirname: string) {} @@ -67,8 +40,7 @@ export class ExportsImpl { readonly getVolumeMountPoints = async ( opts: Partial = {}, ): Promise => { - const o = options(opts); - + const o = optionsWithDefaults(opts); return thenOrTimeout( isWindows ? this.#getWindowsMountPoints(o) : this.#getUnixMountPoints(o), { timeoutMs: o.timeoutMs, desc: "getVolumeMountPoints()" }, @@ -99,109 +71,13 @@ export class ExportsImpl { mountPoint: string, opts: Partial> = {}, ): Promise => { - const o = options(opts); - return thenOrTimeout(this.#getVolumeMetadata(mountPoint, o), { + const o = optionsWithDefaults(opts); + return thenOrTimeout(getVolumeMetadata(mountPoint, o, this.#nativeFn), { timeoutMs: o.timeoutMs, desc: "getVolumeMetadata()", }); }; - async #getVolumeMetadata( - mountPoint: string, - o: Options, - ): Promise { - if (isBlank(mountPoint)) { - throw new TypeError( - "mountPoint is required: got " + JSON.stringify(mountPoint), - ); - } - - mountPoint = normalizePath(mountPoint); - - if (o.onlyDirectories || isWindows) { - let s: Stats; - try { - s = await stat(mountPoint); - } catch (e) { - throw new WrappedError(`mountPoint ${mountPoint} is not accessible`, e); - } - if (!s.isDirectory()) { - throw new TypeError(`mountPoint ${mountPoint} is not a directory`); - } - } - let remote: boolean = false; - - // Get filesystem info from mtab first on Linux - let mtabRemoteInfo: undefined | RemoteInfo = undefined; - let device: undefined | string; - if (isLinux) { - try { - const m = await getLinuxMtabMetadata(mountPoint, o); - if (isRemoteInfo(m)) { - remote = m.remote ?? false; - mtabRemoteInfo = m; - } - if (isNotBlank(m.fs_spec)) { - device = m.fs_spec; - } - } catch (error) { - console.warn("Failed to read mount table:", error); - } - } - - const nativeOptions: GetVolumeMetadataOptions = {}; - if (gt0(o.timeoutMs)) { - nativeOptions.timeoutMs = o.timeoutMs; - } - if (isNotBlank(device)) { - nativeOptions.device = device; - } - const metadata = (await ( - await this.#nativeFn() - ).getVolumeMetadata(mountPoint, nativeOptions)) as VolumeMetadata; - - // Some implementations leave it up to us to extract remote info: - const remoteInfo = - mtabRemoteInfo ?? - extractRemoteInfo(metadata.uri) ?? - extractRemoteInfo(metadata.mountFrom) ?? - (isWindows ? parseUNCPath(mountPoint) : undefined); - - remote ||= - isRemoteFsType(metadata.fileSystem) || - (remoteInfo?.remote ?? metadata.remote ?? false); - - const result = compactValues({ - ...compactValues(mtabRemoteInfo), - ...compactValues(remoteInfo), - ...compactValues(metadata), - mountPoint, - remote, - }) as unknown as VolumeMetadata; - - // Backfill if blkid or gio failed us: - if (isLinux && isNotBlank(device)) { - if (isBlank(result.uuid)) { - // Sometimes blkid doesn't have the UUID in cache. Try to get it from - // /dev/disk/by-uuid: - result.uuid = (await getUuidFromDevDisk(device)) ?? ""; - } - if (isBlank(result.label)) { - result.label = (await getLabelFromDevDisk(device)) ?? ""; - } - } - - // Fix microsoft UUID format: - result.uuid = extractUUID(result.uuid) ?? result.uuid ?? ""; - - // Normalize remote share path - if (isNotBlank(result.remoteShare)) { - result.remoteShare = normalizePath(result.remoteShare); - } - - return compactValues(result) as unknown as VolumeMetadata; - } - /** * Get metadata for all volumes on the system. * @@ -224,36 +100,15 @@ export class ExportsImpl { * @param path Path to file or directory * @returns Promise resolving to boolean indicating hidden state */ - readonly isHidden = async (path: string): Promise => { - if (isLinux || isMacOS) { - if (basename(path).startsWith(".")) { - return true; - } - } - if (isWindows && isRootDirectory(path)) { - // windows `attr` thinks all drive letters don't exist. - return false; - } - if (isWindows || isMacOS) { - return (await this.#nativeFn()).isHidden(path); - } - return false; - }; + readonly isHidden = (path: string): Promise => + isHidden(path, this.#nativeFn); /** * Check if a file or directory is hidden, or if any of its ancestor * directories are hidden. */ - readonly isHiddenRecursive = async (path: string): Promise => { - let p = normalizePath(path); - while (!isRootDirectory(p)) { - if (await this.isHidden(p)) { - return true; - } - p = dirname(p); - } - return false; - }; + readonly isHiddenRecursive = (path: string): Promise => + isHiddenRecursive(path, this.#nativeFn); /** * Set the hidden state of a file or directory @@ -262,24 +117,6 @@ export class ExportsImpl { * @returns Promise resolving the final name of the file or directory (as it * will change on POSIX systems) */ - readonly setHidden = async ( - path: string, - hidden: boolean, - ): Promise => { - if ((await this.isHidden(path)) === hidden) { - return path; - } - - if (isLinux || isMacOS) { - const dir = dirname(path); - const srcBase = basename(path).replace(/^\./, ""); - const dest = join(dir, (hidden ? "." : "") + srcBase); - if (path !== dest) await rename(path, dest); - return dest; - } - if (isWindows) { - await (await this.#nativeFn()).setHidden(path, hidden); - } - return path; - }; + readonly setHidden = (path: string, hidden: boolean): Promise => + setHidden(path, hidden, this.#nativeFn); } diff --git a/src/hidden.ts b/src/hidden.ts new file mode 100644 index 0000000..24cc89c --- /dev/null +++ b/src/hidden.ts @@ -0,0 +1,79 @@ +import { rename } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { statAsync } from "./fs_promises.js"; +import { NativeBindingsFn } from "./native_bindings.js"; +import { isRootDirectory, normalizePath } from "./path.js"; +import { isLinux, isMacOS, isWindows } from "./platform.js"; + +export async function isHidden( + path: string, + nativeFn: NativeBindingsFn, +): Promise { + // Make sure the native code sees a normalized path: + path = normalizePath(path); + + // Windows doesn't hide dot-prefixed files or directories: + if (isLinux || isMacOS) { + const b = basename(path); + if (b.startsWith(".") && b !== "." && b !== "..") { + return true; + } + } + + if (isWindows && isRootDirectory(path)) { + // windows `attr` thinks all drive letters don't exist. + return false; + } + + // Don't bother the native code if the file doesn't exist. + try { + await statAsync(path); + } catch { + return false; + } + + // only windows has a native implementation: + if (isWindows) { + return (await nativeFn()).isHidden(path); + } + + return false; +} + +export async function isHiddenRecursive( + path: string, + nativeFn: NativeBindingsFn, +): Promise { + let p = normalizePath(path); + while (!isRootDirectory(p)) { + if (await isHidden(p, nativeFn)) { + return true; + } + p = dirname(p); + } + return false; +} + +export async function setHidden( + path: string, + hidden: boolean, + nativeFn: NativeBindingsFn, +): Promise { + if ((await isHidden(path, nativeFn)) === hidden) { + return path; + } + + if (isLinux || isMacOS) { + const dir = dirname(path); + const srcBase = basename(path).replace(/^\./, ""); + const dest = join(dir, (hidden ? "." : "") + srcBase); + if (path !== dest) await rename(path, dest); + return dest; + } + + if (isWindows) { + await (await nativeFn()).setHidden(path, hidden); + } + + return path; +} diff --git a/src/index.cts b/src/index.cts index bfbd50e..a1a4667 100644 --- a/src/index.cts +++ b/src/index.cts @@ -6,12 +6,12 @@ import { ExportsImpl } from "./exports.js"; export { ExcludedFileSystemTypesDefault, ExcludedMountPointGlobsDefault, - options, - Options, OptionsDefault, + optionsWithDefaults, TimeoutMsDefault, } from "./options.js"; -export { VolumeMetadata } from "./volume_metadata.js"; +export type { Options } from "./options.js"; +export type { VolumeMetadata } from "./volume_metadata.js"; const impl = new ExportsImpl(__dirname); diff --git a/src/index.ts b/src/index.ts index c321f94..6037474 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { ExportsImpl } from "./exports.js"; export { ExcludedFileSystemTypesDefault, ExcludedMountPointGlobsDefault, - options, + optionsWithDefaults as options, OptionsDefault, TimeoutMsDefault, } from "./options.js"; @@ -17,8 +17,6 @@ export type { VolumeMetadata } from "./volume_metadata.js"; const impl = new ExportsImpl(dirname(fileURLToPath(import.meta.url))); -// I thought `export default impl` would work, but the types get lost 😢 - export const getVolumeMountPoints = impl.getVolumeMountPoints; export const getVolumeMetadata = impl.getVolumeMetadata; export const getAllVolumeMetadata = impl.getAllVolumeMetadata; diff --git a/src/linux/mount_points.ts b/src/linux/mount_points.ts index aca73fd..50a8701 100644 --- a/src/linux/mount_points.ts +++ b/src/linux/mount_points.ts @@ -3,8 +3,7 @@ import { readFile } from "node:fs/promises"; import { toError, WrappedError } from "../error.js"; import type { NativeBindingsFn } from "../native_bindings.js"; -import { type Options, options } from "../options.js"; -import { RemoteInfo } from "../remote_info.js"; +import { type Options, optionsWithDefaults } from "../options.js"; import { toNotBlank } from "../string.js"; import { isTypedMountPoint, @@ -27,7 +26,7 @@ export async function getLinuxMountPoints( } let caughtError: Error | undefined; - const inputs = options(opts).linuxMountTablePaths; + const inputs = optionsWithDefaults(opts).linuxMountTablePaths; for (const input of inputs) { try { const mtabMounts: TypedMountPoint[] = []; @@ -56,9 +55,9 @@ export async function getLinuxMountPoints( export async function getLinuxMtabMetadata( mountPoint: string, opts?: Pick, -): Promise { +): Promise { let caughtError: Error | undefined; - const inputs = options(opts).linuxMountTablePaths; + const inputs = optionsWithDefaults(opts).linuxMountTablePaths; for (const input of inputs) { try { const mtabContent = await readFile(input, "utf8"); diff --git a/src/linux/mtab.ts b/src/linux/mtab.ts index e40007a..631cc21 100644 --- a/src/linux/mtab.ts +++ b/src/linux/mtab.ts @@ -1,17 +1,15 @@ // src/linux/mtab.ts +import { basename } from "path"; import { toInt } from "../number.js"; import { normalizeLinuxPath } from "../path.js"; -import { - extractRemoteInfo, - isRemoteFsType, - RemoteInfo, -} from "../remote_info.js"; +import { extractRemoteInfo } from "../remote_info.js"; import { decodeEscapeSequences, encodeEscapeSequences, isBlank, } from "../string.js"; +import { VolumeMetadata } from "../volume_metadata.js"; /** * Represents an entry in the mount table. @@ -43,15 +41,31 @@ export interface MountEntry { fs_passno: number | undefined; } +export type MtabVolumeMetadata = Omit< + VolumeMetadata, + "size" | "used" | "available" | "label" | "uuid" | "status" +>; + +export function mountEntryToPartialVolumeMetadata( + entry: MountEntry, +): MtabVolumeMetadata { + return { + mountPoint: entry.fs_file, + mountName: basename(entry.fs_file), + fileSystem: entry.fs_vfstype, + mountFrom: entry.fs_spec, + remote: false, // < default to false + ...extractRemoteInfo(entry.fs_spec), + }; +} + /** * Parses an mtab/fstab file content into structured mount entries * @param content - Raw content of the mtab/fstab file * @returns Array of parsed mount entries */ -export function parseMtab( - content: string, -): (MountEntry | (MountEntry & RemoteInfo))[] { - const entries: (MountEntry | (MountEntry & RemoteInfo))[] = []; +export function parseMtab(content: string): MountEntry[] { + const entries: MountEntry[] = []; const lines = content.split("\n"); for (const line of lines) { @@ -68,8 +82,7 @@ export function parseMtab( if (!fields || fields.length < 3) { continue; // Skip malformed lines } - - const entry: MountEntry = { + entries.push({ fs_spec: fields[0]!, // normalizeLinuxPath DOES NOT resolve()! fs_file: normalizeLinuxPath(fields[1] ?? ""), @@ -77,18 +90,8 @@ export function parseMtab( fs_mntops: fields[3]!, fs_freq: toInt(fields[4]), fs_passno: toInt(fields[5]), - }; - - const remoteInfo = isRemoteFsType(entry.fs_vfstype) - ? extractRemoteInfo(entry.fs_spec) - : undefined; - if (remoteInfo) { - entries.push({ ...entry, ...remoteInfo }); - } else { - entries.push(entry); - } + }); } - return entries; } diff --git a/src/remote_info.ts b/src/remote_info.ts index a20e982..1a17cce 100644 --- a/src/remote_info.ts +++ b/src/remote_info.ts @@ -66,7 +66,7 @@ const NETWORK_FS_TYPES = new Set([ "webdav", ]); -function normalizeProtocol(protocol: string): string { +export function normalizeProtocol(protocol: string): string { return (protocol ?? "").toLowerCase().replace(/:$/, ""); } @@ -74,7 +74,7 @@ export function isRemoteFsType(fstype: string | undefined): boolean { return isNotBlank(fstype) && NETWORK_FS_TYPES.has(normalizeProtocol(fstype)); } -function parseURL(s: string): URL | undefined { +export function parseURL(s: string): URL | undefined { try { return isBlank(s) ? undefined : new URL(s); } catch { @@ -109,7 +109,7 @@ export function extractRemoteInfo( // NFS pattern: hostname:/share { protocol: "nfs", - regex: /^(?[^:]+):\/(?.+)$/, + regex: /^(?[^:]+):\/(?!\/)(?.+)$/, }, ]; diff --git a/src/test-utils/hidden-tests.ts b/src/test-utils/hidden-tests.ts new file mode 100644 index 0000000..f847198 --- /dev/null +++ b/src/test-utils/hidden-tests.ts @@ -0,0 +1,25 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isHidden, isHiddenRecursive, setHidden } from "../index.js"; + +export async function validateHidden(dir: string) { + await mkdir(dir, { recursive: true }); + let file = join(dir, "test.txt"); + await writeFile(file, "test"); + expect(await isHidden(dir)).toBe(false); + expect(await isHidden(file)).toBe(false); + expect(await isHiddenRecursive(dir)).toBe(false); + expect(await isHiddenRecursive(file)).toBe(false); + const hiddenDir = await setHidden(dir, true); + expect(await isHidden(hiddenDir)).toBe(true); + + file = join(hiddenDir, "test.txt"); + expect(await isHidden(file)).toBe(false); + expect(await isHiddenRecursive(file)).toBe(true); + + // This should be a no-op: + expect(await setHidden(hiddenDir, true)).toEqual(hiddenDir); + const hiddenFile = await setHidden(file, true); + expect(await isHidden(hiddenFile)).toBe(true); + expect(await isHidden(hiddenDir)).toBe(true); +} diff --git a/src/test-utils/platform.ts b/src/test-utils/platform.ts index 47b73a8..3d39812 100644 --- a/src/test-utils/platform.ts +++ b/src/test-utils/platform.ts @@ -14,10 +14,17 @@ export function describePlatform(...supported: NodeJS.Platform[]) { return supported.includes(platform) ? describe : describe.skip; } +export function systemDrive() { + if (isWindows) { + return toNotBlank(env["SystemDrive"] ?? "") ?? "C:\\"; + } else { + return "/"; + } +} + export function tmpDirNotHidden() { if (isWindows) { - const systemDrive = toNotBlank(env["SystemDrive"] ?? "") ?? "C:\\"; - return join(systemDrive, "tmp"); + return join(systemDrive(), "tmp"); } else { return "/tmp"; } diff --git a/src/volume_metadata.ts b/src/volume_metadata.ts index 9de9e3d..589e63c 100644 --- a/src/volume_metadata.ts +++ b/src/volume_metadata.ts @@ -1,6 +1,31 @@ // src/volume_metadata.ts -import { RemoteInfo } from "./remote_info.js"; +import { Stats } from "fs"; +import { WrappedError } from "./error.js"; +import { statAsync } from "./fs_promises.js"; +import { getLabelFromDevDisk, getUuidFromDevDisk } from "./linux/dev_disk.js"; +import { getLinuxMtabMetadata } from "./linux/mount_points.js"; +import { + MtabVolumeMetadata, + mountEntryToPartialVolumeMetadata, +} from "./linux/mtab.js"; +import { + GetVolumeMetadataOptions, + NativeBindingsFn, +} from "./native_bindings.js"; +import { gt0 } from "./number.js"; +import { compactValues } from "./object.js"; +import { Options } from "./options.js"; +import { normalizePath } from "./path.js"; +import { isLinux, isWindows } from "./platform.js"; +import { + RemoteInfo, + extractRemoteInfo, + isRemoteFsType, +} from "./remote_info.js"; +import { isBlank, isNotBlank } from "./string.js"; +import { parseUNCPath } from "./unc.js"; +import { extractUUID } from "./uuid.js"; /** * Metadata associated to a volume. @@ -35,6 +60,7 @@ export interface VolumeMetadata extends RemoteInfo { * Available size in bytes */ available: number; + /** * Device or service that the mountpoint is from. May be `/dev/sda1`, * `nfs-server:/export`, `//username@remoteHost/remoteShare`, or @@ -46,6 +72,7 @@ export interface VolumeMetadata extends RemoteInfo { * UUID for the volume, like "d46edc85-a030-4dd7-a2a8-68344034e27d". */ uuid?: string; + /** * If there are non-critical errors while extracting metadata, those error * messages may be added to this field (say, from blkid or gio). @@ -55,3 +82,99 @@ export interface VolumeMetadata extends RemoteInfo { */ status?: string; } + +export async function getVolumeMetadata( + mountPoint: string, + o: GetVolumeMetadataOptions & Options, + nativeFn: NativeBindingsFn, +): Promise { + if (isBlank(mountPoint)) { + throw new TypeError( + "mountPoint is required: got " + JSON.stringify(mountPoint), + ); + } + + mountPoint = normalizePath(mountPoint); + + if (o.onlyDirectories || isWindows) { + let s: Stats; + try { + s = await statAsync(mountPoint); + } catch (e) { + throw new WrappedError(`mountPoint ${mountPoint} is not accessible`, e); + } + if (!s.isDirectory()) { + throw new TypeError(`mountPoint ${mountPoint} is not a directory`); + } + } + let remote: boolean = false; + // Get filesystem info from mtab first on Linux + let mtabInfo: undefined | MtabVolumeMetadata; + let device: undefined | string; + if (isLinux) { + try { + const m = await getLinuxMtabMetadata(mountPoint, o); + mtabInfo = mountEntryToPartialVolumeMetadata(m); + if (mtabInfo.remote) { + remote = true; + } + if (isNotBlank(m.fs_spec)) { + device = m.fs_spec; + } + } catch { + // this may be a GIO mount. Ignore the error and continue. + } + } + + const nativeOptions: GetVolumeMetadataOptions = {}; + if (gt0(o.timeoutMs)) { + nativeOptions.timeoutMs = o.timeoutMs; + } + if (isNotBlank(device)) { + nativeOptions.device = device; + } + const metadata = (await ( + await nativeFn() + ).getVolumeMetadata(mountPoint, nativeOptions)) as VolumeMetadata; + + // Some implementations leave it up to us to extract remote info: + const remoteInfo = + mtabInfo ?? + extractRemoteInfo(metadata.uri) ?? + extractRemoteInfo(metadata.mountFrom) ?? + (isWindows ? parseUNCPath(mountPoint) : undefined); + + remote ||= + isRemoteFsType(metadata.fileSystem) || + (remoteInfo?.remote ?? metadata.remote ?? false); + + const result = compactValues({ + ...compactValues(mtabInfo), + ...compactValues(remoteInfo), + ...compactValues(metadata), + mountPoint, + remote, + }) as unknown as VolumeMetadata; + + // Backfill if blkid or gio failed us: + if (isLinux && isNotBlank(device)) { + if (isBlank(result.uuid)) { + // Sometimes blkid doesn't have the UUID in cache. Try to get it from + // /dev/disk/by-uuid: + result.uuid = (await getUuidFromDevDisk(device)) ?? ""; + } + if (isBlank(result.label)) { + result.label = (await getLabelFromDevDisk(device)) ?? ""; + } + } + + // Fix microsoft UUID format: + result.uuid = extractUUID(result.uuid) ?? result.uuid ?? ""; + + // Normalize remote share path + if (isNotBlank(result.remoteShare)) { + result.remoteShare = normalizePath(result.remoteShare); + } + + return compactValues(result) as unknown as VolumeMetadata; +}