@photostructure/fs-metadata 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/CODE_OF_CONDUCT.md +128 -0
  3. package/CONTRIBUTING.md +46 -0
  4. package/README.md +4 -65
  5. package/SECURITY.md +9 -0
  6. package/dist/index.cjs +234 -225
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.mjs +232 -223
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/types/array.d.ts +0 -5
  11. package/dist/types/debuglog.d.ts +0 -1
  12. package/dist/types/exports.d.ts +2 -1
  13. package/dist/types/mount_point.d.ts +1 -9
  14. package/dist/types/number.d.ts +0 -4
  15. package/dist/types/object.d.ts +0 -4
  16. package/dist/types/volume_metadata.d.ts +3 -3
  17. package/dist/types/volume_mount_points.d.ts +9 -0
  18. package/package.json +9 -9
  19. package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  20. package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
  21. package/src/array.ts +44 -0
  22. package/src/async.ts +145 -0
  23. package/src/debuglog.ts +30 -0
  24. package/src/defer.ts +30 -0
  25. package/src/error.ts +79 -0
  26. package/src/exports.ts +156 -0
  27. package/src/fs.ts +89 -0
  28. package/src/glob.ts +127 -0
  29. package/src/hidden.ts +249 -0
  30. package/src/index.cts +15 -0
  31. package/src/index.mts +17 -0
  32. package/src/linux/dev_disk.ts +77 -0
  33. package/src/linux/mount_points.ts +91 -0
  34. package/src/linux/mtab.ts +136 -0
  35. package/src/mount_point.ts +58 -0
  36. package/src/number.ts +21 -0
  37. package/src/object.ts +55 -0
  38. package/src/options.ts +179 -0
  39. package/src/path.ts +54 -0
  40. package/src/platform.ts +9 -0
  41. package/src/random.ts +40 -0
  42. package/src/remote_info.ts +161 -0
  43. package/src/setup.ts +69 -0
  44. package/src/string.ts +99 -0
  45. package/src/string_enum.ts +41 -0
  46. package/src/system_volume.ts +67 -0
  47. package/src/test-utils/assert.ts +69 -0
  48. package/src/test-utils/hidden-tests.ts +33 -0
  49. package/src/test-utils/jest-matchers.ts +29 -0
  50. package/src/test-utils/platform.ts +39 -0
  51. package/src/types/native_bindings.ts +64 -0
  52. package/src/types/node-gyp-build.d.ts +6 -0
  53. package/src/unc.ts +63 -0
  54. package/src/units.ts +31 -0
  55. package/src/uuid.ts +24 -0
  56. package/src/volume_health_status.ts +58 -0
  57. package/src/volume_metadata.ts +294 -0
  58. package/src/volume_mount_points.ts +109 -0
  59. package/tsup.config.ts +8 -0
  60. package/dist/types/cache.d.ts +0 -4
@@ -0,0 +1,41 @@
1
+ // src/string_enum.ts
2
+
3
+ // See https://basarat.gitbooks.io/typescript/content/docs/types/literal-types.html
4
+
5
+ export type StringEnumType<T extends string> = {
6
+ [K in T]: K;
7
+ };
8
+
9
+ export type StringEnum<T extends string> = StringEnumType<T> & {
10
+ values: T[];
11
+ size: number;
12
+ get(s: string | undefined): T | undefined;
13
+ };
14
+
15
+ export type StringEnumKeys<Type> = Type extends StringEnum<infer X> ? X : never;
16
+
17
+ /**
18
+ * Create a string enum with the given values.
19
+
20
+ Example usage:
21
+
22
+ export const Directions = stringEnum("North", "South", "East", "West")
23
+ export type Direction = StringEnumKeys<typeof Directions>
24
+
25
+ */
26
+ export function stringEnum<T extends string>(...o: T[]): StringEnum<T> {
27
+ const set = new Set(o);
28
+
29
+ const dict: StringEnumType<T> = {} as StringEnumType<T>;
30
+ for (const key of o) {
31
+ dict[key] = key;
32
+ }
33
+
34
+ return {
35
+ ...dict,
36
+ values: Object.freeze([...set]) as T[],
37
+ size: set.size,
38
+ get: (s: string | undefined) =>
39
+ s != null && set.has(s as T) ? (s as T) : undefined,
40
+ };
41
+ }
@@ -0,0 +1,67 @@
1
+ // src/system_volume.ts
2
+
3
+ import { debug } from "./debuglog.js";
4
+ import { compileGlob } from "./glob.js";
5
+ import { MountPoint } from "./mount_point.js";
6
+ import {
7
+ Options,
8
+ SystemFsTypesDefault,
9
+ SystemPathPatternsDefault,
10
+ } from "./options.js";
11
+ import { normalizePath } from "./path.js";
12
+ import { isWindows } from "./platform.js";
13
+ import { isNotBlank } from "./string.js";
14
+
15
+ /**
16
+ * Configuration for system volume detection
17
+ *
18
+ * @see {@link MountPoint.isSystemVolume}
19
+ */
20
+ export type SystemVolumeConfig = Pick<
21
+ Options,
22
+ "systemPathPatterns" | "systemFsTypes"
23
+ >;
24
+
25
+ /**
26
+ * Determines if a mount point represents a system volume based on its path and
27
+ * filesystem type
28
+ */
29
+ export function isSystemVolume(
30
+ mountPoint: string,
31
+ fstype: string | undefined,
32
+ config: Partial<SystemVolumeConfig> = {},
33
+ ): boolean {
34
+ debug("[isSystemVolume] checking %s (fstype: %s)", mountPoint, fstype);
35
+ if (isWindows) {
36
+ const systemDrive = normalizePath(process.env["SystemDrive"]);
37
+ if (systemDrive != null && mountPoint === systemDrive) {
38
+ debug("[isSystemVolume] %s is the Windows system drive", mountPoint);
39
+ return true;
40
+ }
41
+ }
42
+ const result =
43
+ (isNotBlank(fstype) &&
44
+ (config.systemFsTypes ?? SystemFsTypesDefault).has(fstype)) ||
45
+ compileGlob(config.systemPathPatterns ?? SystemPathPatternsDefault).test(
46
+ mountPoint,
47
+ );
48
+ debug("[isSystemVolume] %s -> %s", mountPoint, result);
49
+ return result;
50
+ }
51
+
52
+ export function assignSystemVolume(
53
+ mp: MountPoint,
54
+ config: Partial<SystemVolumeConfig>,
55
+ ) {
56
+ const result = isSystemVolume(mp.mountPoint, mp.fstype, config);
57
+
58
+ if (isWindows) {
59
+ // native code actually knows the system drive and has more in-depth
60
+ // metadata information that we trust more than these heuristics
61
+ mp.isSystemVolume ??= result;
62
+ } else {
63
+ // macOS and Linux don't have a concept of an explicit "system drive" like
64
+ // Windows--always trust our heuristics
65
+ mp.isSystemVolume = result;
66
+ }
67
+ }
@@ -0,0 +1,69 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
+ // src/test-utils/assert.ts
3
+ import { isMacOS } from "../platform.js";
4
+ import type { VolumeMetadata } from "../volume_metadata.js";
5
+
6
+ /**
7
+ * Asserts that the given metadata object has valid filesystem metadata
8
+ * properties
9
+ * @param metadata The metadata object to validate
10
+ */
11
+ export function assertMetadata(metadata: VolumeMetadata | undefined) {
12
+ try {
13
+ // Basic type checks
14
+ expect(metadata).toBeDefined();
15
+ if (metadata == null) throw new Error("Metadata is undefined");
16
+
17
+ expect(metadata.mountPoint).toBeDefined();
18
+ expect(typeof metadata.mountPoint).toBe("string");
19
+ expect(metadata.mountPoint.length).toBeGreaterThan(0);
20
+
21
+ if (metadata.fstype !== undefined) {
22
+ expect(typeof metadata.fstype).toBe("string");
23
+ expect(metadata.fstype).toMatch(/^[^/]+$/);
24
+ }
25
+
26
+ // Size checks
27
+ if (isMacOS && metadata.mountPoint === "/System/Volumes/Data/home") {
28
+ // skip size checks for this path on macOS, it's for the legacy /home mount which may be empty
29
+ } else {
30
+ expect(metadata.size).toBeGreaterThan(0);
31
+ expect(metadata.used).toBeGreaterThanOrEqual(0);
32
+ expect(metadata.available).toBeGreaterThanOrEqual(0);
33
+ expect(metadata.used! + metadata.available!).toBeLessThanOrEqual(
34
+ metadata.size!,
35
+ );
36
+ }
37
+
38
+ // Optional fields with type checking
39
+ if (metadata.label !== undefined) {
40
+ expect(typeof metadata.label).toBe("string");
41
+ expect(metadata.label.length).toBeGreaterThan(0);
42
+ }
43
+
44
+ if (metadata.uuid !== undefined) {
45
+ expect(typeof metadata.uuid).toBe("string");
46
+ expect(metadata.uuid).toMatch(/^[0-9a-z-]{8,}$/i);
47
+ }
48
+
49
+ if (metadata.remote !== undefined) {
50
+ expect(typeof metadata.remote).toBe("boolean");
51
+
52
+ // If it's a remote volume, check for remote-specific properties
53
+ if (metadata.remote === true) {
54
+ if (metadata.remoteHost !== undefined) {
55
+ expect(typeof metadata.remoteHost).toBe("string");
56
+ expect(metadata.remoteHost.length).toBeGreaterThan(0);
57
+ }
58
+
59
+ if (metadata.remoteShare !== undefined) {
60
+ expect(typeof metadata.remoteShare).toBe("string");
61
+ expect(metadata.remoteShare.length).toBeGreaterThan(0);
62
+ }
63
+ }
64
+ }
65
+ } catch (e) {
66
+ console.log("Assertions failed: " + e, { metadata });
67
+ throw e;
68
+ }
69
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isHidden, isHiddenRecursive, setHidden } from "../..";
4
+
5
+ /**
6
+ * This function exercises the hidden file functionality and is used by both
7
+ * memory.test and hidden.test suites.
8
+ */
9
+ export async function validateHidden(dir: string) {
10
+ await mkdir(dir, { recursive: true });
11
+ let file = join(dir, "test.txt");
12
+ await writeFile(file, "test");
13
+ expect(await isHidden(dir)).toBe(false);
14
+ expect(await isHidden(file)).toBe(false);
15
+ expect(await isHiddenRecursive(dir)).toBe(false);
16
+ expect(await isHiddenRecursive(file)).toBe(false);
17
+ const hiddenDir = (await setHidden(dir, true)).pathname;
18
+ expect(await isHidden(hiddenDir)).toBe(true);
19
+
20
+ file = join(hiddenDir, "test.txt");
21
+ expect(await isHidden(file)).toBe(false);
22
+ expect(await isHiddenRecursive(file)).toBe(true);
23
+
24
+ // This should be a no-op:
25
+ expect(await setHidden(hiddenDir, true)).toEqual(
26
+ expect.objectContaining({
27
+ pathname: hiddenDir,
28
+ }),
29
+ );
30
+ const hiddenFile = (await setHidden(file, true)).pathname;
31
+ expect(await isHidden(hiddenFile)).toBe(true);
32
+ expect(await isHidden(hiddenDir)).toBe(true);
33
+ }
@@ -0,0 +1,29 @@
1
+ /* eslint-disable @typescript-eslint/no-namespace */
2
+ import { expect } from "@jest/globals";
3
+
4
+ declare global {
5
+ namespace jest {
6
+ interface Matchers<R> {
7
+ toBeWithinDelta(expected: number, delta: number): R;
8
+ }
9
+ }
10
+ }
11
+
12
+ function toBeWithinDelta(received: number, expected: number, delta: number) {
13
+ const pass = Math.abs(received - expected) <= delta;
14
+ if (pass) {
15
+ return {
16
+ message: () =>
17
+ `expected ${received} not to be within ${delta} of ${expected}`,
18
+ pass: true,
19
+ };
20
+ } else {
21
+ return {
22
+ message: () =>
23
+ `expected ${received} to be within ${delta} of ${expected}`,
24
+ pass: false,
25
+ };
26
+ }
27
+ }
28
+
29
+ expect.extend({ toBeWithinDelta });
@@ -0,0 +1,39 @@
1
+ // src/test-utils/platform.ts
2
+
3
+ import { mkdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { env, platform } from "node:process";
7
+ import { normalizePath } from "../path.js";
8
+ import { isMacOS, isWindows } from "../platform.js";
9
+ import { toNotBlank } from "../string.js";
10
+
11
+ /**
12
+ * Helper function to skip tests based on platform
13
+ * @param platform The platform to run tests on ('win32', 'darwin', 'linux')
14
+ * @returns jest.Describe function that only runs on specified platform
15
+ */
16
+ export function describePlatform(...supported: NodeJS.Platform[]) {
17
+ return supported.includes(platform) ? describe : describe.skip;
18
+ }
19
+
20
+ export function systemDrive() {
21
+ if (isWindows) {
22
+ return normalizePath(
23
+ toNotBlank(env["SystemDrive"] ?? "") ?? "C:\\",
24
+ ) as string;
25
+ } else {
26
+ return "/";
27
+ }
28
+ }
29
+
30
+ export function tmpDirNotHidden() {
31
+ const dir = isMacOS
32
+ ? join(homedir(), "tmp")
33
+ : isWindows
34
+ ? join(systemDrive(), "tmp")
35
+ : "/tmp";
36
+
37
+ mkdirSync(dir, { recursive: true });
38
+ return dir;
39
+ }
@@ -0,0 +1,64 @@
1
+ // src/types/native_bindings.ts
2
+
3
+ import { MountPoint } from "../mount_point.js";
4
+ import type { Options } from "../options.js";
5
+ import type { VolumeMetadata } from "../volume_metadata.js";
6
+
7
+ export interface NativeBindings {
8
+ /**
9
+ * Enable or disable debug logging. Set automatically if the `NODE_DEBUG`
10
+ * environment matches `fs-meta`, `fs-metadata`, or
11
+ * `photostructure:fs-metadata`.
12
+ */
13
+ setDebugLogging(enabled: boolean): void;
14
+
15
+ /**
16
+ * Sets a prefix for debug log messages. Defaults to the shortest enabled
17
+ * debug log context, plus the process ID.
18
+ */
19
+ setDebugPrefix(prefix: string): void;
20
+
21
+ /**
22
+ * This is only available on macOS and Windows--Linux only hides files via
23
+ * filename (if basename starts with a dot).
24
+ */
25
+ isHidden(path: string): Promise<boolean>;
26
+
27
+ /**
28
+ * This is only available on macOS and Windows--Linux only hides files via
29
+ * filename (if basename starts with a dot).
30
+ *
31
+ * @param path The path to the file or directory to hide or unhide
32
+ * @param hidden If true, the file or directory will be hidden; if false, it
33
+ * will be unhidden
34
+ * @throws {Error} If the operation fails
35
+ */
36
+ setHidden(path: string, hidden: boolean): Promise<void>;
37
+
38
+ /**
39
+ * This is only available on macOS and Windows--Linux directly reads from the
40
+ * proc mounts table.
41
+ */
42
+ getVolumeMountPoints(
43
+ options?: Pick<Options, "timeoutMs">,
44
+ ): Promise<MountPoint[]>;
45
+
46
+ /**
47
+ * This is only available on Linux, and only if libglib-2.0 is installed.
48
+ */
49
+ getGioMountPoints?(): Promise<MountPoint[]>;
50
+
51
+ /**
52
+ * This is only a partial implementation for most platforms, to minimize
53
+ * native code when possible. The javascript side handles a bunch of
54
+ * subsequent parsing and extraction logic.
55
+ */
56
+ getVolumeMetadata(options: GetVolumeMetadataOptions): Promise<VolumeMetadata>;
57
+ }
58
+
59
+ export type GetVolumeMetadataOptions = {
60
+ mountPoint: string;
61
+ device?: string;
62
+ } & Partial<Pick<Options, "timeoutMs">>;
63
+
64
+ export type NativeBindingsFn = () => NativeBindings | Promise<NativeBindings>;
@@ -0,0 +1,6 @@
1
+ // src/types/node-gyp-build.d.ts
2
+
3
+ declare module "node-gyp-build" {
4
+ function NodeGypBuild(dir: string): unknown;
5
+ export default NodeGypBuild;
6
+ }
package/src/unc.ts ADDED
@@ -0,0 +1,63 @@
1
+ // src/unc.ts
2
+
3
+ import { RemoteInfo } from "./remote_info.js";
4
+ import { isBlank, isString } from "./string.js";
5
+
6
+ /**
7
+ * Checks if a string is formatted as a valid UNC path.
8
+ * A valid UNC path starts with double backslashes or slashes,
9
+ * followed by a server/host name, and then a share name.
10
+ * The path must use consistent slashes (all forward or all backward).
11
+ *
12
+ * @param path - The string to check
13
+ * @returns boolean - True if the string is a valid UNC path, false otherwise
14
+ */
15
+ export function parseUNCPath(
16
+ path: string | null | undefined,
17
+ ): RemoteInfo | undefined {
18
+ if (path == null || isBlank(path) || !isString(path)) {
19
+ return;
20
+ }
21
+
22
+ // Check for two forward slashes or two backslashes at start
23
+ if (!path.startsWith("\\\\") && !path.startsWith("//")) {
24
+ return;
25
+ }
26
+
27
+ // Determine slash type from the start of the path
28
+ const isForwardSlash = path.startsWith("//");
29
+ const slashChar = isForwardSlash ? "/" : "\\";
30
+
31
+ // Split path using the correct slash type
32
+ const parts = path.slice(2).split(slashChar);
33
+
34
+ // Check minimum required parts (server and share)
35
+ if (parts.length < 2) {
36
+ return;
37
+ }
38
+
39
+ // Validate server and share names exist and aren't empty
40
+ const [remoteHost, remoteShare] = parts;
41
+ if (
42
+ remoteHost == null ||
43
+ isBlank(remoteHost) ||
44
+ remoteShare == null ||
45
+ isBlank(remoteShare)
46
+ ) {
47
+ return;
48
+ }
49
+
50
+ // Check for invalid characters in server and share names
51
+ const invalidChars = /[<>:"|?*]/;
52
+ if (invalidChars.test(remoteHost) || invalidChars.test(remoteShare)) {
53
+ return;
54
+ }
55
+
56
+ // Check for mixed slash usage
57
+ const wrongSlash = isForwardSlash ? "\\" : "/";
58
+ if (path.includes(wrongSlash)) {
59
+ return;
60
+ }
61
+
62
+ return { remoteHost, remoteShare, remote: true };
63
+ }
package/src/units.ts ADDED
@@ -0,0 +1,31 @@
1
+ // src/units.ts
2
+
3
+ /**
4
+ * KiB = 1024 bytes
5
+ * @see https://en.wikipedia.org/wiki/Kibibyte
6
+ */
7
+ export const KiB = 1024;
8
+
9
+ /**
10
+ * MiB = 1024 KiB
11
+ * @see https://en.wikipedia.org/wiki/Mebibyte
12
+ */
13
+ export const MiB = 1024 * KiB;
14
+
15
+ /**
16
+ * GiB = 1024 MiB
17
+ * @see https://en.wikipedia.org/wiki/Gibibyte
18
+ */
19
+ export const GiB = 1024 * MiB;
20
+
21
+ export function fmtBytes(bytes: number): string {
22
+ if (bytes < KiB) {
23
+ return `${bytes} B`;
24
+ } else if (bytes < MiB) {
25
+ return `${(bytes / KiB).toFixed(2)} KiB`;
26
+ } else if (bytes < GiB) {
27
+ return `${(bytes / MiB).toFixed(2)} MiB`;
28
+ } else {
29
+ return `${(bytes / GiB).toFixed(2)} GiB`;
30
+ }
31
+ }
package/src/uuid.ts ADDED
@@ -0,0 +1,24 @@
1
+ // src/uuid.ts
2
+
3
+ import { toS } from "./string.js";
4
+
5
+ const uuidRegex = /[a-z0-9][a-z0-9-]{7,}/i;
6
+
7
+ /**
8
+ * Some volume UUIDs are short, like, `ABCD1234`.
9
+ *
10
+ * Some volume UUIDs are in hexadecimal, but others and use G-Z. We will allow
11
+ * that.
12
+ *
13
+ * Some Windows syscalls wrap the UUID in a "\\\\?\\Volume{...}\\" prefix and
14
+ * suffix. This function will strip out that prefix and suffix.
15
+ *
16
+ * We will ignore any UUID-ish string that is not at least 8 characters long
17
+ * (and return `undefined` if no other, longer uuid-ish string is found).
18
+ *
19
+ * UUIDs cannot start with a hyphen, and can only contain a-z, 0-9, and hyphens
20
+ * (case-insensitive).
21
+ */
22
+ export function extractUUID(uuid: string | undefined): string | undefined {
23
+ return toS(uuid).match(uuidRegex)?.[0];
24
+ }
@@ -0,0 +1,58 @@
1
+ // src/volume_health_status.ts
2
+
3
+ import { TimeoutError } from "./async.js";
4
+ import { debug } from "./debuglog.js";
5
+ import { toError } from "./error.js";
6
+ import { canReaddir } from "./fs.js";
7
+ import { isObject } from "./object.js";
8
+ import { stringEnum, StringEnumKeys } from "./string_enum.js";
9
+
10
+ /**
11
+ * Health statuses for volumes (mostly applicable to Windows).
12
+ *
13
+ * - `healthy`: Volume is "OK": accessible and functioning normally
14
+ * - `timeout`: Volume could not be accessed before the specified timeout. It
15
+ * may be inaccessible or disconnected.
16
+ * - `inaccessible`: Volume exists but can't be accessed (permissions/locks)
17
+ * - `disconnected`: Network volume that's offline
18
+ * - `unknown`: Status can't be determined
19
+ */
20
+ export const VolumeHealthStatuses = stringEnum(
21
+ "healthy",
22
+ "timeout",
23
+ "inaccessible",
24
+ "disconnected",
25
+ "unknown",
26
+ );
27
+
28
+ export type VolumeHealthStatus = StringEnumKeys<typeof VolumeHealthStatuses>;
29
+
30
+ /**
31
+ * Attempt to read a directory to determine if it's accessible, and if an error
32
+ * is thrown, convert to a health status.
33
+ * @returns the "health status" of the directory, based on the success of `readdir(dir)`.
34
+ * @throws never
35
+ */
36
+ export async function directoryStatus(
37
+ dir: string,
38
+ timeoutMs: number,
39
+ canReaddirImpl: typeof canReaddir = canReaddir,
40
+ ): Promise<{ status: VolumeHealthStatus; error?: Error }> {
41
+ try {
42
+ if (await canReaddirImpl(dir, timeoutMs)) {
43
+ return { status: VolumeHealthStatuses.healthy };
44
+ }
45
+ } catch (error) {
46
+ debug("[directoryStatus] %s: %s", dir, error);
47
+ let status: VolumeHealthStatus = VolumeHealthStatuses.unknown;
48
+ if (error instanceof TimeoutError) {
49
+ status = VolumeHealthStatuses.timeout;
50
+ } else if (isObject(error) && error instanceof Error && "code" in error) {
51
+ if (error.code === "EPERM" || error.code === "EACCES") {
52
+ status = VolumeHealthStatuses.inaccessible;
53
+ }
54
+ }
55
+ return { status, error: toError(error) };
56
+ }
57
+ return { status: VolumeHealthStatuses.unknown };
58
+ }