@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,268 @@
1
+ # System Volume Detection
2
+
3
+ ## Overview
4
+
5
+ `fs-metadata` uses a two-layer detection strategy across all platforms:
6
+
7
+ 1. **Layer 1 (C++, native)** — platform-specific APIs detect system volumes at
8
+ enumeration time. Each `MountPoint` gets `isSystemVolume` set by native code.
9
+ 2. **Layer 2 (TypeScript, heuristic)** — path patterns and filesystem type lists
10
+ catch pseudo-filesystems, container runtimes, and platform-specific system
11
+ paths. Heuristics **never downgrade** a native `true` — they only upgrade
12
+ `false` to `true`.
13
+
14
+ The `isSystemVolume` field is exposed on both `MountPoint` and `VolumeMetadata`.
15
+
16
+ By default, system volumes are **excluded** from `getAllVolumeMetadata()` results
17
+ on Linux and macOS (`includeSystemVolumes: false`), but **included** on Windows
18
+ because `C:\` is both a system drive and the primary user storage location.
19
+
20
+ ---
21
+
22
+ ## macOS
23
+
24
+ ### Background
25
+
26
+ On macOS 10.15 (Catalina) and later, the boot volume is split into multiple
27
+ APFS volumes within a single container:
28
+
29
+ | Volume | Mount Point | APFS Role | Description |
30
+ | ------------------- | ---------------------------- | --------- | --------------------------------------------------------- |
31
+ | Macintosh HD | `/` (snapshot) | System | Sealed, read-only OS snapshot |
32
+ | Macintosh HD - Data | `/System/Volumes/Data` | Data | User data (firmlinked to `/Users`, `/Applications`, etc.) |
33
+ | VM | `/System/Volumes/VM` | VM | Virtual memory swap |
34
+ | Preboot | `/System/Volumes/Preboot` | Preboot | Boot-time resources |
35
+ | Recovery | `/System/Volumes/Recovery` | Recovery | Recovery OS |
36
+ | Update | `/System/Volumes/Update` | Update | OS update staging |
37
+ | Hardware | `/System/Volumes/Hardware` | Hardware | Hardware-specific data |
38
+ | xarts | `/System/Volumes/xarts` | xART | Secure token storage |
39
+ | iSCPreboot | `/System/Volumes/iSCPreboot` | Prelogin | Pre-login resources |
40
+
41
+ ### The Root Volume UUID Problem
42
+
43
+ The volume mounted at `/` is an **APFS sealed system snapshot** — a
44
+ cryptographically signed, read-only image of the OS. Its UUID changes on
45
+ every macOS update. This makes it unsuitable for persistent identification
46
+ (e.g., licensing fingerprints, asset URIs).
47
+
48
+ The **Data volume** at `/System/Volumes/Data` has a stable UUID and contains
49
+ all user data. Users access it transparently through APFS firmlinks (`/Users`,
50
+ `/Applications`, `/Library`, etc.).
51
+
52
+ ### Native detection (Layer 1)
53
+
54
+ Detection uses a combined formula:
55
+
56
+ ```
57
+ isSystemVolume = MNT_SNAPSHOT || (MNT_DONTBROWSE && hasApfsRole && role != "Data")
58
+ ```
59
+
60
+ This is implemented in `ClassifyMacVolume()` in `src/darwin/system_volume.h`.
61
+
62
+ Each APFS volume has a **role** stored in its superblock (an unsigned 16-bit
63
+ integer). We read this via:
64
+
65
+ 1. `DADiskCreateFromBSDName()` to get a DiskArbitration disk ref
66
+ 2. `DADiskCopyIOMedia()` to get the IOKit IOMedia service
67
+ 3. `IORegistryEntryCreateCFProperty(media, "Role")` to read the role array
68
+
69
+ For APFS snapshots (e.g., `/` is `disk3s7s1`, a snapshot of `disk3s7`), the
70
+ snapshot's IOMedia entry has no `Role` property — we walk one parent up in
71
+ the IOService plane to find the parent volume's role.
72
+
73
+ The role string is exposed as `volumeRole` in both `MountPoint` and
74
+ `VolumeMetadata` (e.g., `"System"`, `"Data"`, `"VM"`).
75
+
76
+ **System volume classification** combines two signals:
77
+
78
+ - **`MNT_SNAPSHOT`** alone marks a volume as system. This catches sealed APFS
79
+ snapshots (`/` and Recovery).
80
+ - **`MNT_DONTBROWSE`** combined with a non-`"Data"` APFS role marks a volume
81
+ as system. This catches all infrastructure volumes (VM, Preboot, Update,
82
+ Hardware, xART, etc.) while correctly excluding the Data volume.
83
+
84
+ **Why this works**: `MNT_DONTBROWSE` means "hidden from Finder" and is set on
85
+ all `/System/Volumes/*` infrastructure mounts. The only false positive would
86
+ be `/System/Volumes/Data`, which has `MNT_DONTBROWSE` but a `"Data"` role —
87
+ so the role check excludes it.
88
+
89
+ **Why not a role whitelist?** The previous approach maintained a whitelist of
90
+ 13 system role strings. The flags+exclusion approach is simpler and
91
+ future-proof: if Apple adds new infrastructure roles with `MNT_DONTBROWSE`,
92
+ they're auto-detected without code changes.
93
+
94
+ **Non-APFS `MNT_DONTBROWSE` mounts** (e.g., `devfs` at `/dev`, or a
95
+ hypothetical NFS mount with `nobrowse`) have no APFS role, so the
96
+ `MNT_DONTBROWSE` branch doesn't fire. These fall through to TypeScript
97
+ heuristics (Layer 2).
98
+
99
+ ### Native fallback: `MNT_SNAPSHOT` only
100
+
101
+ If a DiskArbitration session can't be created, we fall back to checking only
102
+ `MNT_SNAPSHOT` in the `statfs` `f_flags`. This catches the sealed system
103
+ snapshots (`/` and `/System/Volumes/Recovery`) but misses the other
104
+ infrastructure volumes (VM, Preboot, etc.).
105
+
106
+ ### Flag and role summary (observed on macOS 26 Tahoe)
107
+
108
+ | Mount Point | APFS Role | MNT_SNAPSHOT | MNT_DONTBROWSE | isSystemVolume |
109
+ | ---------------------------- | ------------------- | ------------ | -------------- | ---------------- |
110
+ | `/` | System (via parent) | **yes** | no | **yes** |
111
+ | `/System/Volumes/Data` | Data | no | **yes** | no |
112
+ | `/System/Volumes/VM` | VM | no | **yes** | **yes** |
113
+ | `/System/Volumes/Preboot` | Preboot | no | **yes** | **yes** |
114
+ | `/System/Volumes/Recovery` | Recovery | **yes** | no | **yes** |
115
+ | `/System/Volumes/Update` | Update | no | **yes** | **yes** |
116
+ | `/System/Volumes/xarts` | xART | no | **yes** | **yes** |
117
+ | `/System/Volumes/iSCPreboot` | Prelogin | no | **yes** | **yes** |
118
+ | `/System/Volumes/Hardware` | Hardware | no | **yes** | **yes** |
119
+ | `/dev` | _(no IOMedia)_ | no | **yes** | **yes** (fstype) |
120
+ | `/Volumes/sandisk-extreme` | _(no role)_ | no | no | no |
121
+
122
+ ---
123
+
124
+ ## Linux
125
+
126
+ ### Background
127
+
128
+ Linux has no unified "system volume" concept. Instead, the kernel exposes
129
+ dozens of pseudo-filesystems for kernel interfaces, and distributions mount
130
+ various infrastructure paths at boot. Container runtimes add additional
131
+ overlay and bind mounts.
132
+
133
+ There is **no native C++ system volume detection** on Linux. All
134
+ classification happens in TypeScript (Layer 2) using filesystem type matching
135
+ and path pattern globs.
136
+
137
+ ### Why no native detection?
138
+
139
+ Unlike macOS (which has APFS roles and `MNT_SNAPSHOT`/`MNT_DONTBROWSE` flags)
140
+ or Windows (which has `CSIDL_WINDOWS` and volume capability flags), Linux has
141
+ no kernel API that directly identifies "this is a system volume." The closest
142
+ signal is the filesystem type from `/proc/self/mounts`, which is already
143
+ available in TypeScript without a native call.
144
+
145
+ ### Filesystem type detection
146
+
147
+ The `SystemFsTypesDefault` list in `src/options.ts` identifies pseudo-filesystems
148
+ that don't represent real storage:
149
+
150
+ | Category | Filesystem Types |
151
+ | ------------------------- | ---------------------------------------------------------------------- |
152
+ | Process/kernel interfaces | `proc`, `sysfs`, `debugfs`, `tracefs`, `configfs`, `securityfs`, `bpf` |
153
+ | Device pseudo-filesystems | `devpts`, `devtmpfs` |
154
+ | Memory/temporary | `tmpfs`, `ramfs`, `rootfs`, `hugetlbfs` |
155
+ | Cgroups | `cgroup`, `cgroup2` |
156
+ | Boot/firmware | `efivarfs`, `pstore`, `binfmt_misc` |
157
+ | Automount | `autofs`, `fusectl` |
158
+ | Container/sandbox | `fuse.lxcfs`, `fuse.portal`, `fuse.snapfuse`, `squashfs` |
159
+ | Kernel internal | `nsfs`, `mqueue`, `rpc_pipefs`, `none` |
160
+
161
+ ### Path pattern detection
162
+
163
+ The `SystemPathPatternsDefault` list in `src/options.ts` catches system mount
164
+ points by path glob:
165
+
166
+ | Category | Patterns |
167
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
168
+ | Core system | `/boot`, `/boot/efi`, `/dev`, `/dev/**`, `/proc/**`, `/sys/**` |
169
+ | Runtime | `/run`, `/run/lock`, `/run/credentials/**` |
170
+ | Temporary | `/tmp`, `/var/tmp` |
171
+ | Container runtimes | `/run/docker/**`, `/var/lib/docker/**`, `/run/containerd/**`, `/var/lib/containerd/**`, `/run/containers/**`, `/var/lib/containers/**`, `/var/lib/kubelet/**` |
172
+ | Linux containers | `/var/lib/lxc/**`, `/var/lib/lxd/**` |
173
+ | Snap/Flatpak | `/snap/**`, `/run/snapd/**`, `/run/flatpak/**`, `/run/user/*/doc`, `/run/user/*/gvfs` |
174
+ | WSL infrastructure | `/mnt/wslg/distro`, `/mnt/wslg/doc`, `/usr/lib/wsl/drivers` |
175
+ | Snapshot dirs | `**/#snapshot` |
176
+
177
+ ### Typical detection results
178
+
179
+ | Mount Point | fstype | Detected By | isSystemVolume |
180
+ | ------------------------------ | ---------- | ------------- | -------------- |
181
+ | `/` | `ext4` | _(none)_ | no |
182
+ | `/boot` | `ext4` | path | **yes** |
183
+ | `/boot/efi` | `vfat` | path | **yes** |
184
+ | `/dev` | `devtmpfs` | fstype + path | **yes** |
185
+ | `/proc` | `proc` | fstype + path | **yes** |
186
+ | `/sys` | `sysfs` | fstype + path | **yes** |
187
+ | `/run` | `tmpfs` | fstype + path | **yes** |
188
+ | `/tmp` | `tmpfs` | fstype + path | **yes** |
189
+ | `/home` | `ext4` | _(none)_ | no |
190
+ | `/var/lib/docker/overlay2/...` | `overlay` | path | **yes** |
191
+ | `/mnt/data` | `ext4` | _(none)_ | no |
192
+
193
+ ### Customization
194
+
195
+ Both lists are configurable via `Options.systemFsTypes` and
196
+ `Options.systemPathPatterns`, allowing callers to add or replace detection
197
+ rules for specialized environments.
198
+
199
+ ---
200
+
201
+ ## Windows
202
+
203
+ ### Background
204
+
205
+ Windows has a simpler model: drives are identified by letter (`C:\`, `D:\`,
206
+ etc.), and one drive is the "system drive" containing the Windows
207
+ installation. Unlike macOS's split-volume architecture, the system drive also
208
+ holds user data (`C:\Users`).
209
+
210
+ ### Native detection (Layer 1)
211
+
212
+ `IsSystemVolume()` in `src/windows/system_volume.h` uses two checks:
213
+
214
+ 1. **`SHGetFolderPathW(CSIDL_WINDOWS)`** — retrieves the Windows system
215
+ folder path (e.g., `C:\Windows`), extracts the drive letter, and compares
216
+ it against the volume being tested. This is the primary detection method.
217
+
218
+ 2. **Volume capability flags** — calls `GetVolumeInformationW()` and checks
219
+ for `FILE_SUPPORTS_SYSTEM_PATHS` (0x00100000) and
220
+ `FILE_SUPPORTS_SYSTEM_FILES` (0x00200000). These are modern (Windows 10+)
221
+ volume flags that indicate system volume capabilities.
222
+
223
+ ### TypeScript detection (Layer 2)
224
+
225
+ The TypeScript layer (`src/system_volume.ts`) provides a redundant check using
226
+ `process.env.SystemDrive` (typically `"C:"`), normalized to `"C:\"` for
227
+ comparison.
228
+
229
+ ### Why `includeSystemVolumes` defaults to `true` on Windows
230
+
231
+ On macOS and Linux, system volumes (pseudo-filesystems, sealed snapshots) are
232
+ genuinely uninteresting to most callers. On Windows, `C:\` is both the system
233
+ drive _and_ the primary user storage location — excluding it would hide the
234
+ most important volume. So `IncludeSystemVolumesDefault = true` on Windows
235
+ (see `src/options.ts`).
236
+
237
+ ### Typical detection results
238
+
239
+ | Mount Point | Detection Method | isSystemVolume |
240
+ | ---------------- | --------------------------- | -------------- |
241
+ | `C:\` | CSIDL_WINDOWS + SystemDrive | **yes** |
242
+ | `D:\` | _(none)_ | no |
243
+ | `E:\` | _(none)_ | no |
244
+ | `\\server\share` | _(none)_ | no |
245
+
246
+ ---
247
+
248
+ ## Layer 2: TypeScript heuristics (all platforms)
249
+
250
+ The `assignSystemVolume()` function in `src/system_volume.ts` runs after native
251
+ enumeration and applies the shared heuristic layer:
252
+
253
+ 1. On Windows, checks `process.env.SystemDrive`
254
+ 2. Checks `fstype` against `SystemFsTypesDefault` (configurable)
255
+ 3. Checks `mountPoint` against `SystemPathPatternsDefault` glob patterns
256
+ (configurable)
257
+
258
+ Native `isSystemVolume: true` is **never downgraded** — only upgraded from
259
+ `false` to `true`.
260
+
261
+ ## Related Files
262
+
263
+ - `src/darwin/system_volume.h` — `ClassifyMacVolume()`: macOS flags + APFS role detection
264
+ - `src/darwin/raii_utils.h` — RAII wrappers for DA/CF/IOKit resources
265
+ - `src/windows/system_volume.h` — `IsSystemVolume()`: Windows CSIDL + volume flags
266
+ - `src/options.ts` — `SystemFsTypesDefault`, `SystemPathPatternsDefault`, `IncludeSystemVolumesDefault`
267
+ - `src/system_volume.ts` — `isSystemVolume()`, `assignSystemVolume()`: TypeScript heuristic layer
268
+ - `src/types/mount_point.ts` — `MountPoint.volumeRole` / `isSystemVolume` / `isReadOnly` fields
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@photostructure/fs-metadata",
3
- "version": "1.0.1",
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",
@@ -58,7 +58,7 @@
58
58
  "fmt:pkg": "npm pkg fix",
59
59
  "update": "run-p update:*",
60
60
  "update:deps": "ncu -u",
61
- "install:pinact": "go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest",
61
+ "install:pinact": "go install github.com/suzuki-shunsuke/pinact/v3/cmd/pinact@latest",
62
62
  "update:actions": "pinact run -u || echo \"problem with pinact\"",
63
63
  "// precommit": "should be manually run by developers before they run `git commit`",
64
64
  "precommit": "npx --yes tsx scripts/precommit.ts",
@@ -91,34 +91,34 @@
91
91
  "cross-platform"
92
92
  ],
93
93
  "dependencies": {
94
- "node-addon-api": "^8.5.0",
94
+ "node-addon-api": "^8.7.0",
95
95
  "node-gyp-build": "^4.8.4"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@types/jest": "^30.0.0",
99
- "@types/node": "^25.3.0",
99
+ "@types/node": "^25.5.0",
100
100
  "@types/semver": "^7.7.1",
101
101
  "cross-env": "^10.1.0",
102
102
  "del-cli": "^7.0.0",
103
103
  "eslint": "9.39.1",
104
- "eslint-plugin-regexp": "^3.0.0",
104
+ "eslint-plugin-regexp": "^3.1.0",
105
105
  "eslint-plugin-security": "^4.0.0",
106
- "globals": "^17.3.0",
107
- "jest": "^30.2.0",
108
- "jest-environment-node": "^30.2.0",
106
+ "globals": "^17.4.0",
107
+ "jest": "^30.3.0",
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.4.1",
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",
115
115
  "prettier-plugin-organize-imports": "4.3.0",
116
- "terser": "^5.46.0",
116
+ "terser": "^5.46.1",
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.56.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
@@ -40,9 +40,9 @@ struct VolumeMetadataOptions {
40
40
  struct VolumeMetadata {
41
41
  std::string label;
42
42
  std::string fstype;
43
- double size;
44
- double used;
45
- double available;
43
+ double size = 0.0;
44
+ double used = 0.0;
45
+ double available = 0.0;
46
46
  std::string uuid;
47
47
  std::string mountFrom;
48
48
  std::string mountName;
@@ -52,6 +52,8 @@ struct VolumeMetadata {
52
52
  std::string remoteHost;
53
53
  std::string remoteShare;
54
54
  bool isSystemVolume = false;
55
+ bool isReadOnly = false;
56
+ std::string volumeRole;
55
57
  std::string error;
56
58
 
57
59
  Napi::Object ToObject(Napi::Env env) const {
@@ -120,6 +122,11 @@ struct VolumeMetadata {
120
122
  }
121
123
 
122
124
  result.Set("isSystemVolume", Napi::Boolean::New(env, isSystemVolume));
125
+ result.Set("isReadOnly", Napi::Boolean::New(env, isReadOnly));
126
+
127
+ if (!volumeRole.empty()) {
128
+ result.Set("volumeRole", Napi::String::New(env, volumeRole));
129
+ }
123
130
 
124
131
  return result;
125
132
  }
@@ -22,7 +22,9 @@ struct MountPoint {
22
22
  std::string mountPoint;
23
23
  std::string fstype;
24
24
  std::string status;
25
- bool isSystemVolume = false; // Default to false
25
+ bool isSystemVolume = false;
26
+ bool isReadOnly = false;
27
+ std::string volumeRole;
26
28
  std::string error;
27
29
 
28
30
  Napi::Object ToObject(Napi::Env env) const {
@@ -38,6 +40,10 @@ struct MountPoint {
38
40
  obj.Set("status", status);
39
41
  }
40
42
  obj.Set("isSystemVolume", isSystemVolume);
43
+ obj.Set("isReadOnly", isReadOnly);
44
+ if (!volumeRole.empty()) {
45
+ obj.Set("volumeRole", volumeRole);
46
+ }
41
47
  obj.Set("error", error);
42
48
  return obj;
43
49
  }
@@ -0,0 +1,23 @@
1
+ // src/darwin/da_mutex.h
2
+ //
3
+ // Shared mutex for DiskArbitration operations.
4
+ //
5
+ // Apple's DiskArbitration framework does not document thread safety for
6
+ // concurrent DASession usage across threads. To prevent data races, all DA
7
+ // operations (session creation, disk description, IOKit queries via
8
+ // ClassifyMacVolume) must be serialized through this mutex.
9
+ //
10
+ // See: Finding #5 in SECURITY_AUDIT_2025.md (original)
11
+ // Finding #2 in SECURITY_AUDIT_2026.md (mount points regression)
12
+
13
+ #pragma once
14
+
15
+ #include <mutex>
16
+
17
+ namespace FSMeta {
18
+
19
+ // Defined in volume_metadata.cpp. Serializes all DiskArbitration + IOKit
20
+ // operations across both getVolumeMetadata and getVolumeMountPoints workers.
21
+ extern std::mutex g_diskArbitrationMutex;
22
+
23
+ } // namespace FSMeta
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <CoreFoundation/CoreFoundation.h>
4
4
  #include <DiskArbitration/DiskArbitration.h>
5
+ #include <IOKit/IOKitLib.h>
5
6
  #include <sys/mount.h>
6
7
 
7
8
  // RAII (Resource Acquisition Is Initialization) utilities for macOS APIs.
@@ -127,6 +128,44 @@ public:
127
128
  }
128
129
  };
129
130
 
131
+ // RAII wrapper for IOKit io_object_t handles (io_service_t,
132
+ // io_registry_entry_t). IOKit objects must be released with IOObjectRelease(),
133
+ // not CFRelease().
134
+ class IOObjectGuard {
135
+ private:
136
+ io_object_t obj_;
137
+
138
+ public:
139
+ explicit IOObjectGuard(io_object_t obj = 0) noexcept : obj_(obj) {}
140
+ ~IOObjectGuard() noexcept {
141
+ if (obj_) {
142
+ IOObjectRelease(obj_);
143
+ }
144
+ }
145
+
146
+ io_object_t get() const noexcept { return obj_; }
147
+ bool isValid() const noexcept { return obj_ != 0; }
148
+
149
+ // Prevent copying
150
+ IOObjectGuard(const IOObjectGuard &) = delete;
151
+ IOObjectGuard &operator=(const IOObjectGuard &) = delete;
152
+
153
+ // Allow moving
154
+ IOObjectGuard(IOObjectGuard &&other) noexcept : obj_(other.obj_) {
155
+ other.obj_ = 0;
156
+ }
157
+ IOObjectGuard &operator=(IOObjectGuard &&other) noexcept {
158
+ if (this != &other) {
159
+ if (obj_) {
160
+ IOObjectRelease(obj_);
161
+ }
162
+ obj_ = other.obj_;
163
+ other.obj_ = 0;
164
+ }
165
+ return *this;
166
+ }
167
+ };
168
+
130
169
  // Specialized RAII wrapper for DASession that handles dispatch queue lifecycle.
131
170
  // DASessionSetDispatchQueue must be called with NULL before the session is
132
171
  // released. This wrapper ensures proper cleanup order: unschedule then release.