@photostructure/fs-metadata 1.0.1 → 1.1.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 (41) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CLAUDE.md +13 -0
  3. package/claude.sh +29 -5
  4. package/dist/index.cjs +86 -26
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +39 -3
  7. package/dist/index.d.mts +39 -3
  8. package/dist/index.d.ts +39 -3
  9. package/dist/index.mjs +86 -27
  10. package/dist/index.mjs.map +1 -1
  11. package/doc/SECURITY_AUDIT_2025.md +1 -1
  12. package/doc/SECURITY_AUDIT_2026.md +361 -0
  13. package/doc/TPP-GUIDE.md +144 -0
  14. package/doc/system-volume-detection.md +268 -0
  15. package/package.json +11 -11
  16. package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  17. package/prebuilds/darwin-x64/@photostructure+fs-metadata.glibc.node +0 -0
  18. package/prebuilds/linux-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  19. package/prebuilds/linux-arm64/@photostructure+fs-metadata.musl.node +0 -0
  20. package/prebuilds/linux-x64/@photostructure+fs-metadata.glibc.node +0 -0
  21. package/prebuilds/linux-x64/@photostructure+fs-metadata.musl.node +0 -0
  22. package/prebuilds/win32-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  23. package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
  24. package/src/common/volume_metadata.h +10 -3
  25. package/src/common/volume_mount_points.h +7 -1
  26. package/src/darwin/da_mutex.h +23 -0
  27. package/src/darwin/raii_utils.h +39 -0
  28. package/src/darwin/system_volume.h +156 -0
  29. package/src/darwin/volume_metadata.cpp +18 -2
  30. package/src/darwin/volume_mount_points.cpp +46 -14
  31. package/src/index.ts +22 -0
  32. package/src/linux/mtab.ts +6 -0
  33. package/src/options.ts +7 -17
  34. package/src/path.ts +16 -1
  35. package/src/system_volume.ts +5 -9
  36. package/src/test-utils/assert.ts +4 -0
  37. package/src/types/mount_point.ts +28 -1
  38. package/src/volume_metadata.ts +97 -2
  39. package/src/windows/system_volume.h +21 -16
  40. package/src/windows/volume_metadata.cpp +13 -7
  41. 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.1.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.6.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.5",
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
120
  "typedoc": "^0.28.17",
121
121
  "typescript": "^5.9.3",
122
- "typescript-eslint": "^8.56.1"
122
+ "typescript-eslint": "^8.57.1"
123
123
  }
124
124
  }
@@ -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
@@ -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.
@@ -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