diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 88939b6f..bc9a8dbc 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,6 +29,18 @@ jobs: - run: npm ci - run: npm run build - run: npm test --workspace gyp-to-cmake --workspace react-native-node-api-cmake --workspace react-native-node-api-modules + test-windows: + name: Run tests on Windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/jod + - run: npm ci + - run: npm run build + - run: npm test --workspace gyp-to-cmake --workspace react-native-node-api-cmake --workspace react-native-node-api-modules + test-macos: name: Run tests which requires MacOS runs-on: macos-latest diff --git a/package-lock.json b/package-lock.json index 2db6dc1e..968986a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6043,6 +6043,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fswin": { + "version": "3.24.829", + "resolved": "https://registry.npmjs.org/fswin/-/fswin-3.24.829.tgz", + "integrity": "sha512-t3KHDNSMHbUzjpzb35c+27dGMLcE5gXvYZ4to5BITvCvPr3dZvX41VUzgEMQ8mVozbn5uiQ9p61/cQVLDEy+ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -10425,6 +10435,7 @@ "devDependencies": { "@babel/core": "^7.26.10", "@babel/types": "^7.27.0", + "fswin": "^3.24.829", "metro-config": "0.81.1", "node-api-headers": "^1.5.0", "zod": "^3.24.3" diff --git a/packages/react-native-node-api-modules/package.json b/packages/react-native-node-api-modules/package.json index c7f07a71..836f960a 100644 --- a/packages/react-native-node-api-modules/package.json +++ b/packages/react-native-node-api-modules/package.json @@ -84,6 +84,7 @@ "devDependencies": { "@babel/core": "^7.26.10", "@babel/types": "^7.27.0", + "fswin": "^3.24.829", "metro-config": "0.81.1", "node-api-headers": "^1.5.0", "zod": "^3.24.3" diff --git a/packages/react-native-node-api-modules/src/node/path-utils.test.ts b/packages/react-native-node-api-modules/src/node/path-utils.test.ts index fcd95ad3..08a788ff 100644 --- a/packages/react-native-node-api-modules/src/node/path-utils.test.ts +++ b/packages/react-native-node-api-modules/src/node/path-utils.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import path from "node:path"; import fs from "node:fs"; +import fswin from "fswin"; import { determineModuleContext, @@ -13,6 +14,41 @@ import { } from "./path-utils.js"; import { setupTempDirectory } from "./test-utils.js"; +function removeReadPermissions(p: string) { + if (process.platform !== "win32") { + // Unix-like: clear all perms + fs.chmodSync(p, 0); + return; + } + + // Windows: simulate unreadable by setting file to offline + const attributes = { + IS_READ_ONLY: true, + IS_OFFLINE: true, + IS_TEMPORARY: true, + }; + + const result = fswin.setAttributesSync(p, attributes); + if (!result) throw new Error("can not set attributes to remove read permissions"); +} + +function restoreReadPermissions(p: string) { + if (process.platform !== "win32") { + // Unix-like: clear all perms + fs.chmodSync(p, 0o700); + return; + } + + const attributes = { + IS_READ_ONLY: false, + IS_OFFLINE: false, + IS_TEMPORARY: false, + }; + + const result = fswin.setAttributesSync(p, attributes); + if (!result) throw new Error("can not set attributes to restore read permissions"); +} + describe("isNodeApiModule", () => { it("returns true for .node", (context) => { const tempDirectoryPath = setupTempDirectory(context, { @@ -23,19 +59,19 @@ describe("isNodeApiModule", () => { assert(isNodeApiModule(path.join(tempDirectoryPath, "addon.node"))); }); - it("returns false when directory cannot be read due to permissions", (context) => { + // there is no way to set ACLs on directories in Node.js on Windows with brittle powershell commands + it("returns false when directory cannot be read due to permissions", { skip: process.platform === "win32" }, (context) => { const tempDirectoryPath = setupTempDirectory(context, { "addon.android.node": "", }); - // remove read permissions on directory - fs.chmodSync(tempDirectoryPath, 0); + removeReadPermissions(tempDirectoryPath); try { assert.equal( isNodeApiModule(path.join(tempDirectoryPath, "addon")), false ); } finally { - fs.chmodSync(tempDirectoryPath, 0o700); + restoreReadPermissions(tempDirectoryPath); } }); @@ -44,15 +80,14 @@ describe("isNodeApiModule", () => { "addon.android.node": "", }); const candidate = path.join(tempDirectoryPath, "addon.android.node"); - // remove read permission on file - fs.chmodSync(candidate, 0); + removeReadPermissions(candidate); try { assert.throws( () => isNodeApiModule(path.join(tempDirectoryPath, "addon")), /Found an unreadable module addon\.android\.node/ ); } finally { - fs.chmodSync(candidate, 0o600); + restoreReadPermissions(candidate); } }); @@ -81,12 +116,12 @@ describe("isNodeApiModule", () => { }); const unreadable = path.join(tempDirectoryPath, "addon.android.node"); // only android module is unreadable - fs.chmodSync(unreadable, 0); + removeReadPermissions(unreadable); assert.throws( () => isNodeApiModule(path.join(tempDirectoryPath, "addon")), /Found an unreadable module addon\.android\.node/ ); - fs.chmodSync(unreadable, 0o600); + restoreReadPermissions(unreadable); }); }); diff --git a/packages/react-native-node-api-modules/src/node/path-utils.ts b/packages/react-native-node-api-modules/src/node/path-utils.ts index 6c99e176..e09b31ac 100644 --- a/packages/react-native-node-api-modules/src/node/path-utils.ts +++ b/packages/react-native-node-api-modules/src/node/path-utils.ts @@ -45,15 +45,43 @@ export function isNodeApiModule(modulePath: string): boolean { if (!entries.includes(fileName)) { return false; } + + const filePath = path.join(dir, fileName); + try { - fs.accessSync(path.join(dir, fileName), fs.constants.R_OK); - return true; - } catch (cause) { - throw new Error(`Found an unreadable module ${fileName}: ${cause}`); + // First, check if file exists (works the same on all platforms) + fs.accessSync(filePath, fs.constants.F_OK); + + // Then check if it's readable (behavior differs by platform) + if (!isReadableSync(filePath)) { + throw new Error(`Found an unreadable module ${fileName}`); + } + } catch (err) { + throw new Error(`Found an unreadable module ${fileName}`, { cause: err }); } + return true; }); } +/** + * Check if a path is readable according to permission bits. + * On Windows, tests store POSIX S_IWUSR bit in stats.mode. + * On Unix-like, uses fs.accessSync for R_OK. + */ +function isReadableSync(p: string): boolean { + try { + if (process.platform === "win32") { + const stats = fs.statSync(p); + return !!(stats.mode & fs.constants.S_IWUSR); + } else { + fs.accessSync(p, fs.constants.R_OK); + return true; + } + } catch { + return false; + } +} + /** * Strip of any platform specific extensions from a module path. */ @@ -107,7 +135,8 @@ export function normalizeModulePath(modulePath: string) { const dirname = path.normalize(path.dirname(modulePath)); const basename = path.basename(modulePath); const strippedBasename = stripExtension(basename).replace(/^lib/, ""); - return path.join(dirname, strippedBasename); + // Replace backslashes with forward slashes for cross-platform compatibility + return path.join(dirname, strippedBasename).replaceAll("\\", "/"); } export function escapePath(modulePath: string) { @@ -247,6 +276,13 @@ export function findNodeApiModulePaths( return []; } const candidatePath = path.join(fromPath, suffix); + // Normalize path separators for consistent pattern matching on all platforms + const normalizedSuffix = suffix.split(path.sep).join('/'); + + if (excludePatterns.some((pattern) => pattern.test(normalizedSuffix))) { + return []; + } + return fs .readdirSync(candidatePath, { withFileTypes: true }) .flatMap((file) => {