@photostructure/fs-metadata 1.0.1 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CLAUDE.md +13 -0
  3. package/binding.gyp +1 -0
  4. package/claude.sh +29 -5
  5. package/dist/index.cjs +237 -129
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +55 -3
  8. package/dist/index.d.mts +55 -3
  9. package/dist/index.d.ts +55 -3
  10. package/dist/index.mjs +236 -130
  11. package/dist/index.mjs.map +1 -1
  12. package/doc/SECURITY_AUDIT_2025.md +1 -1
  13. package/doc/SECURITY_AUDIT_2026.md +361 -0
  14. package/doc/TPP-GUIDE.md +144 -0
  15. package/doc/system-volume-detection.md +268 -0
  16. package/package.json +12 -12
  17. package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  18. package/prebuilds/darwin-x64/@photostructure+fs-metadata.glibc.node +0 -0
  19. package/prebuilds/linux-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  20. package/prebuilds/linux-arm64/@photostructure+fs-metadata.musl.node +0 -0
  21. package/prebuilds/linux-x64/@photostructure+fs-metadata.glibc.node +0 -0
  22. package/prebuilds/linux-x64/@photostructure+fs-metadata.musl.node +0 -0
  23. package/prebuilds/win32-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  24. package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
  25. package/src/binding.cpp +11 -0
  26. package/src/common/volume_metadata.h +10 -3
  27. package/src/common/volume_mount_points.h +7 -1
  28. package/src/darwin/da_mutex.h +23 -0
  29. package/src/darwin/get_mount_point.cpp +96 -0
  30. package/src/darwin/get_mount_point.h +13 -0
  31. package/src/darwin/raii_utils.h +39 -0
  32. package/src/darwin/system_volume.h +156 -0
  33. package/src/darwin/volume_metadata.cpp +18 -2
  34. package/src/darwin/volume_mount_points.cpp +46 -14
  35. package/src/index.ts +49 -0
  36. package/src/linux/mtab.ts +6 -0
  37. package/src/mount_point_for_path.ts +54 -0
  38. package/src/options.ts +7 -17
  39. package/src/path.ts +16 -1
  40. package/src/system_volume.ts +5 -9
  41. package/src/test-utils/assert.ts +4 -0
  42. package/src/types/mount_point.ts +28 -1
  43. package/src/types/native_bindings.ts +7 -0
  44. package/src/volume_metadata.ts +117 -2
  45. package/src/windows/system_volume.h +21 -16
  46. package/src/windows/volume_metadata.cpp +13 -7
  47. package/src/windows/volume_mount_points.cpp +11 -7
@@ -0,0 +1,156 @@
1
+ // src/darwin/system_volume.h
2
+ //
3
+ // Shared macOS system volume detection.
4
+ //
5
+ // Detection uses two signals combined:
6
+ // 1. MNT_SNAPSHOT (statfs f_flags) — catches sealed APFS system snapshots
7
+ // ("/" and /System/Volumes/Recovery on macOS Catalina+).
8
+ // 2. MNT_DONTBROWSE + APFS volume role — catches infrastructure volumes
9
+ // under /System/Volumes/* that are hidden from Finder. The "Data" role
10
+ // is excluded because /System/Volumes/Data is the primary user data
11
+ // volume (photos, documents, application data).
12
+ //
13
+ // Formula: MNT_SNAPSHOT || (MNT_DONTBROWSE && hasApfsRole && role != "Data")
14
+ //
15
+ // This is future-proof: new Apple infrastructure roles with MNT_DONTBROWSE
16
+ // are auto-detected without maintaining a whitelist.
17
+ //
18
+ // Non-APFS MNT_DONTBROWSE mounts (e.g., devfs at /dev) fall through to
19
+ // TypeScript fstype/path heuristics.
20
+ //
21
+ // See doc/system-volume-detection.md for the full rationale.
22
+ // See: mount(2), sys/mount.h, IOKit/IOKitLib.h
23
+
24
+ #pragma once
25
+
26
+ #include "../common/debug_log.h"
27
+ #include "raii_utils.h"
28
+ #include <DiskArbitration/DiskArbitration.h>
29
+ #include <IOKit/IOKitLib.h>
30
+ #include <string>
31
+ #include <sys/mount.h>
32
+
33
+ namespace FSMeta {
34
+
35
+ // Result of APFS volume role detection + system volume classification.
36
+ struct VolumeRoleResult {
37
+ bool isSystemVolume = false;
38
+ std::string role; // e.g., "System", "Data", "VM", "" if unknown
39
+ };
40
+
41
+ // Extract the APFS volume role string via IOKit for a given DiskArbitration
42
+ // disk ref. For snapshots (e.g., disk3s7s1), walks one parent up in the
43
+ // IOService plane to find the parent volume's role.
44
+ // Returns the first role string found, or "" if no role can be determined.
45
+ inline std::string GetApfsVolumeRole(DADiskRef disk) {
46
+ if (!disk) {
47
+ return "";
48
+ }
49
+
50
+ IOObjectGuard media(DADiskCopyIOMedia(disk));
51
+ if (!media.isValid()) {
52
+ DEBUG_LOG("[GetApfsVolumeRole] Failed to get IOMedia");
53
+ return "";
54
+ }
55
+
56
+ // Check the volume's own Role property first
57
+ CFReleaser<CFArrayRef> role(
58
+ static_cast<CFArrayRef>(IORegistryEntryCreateCFProperty(
59
+ media.get(), CFSTR("Role"), kCFAllocatorDefault, 0)));
60
+
61
+ // If no Role on this entry, try the parent (handles snapshot → volume case:
62
+ // disk3s7s1 (snapshot) → disk3s7 (volume with System role))
63
+ IOObjectGuard parent;
64
+ if (!role.isValid()) {
65
+ io_registry_entry_t parentRef = 0;
66
+ kern_return_t kr =
67
+ IORegistryEntryGetParentEntry(media.get(), kIOServicePlane, &parentRef);
68
+ if (kr == KERN_SUCCESS) {
69
+ parent = IOObjectGuard(parentRef);
70
+ role.reset(static_cast<CFArrayRef>(IORegistryEntryCreateCFProperty(
71
+ parent.get(), CFSTR("Role"), kCFAllocatorDefault, 0)));
72
+ }
73
+ }
74
+
75
+ std::string result;
76
+ if (role.isValid() && CFGetTypeID(role.get()) == CFArrayGetTypeID()) {
77
+ CFIndex count = CFArrayGetCount(role.get());
78
+ if (count > 0) {
79
+ CFStringRef roleStr =
80
+ static_cast<CFStringRef>(CFArrayGetValueAtIndex(role.get(), 0));
81
+ if (roleStr && CFGetTypeID(roleStr) == CFStringGetTypeID()) {
82
+ char buf[64];
83
+ if (CFStringGetCString(roleStr, buf, sizeof(buf),
84
+ kCFStringEncodingUTF8)) {
85
+ DEBUG_LOG("[GetApfsVolumeRole] Role: %s", buf);
86
+ result = buf;
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // IOObjectGuard destructors automatically release media and parent
93
+ return result;
94
+ }
95
+
96
+ // Classify a macOS volume as system or user using mount flags and APFS role.
97
+ //
98
+ // Detection formula:
99
+ // MNT_SNAPSHOT || (MNT_DONTBROWSE && hasApfsRole && role != "Data")
100
+ //
101
+ // - MNT_SNAPSHOT alone catches sealed APFS system snapshots (/ and Recovery)
102
+ // - MNT_DONTBROWSE combined with an APFS role catches infrastructure volumes
103
+ // (VM, Preboot, Update, Hardware, xART, etc.) while excluding the Data
104
+ // volume which contains user files
105
+ // - Non-APFS MNT_DONTBROWSE mounts (devfs, NFS with nobrowse) are left for
106
+ // TypeScript heuristics
107
+ inline VolumeRoleResult ClassifyMacVolume(const char *bsdDeviceName,
108
+ uint32_t f_flags,
109
+ DASessionRef session) {
110
+ VolumeRoleResult result;
111
+
112
+ // Layer 1: MNT_SNAPSHOT alone → system (sealed APFS snapshot)
113
+ if (f_flags & MNT_SNAPSHOT) {
114
+ result.isSystemVolume = true;
115
+ }
116
+
117
+ // Layer 2: APFS role via IOKit (if DA session available)
118
+ if (session && bsdDeviceName) {
119
+ // Strip "/dev/" prefix if present
120
+ const char *bsdName = bsdDeviceName;
121
+ if (strncmp(bsdName, "/dev/", 5) == 0) {
122
+ bsdName += 5;
123
+ }
124
+
125
+ CFReleaser<DADiskRef> disk(
126
+ DADiskCreateFromBSDName(kCFAllocatorDefault, session, bsdName));
127
+ if (disk.isValid()) {
128
+ result.role = GetApfsVolumeRole(disk.get());
129
+
130
+ // MNT_DONTBROWSE + known APFS role that isn't Data → system
131
+ if (!result.role.empty() && result.role != "Data" &&
132
+ (f_flags & MNT_DONTBROWSE)) {
133
+ result.isSystemVolume = true;
134
+ }
135
+ } else {
136
+ DEBUG_LOG("[ClassifyMacVolume] Failed to create disk ref for %s",
137
+ bsdName);
138
+ }
139
+ }
140
+
141
+ DEBUG_LOG("[ClassifyMacVolume] %s -> role=%s, isSystem=%s",
142
+ bsdDeviceName ? bsdDeviceName : "(null)", result.role.c_str(),
143
+ result.isSystemVolume ? "true" : "false");
144
+
145
+ return result;
146
+ }
147
+
148
+ // Lightweight fallback using only statfs f_flags (no DA/IOKit needed).
149
+ // MNT_SNAPSHOT catches sealed APFS system snapshots ("/" and Recovery).
150
+ inline VolumeRoleResult ClassifyMacVolumeByFlags(uint32_t f_flags) {
151
+ VolumeRoleResult result;
152
+ result.isSystemVolume = (f_flags & MNT_SNAPSHOT) != 0;
153
+ return result;
154
+ }
155
+
156
+ } // namespace FSMeta
@@ -5,8 +5,10 @@
5
5
  #include "../common/fd_guard.h"
6
6
  #include "../common/path_security.h"
7
7
  #include "../common/volume_utils.h"
8
+ #include "./da_mutex.h"
8
9
  #include "./fs_meta.h"
9
10
  #include "./raii_utils.h"
11
+ #include "./system_volume.h"
10
12
 
11
13
  #include <CoreFoundation/CoreFoundation.h>
12
14
  #include <DiskArbitration/DiskArbitration.h>
@@ -22,8 +24,8 @@
22
24
 
23
25
  namespace FSMeta {
24
26
 
25
- // Global mutex for DiskArbitration operations
26
- static std::mutex g_diskArbitrationMutex;
27
+ // Global mutex for DiskArbitration operations (declared in da_mutex.h)
28
+ std::mutex g_diskArbitrationMutex;
27
29
 
28
30
  // Helper function to convert CFString to std::string
29
31
  static std::string CFStringToString(CFStringRef cfString) {
@@ -109,6 +111,7 @@ public:
109
111
 
110
112
  private:
111
113
  VolumeMetadataOptions options_;
114
+ uint32_t f_flags_ = 0;
112
115
 
113
116
  bool GetBasicVolumeInfo() {
114
117
  DEBUG_LOG("[GetVolumeMetadataWorker] Getting basic volume info for: %s",
@@ -186,6 +189,12 @@ private:
186
189
  metadata.fstype = fs.f_fstypename;
187
190
  metadata.mountFrom = fs.f_mntfromname;
188
191
  metadata.mountName = fs.f_mntonname;
192
+ metadata.isReadOnly = (fs.f_flags & MNT_RDONLY) != 0;
193
+ // Store flags for ClassifyMacVolume in GetDiskArbitrationInfoSafe()
194
+ f_flags_ = fs.f_flags;
195
+ // Preliminary flag-only check; upgraded by ClassifyMacVolume
196
+ auto flagResult = ClassifyMacVolumeByFlags(fs.f_flags);
197
+ metadata.isSystemVolume = flagResult.isSystemVolume;
189
198
  metadata.status = "ready";
190
199
 
191
200
  DEBUG_LOG("[GetVolumeMetadataWorker] Volume info - size: %.0f, available: "
@@ -243,6 +252,13 @@ private:
243
252
  session.scheduleOnQueue(da_queue);
244
253
 
245
254
  try {
255
+ // Classify volume using mount flags + APFS role
256
+ auto classification = ClassifyMacVolume(metadata.mountFrom.c_str(),
257
+ f_flags_, session.get());
258
+ metadata.isSystemVolume =
259
+ metadata.isSystemVolume || classification.isSystemVolume;
260
+ metadata.volumeRole = classification.role;
261
+
246
262
  CFReleaser<DADiskRef> disk(DADiskCreateFromBSDName(
247
263
  kCFAllocatorDefault, session.get(), metadata.mountFrom.c_str()));
248
264
 
@@ -2,8 +2,10 @@
2
2
  #include "../common/volume_mount_points.h"
3
3
  #include "../common/debug_log.h"
4
4
  #include "../common/error_utils.h"
5
+ #include "./da_mutex.h"
5
6
  #include "./fs_meta.h"
6
7
  #include "./raii_utils.h"
8
+ #include "./system_volume.h"
7
9
  #include <chrono>
8
10
  #include <future>
9
11
  #include <sys/mount.h>
@@ -48,27 +50,56 @@ public:
48
50
  }
49
51
  }
50
52
 
53
+ // Classify all mount points under the DA mutex, then release the
54
+ // lock before launching async accessibility checks. This serializes
55
+ // DiskArbitration + IOKit operations with getVolumeMetadata workers.
56
+ std::vector<MountPoint> allMountPoints;
57
+ {
58
+ std::lock_guard<std::mutex> lock(g_diskArbitrationMutex);
59
+
60
+ DASessionRAII session(DASessionCreate(kCFAllocatorDefault));
61
+ if (session.isValid()) {
62
+ static dispatch_queue_t da_queue = dispatch_queue_create(
63
+ "com.photostructure.fs-metadata.mountpoints",
64
+ DISPATCH_QUEUE_SERIAL);
65
+ session.scheduleOnQueue(da_queue);
66
+ }
67
+
68
+ for (int j = 0; j < count; j++) {
69
+ MountPoint mp;
70
+ mp.mountPoint = mntbuf.get()[j].f_mntonname;
71
+ mp.fstype = mntbuf.get()[j].f_fstypename;
72
+ mp.isReadOnly = (mntbuf.get()[j].f_flags & MNT_RDONLY) != 0;
73
+
74
+ auto classification =
75
+ session.isValid()
76
+ ? ClassifyMacVolume(mntbuf.get()[j].f_mntfromname,
77
+ mntbuf.get()[j].f_flags, session.get())
78
+ : ClassifyMacVolumeByFlags(mntbuf.get()[j].f_flags);
79
+ mp.isSystemVolume = classification.isSystemVolume;
80
+ mp.volumeRole = classification.role;
81
+ mp.error = "";
82
+ allMountPoints.push_back(std::move(mp));
83
+ }
84
+ // DA session RAII unschedules and releases here under the lock
85
+ }
86
+
51
87
  // Process mount points in batches to limit concurrent threads
52
88
  const size_t maxConcurrentChecks = 4; // Limit concurrent access checks
53
89
 
54
- for (size_t i = 0; i < static_cast<size_t>(count);
55
- i += maxConcurrentChecks) {
90
+ for (size_t i = 0; i < allMountPoints.size(); i += maxConcurrentChecks) {
56
91
  std::vector<std::future<std::pair<std::string, bool>>> futures;
57
- std::vector<MountPoint> batchMountPoints;
92
+ std::vector<MountPoint *> batchPtrs;
58
93
 
59
- // Create batch of mount points and launch their checks
94
+ // Launch async accessibility checks (no DA operations here)
60
95
  for (size_t j = i;
61
- j < static_cast<size_t>(count) && j < i + maxConcurrentChecks;
62
- j++) {
63
- MountPoint mp;
64
- mp.mountPoint = mntbuf.get()[j].f_mntonname;
65
- mp.fstype = mntbuf.get()[j].f_fstypename;
66
- mp.error = ""; // Initialize error field
96
+ j < allMountPoints.size() && j < i + maxConcurrentChecks; j++) {
97
+ auto &mp = allMountPoints[j];
67
98
 
68
99
  DEBUG_LOG("[GetVolumeMountPointsWorker] Checking mount point: %s",
69
100
  mp.mountPoint.c_str());
70
101
 
71
- batchMountPoints.push_back(mp);
102
+ batchPtrs.push_back(&mp);
72
103
 
73
104
  // Launch async check
74
105
  futures.push_back(
@@ -86,7 +117,7 @@ public:
86
117
 
87
118
  // Process results for this batch
88
119
  for (size_t k = 0; k < futures.size(); k++) {
89
- auto &mp = batchMountPoints[k];
120
+ auto &mp = *batchPtrs[k];
90
121
  try {
91
122
  auto status =
92
123
  futures[k].wait_for(std::chrono::milliseconds(timeoutMs_));
@@ -131,10 +162,11 @@ public:
131
162
  mp.error = std::string("Mount point check failed: ") + e.what();
132
163
  DEBUG_LOG("[GetVolumeMountPointsWorker] Exception: %s", e.what());
133
164
  }
134
-
135
- mountPoints_.push_back(std::move(mp));
136
165
  }
137
166
  }
167
+
168
+ // Move all classified + accessibility-checked mount points to results
169
+ mountPoints_ = std::move(allMountPoints);
138
170
  } catch (const std::exception &e) {
139
171
  SetError(std::string("Failed to process mount points: ") + e.what());
140
172
  DEBUG_LOG("[GetVolumeMountPointsWorker] Exception: %s", e.what());
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,
@@ -34,6 +35,7 @@ import type { VolumeHealthStatus } from "./volume_health_status";
34
35
  import { VolumeHealthStatuses } from "./volume_health_status";
35
36
  import {
36
37
  getAllVolumeMetadataImpl,
38
+ getVolumeMetadataForPathImpl,
37
39
  getVolumeMetadataImpl,
38
40
  } from "./volume_metadata";
39
41
  import type { GetVolumeMountPointOptions } from "./volume_mount_points";
@@ -108,6 +110,53 @@ export function getVolumeMetadata(
108
110
  );
109
111
  }
110
112
 
113
+ /**
114
+ * Get metadata for the volume that contains the given file or directory path.
115
+ *
116
+ * Unlike {@link getVolumeMetadata}, this accepts any path — not just mount
117
+ * points. Symlinks are resolved, and macOS APFS firmlinks (e.g. `/Users` →
118
+ * `/System/Volumes/Data`) are handled correctly, mirroring what `df` does.
119
+ *
120
+ * @param pathname Path to any file or directory
121
+ * @param opts Optional filesystem operation settings
122
+ */
123
+ export function getVolumeMetadataForPath(
124
+ pathname: string,
125
+ opts?: Partial<Pick<Options, "timeoutMs" | "linuxMountTablePaths">>,
126
+ ): Promise<VolumeMetadata> {
127
+ return getVolumeMetadataForPathImpl(
128
+ pathname,
129
+ optionsWithDefaults(opts),
130
+ nativeFn,
131
+ );
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
+
111
160
  /**
112
161
  * Retrieves metadata for all mounted volumes with optional filtering and
113
162
  * concurrency control.
package/src/linux/mtab.ts CHANGED
@@ -45,6 +45,10 @@ export interface MountEntry {
45
45
  fs_passno: number | undefined;
46
46
  }
47
47
 
48
+ function isReadOnlyMount(fs_mntops: string | undefined): boolean {
49
+ return fs_mntops?.split(",").includes("ro") ?? false;
50
+ }
51
+
48
52
  export function mountEntryToMountPoint(
49
53
  entry: MountEntry,
50
54
  ): MountPoint | undefined {
@@ -55,6 +59,7 @@ export function mountEntryToMountPoint(
55
59
  : {
56
60
  mountPoint,
57
61
  fstype,
62
+ isReadOnly: isReadOnlyMount(entry.fs_mntops),
58
63
  };
59
64
  }
60
65
 
@@ -77,6 +82,7 @@ export function mountEntryToPartialVolumeMetadata(
77
82
  fstype: entry.fs_vfstype,
78
83
  mountFrom: entry.fs_spec,
79
84
  isSystemVolume: isSystemVolume(entry.fs_file, entry.fs_vfstype, options),
85
+ isReadOnly: isReadOnlyMount(entry.fs_mntops),
80
86
  remote: false, // < default to false, but it may be overridden by extractRemoteInfo
81
87
  ...extractRemoteInfo(entry.fs_spec, networkFsTypes),
82
88
  };
@@ -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
+ }
package/src/options.ts CHANGED
@@ -83,23 +83,13 @@ export const SystemPathPatternsDefault = [
83
83
  "/mnt/wslg/versions.txt",
84
84
  "/usr/lib/wsl/drivers",
85
85
 
86
- // macOS system paths:
87
- "/private/var/vm", // macOS swap
88
- "/System/Volumes/Hardware",
89
- "/System/Volumes/iSCPreboot",
90
- "/System/Volumes/Preboot",
91
- "/System/Volumes/Recovery",
92
- "/System/Volumes/Reserved",
93
- "/System/Volumes/Update",
94
- "/System/Volumes/VM",
95
- "/System/Volumes/xarts",
96
-
97
- // macOS per-volume metadata (Spotlight, FSEvents, versioning, Trash):
98
- // https://eclecticlight.co/2021/01/28/spotlight-on-search-how-spotlight-works/
99
- "**/.DocumentRevisions-V100",
100
- "**/.fseventsd",
101
- "**/.Spotlight-V100",
102
- "**/.Trashes",
86
+ // macOS system volumes are detected natively via APFS volume roles
87
+ // (IOKit IOMedia "Role" property) with MNT_SNAPSHOT as a fallback.
88
+ // No path patterns needed. See src/darwin/system_volume.h.
89
+ //
90
+ // /private/var/vm is the macOS swap directory (not a mount point on most
91
+ // systems, but included for completeness if it appears as one).
92
+ "/private/var/vm",
103
93
  ] as const;
104
94
 
105
95
  /**
package/src/path.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/path.ts
2
2
 
3
- import { dirname, resolve } from "node:path";
3
+ import { dirname, resolve, sep } from "node:path";
4
4
  import { isWindows } from "./platform";
5
5
  import { isBlank } from "./string";
6
6
 
@@ -70,3 +70,18 @@ export function isRootDirectory(path: string): boolean {
70
70
  const n = normalizePath(path);
71
71
  return n == null ? false : isWindows ? dirname(n) === n : n === "/";
72
72
  }
73
+
74
+ /**
75
+ * @return true if `ancestor` is the same path as `descendant`, or is a parent
76
+ * directory of `descendant`. Both paths should be normalized/resolved.
77
+ */
78
+ export function isAncestorOrSelf(
79
+ ancestor: string,
80
+ descendant: string,
81
+ ): boolean {
82
+ if (ancestor === descendant) return true;
83
+ // Root dirs already end with sep (e.g. "/" or "C:\"); others need sep
84
+ // appended to avoid "/home" matching "/homeother".
85
+ const prefix = isRootDirectory(ancestor) ? ancestor : ancestor + sep;
86
+ return descendant.startsWith(prefix);
87
+ }
@@ -60,13 +60,9 @@ export function assignSystemVolume(
60
60
  ) {
61
61
  const result = isSystemVolume(mp.mountPoint, mp.fstype, config);
62
62
 
63
- if (isWindows) {
64
- // native code actually knows the system drive and has more in-depth
65
- // metadata information that we trust more than these heuristics
66
- mp.isSystemVolume ??= result;
67
- } else {
68
- // macOS and Linux don't have a concept of an explicit "system drive" like
69
- // Windows--always trust our heuristics
70
- mp.isSystemVolume = result;
71
- }
63
+ // Native code may have already marked this as a system volume (e.g.,
64
+ // Windows system drive detection, macOS MNT_SNAPSHOT for the sealed
65
+ // APFS system snapshot at /). Never downgrade a native true — only
66
+ // upgrade via path/fstype heuristics.
67
+ mp.isSystemVolume = mp.isSystemVolume || result;
72
68
  }
@@ -46,6 +46,10 @@ export function assertMetadata(metadata: VolumeMetadata | undefined) {
46
46
  expect(metadata.uuid).toMatch(/^[0-9a-z-]{8,}$/i);
47
47
  }
48
48
 
49
+ if (metadata.isReadOnly !== undefined) {
50
+ expect(typeof metadata.isReadOnly).toBe("boolean");
51
+ }
52
+
49
53
  if (metadata.remote !== undefined) {
50
54
  expect(typeof metadata.remote).toBe("boolean");
51
55
 
@@ -39,12 +39,39 @@ export interface MountPoint {
39
39
  * Indicates if this volume is primarily for system use (e.g., swap, snap
40
40
  * loopbacks, EFI boot, or only system directories).
41
41
  *
42
- * Note: This is a best-effort classification and is not 100% accurate.
42
+ * On macOS, the sealed APFS system snapshot at `/` is detected natively via
43
+ * `MNT_SNAPSHOT`; other infrastructure volumes under `/System/Volumes/*` are
44
+ * detected via APFS volume roles (IOKit). Note that `/System/Volumes/Data`
45
+ * is **not** a system volume — it holds all user data, accessed via firmlinks.
43
46
  *
44
47
  * @see {@link Options.systemPathPatterns} and {@link Options.systemFsTypes}
45
48
  */
46
49
  isSystemVolume?: boolean;
47
50
 
51
+ /**
52
+ * The APFS volume role, if available. Only present on macOS for APFS volumes.
53
+ *
54
+ * Common roles: `"System"`, `"Data"`, `"VM"`, `"Preboot"`, `"Recovery"`,
55
+ * `"Update"`, `"Hardware"`, `"xART"`, `"Prelogin"`, `"Backup"`.
56
+ *
57
+ * Used for system volume detection: volumes with a non-`"Data"` role and
58
+ * `MNT_DONTBROWSE` are classified as system volumes.
59
+ *
60
+ * @see https://eclecticlight.co/2024/11/21/how-do-apfs-volume-roles-work/
61
+ */
62
+ volumeRole?: string;
63
+
64
+ /**
65
+ * Whether the volume is mounted read-only.
66
+ *
67
+ * Examples of read-only volumes include the macOS APFS system snapshot at
68
+ * `/`, mounted ISO images, and write-protected media.
69
+ *
70
+ * Note that the macOS root volume (`/`) UUID changes on every OS update, so
71
+ * consumers should avoid using it for persistent identification.
72
+ */
73
+ isReadOnly?: boolean;
74
+
48
75
  /**
49
76
  * If there are non-critical errors while extracting metadata, those errors
50
77
  * may be added to this field.
@@ -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 = {