@photostructure/fs-metadata 1.1.0 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@photostructure/fs-metadata",
3
- "version": "1.1.0",
3
+ "version": "1.2.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,
@@ -130,6 +131,32 @@ export function getVolumeMetadataForPath(
130
131
  );
131
132
  }
132
133
 
134
+ /**
135
+ * Get the mount point path for an arbitrary file or directory path.
136
+ *
137
+ * This is a lightweight alternative to {@link getVolumeMetadataForPath} when
138
+ * you only need the mount point string. On macOS it uses a single fstatfs()
139
+ * call (no DiskArbitration, IOKit, or space calculations). On Linux/Windows
140
+ * it uses device ID matching against the mount table.
141
+ *
142
+ * Symlinks are resolved, and macOS APFS firmlinks (e.g. `/Users` →
143
+ * `/System/Volumes/Data`) are handled correctly.
144
+ *
145
+ * @param pathname Path to any file or directory
146
+ * @param opts Optional settings (timeoutMs, linuxMountTablePaths)
147
+ * @returns The mount point path (e.g., "/", "/System/Volumes/Data", "C:\\")
148
+ */
149
+ export function getMountPointForPath(
150
+ pathname: string,
151
+ opts?: Partial<Pick<Options, "timeoutMs" | "linuxMountTablePaths">>,
152
+ ): Promise<string> {
153
+ return getMountPointForPathImpl(
154
+ pathname,
155
+ optionsWithDefaults(opts),
156
+ nativeFn,
157
+ );
158
+ }
159
+
133
160
  /**
134
161
  * Retrieves metadata for all mounted volumes with optional filtering and
135
162
  * 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,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,6 +210,30 @@ 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.
213
+ const mountPoint = await findMountPointByDeviceId(
214
+ resolved,
215
+ resolvedStat,
216
+ opts,
217
+ nativeFn,
218
+ );
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> {
212
237
  const targetDev = resolvedStat.dev;
213
238
  const mountPoints = await getVolumeMountPointsImpl(
214
239
  { ...opts, includeSystemVolumes: true },
@@ -234,18 +259,13 @@ export async function getVolumeMetadataForPathImpl(
234
259
  }),
235
260
  );
236
261
 
237
- // Longest prefix match wins; fall back to device-only match.
238
262
  const candidates = prefixMatches.length > 0 ? prefixMatches : deviceMatches;
239
263
  if (candidates.length === 0) {
240
264
  throw new Error(
241
- "No mount point found for path: " + JSON.stringify(pathname),
265
+ "No mount point found for path: " + JSON.stringify(resolved),
242
266
  );
243
267
  }
244
- const mountPoint = candidates.reduce((a, b) =>
245
- a.length >= b.length ? a : b,
246
- );
247
-
248
- return getVolumeMetadataImpl({ ...opts, mountPoint }, nativeFn);
268
+ return candidates.reduce((a, b) => (a.length >= b.length ? a : b));
249
269
  }
250
270
 
251
271
  export async function getAllVolumeMetadataImpl(