@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,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Custom install script that handles Windows architecture defines
5
+ * when node-gyp-build needs to compile from source
6
+ */
7
+
8
+ const { spawn } = require("child_process");
9
+ const { platform, arch } = require("os");
10
+
11
+ // If in CI and on Windows, set architecture defines
12
+ if (process.env.CI && platform() === "win32") {
13
+ const currentArch = arch();
14
+
15
+ // Set architecture-specific defines for Windows
16
+ if (currentArch === "x64") {
17
+ process.env.CL = "/D_M_X64 /D_WIN64 /D_AMD64_";
18
+ } else if (currentArch === "arm64") {
19
+ process.env.CL = "/D_M_ARM64 /D_WIN64";
20
+ }
21
+
22
+ console.log(`Windows CI detected: arch=${currentArch}, CL=${process.env.CL}`);
23
+ }
24
+
25
+ // Run node-gyp-build
26
+ const child = spawn("npx", ["node-gyp-build"], {
27
+ stdio: "inherit",
28
+ shell: true,
29
+ env: process.env,
30
+ });
31
+
32
+ child.on("error", (error) => {
33
+ console.error("Failed to run node-gyp-build:", error);
34
+ process.exit(1);
35
+ });
36
+
37
+ child.on("exit", (code) => {
38
+ if (code !== 0) {
39
+ console.error(`node-gyp-build exited with code ${code}`);
40
+ process.exit(code || 1);
41
+ }
42
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { platform } from "os";
2
+ import { platform } from "node:os";
3
3
 
4
4
  const targetPlatform = process.argv[2];
5
5
  if (!targetPlatform) {
@@ -0,0 +1,155 @@
1
+ #!/bin/bash
2
+ # macOS AddressSanitizer test script
3
+
4
+ set -euo pipefail
5
+
6
+ # Colors for output
7
+ RED='\033[0;31m'
8
+ GREEN='\033[0;32m'
9
+ YELLOW='\033[1;33m'
10
+ NC='\033[0m' # No Color
11
+
12
+ echo -e "${YELLOW}=== macOS AddressSanitizer Memory Test ===${NC}"
13
+
14
+ # Check if we're on macOS
15
+ if [[ "$(uname)" != "Darwin" ]]; then
16
+ echo -e "${YELLOW}Not on macOS. Skipping macOS-specific memory tests.${NC}"
17
+ exit 0
18
+ fi
19
+
20
+ # Clean and rebuild with AddressSanitizer
21
+ echo -e "${YELLOW}Cleaning previous builds...${NC}"
22
+ npm run clean:native
23
+
24
+ # Configure build with ASan flags
25
+ echo -e "${YELLOW}Building with AddressSanitizer enabled...${NC}"
26
+ export CFLAGS="-fsanitize=address -g -O1 -fno-omit-frame-pointer"
27
+ export CXXFLAGS="-fsanitize=address -g -O1 -fno-omit-frame-pointer"
28
+ export LDFLAGS="-fsanitize=address"
29
+
30
+ # Set ASan options
31
+ export ASAN_OPTIONS="detect_leaks=1:check_initialization_order=1:strict_init_order=1:print_stats=1:halt_on_error=0"
32
+ export MallocScribble=1
33
+ export MallocGuardEdges=1
34
+
35
+ # Find and set the ASan library path for macOS
36
+ # First try to find the most recent version
37
+ ASAN_LIB=$(find /Library/Developer/CommandLineTools/usr/lib/clang/*/lib/darwin -name "libclang_rt.asan_osx_dynamic.dylib" 2>/dev/null | sort -V | tail -1)
38
+ if [[ -z "$ASAN_LIB" ]]; then
39
+ # Try alternative location
40
+ ASAN_LIB=$(find /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/*/lib/darwin -name "libclang_rt.asan_osx_dynamic.dylib" 2>/dev/null | sort -V | tail -1)
41
+ fi
42
+
43
+ if [[ -n "$ASAN_LIB" ]]; then
44
+ export DYLD_INSERT_LIBRARIES="$ASAN_LIB"
45
+ echo -e "${GREEN}Using ASan library: $ASAN_LIB${NC}"
46
+ else
47
+ echo -e "${RED}Warning: Could not find ASan library. Tests may not run properly.${NC}"
48
+ fi
49
+
50
+ # Build the native module
51
+ npm run node-gyp-rebuild
52
+
53
+ # Run tests with ASan
54
+ echo -e "${YELLOW}Running tests with AddressSanitizer...${NC}"
55
+
56
+ # Note: On macOS with SIP enabled, DYLD_INSERT_LIBRARIES is stripped from
57
+ # child processes. Jest uses worker processes, so we need to run tests
58
+ # in a single process to ensure ASAN works correctly.
59
+
60
+ # Run the test and capture output
61
+ TEST_OUTPUT=$(npm test -- --runInBand 2>&1)
62
+ TEST_EXIT_CODE=$?
63
+
64
+ if [[ $TEST_EXIT_CODE -eq 0 ]]; then
65
+ echo -e "${GREEN}✓ Tests passed with AddressSanitizer${NC}"
66
+ else
67
+ # Check if the failure is due to SIP interceptor issues
68
+ if echo "$TEST_OUTPUT" | grep -q "interceptors not installed"; then
69
+ echo -e "${YELLOW}⚠ Tests completed but AddressSanitizer interceptors not installed${NC}"
70
+ echo -e "${YELLOW} This is due to macOS System Integrity Protection (SIP) stripping${NC}"
71
+ echo -e "${YELLOW} DYLD_INSERT_LIBRARIES from child processes. This is expected behavior.${NC}"
72
+ echo -e "${YELLOW} To run ASAN tests properly, you may need to disable SIP temporarily.${NC}"
73
+ # Don't treat this as a failure
74
+ else
75
+ echo -e "${RED}✗ Tests failed with AddressSanitizer${NC}"
76
+ echo "$TEST_OUTPUT"
77
+ # This is a real failure, exit with error
78
+ exit 1
79
+ fi
80
+ fi
81
+
82
+ # Run memory leak check using leaks tool
83
+ echo -e "${YELLOW}Running macOS leaks tool...${NC}"
84
+ if command -v leaks >/dev/null 2>&1; then
85
+ # Get the project root directory
86
+ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
87
+
88
+ # Check if the native module exists
89
+ if [ ! -f "${PROJECT_ROOT}/build/Release/fs_metadata.node" ]; then
90
+ echo -e "${RED}Native module not found. Skipping leaks test.${NC}"
91
+ exit 1
92
+ fi
93
+
94
+ # Create a simple test script
95
+ cat > /tmp/test-leaks.js << EOF
96
+ const fs = require('fs');
97
+ const binding = require('${PROJECT_ROOT}/build/Release/fs_metadata.node');
98
+
99
+ async function testVolumeMountPoints() {
100
+ for (let i = 0; i < 100; i++) {
101
+ await binding.getVolumeMountPoints();
102
+ }
103
+ }
104
+
105
+ async function testVolumeMetadata() {
106
+ const mountPoints = await binding.getVolumeMountPoints();
107
+ if (mountPoints.length > 0) {
108
+ for (let i = 0; i < 10; i++) {
109
+ await binding.getVolumeMetadata({ mountPoint: mountPoints[0].mountPoint });
110
+ }
111
+ }
112
+ }
113
+
114
+ async function runTests() {
115
+ await testVolumeMountPoints();
116
+ await testVolumeMetadata();
117
+
118
+ // Force garbage collection if available
119
+ if (global.gc) {
120
+ global.gc();
121
+ }
122
+ }
123
+
124
+ runTests().then(() => {
125
+ console.log('Tests completed');
126
+ // Give time for cleanup
127
+ setTimeout(() => process.exit(0), 1000);
128
+ }).catch(err => {
129
+ console.error('Test failed:', err);
130
+ process.exit(1);
131
+ });
132
+ EOF
133
+
134
+ # Run with leaks detection
135
+ echo -e "${YELLOW}Executing memory leak test...${NC}"
136
+ if leaks --atExit -- node --expose-gc /tmp/test-leaks.js > /tmp/leaks-output.txt 2>&1; then
137
+ echo -e "${GREEN}✓ No memory leaks detected${NC}"
138
+ # Show summary if available
139
+ if grep -q "Process" /tmp/leaks-output.txt; then
140
+ echo -e "${YELLOW}Summary:${NC}"
141
+ grep -E "(Process|leaks for|total leaked bytes)" /tmp/leaks-output.txt || true
142
+ fi
143
+ else
144
+ echo -e "${RED}✗ Memory leaks detected or leaks tool failed:${NC}"
145
+ cat /tmp/leaks-output.txt
146
+ # Don't exit with failure - leaks tool can have false positives
147
+ echo -e "${YELLOW}Note: The leaks tool may report false positives from Node.js internals.${NC}"
148
+ fi
149
+
150
+ rm -f /tmp/test-leaks.js /tmp/leaks-output.txt
151
+ else
152
+ echo -e "${YELLOW}leaks tool not available. Skipping native leak detection.${NC}"
153
+ fi
154
+
155
+ echo -e "${GREEN}=== All macOS memory tests passed! ===${NC}"
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { copyFile } from "fs/promises";
4
- import { dirname, join } from "path";
5
- import { fileURLToPath } from "url";
3
+ import { copyFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const distDir = join(__dirname, "..", "dist");
@@ -97,11 +97,22 @@ docker exec "$CONTAINER_NAME" sh -c "
97
97
  $BUILD_CMD
98
98
  "
99
99
 
100
- # Copy artifacts back with proper ownership
100
+ # Copy artifacts back
101
101
  docker cp "$CONTAINER_NAME:/tmp/project/prebuilds" . 2>/dev/null || true
102
102
  docker cp "$CONTAINER_NAME:/tmp/project/build" . 2>/dev/null || true
103
103
  docker cp "$CONTAINER_NAME:/tmp/project/config.gypi" . 2>/dev/null || true
104
104
 
105
+ # Fix ownership (docker cp preserves container's root ownership)
106
+ if [ -d prebuilds ]; then
107
+ chown -R "$(id -u):$(id -g)" prebuilds
108
+ fi
109
+ if [ -d build ]; then
110
+ chown -R "$(id -u):$(id -g)" build
111
+ fi
112
+ if [ -f config.gypi ]; then
113
+ chown "$(id -u):$(id -g)" config.gypi
114
+ fi
115
+
105
116
  # Clean up container
106
117
  docker rm -f "$CONTAINER_NAME" >/dev/null
107
118
 
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { arch, platform } from "node:os";
5
+
6
+ /**
7
+ * Wrapper for prebuildify to ensure architecture is explicitly passed This
8
+ * works around the issue where prebuildify doesn't properly evaluate
9
+ * binding.gyp conditions for Windows architecture defines
10
+ *
11
+ * NOTE: if you don't include <windows.h> in your binding.gyp, this script is
12
+ * unnecessary.
13
+ */
14
+
15
+ // Get the current architecture and platform
16
+ const currentArch = arch(); // 'x64', 'arm64', etc.
17
+ const currentPlatform = platform(); // 'win32', 'darwin', 'linux'
18
+
19
+ console.log(`Building for platform: ${currentPlatform}, arch: ${currentArch}`);
20
+
21
+ // Set up environment variables to help node-gyp
22
+ const env = { ...process.env };
23
+
24
+ // Set architecture-specific defines for Windows
25
+ if (currentPlatform === "win32") {
26
+ // Try various environment variables that might work
27
+ env.npm_config_arch = currentArch;
28
+ env.npm_config_target_arch = currentArch;
29
+ env.PREBUILD_ARCH = currentArch;
30
+
31
+ // Try setting compiler flags directly
32
+ if (currentArch === "x64") {
33
+ env.CL = "/D_M_X64 /D_WIN64 /D_AMD64_";
34
+ } else if (currentArch === "arm64") {
35
+ env.CL = "/D_M_ARM64 /D_WIN64";
36
+ }
37
+ }
38
+
39
+ // Build the prebuildify command with explicit architecture
40
+ const args = [
41
+ "--napi",
42
+ "--tag-libc",
43
+ "--strip",
44
+ "--arch",
45
+ currentArch,
46
+ "--platform",
47
+ currentPlatform,
48
+ ];
49
+
50
+ // Add any additional arguments passed to this script
51
+ if (process.argv.length > 2) {
52
+ args.push(...process.argv.slice(2));
53
+ }
54
+
55
+ console.log(`Running: prebuildify ${args.join(" ")}`);
56
+ if (currentPlatform === "win32" && env.CL) {
57
+ console.log(`CL environment variable: ${env.CL}`);
58
+ }
59
+
60
+ // Spawn prebuildify with the arguments
61
+ const child = spawn("prebuildify", args, {
62
+ stdio: "inherit",
63
+ shell: true,
64
+ env,
65
+ });
66
+
67
+ child.on("error", (error) => {
68
+ console.error("Failed to start prebuildify:", error);
69
+ process.exit(1);
70
+ });
71
+
72
+ child.on("exit", (code) => {
73
+ if (code !== 0) {
74
+ console.error(`prebuildify exited with code ${code}`);
75
+ process.exit(code || 1);
76
+ }
77
+ });
@@ -1,45 +1,70 @@
1
- import { familySync } from "detect-libc";
2
1
  import { execSync } from "node:child_process";
2
+ import { rmSync } from "node:fs";
3
3
  import { platform } from "node:os";
4
+ import { exit } from "node:process";
4
5
 
5
6
  const isLinux = platform() === "linux";
6
7
  const isMacOS = platform() === "darwin";
7
- const isGlibc = isLinux && familySync() === "glibc";
8
8
 
9
- function run(command: string, description: string) {
10
- console.log(`\n▶ ${description ?? command}`);
9
+ function run({
10
+ cmd,
11
+ desc,
12
+ exitOnFail: shouldExit = true,
13
+ }: {
14
+ cmd: string;
15
+ desc: string;
16
+ exitOnFail?: boolean;
17
+ }) {
18
+ console.log(`\n▶ ${desc ?? cmd}`);
11
19
  try {
12
- execSync(command, { stdio: "inherit" });
20
+ execSync(cmd, { stdio: "inherit" });
13
21
  } catch (error) {
14
- console.error(`✗ Failed: ${description ?? command}: ` + error);
15
- process.exit(1);
22
+ console.error(`✗ Failed: ${desc ?? cmd}: ` + error);
23
+ if (shouldExit) exit(1);
16
24
  }
17
25
  }
18
26
 
19
- // Always run these
20
- run("npm run clean", "Start fresh");
21
- run("npm run fmt", "Formatting code");
22
- run("npm run lint", "Running linting checks");
23
- run("npm run build:dist", "Building distribution files");
27
+ run({ cmd: "npm install", desc: "Installing dependencies" });
28
+ run({ cmd: "npm run update", desc: "Updating dependencies" });
29
+ rmSync("package-lock.json", { force: true });
30
+ run({ cmd: "npm install", desc: "Updating dependencies" });
31
+ run({ cmd: "npm run clean", desc: "Start fresh" });
32
+ run({ cmd: "npm run fmt", desc: "Formatting code" });
33
+ run({ cmd: "npm run lint", desc: "Running linting checks" });
34
+ run({ cmd: "npm run docs", desc: "TypeDoc generation" });
35
+ run({ cmd: "npm run build:dist", desc: "Building distribution files" });
36
+
37
+ // Detect if we're using glibc (vs musl)
38
+ // Check process.report for musl loader - if not found, assume glibc
39
+ const isGlibc = (() => {
40
+ if (!isLinux) return false;
41
+ const report = process.report?.getReport() as any;
42
+ return !report?.sharedObjects?.some((lib: string) => /ld-musl/.test(lib));
43
+ })();
24
44
 
25
45
  // Build native module with portable GLIBC
26
46
  if (isLinux && isGlibc) {
27
- run(
28
- "npm run build:linux-glibc",
29
- "Building native project with portable GLIBC",
30
- );
47
+ run({
48
+ cmd: "npm run build:linux-glibc",
49
+ desc: "Building native project with portable GLIBC",
50
+ });
31
51
  } else {
32
- run("npm run build:native", "Building native module");
52
+ // Clean old native builds to ensure fresh compilation
53
+ run({ cmd: "npm run clean:native", desc: "Cleaning old native builds" });
54
+ run({ cmd: "npm run build:native", desc: "Building native module" });
33
55
  }
34
56
 
35
- run("npm run tests", "Running tests in ESM & CJS mode");
57
+ run({ cmd: "npm run tests", desc: "Running tests in ESM & CJS mode" });
36
58
 
37
59
  // Platform-specific checks
38
60
  if (isLinux || isMacOS) {
39
- run("npm run lint:native", "Running clang-tidy");
61
+ // Remove stale compile_commands.json to ensure it's regenerated with current settings
62
+ rmSync("compile_commands.json", { force: true });
63
+ run({ cmd: "npm run lint:native", desc: "Running clang-tidy" });
40
64
  }
41
65
 
42
66
  // Run comprehensive memory tests (cross-platform)
43
- run("npm run check:memory", "Comprehensive memory tests");
67
+ // This includes Windows debug memory check on Windows
68
+ run({ cmd: "npm run check:memory", desc: "Comprehensive memory tests" });
44
69
 
45
70
  console.log("\n✅ All precommit checks passed!");
@@ -78,7 +78,7 @@ fi
78
78
  echo "Building with AddressSanitizer..."
79
79
  npm run setup:native
80
80
  npm run clean:native
81
- node-gyp configure build
81
+ npm run node-gyp-rebuild
82
82
 
83
83
  # Run tests and capture output
84
84
  echo -e "${YELLOW}Running tests with AddressSanitizer...${NC}"
@@ -8,6 +8,8 @@ struct VolumeMetadataOptions {
8
8
  std::string mountPoint; // Required mount point path
9
9
  uint32_t timeoutMs = 5000; // Optional timeout with default
10
10
  std::string device; // Optional device path
11
+ bool skipNetworkVolumes =
12
+ false; // Skip detailed info for network volumes to avoid blocking
11
13
 
12
14
  static VolumeMetadataOptions FromObject(const Napi::Object &obj) {
13
15
  VolumeMetadataOptions options;
@@ -25,6 +27,10 @@ struct VolumeMetadataOptions {
25
27
  if (obj.Has("device")) {
26
28
  options.device = obj.Get("device").As<Napi::String>();
27
29
  }
30
+ if (obj.Has("skipNetworkVolumes")) {
31
+ options.skipNetworkVolumes =
32
+ obj.Get("skipNetworkVolumes").As<Napi::Boolean>().Value();
33
+ }
28
34
 
29
35
  return options;
30
36
  }
@@ -2,6 +2,9 @@
2
2
  #include "hidden.h"
3
3
  #include "../common/debug_log.h"
4
4
  #include "../common/error_utils.h"
5
+ #include "path_security.h"
6
+ #include <string.h> // for strcmp
7
+ #include <sys/mount.h>
5
8
  #include <sys/stat.h>
6
9
  #include <unistd.h>
7
10
 
@@ -9,35 +12,51 @@ namespace FSMeta {
9
12
 
10
13
  GetHiddenWorker::GetHiddenWorker(std::string path,
11
14
  Napi::Promise::Deferred deferred)
12
- : Napi::AsyncWorker(deferred.Env()), path_(path), deferred_(deferred),
13
- is_hidden_(false) {
15
+ : Napi::AsyncWorker(deferred.Env()), path_(std::move(path)),
16
+ deferred_(deferred), is_hidden_(false) {
14
17
  DEBUG_LOG("[GetHiddenWorker] created for path: %s", path_.c_str());
15
18
  }
16
19
 
17
20
  void GetHiddenWorker::Execute() {
18
21
  DEBUG_LOG("[GetHiddenWorker] checking hidden status for: %s", path_.c_str());
19
22
 
20
- // Add path validation to prevent directory traversal
21
- if (path_.find("..") != std::string::npos) {
22
- SetError("Invalid path containing '..'");
23
+ // Validate and canonicalize path using realpath() to prevent directory
24
+ // traversal This follows Apple's Secure Coding Guide recommendations For
25
+ // isHidden(), we allow non-existent paths (they will fail stat() below)
26
+ std::string error;
27
+ std::string validated_path = ValidateAndCanonicalizePath(path_, error, true);
28
+ if (validated_path.empty()) {
29
+ // If validation failed, check if it's because the path doesn't exist
30
+ // In that case, return the expected "Path not found" error for TypeScript
31
+ // layer
32
+ if (error.find("realpath") != std::string::npos &&
33
+ error.find("No such file or directory") != std::string::npos) {
34
+ SetError("Path not found: '" + path_ + "'");
35
+ } else {
36
+ SetError(error);
37
+ }
23
38
  return;
24
39
  }
25
40
 
41
+ // Use the validated path for all subsequent operations
42
+ DEBUG_LOG("[GetHiddenWorker] Using validated path: %s",
43
+ validated_path.c_str());
44
+
26
45
  struct stat statbuf;
27
- if (stat(path_.c_str(), &statbuf) != 0) {
46
+ if (stat(validated_path.c_str(), &statbuf) != 0) {
28
47
  int error = errno;
29
48
  if (error == ENOENT) {
30
- DEBUG_LOG("[GetHiddenWorker] path not found: %s", path_.c_str());
31
- SetError("Path not found: '" + path_ + "'");
49
+ DEBUG_LOG("[GetHiddenWorker] path not found: %s", validated_path.c_str());
50
+ SetError("Path not found: '" + validated_path + "'");
32
51
  } else {
33
52
  DEBUG_LOG("[GetHiddenWorker] failed to stat path %s: %s (%d)",
34
- path_.c_str(), strerror(error), error);
35
- SetError(CreatePathErrorMessage("stat", path_, error));
53
+ validated_path.c_str(), strerror(error), error);
54
+ SetError(CreatePathErrorMessage("stat", validated_path, error));
36
55
  }
37
56
  return;
38
57
  }
39
58
  is_hidden_ = (statbuf.st_flags & UF_HIDDEN) != 0;
40
- DEBUG_LOG("[GetHiddenWorker] path %s is %s", path_.c_str(),
59
+ DEBUG_LOG("[GetHiddenWorker] path %s is %s", validated_path.c_str(),
41
60
  is_hidden_ ? "hidden" : "not hidden");
42
61
  }
43
62
 
@@ -74,8 +93,8 @@ Napi::Promise GetHiddenAttribute(const Napi::CallbackInfo &info) {
74
93
 
75
94
  SetHiddenWorker::SetHiddenWorker(std::string path, bool hidden,
76
95
  Napi::Promise::Deferred deferred)
77
- : Napi::AsyncWorker(deferred.Env()), path_(path), hidden_(hidden),
78
- deferred_(deferred) {
96
+ : Napi::AsyncWorker(deferred.Env()), path_(std::move(path)),
97
+ hidden_(hidden), deferred_(deferred) {
79
98
  DEBUG_LOG("[SetHiddenWorker] created for path: %s, hidden: %d", path_.c_str(),
80
99
  hidden_);
81
100
  }
@@ -84,22 +103,34 @@ void SetHiddenWorker::Execute() {
84
103
  DEBUG_LOG("[SetHiddenWorker] setting hidden=%d for: %s", hidden_,
85
104
  path_.c_str());
86
105
 
87
- // Add path validation to prevent directory traversal
88
- if (path_.find("..") != std::string::npos) {
89
- SetError("Invalid path containing '..'");
106
+ // macOS uses BSD file flags (UF_HIDDEN) to control file visibility.
107
+ // This is different from the dot-prefix convention used on Unix systems.
108
+ // The chflags() system call modifies these BSD-specific file flags.
109
+
110
+ // Validate and canonicalize path using realpath() to prevent directory
111
+ // traversal This follows Apple's Secure Coding Guide recommendations For
112
+ // setHidden, the file must exist, so we use ValidatePathForRead
113
+ std::string error;
114
+ std::string validated_path = ValidatePathForRead(path_, error);
115
+ if (validated_path.empty()) {
116
+ SetError(error);
90
117
  return;
91
118
  }
92
119
 
120
+ // Use the validated path for all subsequent operations
121
+ DEBUG_LOG("[SetHiddenWorker] Using validated path: %s",
122
+ validated_path.c_str());
123
+
93
124
  struct stat statbuf;
94
- if (stat(path_.c_str(), &statbuf) != 0) {
125
+ if (stat(validated_path.c_str(), &statbuf) != 0) {
95
126
  int error = errno;
96
127
  if (error == ENOENT) {
97
- DEBUG_LOG("[SetHiddenWorker] path not found: %s", path_.c_str());
98
- SetError("Path not found: '" + path_ + "'");
128
+ DEBUG_LOG("[SetHiddenWorker] path not found: %s", validated_path.c_str());
129
+ SetError("Path not found: '" + validated_path + "'");
99
130
  } else {
100
131
  DEBUG_LOG("[SetHiddenWorker] failed to stat path %s: %s (%d)",
101
- path_.c_str(), strerror(error), error);
102
- SetError(CreatePathErrorMessage("stat", path_, error));
132
+ validated_path.c_str(), strerror(error), error);
133
+ SetError(CreatePathErrorMessage("stat", validated_path, error));
103
134
  }
104
135
  return;
105
136
  }
@@ -111,15 +142,32 @@ void SetHiddenWorker::Execute() {
111
142
  new_flags = statbuf.st_flags & ~UF_HIDDEN;
112
143
  }
113
144
 
114
- if (chflags(path_.c_str(), new_flags) != 0) {
145
+ if (chflags(validated_path.c_str(), new_flags) != 0) {
115
146
  int error = errno;
116
147
  DEBUG_LOG("[SetHiddenWorker] failed to set flags for %s: %s (%d)",
117
- path_.c_str(), strerror(error), error);
118
- SetError(CreatePathErrorMessage("chflags", path_, error));
148
+ validated_path.c_str(), strerror(error), error);
149
+
150
+ // Check if this is an APFS filesystem issue
151
+ struct statfs fs;
152
+ bool is_apfs = false;
153
+ if (statfs(validated_path.c_str(), &fs) == 0) {
154
+ is_apfs = (strcmp(fs.f_fstypename, "apfs") == 0);
155
+ DEBUG_LOG("[SetHiddenWorker] filesystem type: %s", fs.f_fstypename);
156
+ }
157
+
158
+ // Provide more detailed error message for APFS
159
+ if (is_apfs && (error == EPERM || error == ENOTSUP)) {
160
+ SetError("Setting hidden attribute failed on APFS filesystem. "
161
+ "This is a known issue with some APFS volumes. "
162
+ "Error: " +
163
+ CreatePathErrorMessage("chflags", validated_path, error));
164
+ } else {
165
+ SetError(CreatePathErrorMessage("chflags", validated_path, error));
166
+ }
119
167
  return;
120
168
  }
121
169
  DEBUG_LOG("[SetHiddenWorker] successfully set hidden=%d for: %s", hidden_,
122
- path_.c_str());
170
+ validated_path.c_str());
123
171
  }
124
172
 
125
173
  void SetHiddenWorker::OnOK() {