@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
package/CHANGELOG.md CHANGED
@@ -14,6 +14,44 @@ Fixed for any bug fixes.
14
14
  Security in case of vulnerabilities.
15
15
  -->
16
16
 
17
+ ## 1.1.0 - 2026-03-16
18
+
19
+ ### Added
20
+
21
+ - New `getVolumeMetadataForPath(pathname)` function: given any file or directory path, returns the `VolumeMetadata` for the volume that contains it. Mirrors the behavior of `df pathname`:
22
+ - Resolves POSIX symlinks via `realpath()`
23
+ - On **macOS**: uses `fstatfs()` `f_mntonname` to correctly resolve APFS firmlinks (e.g. `/Users` → `/System/Volumes/Data`) — `stat().dev` does not follow firmlinks and would give the wrong result
24
+ - On **Linux**: uses `stat().dev` device ID matching with path-prefix disambiguation for bind mounts and GIO mounts that share a device ID. Also works correctly in Docker containers, where `/proc/self/mounts` reflects the container's mount namespace.
25
+ - On **Windows**: uses device ID and path-prefix matching against logical drives
26
+
27
+ - New `isReadOnly` field on `MountPoint` (and by extension `VolumeMetadata`) indicating whether a volume is mounted read-only. This is useful for identifying volumes with unstable UUIDs, like the macOS APFS system snapshot at `/`, whose UUID changes on every OS update. Available on all platforms:
28
+ - **macOS**: reads `MNT_RDONLY` from `statfs` flags
29
+ - **Linux**: parses `ro` from mount options in `/proc/mounts`
30
+ - **Windows**: checks `FILE_READ_ONLY_VOLUME` from `GetVolumeInformation`
31
+
32
+ ### Changed
33
+
34
+ - **macOS `isSystemVolume` detection now uses APFS volume roles via IOKit** instead of path pattern heuristics. Each APFS volume has a role (System, Data, VM, Preboot, Recovery, etc.) stored in its superblock. We read this via `DADiskCopyIOMedia()` → `IORegistryEntryCreateCFProperty("Role")`, with a `MNT_SNAPSHOT` fallback if DiskArbitration is unavailable. This is factual (Apple assigns the roles), not heuristic, and correctly distinguishes:
35
+ - `/` (System role) → `isSystemVolume: true` — sealed OS snapshot, unstable UUID
36
+ - `/System/Volumes/Data` (Data role) → `isSystemVolume: false` — primary user data volume
37
+ - `/System/Volumes/VM`, `Preboot`, `Update`, `Hardware`, `xarts`, etc. → `isSystemVolume: true`
38
+ - See [`doc/system-volume-detection.md`](./doc/system-volume-detection.md) for full details
39
+
40
+ ### Security
41
+
42
+ - macOS: RAII wrapper (`IOObjectGuard`) for IOKit objects in APFS volume role detection, preventing Mach port resource leaks if exceptions occur during `GetApfsVolumeRole()`
43
+ - macOS: DiskArbitration operations in `getVolumeMountPoints()` now serialize through the same `g_diskArbitrationMutex` used by `getVolumeMetadata()`, preventing potential data races when both APIs are called concurrently
44
+ - See [`doc/SECURITY_AUDIT_2026.md`](./doc/SECURITY_AUDIT_2026.md) for full audit details
45
+
46
+ ### Fixed
47
+
48
+ - Zero-initialized `VolumeMetadata` size/used/available fields to prevent uninitialized values when volume info retrieval fails early
49
+ - Windows: eliminated redundant `GetVolumeInformationW` call per drive in `getVolumeMountPoints()`
50
+
51
+ ### Removed
52
+
53
+ - Removed macOS `/System/Volumes/*` path patterns from `SystemPathPatternsDefault` — these are now handled natively via APFS volume roles. The Spotlight, FSEvents, and Trashes glob patterns (`**/.Spotlight-V100`, `**/.fseventsd`, etc.) were also removed as they matched directories within volumes, not mount points.
54
+
17
55
  ## 1.0.1 - 2026-03-01
18
56
 
19
57
  ### Fixed
package/CLAUDE.md CHANGED
@@ -44,6 +44,19 @@ Use `_dirname()` from `./dirname` instead of `__dirname` - works in both CommonJ
44
44
 
45
45
  Jest 30 doesn't support Node.js 23. Use Node.js 20, 22, or 24.
46
46
 
47
+ ## System Volume Detection
48
+
49
+ **IMPORTANT: Read `doc/system-volume-detection.md` before modifying any system volume detection logic.** It documents the full detection strategy across all platforms, including flag matrices and rationale for each approach.
50
+
51
+ Summary:
52
+
53
+ - The root `/` is a sealed, read-only APFS snapshot whose **UUID changes on every OS update** — never use it for persistent identification.
54
+ - **Primary detection** combines mount flags with APFS volume roles: `MNT_SNAPSHOT || (MNT_DONTBROWSE && hasApfsRole && role != "Data")`. See `ClassifyMacVolume()` in `src/darwin/system_volume.h`.
55
+ - The APFS role string is exposed as `volumeRole` on `MountPoint` and `VolumeMetadata`.
56
+ - **Fallback** uses `MNT_SNAPSHOT` only from `statfs` `f_flags` if DA session creation fails.
57
+ - `MNT_DONTBROWSE` is safe to use **only when combined with a non-Data APFS role**. The Data volume (`/System/Volumes/Data`) has `MNT_DONTBROWSE` but role `"Data"`, so it is correctly excluded.
58
+ - Pseudo-filesystems like `devfs` (no IOMedia, no APFS role) are caught by TypeScript fstype/path heuristics.
59
+
47
60
  ## Windows-Specific Issues
48
61
 
49
62
  ### Windows CI Jest Worker Failures
package/claude.sh CHANGED
@@ -1,14 +1,31 @@
1
1
  #!/bin/bash
2
2
 
3
- # To make sure we use this if available:
4
- # alias claude='if [ -f "./claude.sh" ]; then ./claude.sh; else command claude; fi'
3
+ # Claude Code wrapper: appends a project-specific system prompt to every session.
4
+ #
5
+ # Appends TPP instructions and mandatory guidelines via --append-system-prompt.
6
+ # See https://photostructure.com/coding/claude-code-tpp/ for details.
7
+ #
8
+ # Setup: add this function to your ~/.bashrc, ~/.bash_aliases, or ~/.zshrc:
9
+ #
10
+ # cla() {
11
+ # if [ -f "./claude.sh" ]; then ./claude.sh "$@"; else command claude "$@"; fi
12
+ # }
13
+ #
14
+ # Usage:
15
+ # cla # Starts a TPP-aware session
16
+ # cla --resume # Resume with TPP context
17
+ # claude update # Vanilla claude still works for non-TPP use
18
+ #
19
+ # The --append-system-prompt below is also a good place to add brief,
20
+ # high-value instructions that Claude tends to ignore in CLAUDE.md.
21
+ # Keep it concise! Every token here reduces your available context window.
5
22
 
6
- echo "Adding our system prompt..."
23
+ echo "Adding project system prompt..."
7
24
 
8
25
  DATE=$(date +%Y-%m-%d)
9
26
 
10
- claude --append-system-prompt "$(
11
- cat <<'EOF'
27
+ command claude --append-system-prompt "$(
28
+ cat <<EOF
12
29
  # MANDATORY GUIDELINES
13
30
  - **Study your CLAUDE.md** - Every conversation begins by studying CLAUDE.md
14
31
  - **Always Start By Reading** - You must study the referenced codebase and related documentation before making any change. NEVER assume APIs or implementation details.
@@ -22,5 +39,12 @@ claude --append-system-prompt "$(
22
39
  - **It's YOUR JOB to keep docs current** - If your edits change **any** behavior or type signatures, search and update both code comments and documentation and edit them to reflect those changes.
23
40
  - **Do not delete files without asking** - If you need to delete a file, please ask for permission first, and provide a justification for why it should be deleted.
24
41
  - The current date is $DATE -- it is not 2024.
42
+
43
+ # TECHNICAL PROJECT PLANS (TPPs)
44
+ This project uses Technical Project Plans (TPPs) in \`_todo/*.md\` to share research, design decisions, and next steps between sessions.
45
+
46
+ - When you exit plan mode, your first step should be to write or update a relevant TPP using the /handoff skill.
47
+ - When you run low on context and you are working on a TPP, run the /handoff skill.
48
+ - Check \`_todo/\` at the start of every session for active TPPs relevant to the current task.
25
49
  EOF
26
50
  )" "$@"
package/dist/index.cjs CHANGED
@@ -42,6 +42,7 @@ __export(index_exports, {
42
42
  getHiddenMetadata: () => getHiddenMetadata,
43
43
  getTimeoutMsDefault: () => getTimeoutMsDefault,
44
44
  getVolumeMetadata: () => getVolumeMetadata,
45
+ getVolumeMetadataForPath: () => getVolumeMetadataForPath,
45
46
  getVolumeMountPoints: () => getVolumeMountPoints,
46
47
  isHidden: () => isHidden,
47
48
  isHiddenRecursive: () => isHiddenRecursive,
@@ -460,6 +461,11 @@ function isRootDirectory(path) {
460
461
  const n = normalizePath(path);
461
462
  return n == null ? false : isWindows ? (0, import_node_path3.dirname)(n) === n : n === "/";
462
463
  }
464
+ function isAncestorOrSelf(ancestor, descendant) {
465
+ if (ancestor === descendant) return true;
466
+ const prefix = isRootDirectory(ancestor) ? ancestor : ancestor + import_node_path3.sep;
467
+ return descendant.startsWith(prefix);
468
+ }
463
469
 
464
470
  // src/hidden.ts
465
471
  var HiddenSupportByPlatform = {
@@ -686,23 +692,13 @@ var SystemPathPatternsDefault = [
686
692
  "/mnt/wslg/doc",
687
693
  "/mnt/wslg/versions.txt",
688
694
  "/usr/lib/wsl/drivers",
689
- // macOS system paths:
690
- "/private/var/vm",
691
- // macOS swap
692
- "/System/Volumes/Hardware",
693
- "/System/Volumes/iSCPreboot",
694
- "/System/Volumes/Preboot",
695
- "/System/Volumes/Recovery",
696
- "/System/Volumes/Reserved",
697
- "/System/Volumes/Update",
698
- "/System/Volumes/VM",
699
- "/System/Volumes/xarts",
700
- // macOS per-volume metadata (Spotlight, FSEvents, versioning, Trash):
701
- // https://eclecticlight.co/2021/01/28/spotlight-on-search-how-spotlight-works/
702
- "**/.DocumentRevisions-V100",
703
- "**/.fseventsd",
704
- "**/.Spotlight-V100",
705
- "**/.Trashes"
695
+ // macOS system volumes are detected natively via APFS volume roles
696
+ // (IOKit IOMedia "Role" property) with MNT_SNAPSHOT as a fallback.
697
+ // No path patterns needed. See src/darwin/system_volume.h.
698
+ //
699
+ // /private/var/vm is the macOS swap directory (not a mount point on most
700
+ // systems, but included for completeness if it appears as one).
701
+ "/private/var/vm"
706
702
  ];
707
703
  var SystemFsTypesDefault = [
708
704
  "autofs",
@@ -877,6 +873,10 @@ async function directoryStatus(dir, timeoutMs, canReaddirImpl = canReaddir) {
877
873
  return { status: VolumeHealthStatuses.unknown };
878
874
  }
879
875
 
876
+ // src/volume_metadata.ts
877
+ var import_promises5 = require("fs/promises");
878
+ var import_node_path6 = require("path");
879
+
880
880
  // src/linux/dev_disk.ts
881
881
  var import_promises3 = require("fs/promises");
882
882
  var import_node_path5 = require("path");
@@ -1161,20 +1161,20 @@ function isSystemVolume(mountPoint, fstype, config = {}) {
1161
1161
  }
1162
1162
  function assignSystemVolume(mp, config) {
1163
1163
  const result = isSystemVolume(mp.mountPoint, mp.fstype, config);
1164
- if (isWindows) {
1165
- mp.isSystemVolume ??= result;
1166
- } else {
1167
- mp.isSystemVolume = result;
1168
- }
1164
+ mp.isSystemVolume = mp.isSystemVolume || result;
1169
1165
  }
1170
1166
 
1171
1167
  // src/linux/mtab.ts
1168
+ function isReadOnlyMount(fs_mntops) {
1169
+ return fs_mntops?.split(",").includes("ro") ?? false;
1170
+ }
1172
1171
  function mountEntryToMountPoint(entry) {
1173
1172
  const mountPoint = normalizePosixPath(entry.fs_file);
1174
1173
  const fstype = toNotBlank(entry.fs_vfstype) ?? toNotBlank(entry.fs_spec);
1175
1174
  return mountPoint == null || fstype == null ? void 0 : {
1176
1175
  mountPoint,
1177
- fstype
1176
+ fstype,
1177
+ isReadOnly: isReadOnlyMount(entry.fs_mntops)
1178
1178
  };
1179
1179
  }
1180
1180
  function mountEntryToPartialVolumeMetadata(entry, options = {}) {
@@ -1184,6 +1184,7 @@ function mountEntryToPartialVolumeMetadata(entry, options = {}) {
1184
1184
  fstype: entry.fs_vfstype,
1185
1185
  mountFrom: entry.fs_spec,
1186
1186
  isSystemVolume: isSystemVolume(entry.fs_file, entry.fs_vfstype, options),
1187
+ isReadOnly: isReadOnlyMount(entry.fs_mntops),
1187
1188
  remote: false,
1188
1189
  // < default to false, but it may be overridden by extractRemoteInfo
1189
1190
  ...extractRemoteInfo(entry.fs_spec, networkFsTypes)
@@ -1467,6 +1468,57 @@ async function _getVolumeMetadata(o, nativeFn2) {
1467
1468
  debug("[getVolumeMetadata] final result for %s: %o", o.mountPoint, result);
1468
1469
  return compactValues(result);
1469
1470
  }
1471
+ async function getVolumeMetadataForPathImpl(pathname, opts, nativeFn2) {
1472
+ if (isBlank(pathname)) {
1473
+ throw new TypeError("Invalid pathname: got " + JSON.stringify(pathname));
1474
+ }
1475
+ const resolved = await (0, import_promises5.realpath)(pathname);
1476
+ const resolvedStat = await statAsync(resolved);
1477
+ const dir = resolvedStat.isDirectory() ? resolved : (0, import_node_path6.dirname)(resolved);
1478
+ if (isMacOS) {
1479
+ const probe = await getVolumeMetadataImpl(
1480
+ { ...opts, mountPoint: dir },
1481
+ nativeFn2
1482
+ );
1483
+ const canonicalMountPoint = isNotBlank(probe.mountName) ? probe.mountName : dir;
1484
+ if (canonicalMountPoint === dir) return probe;
1485
+ return getVolumeMetadataImpl(
1486
+ { ...opts, mountPoint: canonicalMountPoint },
1487
+ nativeFn2
1488
+ );
1489
+ }
1490
+ const targetDev = resolvedStat.dev;
1491
+ const mountPoints = await getVolumeMountPointsImpl(
1492
+ { ...opts, includeSystemVolumes: true },
1493
+ nativeFn2
1494
+ );
1495
+ const prefixMatches = [];
1496
+ const deviceMatches = [];
1497
+ await Promise.all(
1498
+ mountPoints.map(async ({ mountPoint: mountPoint2 }) => {
1499
+ try {
1500
+ const mpDev = (await statAsync(mountPoint2)).dev;
1501
+ if (mpDev !== targetDev) return;
1502
+ if (isAncestorOrSelf(mountPoint2, resolved)) {
1503
+ prefixMatches.push(mountPoint2);
1504
+ } else {
1505
+ deviceMatches.push(mountPoint2);
1506
+ }
1507
+ } catch {
1508
+ }
1509
+ })
1510
+ );
1511
+ const candidates = prefixMatches.length > 0 ? prefixMatches : deviceMatches;
1512
+ if (candidates.length === 0) {
1513
+ throw new Error(
1514
+ "No mount point found for path: " + JSON.stringify(pathname)
1515
+ );
1516
+ }
1517
+ const mountPoint = candidates.reduce(
1518
+ (a, b) => a.length >= b.length ? a : b
1519
+ );
1520
+ return getVolumeMetadataImpl({ ...opts, mountPoint }, nativeFn2);
1521
+ }
1470
1522
  async function getAllVolumeMetadataImpl(opts, nativeFn2) {
1471
1523
  const o = optionsWithDefaults(opts);
1472
1524
  debug("[getAllVolumeMetadata] starting with options: %o", o);
@@ -1522,11 +1574,11 @@ async function getAllVolumeMetadataImpl(opts, nativeFn2) {
1522
1574
  var nativeFn = defer2(async () => {
1523
1575
  const start = Date.now();
1524
1576
  try {
1525
- const dirname4 = _dirname();
1526
- const dir = await findAncestorDir(dirname4, "binding.gyp");
1577
+ const dirname5 = _dirname();
1578
+ const dir = await findAncestorDir(dirname5, "binding.gyp");
1527
1579
  if (dir == null) {
1528
1580
  throw new Error(
1529
- "Could not find bindings.gyp in any ancestor directory of " + dirname4
1581
+ "Could not find bindings.gyp in any ancestor directory of " + dirname5
1530
1582
  );
1531
1583
  }
1532
1584
  const bindings = (0, import_node_gyp_build.default)(dir);
@@ -1549,6 +1601,13 @@ function getVolumeMetadata(mountPoint, opts) {
1549
1601
  nativeFn
1550
1602
  );
1551
1603
  }
1604
+ function getVolumeMetadataForPath(pathname, opts) {
1605
+ return getVolumeMetadataForPathImpl(
1606
+ pathname,
1607
+ optionsWithDefaults(opts),
1608
+ nativeFn
1609
+ );
1610
+ }
1552
1611
  function getAllVolumeMetadata(opts) {
1553
1612
  return getAllVolumeMetadataImpl(optionsWithDefaults(opts), nativeFn);
1554
1613
  }
@@ -1578,6 +1637,7 @@ function setHidden(pathname, hidden, method = "auto") {
1578
1637
  getHiddenMetadata,
1579
1638
  getTimeoutMsDefault,
1580
1639
  getVolumeMetadata,
1640
+ getVolumeMetadataForPath,
1581
1641
  getVolumeMountPoints,
1582
1642
  isHidden,
1583
1643
  isHiddenRecursive,