@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
package/src/volume_metadata.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// src/volume_metadata.ts
|
|
2
2
|
|
|
3
|
+
import type { Stats } from "node:fs";
|
|
4
|
+
import { realpath } from "node:fs/promises";
|
|
5
|
+
import { dirname } from "node:path";
|
|
3
6
|
import { mapConcurrent, withTimeout } from "./async";
|
|
4
7
|
import { debug } from "./debuglog";
|
|
5
8
|
import { WrappedError } from "./error";
|
|
9
|
+
import { statAsync } from "./fs";
|
|
6
10
|
import { getLabelFromDevDisk, getUuidFromDevDisk } from "./linux/dev_disk";
|
|
7
11
|
import { getLinuxMtabMetadata } from "./linux/mount_points";
|
|
8
12
|
import {
|
|
@@ -11,8 +15,8 @@ import {
|
|
|
11
15
|
} from "./linux/mtab";
|
|
12
16
|
import { compactValues } from "./object";
|
|
13
17
|
import { IncludeSystemVolumesDefault, optionsWithDefaults } from "./options";
|
|
14
|
-
import { normalizePath } from "./path";
|
|
15
|
-
import { isLinux, isWindows } from "./platform";
|
|
18
|
+
import { isAncestorOrSelf, normalizePath } from "./path";
|
|
19
|
+
import { isLinux, isMacOS, isWindows } from "./platform";
|
|
16
20
|
import { extractRemoteInfo, isRemoteFsType } from "./remote_info";
|
|
17
21
|
import { isBlank, isNotBlank } from "./string";
|
|
18
22
|
import { assignSystemVolume } from "./system_volume";
|
|
@@ -153,6 +157,117 @@ async function _getVolumeMetadata(
|
|
|
153
157
|
return compactValues(result) as VolumeMetadata;
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Get volume metadata for an arbitrary file or directory path.
|
|
162
|
+
*
|
|
163
|
+
* Unlike {@link getVolumeMetadataImpl}, this accepts any path — not just mount
|
|
164
|
+
* points. It resolves symlinks and correctly handles macOS APFS firmlinks
|
|
165
|
+
* (e.g. `/Users` → `/System/Volumes/Data`), mirroring what `df` does.
|
|
166
|
+
*
|
|
167
|
+
* On macOS, the native `fstatfs()` call returns `f_mntonname` (the canonical
|
|
168
|
+
* mount point), exposed here as `mountName`. This is used to resolve firmlinks
|
|
169
|
+
* without `stat().dev`, which does NOT follow firmlinks.
|
|
170
|
+
*
|
|
171
|
+
* On Linux and Windows, `stat().dev` device IDs are reliable (no firmlinks),
|
|
172
|
+
* so mount point discovery uses device ID + path prefix matching.
|
|
173
|
+
*/
|
|
174
|
+
export async function getVolumeMetadataForPathImpl(
|
|
175
|
+
pathname: string,
|
|
176
|
+
opts: Options,
|
|
177
|
+
nativeFn: NativeBindingsFn,
|
|
178
|
+
): Promise<VolumeMetadata> {
|
|
179
|
+
if (isBlank(pathname)) {
|
|
180
|
+
throw new TypeError("Invalid pathname: got " + JSON.stringify(pathname));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// realpath() resolves POSIX symlinks. APFS firmlinks are NOT resolved by
|
|
184
|
+
// realpath(), but fstatfs() follows them — handled below.
|
|
185
|
+
const resolved = await realpath(pathname);
|
|
186
|
+
|
|
187
|
+
// getVolumeMetadataImpl requires a directory path, not a file.
|
|
188
|
+
const resolvedStat = await statAsync(resolved);
|
|
189
|
+
const dir = resolvedStat.isDirectory() ? resolved : dirname(resolved);
|
|
190
|
+
|
|
191
|
+
if (isMacOS) {
|
|
192
|
+
// On macOS, native fstatfs() sets mountName = f_mntonname, which is the
|
|
193
|
+
// canonical mount point even through APFS firmlinks. Probe the dir to get
|
|
194
|
+
// it, then re-query with the canonical mount point so the result has
|
|
195
|
+
// mountPoint set correctly.
|
|
196
|
+
const probe = await getVolumeMetadataImpl(
|
|
197
|
+
{ ...opts, mountPoint: dir },
|
|
198
|
+
nativeFn,
|
|
199
|
+
);
|
|
200
|
+
const canonicalMountPoint = isNotBlank(probe.mountName)
|
|
201
|
+
? probe.mountName
|
|
202
|
+
: dir;
|
|
203
|
+
if (canonicalMountPoint === dir) return probe;
|
|
204
|
+
return getVolumeMetadataImpl(
|
|
205
|
+
{ ...opts, mountPoint: canonicalMountPoint },
|
|
206
|
+
nativeFn,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Linux/Windows: stat().dev is reliable (no firmlinks). Find the mount point
|
|
211
|
+
// by comparing device IDs, using path prefix as a tiebreaker for bind mounts
|
|
212
|
+
// or GIO mounts that share the same device id.
|
|
213
|
+
const mountPoint = await findMountPointByDeviceId(
|
|
214
|
+
resolved,
|
|
215
|
+
resolvedStat,
|
|
216
|
+
opts,
|
|
217
|
+
nativeFn,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return getVolumeMetadataImpl({ ...opts, mountPoint }, nativeFn);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Find the mount point for a resolved path using device ID matching.
|
|
225
|
+
* Used on Linux and Windows where stat().dev is reliable (no firmlinks).
|
|
226
|
+
*
|
|
227
|
+
* Compares device IDs of mount points against the target path's device ID,
|
|
228
|
+
* using path prefix as a tiebreaker for bind mounts or GIO mounts that share
|
|
229
|
+
* the same device id. The longest prefix match wins.
|
|
230
|
+
*/
|
|
231
|
+
export async function findMountPointByDeviceId(
|
|
232
|
+
resolved: string,
|
|
233
|
+
resolvedStat: Stats,
|
|
234
|
+
opts: Options,
|
|
235
|
+
nativeFn: NativeBindingsFn,
|
|
236
|
+
): Promise<string> {
|
|
237
|
+
const targetDev = resolvedStat.dev;
|
|
238
|
+
const mountPoints = await getVolumeMountPointsImpl(
|
|
239
|
+
{ ...opts, includeSystemVolumes: true },
|
|
240
|
+
nativeFn,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const prefixMatches: string[] = [];
|
|
244
|
+
const deviceMatches: string[] = [];
|
|
245
|
+
|
|
246
|
+
await Promise.all(
|
|
247
|
+
mountPoints.map(async ({ mountPoint }) => {
|
|
248
|
+
try {
|
|
249
|
+
const mpDev = (await statAsync(mountPoint)).dev;
|
|
250
|
+
if (mpDev !== targetDev) return;
|
|
251
|
+
if (isAncestorOrSelf(mountPoint, resolved)) {
|
|
252
|
+
prefixMatches.push(mountPoint);
|
|
253
|
+
} else {
|
|
254
|
+
deviceMatches.push(mountPoint);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// skip inaccessible mount points
|
|
258
|
+
}
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const candidates = prefixMatches.length > 0 ? prefixMatches : deviceMatches;
|
|
263
|
+
if (candidates.length === 0) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
"No mount point found for path: " + JSON.stringify(resolved),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return candidates.reduce((a, b) => (a.length >= b.length ? a : b));
|
|
269
|
+
}
|
|
270
|
+
|
|
156
271
|
export async function getAllVolumeMetadataImpl(
|
|
157
272
|
opts: Required<Options> & {
|
|
158
273
|
includeSystemVolumes?: boolean;
|
|
@@ -18,7 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
namespace FSMeta {
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Check if a drive is a system volume using SHGetFolderPathW (Windows dir)
|
|
22
|
+
// and optionally FILE_SUPPORTS_SYSTEM_PATHS/FILES volume flags.
|
|
23
|
+
//
|
|
24
|
+
// volumeFlags: if non-zero, uses pre-fetched flags to avoid a redundant
|
|
25
|
+
// GetVolumeInformationW call. Pass 0 to have this function query flags itself.
|
|
26
|
+
inline bool IsSystemVolume(const std::wstring &drive, DWORD volumeFlags = 0) {
|
|
22
27
|
WCHAR systemRoot[MAX_PATH];
|
|
23
28
|
if (SUCCEEDED(
|
|
24
29
|
SHGetFolderPathW(nullptr, CSIDL_WINDOWS, nullptr, 0, systemRoot))) {
|
|
@@ -33,22 +38,22 @@ inline bool IsSystemVolume(const std::wstring &drive) {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
// Modern volume properties check
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
(volumeFlags & FILE_SUPPORTS_SYSTEM_FILES)) {
|
|
45
|
-
DEBUG_LOG("[IsSystemVolume] %ls has system volume flags (0x%08X)",
|
|
46
|
-
drive.c_str(), volumeFlags);
|
|
47
|
-
return true;
|
|
41
|
+
if (volumeFlags == 0) {
|
|
42
|
+
wchar_t fileSystemName[MAX_PATH + 1] = {0};
|
|
43
|
+
if (!GetVolumeInformationW(drive.c_str(), nullptr, 0, nullptr, nullptr,
|
|
44
|
+
&volumeFlags, fileSystemName, MAX_PATH)) {
|
|
45
|
+
DEBUG_LOG("[IsSystemVolume] %ls GetVolumeInformationW failed: %lu",
|
|
46
|
+
drive.c_str(), GetLastError());
|
|
47
|
+
DEBUG_LOG("[IsSystemVolume] %ls is not a system volume", drive.c_str());
|
|
48
|
+
return false;
|
|
48
49
|
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if ((volumeFlags & FILE_SUPPORTS_SYSTEM_PATHS) ||
|
|
53
|
+
(volumeFlags & FILE_SUPPORTS_SYSTEM_FILES)) {
|
|
54
|
+
DEBUG_LOG("[IsSystemVolume] %ls has system volume flags (0x%08X)",
|
|
55
|
+
drive.c_str(), volumeFlags);
|
|
56
|
+
return true;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
DEBUG_LOG("[IsSystemVolume] %ls is not a system volume", drive.c_str());
|
|
@@ -110,6 +110,7 @@ public:
|
|
|
110
110
|
const char *getVolumeName() const { return volumeName; }
|
|
111
111
|
const char *getFileSystem() const { return fstype; }
|
|
112
112
|
DWORD getSerialNumber() const { return serialNumber; }
|
|
113
|
+
DWORD getFlags() const { return fsFlags; }
|
|
113
114
|
};
|
|
114
115
|
|
|
115
116
|
// RAII wrapper for disk space information
|
|
@@ -165,17 +166,13 @@ private:
|
|
|
165
166
|
return; // Don't try to get additional info for non-healthy drives
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
DEBUG_LOG("[GetVolumeMetadata] %s {isSystemVolume: %s}",
|
|
172
|
-
mountPoint.c_str(), metadata.isSystemVolume ? "true" : "false");
|
|
173
|
-
|
|
174
|
-
// Get volume information
|
|
169
|
+
// Get volume information first so we can reuse its flags for
|
|
170
|
+
// IsSystemVolume, avoiding a redundant GetVolumeInformationW call.
|
|
175
171
|
VolumeInfo volInfo(mountPoint);
|
|
176
172
|
if (volInfo.isValid()) {
|
|
177
173
|
metadata.label = volInfo.getVolumeName();
|
|
178
174
|
metadata.fstype = volInfo.getFileSystem();
|
|
175
|
+
metadata.isReadOnly = (volInfo.getFlags() & FILE_READ_ONLY_VOLUME) != 0;
|
|
179
176
|
DEBUG_LOG("[GetVolumeMetadata] %s {label: %s, fstype: %s}",
|
|
180
177
|
mountPoint.c_str(), metadata.label.c_str(),
|
|
181
178
|
metadata.fstype.c_str());
|
|
@@ -207,6 +204,15 @@ private:
|
|
|
207
204
|
}
|
|
208
205
|
}
|
|
209
206
|
|
|
207
|
+
// Check system volume using pre-fetched flags from VolumeInfo to
|
|
208
|
+
// avoid a redundant GetVolumeInformationW call.
|
|
209
|
+
std::wstring widePath = SecurityUtils::SafeStringToWide(mountPoint);
|
|
210
|
+
metadata.isSystemVolume =
|
|
211
|
+
IsSystemVolume(widePath, volInfo.isValid() ? volInfo.getFlags() : 0);
|
|
212
|
+
|
|
213
|
+
DEBUG_LOG("[GetVolumeMetadata] %s {isSystemVolume: %s}",
|
|
214
|
+
mountPoint.c_str(), metadata.isSystemVolume ? "true" : "false");
|
|
215
|
+
|
|
210
216
|
// Check if drive is remote
|
|
211
217
|
metadata.remote = (GetDriveTypeA(mountPoint.c_str()) == DRIVE_REMOTE);
|
|
212
218
|
DEBUG_LOG("[GetVolumeMetadata] %s {remote: %s}", mountPoint.c_str(),
|
|
@@ -84,21 +84,25 @@ public:
|
|
|
84
84
|
mp.mountPoint = paths[i];
|
|
85
85
|
mp.status = DriveStatusToString(statuses[i]);
|
|
86
86
|
|
|
87
|
+
std::wstring widePath = SecurityUtils::SafeStringToWide(paths[i]);
|
|
88
|
+
DWORD fsFlags = 0;
|
|
89
|
+
|
|
87
90
|
if (statuses[i] == DriveStatus::Healthy) {
|
|
88
91
|
WCHAR fsName[MAX_PATH + 1] = {0};
|
|
89
92
|
|
|
90
|
-
if (GetVolumeInformationW(
|
|
91
|
-
|
|
92
|
-
nullptr, nullptr, nullptr, fsName, MAX_PATH)) {
|
|
93
|
+
if (GetVolumeInformationW(widePath.c_str(), nullptr, 0, nullptr,
|
|
94
|
+
nullptr, &fsFlags, fsName, MAX_PATH)) {
|
|
93
95
|
mp.fstype = WideToUtf8(fsName);
|
|
96
|
+
mp.isReadOnly = (fsFlags & FILE_READ_ONLY_VOLUME) != 0;
|
|
94
97
|
DEBUG_LOG("[GetVolumeMountPoints] drive %s filesystem: %s",
|
|
95
98
|
paths[i].c_str(), mp.fstype.c_str());
|
|
96
99
|
}
|
|
97
|
-
}
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
// Only check system volume for healthy drives — IsSystemVolume
|
|
102
|
+
// may call GetVolumeInformationW, which hangs on dead drives and
|
|
103
|
+
// would defeat the async timeout protection from CheckDriveStatus.
|
|
104
|
+
mp.isSystemVolume = IsSystemVolume(widePath, fsFlags);
|
|
105
|
+
}
|
|
102
106
|
mountPoints_.push_back(std::move(mp));
|
|
103
107
|
}
|
|
104
108
|
|