@photostructure/fs-metadata 0.6.1 → 0.7.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 (89) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/CLAUDE.md +141 -315
  3. package/CODE_OF_CONDUCT.md +11 -11
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +34 -103
  6. package/binding.gyp +97 -22
  7. package/claude.sh +23 -0
  8. package/dist/index.cjs +51 -21
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +5 -0
  11. package/dist/index.d.mts +5 -0
  12. package/dist/index.d.ts +5 -0
  13. package/dist/index.mjs +51 -21
  14. package/dist/index.mjs.map +1 -1
  15. package/doc/C++_REVIEW_TODO.md +97 -25
  16. package/doc/GPG_RELEASE_HOWTO.md +44 -13
  17. package/doc/MACOS_API_REFERENCE.md +469 -0
  18. package/doc/SECURITY_AUDIT_2025.md +809 -0
  19. package/doc/SSH_RELEASE_HOWTO.md +28 -24
  20. package/doc/WINDOWS_API_REFERENCE.md +422 -0
  21. package/doc/WINDOWS_ARM64_SECURITY.md +161 -0
  22. package/doc/WINDOWS_DEBUG_GUIDE.md +9 -2
  23. package/doc/examples.md +267 -0
  24. package/doc/gotchas.md +297 -0
  25. package/doc/logo.png +0 -0
  26. package/doc/logo.svg +85 -0
  27. package/doc/macos-asan-sip-issue.md +71 -0
  28. package/doc/social.png +0 -0
  29. package/doc/social.svg +125 -0
  30. package/doc/windows-build.md +226 -0
  31. package/doc/windows-clang-tidy.md +72 -0
  32. package/doc/windows-memory-testing.md +108 -0
  33. package/doc/windows-prebuildify-arm64.md +232 -0
  34. package/jest.config.cjs +23 -0
  35. package/package.json +61 -36
  36. package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  37. package/prebuilds/darwin-x64/@photostructure+fs-metadata.glibc.node +0 -0
  38. package/prebuilds/linux-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  39. package/prebuilds/linux-arm64/@photostructure+fs-metadata.musl.node +0 -0
  40. package/prebuilds/linux-x64/@photostructure+fs-metadata.glibc.node +0 -0
  41. package/prebuilds/linux-x64/@photostructure+fs-metadata.musl.node +0 -0
  42. package/prebuilds/win32-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  43. package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
  44. package/scripts/check-memory.ts +186 -0
  45. package/scripts/clang-tidy.ts +690 -99
  46. package/scripts/install.cjs +42 -0
  47. package/scripts/is-platform.mjs +1 -1
  48. package/scripts/macos-asan.sh +155 -0
  49. package/scripts/post-build.mjs +3 -3
  50. package/scripts/prebuild-linux-glibc.sh +12 -1
  51. package/scripts/prebuildify-wrapper.ts +77 -0
  52. package/scripts/precommit.ts +45 -20
  53. package/scripts/sanitizers-test.sh +1 -1
  54. package/src/common/volume_metadata.h +6 -0
  55. package/src/darwin/hidden.cpp +73 -25
  56. package/src/darwin/path_security.h +149 -0
  57. package/src/darwin/raii_utils.h +104 -4
  58. package/src/darwin/volume_metadata.cpp +132 -58
  59. package/src/darwin/volume_mount_points.cpp +80 -47
  60. package/src/hidden.ts +36 -13
  61. package/src/linux/gio_mount_points.cpp +17 -18
  62. package/src/linux/gio_utils.cpp +92 -37
  63. package/src/linux/gio_utils.h +11 -5
  64. package/src/linux/gio_volume_metadata.cpp +111 -48
  65. package/src/linux/volume_metadata.cpp +67 -4
  66. package/src/object.ts +1 -0
  67. package/src/options.ts +6 -0
  68. package/src/path.ts +11 -0
  69. package/src/remote_info.ts +5 -3
  70. package/src/stack_path.ts +8 -6
  71. package/src/string_enum.ts +1 -0
  72. package/src/test-utils/memory-test-core.ts +336 -0
  73. package/src/test-utils/memory-test-runner.ts +108 -0
  74. package/src/test-utils/platform.ts +46 -1
  75. package/src/test-utils/worker-thread-helper.cjs +154 -27
  76. package/src/types/native_bindings.ts +1 -1
  77. package/src/types/options.ts +6 -0
  78. package/src/windows/drive_status.h +133 -163
  79. package/src/windows/error_utils.h +54 -3
  80. package/src/windows/fs_meta.h +1 -1
  81. package/src/windows/hidden.cpp +60 -43
  82. package/src/windows/security_utils.h +250 -0
  83. package/src/windows/string.h +68 -11
  84. package/src/windows/system_volume.h +1 -1
  85. package/src/windows/thread_pool.h +206 -0
  86. package/src/windows/volume_metadata.cpp +11 -6
  87. package/src/windows/volume_mount_points.cpp +8 -7
  88. package/src/windows/windows_arch.h +39 -0
  89. package/scripts/check-memory.mjs +0 -123
@@ -0,0 +1,149 @@
1
+ // src/darwin/path_security.h
2
+ // Secure path validation for macOS using realpath()
3
+ // Implements recommendations from Apple's Secure Coding Guide
4
+ // https://developer.apple.com/library/archive/documentation/Security/Conceptual/SecureCodingGuide/Articles/RaceConditions.html
5
+
6
+ #pragma once
7
+
8
+ #include "../common/debug_log.h"
9
+ #include "../common/error_utils.h"
10
+ #include <cerrno>
11
+ #include <cstring>
12
+ #include <string>
13
+ #include <sys/param.h> // For PATH_MAX
14
+ #include <unistd.h> // For realpath()
15
+
16
+ namespace FSMeta {
17
+
18
+ /**
19
+ * Validates a path for security issues and canonicalizes it using realpath().
20
+ *
21
+ * This function prevents directory traversal attacks by:
22
+ * 1. Checking for null bytes (path injection)
23
+ * 2. Using realpath() to resolve symbolic links and path references (../, ./)
24
+ * 3. Handling non-existent paths by validating the parent directory
25
+ *
26
+ * @param path The path to validate
27
+ * @param error Output parameter for error message if validation fails
28
+ * @param allow_nonexistent If true, allows paths that don't exist by validating
29
+ * parent
30
+ * @return The canonicalized path, or empty string if validation fails
31
+ */
32
+ inline std::string ValidateAndCanonicalizePath(const std::string &path,
33
+ std::string &error,
34
+ bool allow_nonexistent = false) {
35
+ DEBUG_LOG("[ValidateAndCanonicalizePath] Validating path: %s "
36
+ "(allow_nonexistent: %d)",
37
+ path.c_str(), allow_nonexistent);
38
+
39
+ // Check for empty path
40
+ if (path.empty()) {
41
+ error = "Empty path provided";
42
+ DEBUG_LOG("[ValidateAndCanonicalizePath] %s", error.c_str());
43
+ return "";
44
+ }
45
+
46
+ // Security check #1: Reject paths with null bytes (path injection attack)
47
+ if (path.find('\0') != std::string::npos) {
48
+ error = "Invalid path containing null byte";
49
+ DEBUG_LOG("[ValidateAndCanonicalizePath] %s", error.c_str());
50
+ return "";
51
+ }
52
+
53
+ // Security check #2: Use realpath() to canonicalize and validate
54
+ // realpath() resolves symbolic links and eliminates ../, ./, redundant
55
+ // slashes
56
+ char resolved_path[PATH_MAX];
57
+ if (realpath(path.c_str(), resolved_path) != nullptr) {
58
+ // Path exists and was successfully canonicalized
59
+ std::string canonical_path(resolved_path);
60
+ DEBUG_LOG("[ValidateAndCanonicalizePath] Canonicalized: %s -> %s",
61
+ path.c_str(), canonical_path.c_str());
62
+ return canonical_path;
63
+ }
64
+
65
+ // realpath() failed - check if it's because the path doesn't exist
66
+ int realpath_error = errno;
67
+
68
+ if (realpath_error == ENOENT && allow_nonexistent) {
69
+ // For operations that create files (like setHidden), validate parent
70
+ // directory
71
+ DEBUG_LOG(
72
+ "[ValidateAndCanonicalizePath] Path doesn't exist, validating parent");
73
+
74
+ // Find the parent directory
75
+ size_t last_slash = path.find_last_of('/');
76
+ std::string parent_dir;
77
+
78
+ if (last_slash == std::string::npos) {
79
+ // No slash found - relative path, use current directory
80
+ parent_dir = ".";
81
+ } else if (last_slash == 0) {
82
+ // Root directory
83
+ parent_dir = "/";
84
+ } else {
85
+ parent_dir = path.substr(0, last_slash);
86
+ }
87
+
88
+ // Validate parent directory exists and is accessible
89
+ if (realpath(parent_dir.c_str(), resolved_path) == nullptr) {
90
+ int parent_error = errno;
91
+ error =
92
+ CreatePathErrorMessage("realpath (parent)", parent_dir, parent_error);
93
+ DEBUG_LOG("[ValidateAndCanonicalizePath] Parent validation failed: %s",
94
+ error.c_str());
95
+ return "";
96
+ }
97
+
98
+ // Parent is valid - construct the full path
99
+ std::string parent_canonical(resolved_path);
100
+ std::string filename =
101
+ (last_slash == std::string::npos) ? path : path.substr(last_slash + 1);
102
+
103
+ std::string result;
104
+ if (parent_canonical == "/") {
105
+ result = "/" + filename;
106
+ } else {
107
+ result = parent_canonical + "/" + filename;
108
+ }
109
+
110
+ DEBUG_LOG(
111
+ "[ValidateAndCanonicalizePath] Validated non-existent path: %s -> %s",
112
+ path.c_str(), result.c_str());
113
+ return result;
114
+ }
115
+
116
+ // realpath() failed for a different reason, or path doesn't exist and we
117
+ // don't allow it
118
+ error = CreatePathErrorMessage("realpath", path, realpath_error);
119
+ DEBUG_LOG("[ValidateAndCanonicalizePath] Failed: %s", error.c_str());
120
+ return "";
121
+ }
122
+
123
+ /**
124
+ * Validates that a path is secure for read operations.
125
+ * The path must exist and be accessible.
126
+ *
127
+ * @param path The path to validate
128
+ * @param error Output parameter for error message if validation fails
129
+ * @return The canonicalized path, or empty string if validation fails
130
+ */
131
+ inline std::string ValidatePathForRead(const std::string &path,
132
+ std::string &error) {
133
+ return ValidateAndCanonicalizePath(path, error, false);
134
+ }
135
+
136
+ /**
137
+ * Validates that a path is secure for write operations.
138
+ * The path may not exist, but its parent directory must be valid.
139
+ *
140
+ * @param path The path to validate
141
+ * @param error Output parameter for error message if validation fails
142
+ * @return The canonicalized path, or empty string if validation fails
143
+ */
144
+ inline std::string ValidatePathForWrite(const std::string &path,
145
+ std::string &error) {
146
+ return ValidateAndCanonicalizePath(path, error, true);
147
+ }
148
+
149
+ } // namespace FSMeta
@@ -1,11 +1,18 @@
1
1
  #pragma once
2
2
 
3
3
  #include <CoreFoundation/CoreFoundation.h>
4
+ #include <DiskArbitration/DiskArbitration.h>
4
5
  #include <sys/mount.h>
5
6
 
7
+ // RAII (Resource Acquisition Is Initialization) utilities for macOS APIs.
8
+ // These wrappers ensure proper cleanup of system resources even in the
9
+ // presence of exceptions, preventing memory leaks and resource exhaustion.
10
+
6
11
  namespace FSMeta {
7
12
 
8
- // Generic RAII wrapper for resources that need free()
13
+ // Generic RAII wrapper for resources that need free().
14
+ // This is used for C-style allocations that must be freed with free().
15
+ // Common usage: buffers returned by system APIs like getmntinfo_r_np().
9
16
  template <typename T> class ResourceRAII {
10
17
  private:
11
18
  T *resource_;
@@ -41,10 +48,48 @@ public:
41
48
  ResourceRAII &operator=(const ResourceRAII &) = delete;
42
49
  };
43
50
 
44
- // Specialized for mount info
45
- using MountBufferRAII = ResourceRAII<struct statfs>;
51
+ // Specialized RAII wrapper for mount buffer from getmntinfo_r_np().
52
+ // getmntinfo_r_np() allocates a buffer that the caller must free.
53
+ // This wrapper ensures the buffer is freed even if exceptions occur.
54
+ class MountBufferRAII {
55
+ private:
56
+ struct statfs *buffer_;
57
+
58
+ public:
59
+ MountBufferRAII() : buffer_(nullptr) {}
60
+ ~MountBufferRAII() {
61
+ if (buffer_) {
62
+ free(buffer_);
63
+ }
64
+ }
46
65
 
47
- // CoreFoundation RAII wrapper
66
+ struct statfs **ptr() { return &buffer_; }
67
+ struct statfs *get() { return buffer_; }
68
+
69
+ // Add move operations for better resource management
70
+ MountBufferRAII(MountBufferRAII &&other) noexcept : buffer_(other.buffer_) {
71
+ other.buffer_ = nullptr;
72
+ }
73
+
74
+ MountBufferRAII &operator=(MountBufferRAII &&other) noexcept {
75
+ if (this != &other) {
76
+ if (buffer_)
77
+ free(buffer_);
78
+ buffer_ = other.buffer_;
79
+ other.buffer_ = nullptr;
80
+ }
81
+ return *this;
82
+ }
83
+
84
+ // Prevent copying
85
+ MountBufferRAII(const MountBufferRAII &) = delete;
86
+ MountBufferRAII &operator=(const MountBufferRAII &) = delete;
87
+ };
88
+
89
+ // CoreFoundation RAII wrapper following the Create/Copy/Get rule.
90
+ // Any CF object obtained via Create or Copy functions must be released.
91
+ // This wrapper automatically calls CFRelease() in the destructor,
92
+ // preventing memory leaks from Core Foundation objects.
48
93
  template <typename T> class CFReleaser {
49
94
  private:
50
95
  T ref_;
@@ -82,4 +127,59 @@ public:
82
127
  }
83
128
  };
84
129
 
130
+ // Specialized RAII wrapper for DASession that handles dispatch queue lifecycle.
131
+ // DASessionSetDispatchQueue must be called with NULL before the session is
132
+ // released. This wrapper ensures proper cleanup order: unschedule then release.
133
+ class DASessionRAII {
134
+ private:
135
+ CFReleaser<DASessionRef> session_;
136
+ bool is_scheduled_;
137
+
138
+ public:
139
+ explicit DASessionRAII(DASessionRef session = nullptr) noexcept
140
+ : session_(session), is_scheduled_(false) {}
141
+
142
+ ~DASessionRAII() { unschedule(); }
143
+
144
+ // Schedule the session on a dispatch queue
145
+ void scheduleOnQueue(dispatch_queue_t queue) {
146
+ if (session_.isValid() && queue != nullptr) {
147
+ DASessionSetDispatchQueue(session_.get(), queue);
148
+ is_scheduled_ = true;
149
+ }
150
+ }
151
+
152
+ // Unschedule the session (must be called before session is released)
153
+ void unschedule() {
154
+ if (is_scheduled_ && session_.isValid()) {
155
+ DASessionSetDispatchQueue(session_.get(), nullptr);
156
+ is_scheduled_ = false;
157
+ }
158
+ }
159
+
160
+ DASessionRef get() const noexcept { return session_.get(); }
161
+ bool isValid() const noexcept { return session_.isValid(); }
162
+
163
+ // Prevent copying
164
+ DASessionRAII(const DASessionRAII &) = delete;
165
+ DASessionRAII &operator=(const DASessionRAII &) = delete;
166
+
167
+ // Allow moving
168
+ DASessionRAII(DASessionRAII &&other) noexcept
169
+ : session_(std::move(other.session_)),
170
+ is_scheduled_(other.is_scheduled_) {
171
+ other.is_scheduled_ = false;
172
+ }
173
+
174
+ DASessionRAII &operator=(DASessionRAII &&other) noexcept {
175
+ if (this != &other) {
176
+ unschedule();
177
+ session_ = std::move(other.session_);
178
+ is_scheduled_ = other.is_scheduled_;
179
+ other.is_scheduled_ = false;
180
+ }
181
+ return *this;
182
+ }
183
+ };
184
+
85
185
  } // namespace FSMeta
@@ -1,22 +1,27 @@
1
1
  // src/darwin/volume_metadata.cpp
2
+ // Thread-safe implementation with DiskArbitration mutex synchronization
2
3
 
3
4
  #include "../common/debug_log.h"
4
5
  #include "./fs_meta.h"
6
+ #include "./path_security.h"
5
7
  #include "./raii_utils.h"
6
8
 
7
9
  #include <CoreFoundation/CoreFoundation.h>
8
10
  #include <DiskArbitration/DiskArbitration.h>
9
- #include <IOKit/IOBSD.h>
10
- #include <IOKit/storage/IOMedia.h>
11
- #include <IOKit/storage/IOStorageProtocolCharacteristics.h>
11
+ #include <fcntl.h> // For open(), O_RDONLY, O_DIRECTORY
12
12
  #include <memory>
13
+ #include <mutex>
13
14
  #include <string>
14
15
  #include <sys/mount.h>
15
16
  #include <sys/param.h>
16
17
  #include <sys/statvfs.h>
18
+ #include <unistd.h> // For close()
17
19
 
18
20
  namespace FSMeta {
19
21
 
22
+ // Global mutex for DiskArbitration operations
23
+ static std::mutex g_diskArbitrationMutex;
24
+
20
25
  // Helper function to convert CFString to std::string
21
26
  static std::string CFStringToString(CFStringRef cfString) {
22
27
  if (!cfString) {
@@ -24,16 +29,43 @@ static std::string CFStringToString(CFStringRef cfString) {
24
29
  }
25
30
 
26
31
  CFIndex length = CFStringGetLength(cfString);
32
+ if (length == 0) {
33
+ return "";
34
+ }
35
+
36
+ // Check for overflow when calculating buffer size
27
37
  CFIndex maxSize =
28
- CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
38
+ CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8);
39
+ if (maxSize == kCFNotFound || maxSize > INT_MAX - 1) {
40
+ return "";
41
+ }
42
+ maxSize += 1; // For null terminator
43
+
29
44
  std::string result(maxSize, '\0');
30
45
 
31
- if (!CFStringGetCString(cfString, &result[0], maxSize,
32
- kCFStringEncodingUTF8)) {
46
+ Boolean success =
47
+ CFStringGetCString(cfString, &result[0], maxSize, kCFStringEncodingUTF8);
48
+ if (!success) {
49
+ // Log the failure for debugging
50
+ // Common reasons: encoding issue, buffer too small, or malformed string
51
+ DEBUG_LOG("[CFStringToString] Conversion failed - likely encoding issue or "
52
+ "buffer too small");
53
+ DEBUG_LOG("[CFStringToString] maxSize: %ld, string length: %ld", maxSize,
54
+ CFStringGetLength(cfString));
33
55
  return "";
34
56
  }
35
57
 
36
- result.resize(strlen(result.c_str()));
58
+ // Find actual string length by looking for null terminator
59
+ // This is safer than using strlen which could read past buffer
60
+ size_t actualLength = 0;
61
+ for (size_t i = 0; i < static_cast<size_t>(maxSize); ++i) {
62
+ if (result[i] == '\0') {
63
+ actualLength = i;
64
+ break;
65
+ }
66
+ }
67
+
68
+ result.resize(actualLength);
37
69
  return result;
38
70
  }
39
71
 
@@ -48,10 +80,32 @@ public:
48
80
  DEBUG_LOG("[GetVolumeMetadataWorker] Executing for mount point: %s",
49
81
  mountPoint.c_str());
50
82
  try {
83
+ // Validate and canonicalize mount point using realpath()
84
+ // This follows Apple's Secure Coding Guide recommendations
85
+ std::string error;
86
+ std::string validated_mount_point =
87
+ ValidatePathForRead(mountPoint, error);
88
+ if (validated_mount_point.empty()) {
89
+ SetError(error);
90
+ return;
91
+ }
92
+
93
+ // Use validated path for all subsequent operations
94
+ DEBUG_LOG("[GetVolumeMetadataWorker] Using validated mount point: %s",
95
+ validated_mount_point.c_str());
96
+
97
+ // Temporarily store original mountPoint and replace with validated one
98
+ std::string original_mount_point = mountPoint;
99
+ mountPoint = validated_mount_point;
100
+
51
101
  if (!GetBasicVolumeInfo()) {
102
+ mountPoint = original_mount_point; // Restore for error reporting
52
103
  return;
53
104
  }
54
- GetDiskArbitrationInfo();
105
+ GetDiskArbitrationInfoSafe();
106
+
107
+ // Restore original for consistency
108
+ mountPoint = original_mount_point;
55
109
  } catch (const std::exception &e) {
56
110
  DEBUG_LOG("[GetVolumeMetadataWorker] Exception: %s", e.what());
57
111
  SetError(e.what());
@@ -64,23 +118,53 @@ private:
64
118
  bool GetBasicVolumeInfo() {
65
119
  DEBUG_LOG("[GetVolumeMetadataWorker] Getting basic volume info for: %s",
66
120
  mountPoint.c_str());
121
+
122
+ // Use file descriptors to prevent TOCTOU race conditions
123
+ // Open the mount point directory with O_DIRECTORY to ensure it's a
124
+ // directory
125
+ int fd = open(mountPoint.c_str(), O_RDONLY | O_DIRECTORY);
126
+ if (fd < 0) {
127
+ int error = errno;
128
+ DEBUG_LOG("[GetVolumeMetadataWorker] open failed: %s (%d)",
129
+ strerror(error), error);
130
+ SetError(CreatePathErrorMessage("open", mountPoint, error));
131
+ return false;
132
+ }
133
+
134
+ // RAII wrapper for file descriptor to ensure it's always closed
135
+ struct FdGuard {
136
+ int fd;
137
+ ~FdGuard() {
138
+ if (fd >= 0) {
139
+ close(fd);
140
+ }
141
+ }
142
+ } fd_guard{fd};
143
+
67
144
  struct statvfs vfs;
68
145
  struct statfs fs;
69
146
 
70
- if (statvfs(mountPoint.c_str(), &vfs) != 0) {
71
- DEBUG_LOG("[GetVolumeMetadataWorker] statvfs failed: %s (%d)",
72
- strerror(errno), errno);
73
- SetError(CreatePathErrorMessage("statvfs", mountPoint, errno));
147
+ // Use fstatvfs and fstatfs on the file descriptor to prevent TOCTOU
148
+ // The fd holds a reference to the filesystem, preventing mount changes
149
+ if (fstatvfs(fd, &vfs) != 0) {
150
+ int error = errno;
151
+ DEBUG_LOG("[GetVolumeMetadataWorker] fstatvfs failed: %s (%d)",
152
+ strerror(error), error);
153
+ SetError(CreatePathErrorMessage("fstatvfs", mountPoint, error));
74
154
  return false;
75
155
  }
76
156
 
77
- if (statfs(mountPoint.c_str(), &fs) != 0) {
78
- DEBUG_LOG("[GetVolumeMetadataWorker] statfs failed: %s (%d)",
79
- strerror(errno), errno);
80
- SetError(CreatePathErrorMessage("statfs", mountPoint, errno));
157
+ if (fstatfs(fd, &fs) != 0) {
158
+ int error = errno;
159
+ DEBUG_LOG("[GetVolumeMetadataWorker] fstatfs failed: %s (%d)",
160
+ strerror(error), error);
161
+ SetError(CreatePathErrorMessage("fstatfs", mountPoint, error));
81
162
  return false;
82
163
  }
83
164
 
165
+ // fd_guard will automatically close the file descriptor when this function
166
+ // returns
167
+
84
168
  // Calculate sizes using uint64_t to prevent overflow
85
169
  const uint64_t blockSize = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize;
86
170
  const uint64_t totalBlocks = static_cast<uint64_t>(vfs.f_blocks);
@@ -123,7 +207,7 @@ private:
123
207
  return true;
124
208
  }
125
209
 
126
- void GetDiskArbitrationInfo() {
210
+ void GetDiskArbitrationInfoSafe() {
127
211
  DEBUG_LOG("[GetVolumeMetadataWorker] Getting Disk Arbitration info for: %s",
128
212
  mountPoint.c_str());
129
213
 
@@ -135,8 +219,18 @@ private:
135
219
  return;
136
220
  }
137
221
 
138
- // Create session on current thread
139
- CFReleaser<DASessionRef> session(DASessionCreate(kCFAllocatorDefault));
222
+ // THREAD SAFETY NOTE:
223
+ // Apple's DiskArbitration Programming Guide recommends scheduling DASession
224
+ // on a run loop or dispatch queue before using it. We use a dedicated
225
+ // serial dispatch queue (not the main queue) to avoid deadlock in Node.js
226
+ // context while following Apple's documented usage pattern.
227
+ //
228
+ // The mutex serializes DA operations across worker threads for extra
229
+ // safety.
230
+ std::lock_guard<std::mutex> lock(g_diskArbitrationMutex);
231
+
232
+ // Create session with RAII wrapper that handles unscheduling before release
233
+ DASessionRAII session(DASessionCreate(kCFAllocatorDefault));
140
234
  if (!session.isValid()) {
141
235
  DEBUG_LOG("[GetVolumeMetadataWorker] Failed to create DA session");
142
236
  metadata.status = "partial";
@@ -144,41 +238,18 @@ private:
144
238
  return;
145
239
  }
146
240
 
147
- try {
148
- // Get thread-local runloop or create new one if needed
149
- CFRunLoopRef runLoop = CFRunLoopGetCurrent();
150
- if (!runLoop) {
151
- // If no runloop exists, create a new one for this thread
152
- CFRunLoopRun();
153
- runLoop = CFRunLoopGetCurrent();
154
- if (!runLoop) {
155
- throw std::runtime_error("Failed to create thread-local runloop");
156
- }
157
- }
158
-
159
- // Schedule session with our runloop
160
- DASessionScheduleWithRunLoop(session.get(), runLoop,
161
- kCFRunLoopDefaultMode);
162
-
163
- // Use RAII to ensure cleanup
164
- struct RunLoopCleaner {
165
- DASessionRef session;
166
- CFRunLoopRef runLoop;
167
- bool shouldStop;
168
- RunLoopCleaner(DASessionRef s, CFRunLoopRef l, bool stop = false)
169
- : session(s), runLoop(l), shouldStop(stop) {}
170
- ~RunLoopCleaner() {
171
- DASessionUnscheduleFromRunLoop(session, runLoop,
172
- kCFRunLoopDefaultMode);
173
- if (shouldStop) {
174
- CFRunLoopStop(runLoop);
175
- }
176
- }
177
- } scopeGuard(session.get(), runLoop, !CFRunLoopGetCurrent());
241
+ // Schedule session on a dedicated serial dispatch queue
242
+ // This follows Apple's documented pattern: create session, schedule it, use
243
+ // it. We use a background queue (not main queue) to avoid deadlock in
244
+ // Node.js. The RAII wrapper will automatically unschedule in its
245
+ // destructor.
246
+ static dispatch_queue_t da_queue =
247
+ dispatch_queue_create("com.photostructure.fs-metadata.diskarbitration",
248
+ DISPATCH_QUEUE_SERIAL);
178
249
 
179
- // Run the run loop briefly to ensure DA is ready
180
- CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, true);
250
+ session.scheduleOnQueue(da_queue);
181
251
 
252
+ try {
182
253
  CFReleaser<DADiskRef> disk(DADiskCreateFromBSDName(
183
254
  kCFAllocatorDefault, session.get(), metadata.mountFrom.c_str()));
184
255
 
@@ -186,23 +257,22 @@ private:
186
257
  DEBUG_LOG("[GetVolumeMetadataWorker] Failed to create disk reference");
187
258
  metadata.status = "partial";
188
259
  metadata.error = "Failed to create disk reference";
260
+ // RAII wrapper will automatically unschedule on function exit
189
261
  return;
190
262
  }
191
263
 
264
+ // Now safe to call DADiskCopyDescription with properly scheduled session
192
265
  CFReleaser<CFDictionaryRef> description(
193
266
  DADiskCopyDescription(disk.get()));
194
267
  if (!description.isValid()) {
195
268
  DEBUG_LOG("[GetVolumeMetadataWorker] Failed to get disk description");
196
269
  metadata.status = "partial";
197
270
  metadata.error = "Failed to get disk description";
271
+ // RAII wrapper will automatically unschedule on function exit
198
272
  return;
199
273
  }
200
274
 
201
- // Ensure we have a complete description before continuing
202
- CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, false);
203
-
204
- // Process description synchronously since we're already on the right
205
- // thread
275
+ // Process description immediately
206
276
  ProcessDiskDescription(description.get());
207
277
 
208
278
  if (metadata.status != "partial") {
@@ -214,6 +284,8 @@ private:
214
284
  metadata.status = "error";
215
285
  metadata.error = e.what();
216
286
  }
287
+
288
+ // RAII wrapper automatically unschedules and releases session here
217
289
  }
218
290
 
219
291
  void ProcessDiskDescription(CFDictionaryRef description) {
@@ -248,7 +320,9 @@ private:
248
320
  DEBUG_LOG("[GetVolumeMetadataWorker] Processing network volume");
249
321
  CFBooleanRef isNetworkVolume = (CFBooleanRef)CFDictionaryGetValue(
250
322
  description, kDADiskDescriptionVolumeNetworkKey);
251
- metadata.remote = CFBooleanGetValue(isNetworkVolume);
323
+ if (isNetworkVolume) {
324
+ metadata.remote = CFBooleanGetValue(isNetworkVolume);
325
+ }
252
326
 
253
327
  CFURLRef url = (CFURLRef)CFDictionaryGetValue(
254
328
  description, kDADiskDescriptionVolumePathKey);