@photostructure/fs-metadata 1.1.0 → 1.3.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.
@@ -190,6 +190,7 @@ A caller could theoretically provide a malicious pattern causing ReDoS.
190
190
  **Files**: `src/windows/volume_mount_points.cpp`, `src/windows/volume_metadata.cpp`, `src/windows/system_volume.h`
191
191
 
192
192
  **Issue**: `GetVolumeInformationW` was called redundantly:
193
+
193
194
  - In `volume_mount_points.cpp`: once for `fstype`/`isReadOnly`, then again inside `IsSystemVolume()`
194
195
  - In `volume_metadata.cpp`: `IsSystemVolume()` queried the API, then `VolumeInfo` queried it again 5 lines later
195
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@photostructure/fs-metadata",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Cross-platform native filesystem metadata retrieval for Node.js",
5
5
  "homepage": "https://photostructure.github.io/fs-metadata/",
6
6
  "types": "./dist/index.d.ts",
@@ -91,7 +91,7 @@
91
91
  "cross-platform"
92
92
  ],
93
93
  "dependencies": {
94
- "node-addon-api": "^8.6.0",
94
+ "node-addon-api": "^8.7.0",
95
95
  "node-gyp-build": "^4.8.4"
96
96
  },
97
97
  "devDependencies": {
@@ -108,7 +108,7 @@
108
108
  "jest-environment-node": "^30.3.0",
109
109
  "jest-extended": "^7.0.0",
110
110
  "node-gyp": "^12.2.0",
111
- "npm-check-updates": "^19.6.5",
111
+ "npm-check-updates": "^19.6.6",
112
112
  "npm-run-all2": "8.0.4",
113
113
  "prebuildify": "^6.0.1",
114
114
  "prettier": "^3.8.1",
@@ -117,8 +117,8 @@
117
117
  "ts-jest": "^29.4.6",
118
118
  "tsup": "^8.5.1",
119
119
  "tsx": "^4.21.0",
120
- "typedoc": "^0.28.17",
120
+ "typedoc": "^0.28.18",
121
121
  "typescript": "^5.9.3",
122
- "typescript-eslint": "^8.57.1"
122
+ "typescript-eslint": "^8.57.2"
123
123
  }
124
124
  }
package/src/binding.cpp CHANGED
@@ -8,6 +8,7 @@
8
8
  #include "windows/hidden.h"
9
9
  #elif defined(__APPLE__)
10
10
  #include "darwin/fs_meta.h"
11
+ #include "darwin/get_mount_point.h"
11
12
  #include "darwin/hidden.h"
12
13
  #elif defined(__linux__)
13
14
  #include "common/volume_metadata.h"
@@ -60,6 +61,12 @@ Napi::Value GetVolumeMetadata(const Napi::CallbackInfo &info) {
60
61
  return FSMeta::GetVolumeMetadata(info);
61
62
  }
62
63
 
64
+ #if defined(__APPLE__)
65
+ Napi::Value GetMountPointForPath(const Napi::CallbackInfo &info) {
66
+ return FSMeta::GetMountPoint(info);
67
+ }
68
+ #endif
69
+
63
70
  #if defined(_WIN32) || defined(__APPLE__)
64
71
  Napi::Value GetHiddenAttribute(const Napi::CallbackInfo &info) {
65
72
  return FSMeta::GetHiddenAttribute(info);
@@ -81,6 +88,10 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
81
88
 
82
89
  exports.Set("getVolumeMetadata", Napi::Function::New(env, GetVolumeMetadata));
83
90
 
91
+ #if defined(__APPLE__)
92
+ exports.Set("getMountPoint", Napi::Function::New(env, GetMountPointForPath));
93
+ #endif
94
+
84
95
  #ifdef ENABLE_GIO
85
96
  exports.Set("getGioMountPoints", Napi::Function::New(env, GetGioMountPoints));
86
97
  #endif
@@ -0,0 +1,96 @@
1
+ // src/darwin/get_mount_point.cpp
2
+ // Lightweight mount point lookup using fstatfs() only.
3
+ // Returns f_mntonname without DiskArbitration, IOKit, or space calculations.
4
+
5
+ #include "./get_mount_point.h"
6
+ #include "../common/debug_log.h"
7
+ #include "../common/error_utils.h"
8
+ #include "../common/fd_guard.h"
9
+ #include "../common/path_security.h"
10
+
11
+ #include <fcntl.h>
12
+ #include <string>
13
+ #include <sys/mount.h>
14
+ #include <sys/param.h>
15
+ #include <unistd.h>
16
+
17
+ namespace FSMeta {
18
+
19
+ class GetMountPointWorker : public Napi::AsyncWorker {
20
+ public:
21
+ GetMountPointWorker(const std::string &path,
22
+ const Napi::Promise::Deferred &deferred)
23
+ : Napi::AsyncWorker(deferred.Env()), path_(path), deferred_(deferred) {}
24
+
25
+ void Execute() override {
26
+ DEBUG_LOG("[GetMountPointWorker] Executing for path: %s", path_.c_str());
27
+ try {
28
+ std::string error;
29
+ std::string validated = ValidatePathForRead(path_, error);
30
+ if (validated.empty()) {
31
+ SetError(error);
32
+ return;
33
+ }
34
+
35
+ DEBUG_LOG("[GetMountPointWorker] Using validated path: %s",
36
+ validated.c_str());
37
+
38
+ int fd = open(validated.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
39
+ if (fd < 0) {
40
+ int err = errno;
41
+ DEBUG_LOG("[GetMountPointWorker] open failed: %s (%d)", strerror(err),
42
+ err);
43
+ SetError(CreatePathErrorMessage("open", path_, err));
44
+ return;
45
+ }
46
+
47
+ FdGuard guard(fd);
48
+
49
+ struct statfs fs;
50
+ if (fstatfs(fd, &fs) != 0) {
51
+ int err = errno;
52
+ DEBUG_LOG("[GetMountPointWorker] fstatfs failed: %s (%d)",
53
+ strerror(err), err);
54
+ SetError(CreatePathErrorMessage("fstatfs", path_, err));
55
+ return;
56
+ }
57
+
58
+ result_ = fs.f_mntonname;
59
+ DEBUG_LOG("[GetMountPointWorker] mount point: %s", result_.c_str());
60
+ } catch (const std::exception &e) {
61
+ DEBUG_LOG("[GetMountPointWorker] Exception: %s", e.what());
62
+ SetError(e.what());
63
+ }
64
+ }
65
+
66
+ void OnOK() override {
67
+ Napi::HandleScope scope(Env());
68
+ deferred_.Resolve(Napi::String::New(Env(), result_));
69
+ }
70
+
71
+ void OnError(const Napi::Error &error) override {
72
+ deferred_.Reject(error.Value());
73
+ }
74
+
75
+ private:
76
+ std::string path_;
77
+ std::string result_;
78
+ Napi::Promise::Deferred deferred_;
79
+ };
80
+
81
+ Napi::Value GetMountPoint(const Napi::CallbackInfo &info) {
82
+ auto env = info.Env();
83
+ DEBUG_LOG("[GetMountPoint] called");
84
+
85
+ if (info.Length() < 1 || !info[0].IsString()) {
86
+ throw Napi::TypeError::New(env, "String argument expected");
87
+ }
88
+
89
+ std::string path = info[0].As<Napi::String>().Utf8Value();
90
+ auto deferred = Napi::Promise::Deferred::New(env);
91
+ auto *worker = new GetMountPointWorker(path, deferred);
92
+ worker->Queue();
93
+ return deferred.Promise();
94
+ }
95
+
96
+ } // namespace FSMeta
@@ -0,0 +1,13 @@
1
+ // src/darwin/get_mount_point.h
2
+ // Lightweight mount point lookup using fstatfs() only.
3
+ // Returns f_mntonname without DiskArbitration, IOKit, or space calculations.
4
+
5
+ #pragma once
6
+
7
+ #include <napi.h>
8
+
9
+ namespace FSMeta {
10
+
11
+ Napi::Value GetMountPoint(const Napi::CallbackInfo &info);
12
+
13
+ } // namespace FSMeta
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  isHiddenRecursiveImpl,
13
13
  setHiddenImpl,
14
14
  } from "./hidden";
15
+ import { getMountPointForPathImpl } from "./mount_point_for_path";
15
16
  import {
16
17
  getTimeoutMsDefault,
17
18
  IncludeSystemVolumesDefault,
@@ -121,7 +122,9 @@ export function getVolumeMetadata(
121
122
  */
122
123
  export function getVolumeMetadataForPath(
123
124
  pathname: string,
124
- opts?: Partial<Pick<Options, "timeoutMs" | "linuxMountTablePaths">>,
125
+ opts?: Partial<
126
+ Pick<Options, "timeoutMs" | "linuxMountTablePaths" | "mountPoints">
127
+ >,
125
128
  ): Promise<VolumeMetadata> {
126
129
  return getVolumeMetadataForPathImpl(
127
130
  pathname,
@@ -130,6 +133,34 @@ export function getVolumeMetadataForPath(
130
133
  );
131
134
  }
132
135
 
136
+ /**
137
+ * Get the mount point path for an arbitrary file or directory path.
138
+ *
139
+ * This is a lightweight alternative to {@link getVolumeMetadataForPath} when
140
+ * you only need the mount point string. On macOS it uses a single fstatfs()
141
+ * call (no DiskArbitration, IOKit, or space calculations). On Linux/Windows
142
+ * it uses device ID matching against the mount table.
143
+ *
144
+ * Symlinks are resolved, and macOS APFS firmlinks (e.g. `/Users` →
145
+ * `/System/Volumes/Data`) are handled correctly.
146
+ *
147
+ * @param pathname Path to any file or directory
148
+ * @param opts Optional settings (timeoutMs, linuxMountTablePaths)
149
+ * @returns The mount point path (e.g., "/", "/System/Volumes/Data", "C:\\")
150
+ */
151
+ export function getMountPointForPath(
152
+ pathname: string,
153
+ opts?: Partial<
154
+ Pick<Options, "timeoutMs" | "linuxMountTablePaths" | "mountPoints">
155
+ >,
156
+ ): Promise<string> {
157
+ return getMountPointForPathImpl(
158
+ pathname,
159
+ optionsWithDefaults(opts),
160
+ nativeFn,
161
+ );
162
+ }
163
+
133
164
  /**
134
165
  * Retrieves metadata for all mounted volumes with optional filtering and
135
166
  * concurrency control.
@@ -0,0 +1,54 @@
1
+ // src/mount_point_for_path.ts
2
+
3
+ import { realpath } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { withTimeout } from "./async";
6
+ import { debug } from "./debuglog";
7
+ import { statAsync } from "./fs";
8
+ import { isMacOS } from "./platform";
9
+ import { isBlank, isNotBlank } from "./string";
10
+ import type { NativeBindingsFn } from "./types/native_bindings";
11
+ import type { Options } from "./types/options";
12
+ import { findMountPointByDeviceId } from "./volume_metadata";
13
+
14
+ export async function getMountPointForPathImpl(
15
+ pathname: string,
16
+ opts: Options,
17
+ nativeFn: NativeBindingsFn,
18
+ ): Promise<string> {
19
+ if (isBlank(pathname)) {
20
+ throw new TypeError("Invalid pathname: got " + JSON.stringify(pathname));
21
+ }
22
+
23
+ // realpath() resolves POSIX symlinks. APFS firmlinks are NOT resolved by
24
+ // realpath(), but fstatfs() follows them — handled below on macOS.
25
+ const resolved = await realpath(pathname);
26
+
27
+ const resolvedStat = await statAsync(resolved);
28
+ const dir = resolvedStat.isDirectory() ? resolved : dirname(resolved);
29
+
30
+ if (isMacOS) {
31
+ // Use the lightweight native getMountPoint which only does fstatfs —
32
+ // no DiskArbitration, IOKit, or space calculations.
33
+ const native = await nativeFn();
34
+ if (native.getMountPoint) {
35
+ debug("[getMountPointForPath] using native getMountPoint for %s", dir);
36
+ const p = native.getMountPoint(dir);
37
+ const mountPoint = await withTimeout({
38
+ desc: "getMountPoint()",
39
+ timeoutMs: opts.timeoutMs,
40
+ promise: p,
41
+ });
42
+ if (isNotBlank(mountPoint)) {
43
+ debug("[getMountPointForPath] resolved to %s", mountPoint);
44
+ return mountPoint;
45
+ }
46
+ }
47
+ // Fallback: should not happen on macOS, but defensive
48
+ throw new Error("getMountPoint native function unavailable");
49
+ }
50
+
51
+ // Linux/Windows: device ID matching + path prefix tiebreaker
52
+ debug("[getMountPointForPath] using device matching for %s", resolved);
53
+ return findMountPointByDeviceId(resolved, resolvedStat, opts, nativeFn);
54
+ }
@@ -54,6 +54,13 @@ export interface NativeBindings {
54
54
  * subsequent parsing and extraction logic.
55
55
  */
56
56
  getVolumeMetadata(options: GetVolumeMetadataOptions): Promise<VolumeMetadata>;
57
+
58
+ /**
59
+ * macOS only: lightweight mount point lookup using fstatfs().
60
+ * Returns the f_mntonname for the given directory path without fetching
61
+ * full volume metadata (no DiskArbitration, no IOKit, no space calculation).
62
+ */
63
+ getMountPoint?(path: string): Promise<string>;
57
64
  }
58
65
 
59
66
  export type GetVolumeMetadataOptions = {
@@ -1,5 +1,7 @@
1
1
  // src/types/options.ts
2
2
 
3
+ import type { MountPoint } from "./mount_point";
4
+
3
5
  /**
4
6
  * Configuration options for filesystem operations.
5
7
  *
@@ -7,6 +9,18 @@
7
9
  * @see {@link OptionsDefault} for the default values
8
10
  */
9
11
  export interface Options {
12
+ /**
13
+ * Pre-fetched mount points to use instead of querying the system.
14
+ *
15
+ * When provided, functions like {@link getMountPointForPath} and
16
+ * {@link getVolumeMetadataForPath} will use these mount points for device ID
17
+ * matching instead of calling {@link getVolumeMountPoints} internally. This
18
+ * avoids redundant system queries when resolving multiple paths.
19
+ *
20
+ * Obtain via `getVolumeMountPoints({ includeSystemVolumes: true })` — system
21
+ * volumes must be included for device ID matching to work correctly.
22
+ */
23
+ mountPoints?: MountPoint[];
10
24
  /**
11
25
  * Timeout in milliseconds for filesystem operations.
12
26
  *
@@ -1,5 +1,6 @@
1
1
  // src/volume_metadata.ts
2
2
 
3
+ import type { Stats } from "node:fs";
3
4
  import { realpath } from "node:fs/promises";
4
5
  import { dirname } from "node:path";
5
6
  import { mapConcurrent, withTimeout } from "./async";
@@ -209,12 +210,38 @@ export async function getVolumeMetadataForPathImpl(
209
210
  // Linux/Windows: stat().dev is reliable (no firmlinks). Find the mount point
210
211
  // by comparing device IDs, using path prefix as a tiebreaker for bind mounts
211
212
  // or GIO mounts that share the same device id.
212
- const targetDev = resolvedStat.dev;
213
- const mountPoints = await getVolumeMountPointsImpl(
214
- { ...opts, includeSystemVolumes: true },
213
+ const mountPoint = await findMountPointByDeviceId(
214
+ resolved,
215
+ resolvedStat,
216
+ opts,
215
217
  nativeFn,
216
218
  );
217
219
 
220
+ return getVolumeMetadataImpl({ ...opts, mountPoint }, nativeFn);
221
+ }
222
+
223
+ /**
224
+ * Find the mount point for a resolved path using device ID matching.
225
+ * Used on Linux and Windows where stat().dev is reliable (no firmlinks).
226
+ *
227
+ * Compares device IDs of mount points against the target path's device ID,
228
+ * using path prefix as a tiebreaker for bind mounts or GIO mounts that share
229
+ * the same device id. The longest prefix match wins.
230
+ */
231
+ export async function findMountPointByDeviceId(
232
+ resolved: string,
233
+ resolvedStat: Stats,
234
+ opts: Options,
235
+ nativeFn: NativeBindingsFn,
236
+ ): Promise<string> {
237
+ const targetDev = resolvedStat.dev;
238
+ const mountPoints =
239
+ opts.mountPoints ??
240
+ (await getVolumeMountPointsImpl(
241
+ { ...opts, includeSystemVolumes: true },
242
+ nativeFn,
243
+ ));
244
+
218
245
  const prefixMatches: string[] = [];
219
246
  const deviceMatches: string[] = [];
220
247
 
@@ -234,18 +261,13 @@ export async function getVolumeMetadataForPathImpl(
234
261
  }),
235
262
  );
236
263
 
237
- // Longest prefix match wins; fall back to device-only match.
238
264
  const candidates = prefixMatches.length > 0 ? prefixMatches : deviceMatches;
239
265
  if (candidates.length === 0) {
240
266
  throw new Error(
241
- "No mount point found for path: " + JSON.stringify(pathname),
267
+ "No mount point found for path: " + JSON.stringify(resolved),
242
268
  );
243
269
  }
244
- const mountPoint = candidates.reduce((a, b) =>
245
- a.length >= b.length ? a : b,
246
- );
247
-
248
- return getVolumeMetadataImpl({ ...opts, mountPoint }, nativeFn);
270
+ return candidates.reduce((a, b) => (a.length >= b.length ? a : b));
249
271
  }
250
272
 
251
273
  export async function getAllVolumeMetadataImpl(