@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.
- package/CHANGELOG.md +44 -0
- package/CLAUDE.md +13 -0
- package/binding.gyp +1 -0
- package/claude.sh +29 -5
- package/dist/index.cjs +237 -129
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -3
- package/dist/index.d.mts +55 -3
- package/dist/index.d.ts +55 -3
- package/dist/index.mjs +236 -130
- package/dist/index.mjs.map +1 -1
- package/doc/SECURITY_AUDIT_2025.md +1 -1
- package/doc/SECURITY_AUDIT_2026.md +361 -0
- package/doc/TPP-GUIDE.md +144 -0
- package/doc/system-volume-detection.md +268 -0
- package/package.json +12 -12
- package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
- package/prebuilds/darwin-x64/@photostructure+fs-metadata.glibc.node +0 -0
- package/prebuilds/linux-arm64/@photostructure+fs-metadata.glibc.node +0 -0
- package/prebuilds/linux-arm64/@photostructure+fs-metadata.musl.node +0 -0
- package/prebuilds/linux-x64/@photostructure+fs-metadata.glibc.node +0 -0
- package/prebuilds/linux-x64/@photostructure+fs-metadata.musl.node +0 -0
- package/prebuilds/win32-arm64/@photostructure+fs-metadata.glibc.node +0 -0
- package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
- package/src/binding.cpp +11 -0
- package/src/common/volume_metadata.h +10 -3
- package/src/common/volume_mount_points.h +7 -1
- package/src/darwin/da_mutex.h +23 -0
- package/src/darwin/get_mount_point.cpp +96 -0
- package/src/darwin/get_mount_point.h +13 -0
- package/src/darwin/raii_utils.h +39 -0
- package/src/darwin/system_volume.h +156 -0
- package/src/darwin/volume_metadata.cpp +18 -2
- package/src/darwin/volume_mount_points.cpp +46 -14
- package/src/index.ts +49 -0
- package/src/linux/mtab.ts +6 -0
- package/src/mount_point_for_path.ts +54 -0
- package/src/options.ts +7 -17
- package/src/path.ts +16 -1
- package/src/system_volume.ts +5 -9
- package/src/test-utils/assert.ts +4 -0
- package/src/types/mount_point.ts +28 -1
- package/src/types/native_bindings.ts +7 -0
- package/src/volume_metadata.ts +117 -2
- package/src/windows/system_volume.h +21 -16
- package/src/windows/volume_metadata.cpp +13 -7
- 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
|
-
|
|
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 <
|
|
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
|
|
92
|
+
std::vector<MountPoint *> batchPtrs;
|
|
58
93
|
|
|
59
|
-
//
|
|
94
|
+
// Launch async accessibility checks (no DA operations here)
|
|
60
95
|
for (size_t j = i;
|
|
61
|
-
j <
|
|
62
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"/
|
|
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
|
+
}
|
package/src/system_volume.ts
CHANGED
|
@@ -60,13 +60,9 @@ export function assignSystemVolume(
|
|
|
60
60
|
) {
|
|
61
61
|
const result = isSystemVolume(mp.mountPoint, mp.fstype, config);
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
}
|
package/src/test-utils/assert.ts
CHANGED
|
@@ -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
|
|
package/src/types/mount_point.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 = {
|