@photostructure/fs-metadata 0.7.1 → 0.8.1

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 (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CLAUDE.md +1 -1
  3. package/CONTRIBUTING.md +15 -0
  4. package/README.md +7 -14
  5. package/dist/index.cjs +29 -4
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +11 -6
  8. package/dist/index.d.mts +11 -6
  9. package/dist/index.d.ts +11 -6
  10. package/dist/index.mjs +28 -3
  11. package/dist/index.mjs.map +1 -1
  12. package/doc/LINUX_API_REFERENCE.md +310 -0
  13. package/doc/MACOS_API_REFERENCE.md +367 -31
  14. package/doc/WINDOWS_API_REFERENCE.md +35 -2
  15. package/doc/gotchas.md +28 -0
  16. package/package.json +16 -38
  17. package/prebuilds/darwin-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  18. package/prebuilds/darwin-x64/@photostructure+fs-metadata.glibc.node +0 -0
  19. package/prebuilds/linux-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  20. package/prebuilds/linux-arm64/@photostructure+fs-metadata.musl.node +0 -0
  21. package/prebuilds/linux-x64/@photostructure+fs-metadata.glibc.node +0 -0
  22. package/prebuilds/linux-x64/@photostructure+fs-metadata.musl.node +0 -0
  23. package/prebuilds/win32-arm64/@photostructure+fs-metadata.glibc.node +0 -0
  24. package/prebuilds/win32-x64/@photostructure+fs-metadata.glibc.node +0 -0
  25. package/scripts/precommit.ts +4 -1
  26. package/src/common/fd_guard.h +71 -0
  27. package/src/{darwin → common}/path_security.h +8 -5
  28. package/src/common/volume_utils.h +51 -0
  29. package/src/darwin/hidden.cpp +47 -14
  30. package/src/darwin/raii_utils.h +8 -8
  31. package/src/darwin/volume_metadata.cpp +33 -39
  32. package/src/index.ts +3 -3
  33. package/src/linux/blkid_cache.cpp +5 -11
  34. package/src/linux/blkid_cache.h +21 -0
  35. package/src/linux/gio_utils.cpp +7 -23
  36. package/src/linux/gio_utils.h +16 -40
  37. package/src/linux/gio_volume_metadata.cpp +16 -88
  38. package/src/linux/volume_metadata.cpp +35 -27
  39. package/src/options.ts +35 -3
  40. package/src/types/options.ts +1 -1
  41. package/src/windows/drive_status.h +74 -49
  42. package/src/windows/error_utils.h +2 -2
  43. package/src/windows/security_utils.h +47 -2
  44. package/src/windows/thread_pool.h +29 -4
  45. package/src/windows/volume_metadata.cpp +17 -12
@@ -18,7 +18,8 @@ template <typename T> struct GObjectDeleter {
18
18
  }
19
19
  };
20
20
 
21
- // Custom deleter for g_free
21
+ // Custom deleter for g_free (used for strings from GIO APIs like
22
+ // g_file_get_path)
22
23
  struct GFreeDeleter {
23
24
  void operator()(void *ptr) const {
24
25
  if (ptr) {
@@ -27,18 +28,18 @@ struct GFreeDeleter {
27
28
  }
28
29
  };
29
30
 
30
- // Add this before any existing smart pointer definitions
31
- struct GFileInfoDeleter {
32
- void operator()(GFileInfo *ptr) {
33
- if (ptr)
34
- g_object_unref(ptr);
35
- }
36
- };
37
- using GFileInfoPtr = std::unique_ptr<GFileInfo, GFileInfoDeleter>;
38
-
39
- // Smart pointer aliases
31
+ // Smart pointer aliases for RAII management of GIO resources
32
+ // These ensure proper cleanup even when exceptions occur
40
33
  template <typename T> using GObjectPtr = std::unique_ptr<T, GObjectDeleter<T>>;
41
34
 
35
+ // Common GIO object types
36
+ using GFilePtr = GObjectPtr<GFile>;
37
+ using GMountPtr = GObjectPtr<GMount>;
38
+ using GVolumePtr = GObjectPtr<GVolume>;
39
+ using GVolumeMonitorPtr = GObjectPtr<GVolumeMonitor>;
40
+ using GFileInfoPtr = GObjectPtr<GFileInfo>;
41
+
42
+ // For strings allocated by GIO (g_file_get_path, g_file_get_uri, etc.)
42
43
  using GCharPtr = std::unique_ptr<char, GFreeDeleter>;
43
44
 
44
45
  namespace FSMeta {
@@ -55,37 +56,12 @@ public:
55
56
  // This is safe to call from worker threads
56
57
  static void forEachMount(const MountCallback &callback);
57
58
 
58
- // OPTIONAL: Try to get GVolumeMonitor for metadata enrichment
59
- // Returns nullptr if unavailable (that's OK, not required)
60
- // WARNING: Violates thread-safety when called from worker threads
61
- // Only use for best-effort enrichment
62
- static GVolumeMonitor *tryGetMonitor() noexcept;
59
+ // NOTE: tryGetMonitor() has been removed because GVolumeMonitor is NOT
60
+ // thread-safe. See: https://docs.gtk.org/gio/class.VolumeMonitor.html
63
61
  };
64
62
 
65
- // Helper class for scoped GIO resource management
66
- template <typename T> class GioResource {
67
- public:
68
- explicit GioResource(T *resource) : resource_(resource) {}
69
- ~GioResource() {
70
- if (resource_) {
71
- g_object_unref(resource_);
72
- }
73
- }
74
-
75
- T *get() const { return resource_; }
76
- T *release() {
77
- T *temp = resource_;
78
- resource_ = nullptr;
79
- return temp;
80
- }
81
-
82
- // Prevent copying
83
- GioResource(const GioResource &) = delete;
84
- GioResource &operator=(const GioResource &) = delete;
85
-
86
- private:
87
- T *resource_;
88
- };
63
+ // Note: GioResource<T> has been removed in favor of GObjectPtr<T> above,
64
+ // which provides equivalent RAII semantics with std::unique_ptr.
89
65
 
90
66
  } // namespace gio
91
67
  } // namespace FSMeta
@@ -49,94 +49,22 @@ void addMountMetadata(const std::string &mountPoint, VolumeMetadata &metadata) {
49
49
  }
50
50
  }
51
51
 
52
- // OPTIONAL ENHANCEMENT: Try to get rich metadata from GVolumeMonitor
53
- // This may fail (thread safety violation), but that's OK - we have basic
54
- // data
55
- try {
56
- GVolumeMonitor *monitor = MountIterator::tryGetMonitor();
57
- if (monitor) {
58
- DEBUG_LOG(
59
- "[gio::addMountMetadata] attempting GVolumeMonitor enrichment");
60
-
61
- // Try to find matching GMount for this path
62
- GList *mounts = g_volume_monitor_get_mounts(monitor);
63
- if (mounts) {
64
- for (GList *l = mounts; l != nullptr; l = l->next) {
65
- GMount *mount = G_MOUNT(l->data);
66
- if (!mount || !G_IS_MOUNT(mount)) {
67
- continue;
68
- }
69
-
70
- GFile *root = g_mount_get_root(mount);
71
- if (root) {
72
- char *path = g_file_get_path(root);
73
- if (path && mountPoint == path) {
74
- // Found matching mount - try to get rich metadata
75
-
76
- // Try to get volume label
77
- if (metadata.label.empty()) {
78
- GVolume *volume = g_mount_get_volume(mount);
79
- if (volume) {
80
- char *label = g_volume_get_name(volume);
81
- if (label) {
82
- DEBUG_LOG("[gio::addMountMetadata] {mountPoint: %s, "
83
- "label: %s} (from GVolume)",
84
- path, label);
85
- metadata.label = label;
86
- g_free(label);
87
- }
88
- g_object_unref(volume);
89
- }
90
- }
91
-
92
- // Try to get mount name
93
- if (metadata.mountName.empty()) {
94
- char *mount_name = g_mount_get_name(mount);
95
- if (mount_name) {
96
- metadata.mountName = mount_name;
97
- g_free(mount_name);
98
- }
99
- }
100
-
101
- // Try to get URI
102
- if (metadata.uri.empty()) {
103
- GFile *location = g_mount_get_default_location(mount);
104
- if (location) {
105
- char *uri = g_file_get_uri(location);
106
- if (uri) {
107
- DEBUG_LOG("[gio::addMountMetadata] {mountPoint: %s, uri: "
108
- "%s} (from GMount)",
109
- path, uri);
110
- metadata.uri = uri;
111
- g_free(uri);
112
- }
113
- g_object_unref(location);
114
- }
115
- }
116
-
117
- g_free(path);
118
- g_object_unref(root);
119
- break; // Found our mount
120
- }
121
- if (path)
122
- g_free(path);
123
- g_object_unref(root);
124
- }
125
- }
126
-
127
- // Clean up mounts list - this time correctly without double-free
128
- g_list_free_full(mounts,
129
- reinterpret_cast<GDestroyNotify>(g_object_unref));
130
- }
131
-
132
- // Note: Don't unref monitor - it's a singleton
133
- }
134
- } catch (const std::exception &e) {
135
- DEBUG_LOG("[gio::addMountMetadata] GVolumeMonitor enrichment failed "
136
- "(expected, not critical): %s",
137
- e.what());
138
- // Ignore - we have basic metadata from Unix mount API
139
- }
52
+ // NOTE: GVolumeMonitor enrichment has been removed.
53
+ //
54
+ // According to GIO documentation:
55
+ // https://docs.gtk.org/gio/class.VolumeMonitor.html
56
+ // "GVolumeMonitor is not thread-default-context aware and so should not
57
+ // be used other than from the main thread, with no thread-default-context
58
+ // active."
59
+ //
60
+ // This function is called from Napi::AsyncWorker::Execute() which runs
61
+ // on a worker thread. Using GVolumeMonitor here causes race conditions
62
+ // leading to GLib-GObject-CRITICAL errors like:
63
+ // "g_object_ref: assertion '!object_already_finalized' failed"
64
+ //
65
+ // The basic metadata (fstype, mountFrom) from g_unix_mounts_get() is
66
+ // sufficient and thread-safe. Rich metadata (label, mountName, uri) can
67
+ // be obtained from blkid or other thread-safe sources.
140
68
 
141
69
  return false; // Stop iteration, we found our mount
142
70
  });
@@ -2,12 +2,15 @@
2
2
  #include "../common/volume_metadata.h"
3
3
  #include "../common/debug_log.h"
4
4
  #include "../common/error_utils.h"
5
+ #include "../common/fd_guard.h"
5
6
  #include "../common/metadata_worker.h"
7
+ #include "../common/path_security.h"
8
+ #include "../common/volume_utils.h"
6
9
  #include "blkid_cache.h"
7
10
  #include <fcntl.h> // for open(), O_DIRECTORY, O_RDONLY, O_CLOEXEC
8
11
  #include <memory>
9
12
  #include <sys/statvfs.h>
10
- #include <unistd.h> // for close()
13
+ #include <unistd.h>
11
14
 
12
15
  #ifdef ENABLE_GIO
13
16
  #include "gio_volume_metadata.h"
@@ -32,6 +35,18 @@ public:
32
35
  DEBUG_LOG("[LinuxMetadataWorker] starting statvfs for %s",
33
36
  mountPoint.c_str());
34
37
 
38
+ // Validate and canonicalize mount point using realpath()
39
+ // This prevents directory traversal attacks and resolves symlinks
40
+ std::string error;
41
+ std::string validated_mount_point =
42
+ ValidatePathForRead(mountPoint, error);
43
+ if (validated_mount_point.empty()) {
44
+ throw FSException(error);
45
+ }
46
+
47
+ DEBUG_LOG("[LinuxMetadataWorker] Using validated mount point: %s",
48
+ validated_mount_point.c_str());
49
+
35
50
  // SECURITY: Use file descriptor-based approach to prevent TOCTOU race
36
51
  // condition
37
52
  //
@@ -45,23 +60,18 @@ public:
45
60
  // O_DIRECTORY: Ensures we're opening a directory, fails if not
46
61
  // O_RDONLY: Read-only access (sufficient for fstatvfs)
47
62
  // O_CLOEXEC: Close on exec (prevents fd leaks in multithreaded programs)
48
- int fd = open(mountPoint.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC);
63
+ int fd = open(validated_mount_point.c_str(),
64
+ O_DIRECTORY | O_RDONLY | O_CLOEXEC);
49
65
  if (fd < 0) {
50
66
  int error = errno;
51
67
  DEBUG_LOG("[LinuxMetadataWorker] open failed for %s: %s (%d)",
52
- mountPoint.c_str(), strerror(error), error);
53
- throw FSException(CreatePathErrorMessage("open", mountPoint, error));
68
+ validated_mount_point.c_str(), strerror(error), error);
69
+ throw FSException(
70
+ CreatePathErrorMessage("open", validated_mount_point, error));
54
71
  }
55
72
 
56
73
  // RAII guard to ensure file descriptor is always closed
57
- struct FdGuard {
58
- int fd;
59
- ~FdGuard() {
60
- if (fd >= 0) {
61
- close(fd);
62
- }
63
- }
64
- } fd_guard{fd};
74
+ FdGuard fd_guard(fd);
65
75
 
66
76
  // Use fstatvfs on the file descriptor instead of statvfs on the path
67
77
  // The fd holds a reference to the filesystem, preventing TOCTOU issues
@@ -69,9 +79,9 @@ public:
69
79
  if (fstatvfs(fd, &vfs) != 0) {
70
80
  int error = errno;
71
81
  DEBUG_LOG("[LinuxMetadataWorker] fstatvfs failed for %s: %s (%d)",
72
- mountPoint.c_str(), strerror(error), error);
82
+ validated_mount_point.c_str(), strerror(error), error);
73
83
  throw FSException(
74
- CreatePathErrorMessage("fstatvfs", mountPoint, error));
84
+ CreatePathErrorMessage("fstatvfs", validated_mount_point, error));
75
85
  }
76
86
 
77
87
  // fd_guard will automatically close the file descriptor when this
@@ -83,16 +93,14 @@ public:
83
93
  const uint64_t freeBlocks = static_cast<uint64_t>(vfs.f_bfree);
84
94
 
85
95
  // Check for overflow before multiplication
86
- if (blockSize > 0) {
87
- if (totalBlocks > std::numeric_limits<uint64_t>::max() / blockSize) {
88
- throw FSException("Total volume size calculation would overflow");
89
- }
90
- if (availBlocks > std::numeric_limits<uint64_t>::max() / blockSize) {
91
- throw FSException("Available space calculation would overflow");
92
- }
93
- if (freeBlocks > std::numeric_limits<uint64_t>::max() / blockSize) {
94
- throw FSException("Free space calculation would overflow");
95
- }
96
+ if (WouldOverflow(blockSize, totalBlocks)) {
97
+ throw FSException("Total volume size calculation would overflow");
98
+ }
99
+ if (WouldOverflow(blockSize, availBlocks)) {
100
+ throw FSException("Available space calculation would overflow");
101
+ }
102
+ if (WouldOverflow(blockSize, freeBlocks)) {
103
+ throw FSException("Free space calculation would overflow");
96
104
  }
97
105
 
98
106
  metadata.remote = false;
@@ -108,11 +116,11 @@ public:
108
116
  #ifdef ENABLE_GIO
109
117
  try {
110
118
  DEBUG_LOG("[LinuxMetadataWorker] collecting GIO metadata for %s",
111
- mountPoint.c_str());
112
- gio::addMountMetadata(mountPoint, metadata);
119
+ validated_mount_point.c_str());
120
+ gio::addMountMetadata(validated_mount_point, metadata);
113
121
  } catch (const std::exception &e) {
114
122
  DEBUG_LOG("[LinuxMetadataWorker] GIO error for %s: %s",
115
- mountPoint.c_str(), e.what());
123
+ validated_mount_point.c_str(), e.what());
116
124
  metadata.status = std::string("GIO warning: ") + e.what();
117
125
  }
118
126
  #endif
package/src/options.ts CHANGED
@@ -1,17 +1,30 @@
1
1
  // src/options.ts
2
2
 
3
3
  import { availableParallelism } from "node:os";
4
+ import { env } from "node:process";
4
5
  import { compactValues, isObject } from "./object";
5
6
  import { isWindows } from "./platform";
6
7
  import type { Options } from "./types/options";
7
8
 
9
+ const DefaultTimeoutMs = 5_000;
10
+
8
11
  /**
9
- * Default timeout in milliseconds for {@link Options.timeoutMs}.
12
+ * Get the default timeout in milliseconds for {@link Options.timeoutMs}.
13
+ *
14
+ * This can be overridden by setting the `FS_METADATA_TIMEOUT_MS` environment
15
+ * variable to a positive integer.
10
16
  *
11
17
  * Note that this timeout may be insufficient for some devices, like spun-down
12
18
  * optical drives or network shares that need to spin up or reconnect.
19
+ *
20
+ * @returns The timeout from env var if valid, otherwise 5000ms
13
21
  */
14
- export const TimeoutMsDefault = 5_000 as const;
22
+ export function getTimeoutMsDefault(): number {
23
+ const value = env["FS_METADATA_TIMEOUT_MS"];
24
+ if (value == null) return DefaultTimeoutMs;
25
+ const parsed = parseInt(value, 10);
26
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DefaultTimeoutMs;
27
+ }
15
28
 
16
29
  /**
17
30
  * System paths and globs that indicate system volumes
@@ -35,6 +48,25 @@ export const SystemPathPatternsDefault = [
35
48
  // we aren't including /tmp/**, as some people temporarily mount volumes there, like /tmp/project.
36
49
  "**/#snapshot", // Synology and Kubernetes volume snapshots
37
50
 
51
+ // Container runtime paths - these are internal infrastructure paths that are
52
+ // inaccessible to non-root processes and should never be scanned.
53
+ //
54
+ // Docker: https://docs.docker.com/engine/storage/drivers/overlayfs-driver/
55
+ // - /var/lib/docker contains overlay2 filesystems, container layers, images
56
+ // - /run/docker contains runtime data like network namespaces
57
+ "/run/docker/**",
58
+ "/var/lib/docker/**",
59
+ //
60
+ // containerd: https://github.com/containerd/containerd/blob/main/docs/ops.md
61
+ // - Used by Kubernetes, Docker (as backend), and standalone
62
+ "/run/containerd/**",
63
+ "/var/lib/containerd/**",
64
+ //
65
+ // Podman/CRI-O: https://podman.io/docs/installation#storage
66
+ // - Rootless and rootful container storage
67
+ "/run/containers/**",
68
+ "/var/lib/containers/**",
69
+
38
70
  // windows for linux:
39
71
  "/mnt/wslg/distro",
40
72
  "/mnt/wslg/doc",
@@ -104,7 +136,7 @@ export const SkipNetworkVolumesDefault = false;
104
136
  * @see {@link optionsWithDefaults} for creating an options object with default values
105
137
  */
106
138
  export const OptionsDefault: Options = {
107
- timeoutMs: TimeoutMsDefault,
139
+ timeoutMs: getTimeoutMsDefault(),
108
140
  maxConcurrency: availableParallelism(),
109
141
  systemPathPatterns: [...SystemPathPatternsDefault],
110
142
  systemFsTypes: [...SystemFsTypesDefault],
@@ -12,7 +12,7 @@ export interface Options {
12
12
  *
13
13
  * Disable timeouts by setting this to 0.
14
14
  *
15
- * @see {@link TimeoutMsDefault}.
15
+ * @see {@link getTimeoutMsDefault}.
16
16
  */
17
17
  timeoutMs: number;
18
18
 
@@ -4,13 +4,9 @@
4
4
  #include "security_utils.h"
5
5
  #include "thread_pool.h"
6
6
  #include "windows_arch.h"
7
- #include <atomic>
8
7
  #include <chrono>
9
- #include <condition_variable>
10
8
  #include <future>
11
- #include <mutex>
12
9
  #include <string>
13
- #include <thread>
14
10
  #include <vector>
15
11
 
16
12
  namespace FSMeta {
@@ -83,7 +79,10 @@ private:
83
79
  searchPath += "*";
84
80
 
85
81
  WIN32_FIND_DATAA findData;
86
- HandleGuard findHandle(FindFirstFileExA(
82
+ // Use FindHandleGuard - search handles MUST be closed with FindClose,
83
+ // not CloseHandle. See:
84
+ // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findclose
85
+ FindHandleGuard findHandle(FindFirstFileExA(
87
86
  searchPath.c_str(), FindExInfoBasic, &findData, FindExSearchNameMatch,
88
87
  nullptr,
89
88
  FIND_FIRST_EX_LARGE_FETCH | FIND_FIRST_EX_ON_DISK_ENTRIES_ONLY));
@@ -96,69 +95,77 @@ private:
96
95
  }
97
96
 
98
97
  // Successfully opened - drive is healthy
99
- FindClose(findHandle.release());
98
+ // FindHandleGuard destructor will call FindClose automatically
100
99
  DEBUG_LOG("[DriveStatusChecker] Drive %s is healthy", path.c_str());
101
100
  return DriveStatus::Healthy;
102
101
  }
103
102
 
104
103
  public:
105
- static std::future<DriveStatus> CheckDriveAsync(const std::string &path,
106
- DWORD timeoutMs = 5000) {
104
+ // Submit a drive check to the thread pool and return a future.
105
+ // The caller is responsible for enforcing timeout via future.wait_for().
106
+ // This design avoids detached threads and race conditions.
107
+ static std::future<DriveStatus> CheckDriveAsync(const std::string &path) {
107
108
  auto promise = std::make_shared<std::promise<DriveStatus>>();
108
109
  auto future = promise->get_future();
109
110
 
110
- // Use a shared state for timeout handling
111
- auto state = std::make_shared<std::atomic<bool>>(false);
112
-
113
- GetGlobalThreadPool().Submit([path, promise, state, timeoutMs]() {
114
- // Set up timeout
115
- auto startTime = std::chrono::steady_clock::now();
116
-
117
- // Perform the check
118
- DriveStatus status = CheckDriveInternal(path);
119
-
120
- // Check if we've timed out
121
- auto elapsed = std::chrono::steady_clock::now() - startTime;
122
- if (elapsed > std::chrono::milliseconds(timeoutMs)) {
123
- status = DriveStatus::Timeout;
124
- }
125
-
126
- // Only set the promise if we haven't been cancelled
127
- if (!state->load()) {
111
+ GetGlobalThreadPool().Submit([path, promise]() {
112
+ try {
113
+ DriveStatus status = CheckDriveInternal(path);
128
114
  promise->set_value(status);
115
+ } catch (const std::exception &e) {
116
+ DEBUG_LOG("[DriveStatusChecker] Exception in CheckDriveInternal: %s",
117
+ e.what());
118
+ // Set exception instead of value so caller can handle it
119
+ try {
120
+ promise->set_exception(std::current_exception());
121
+ } catch (...) {
122
+ // Promise may have been abandoned if caller timed out
123
+ }
124
+ } catch (...) {
125
+ DEBUG_LOG(
126
+ "[DriveStatusChecker] Unknown exception in CheckDriveInternal");
127
+ try {
128
+ promise->set_exception(std::current_exception());
129
+ } catch (...) {
130
+ // Promise may have been abandoned if caller timed out
131
+ }
129
132
  }
130
133
  });
131
134
 
132
- // Handle timeout in the caller
133
- if (timeoutMs != INFINITE) {
134
- std::thread([promise, state, timeoutMs]() {
135
- std::this_thread::sleep_for(std::chrono::milliseconds(timeoutMs));
136
- if (!state->exchange(true)) {
137
- try {
138
- promise->set_value(DriveStatus::Timeout);
139
- } catch (...) {
140
- // Promise already satisfied
141
- }
142
- }
143
- }).detach();
144
- }
145
-
146
135
  return future;
147
136
  }
148
137
 
138
+ // Overload that accepts timeoutMs for API compatibility (timeout is enforced
139
+ // in CheckDrive, not here)
140
+ static std::future<DriveStatus> CheckDriveAsync(const std::string &path,
141
+ DWORD /*timeoutMs*/) {
142
+ return CheckDriveAsync(path);
143
+ }
144
+
149
145
  static DriveStatus CheckDrive(const std::string &path,
150
146
  DWORD timeoutMs = 5000) {
151
147
  try {
152
- auto future = CheckDriveAsync(path, timeoutMs);
148
+ auto future = CheckDriveAsync(path);
149
+
150
+ // Use wait_for to enforce timeout - no detached threads needed!
151
+ // The worker thread continues running but we return Timeout to caller.
152
+ // The promise will eventually be satisfied (or abandoned).
153
+ auto waitResult = future.wait_for(std::chrono::milliseconds(timeoutMs));
153
154
 
154
- if (future.wait_for(std::chrono::milliseconds(timeoutMs)) ==
155
- std::future_status::timeout) {
155
+ if (waitResult == std::future_status::timeout) {
156
+ DEBUG_LOG("[DriveStatusChecker] Timeout waiting for drive %s",
157
+ path.c_str());
156
158
  return DriveStatus::Timeout;
157
159
  }
158
160
 
161
+ // Future is ready - get the result (may throw if worker set exception)
159
162
  return future.get();
163
+ } catch (const std::exception &e) {
164
+ DEBUG_LOG("[DriveStatusChecker] Exception checking drive %s: %s",
165
+ path.c_str(), e.what());
166
+ return DriveStatus::Unknown;
160
167
  } catch (...) {
161
- DEBUG_LOG("[DriveStatusChecker] Exception checking drive %s",
168
+ DEBUG_LOG("[DriveStatusChecker] Unknown exception checking drive %s",
162
169
  path.c_str());
163
170
  return DriveStatus::Unknown;
164
171
  }
@@ -172,24 +179,42 @@ public:
172
179
  futures.reserve(paths.size());
173
180
 
174
181
  // Launch all checks concurrently
182
+ auto startTime = std::chrono::steady_clock::now();
175
183
  for (const auto &path : paths) {
176
- futures.push_back(CheckDriveAsync(path, timeoutMs));
184
+ futures.push_back(CheckDriveAsync(path));
177
185
  }
178
186
 
179
- // Collect results
187
+ // Collect results with timeout
180
188
  std::vector<DriveStatus> results;
181
189
  results.reserve(paths.size());
182
190
 
183
191
  for (size_t i = 0; i < futures.size(); ++i) {
184
192
  try {
185
- if (futures[i].wait_for(std::chrono::milliseconds(timeoutMs)) ==
186
- std::future_status::timeout) {
193
+ // Calculate remaining time for this future
194
+ auto elapsed = std::chrono::steady_clock::now() - startTime;
195
+ auto elapsedMs =
196
+ std::chrono::duration_cast<std::chrono::milliseconds>(elapsed)
197
+ .count();
198
+ auto remainingMs = (elapsedMs < static_cast<long long>(timeoutMs))
199
+ ? static_cast<DWORD>(timeoutMs - elapsedMs)
200
+ : 0;
201
+
202
+ if (remainingMs == 0 ||
203
+ futures[i].wait_for(std::chrono::milliseconds(remainingMs)) ==
204
+ std::future_status::timeout) {
205
+ DEBUG_LOG("[DriveStatusChecker] Timeout waiting for drive %s",
206
+ paths[i].c_str());
187
207
  results.push_back(DriveStatus::Timeout);
188
208
  } else {
189
209
  results.push_back(futures[i].get());
190
210
  }
211
+ } catch (const std::exception &e) {
212
+ DEBUG_LOG(
213
+ "[DriveStatusChecker] Exception getting result for drive %s: %s",
214
+ paths[i].c_str(), e.what());
215
+ results.push_back(DriveStatus::Unknown);
191
216
  } catch (...) {
192
- DEBUG_LOG("[DriveStatusChecker] Exception getting result for drive %s",
217
+ DEBUG_LOG("[DriveStatusChecker] Unknown exception for drive %s",
193
218
  paths[i].c_str());
194
219
  results.push_back(DriveStatus::Unknown);
195
220
  }
@@ -62,8 +62,8 @@ private:
62
62
  size_t size = FormatMessageA(
63
63
  FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
64
64
  FORMAT_MESSAGE_IGNORE_INSERTS,
65
- NULL, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
66
- (LPSTR)&messageBuffer, 0, NULL);
65
+ nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
66
+ (LPSTR)&messageBuffer, 0, nullptr);
67
67
 
68
68
  // RAII guard ensures LocalFree is called even if exception thrown
69
69
  LocalFreeGuard guard(messageBuffer);
@@ -124,7 +124,7 @@ public:
124
124
 
125
125
  // Check if process has required privileges
126
126
  static bool HasRequiredPrivileges() {
127
- HANDLE token;
127
+ HANDLE token = nullptr;
128
128
  if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
129
129
  return false;
130
130
  }
@@ -169,7 +169,8 @@ public:
169
169
  }
170
170
  };
171
171
 
172
- // RAII wrapper for HANDLE resources
172
+ // RAII wrapper for HANDLE resources (uses CloseHandle)
173
+ // For search handles from FindFirstFile*, use FindHandleGuard instead
173
174
  class HandleGuard {
174
175
  HANDLE handle;
175
176
 
@@ -213,6 +214,48 @@ public:
213
214
  }
214
215
  };
215
216
 
217
+ // RAII wrapper for search handles from FindFirstFile/FindFirstFileEx
218
+ // These handles MUST be closed with FindClose, not CloseHandle.
219
+ // See:
220
+ // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findclose
221
+ class FindHandleGuard {
222
+ HANDLE handle;
223
+
224
+ public:
225
+ explicit FindHandleGuard(HANDLE h) : handle(h) {}
226
+
227
+ ~FindHandleGuard() {
228
+ if (handle != INVALID_HANDLE_VALUE) {
229
+ FindClose(handle);
230
+ }
231
+ }
232
+
233
+ FindHandleGuard(FindHandleGuard &&other) noexcept : handle(other.handle) {
234
+ other.handle = INVALID_HANDLE_VALUE;
235
+ }
236
+
237
+ FindHandleGuard &operator=(FindHandleGuard &&other) noexcept {
238
+ if (this != &other) {
239
+ if (handle != INVALID_HANDLE_VALUE) {
240
+ FindClose(handle);
241
+ }
242
+ handle = other.handle;
243
+ other.handle = INVALID_HANDLE_VALUE;
244
+ }
245
+ return *this;
246
+ }
247
+
248
+ // Delete copy operations
249
+ FindHandleGuard(const FindHandleGuard &) = delete;
250
+ FindHandleGuard &operator=(const FindHandleGuard &) = delete;
251
+
252
+ HANDLE get() const { return handle; }
253
+
254
+ // Check if handle is valid (FindFirstFile returns INVALID_HANDLE_VALUE on
255
+ // failure, not NULL)
256
+ explicit operator bool() const { return handle != INVALID_HANDLE_VALUE; }
257
+ };
258
+
216
259
  // RAII wrapper for critical sections
217
260
  class CriticalSectionGuard {
218
261
  CRITICAL_SECTION cs;
@@ -244,6 +287,8 @@ public:
244
287
  // Delete copy/move
245
288
  Lock(const Lock &) = delete;
246
289
  Lock &operator=(const Lock &) = delete;
290
+ Lock(Lock &&) = delete;
291
+ Lock &operator=(Lock &&) = delete;
247
292
  };
248
293
  };
249
294