Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/react-native-node-api-modules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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"
Expand Down
53 changes: 44 additions & 9 deletions packages/react-native-node-api-modules/src/node/path-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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, {
Expand All @@ -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
(process.platform === "win32" ? it.skip : it)("returns false when directory cannot be read due to permissions", (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);
}
});

Expand All @@ -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);
}
});

Expand Down Expand Up @@ -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);
});
});

Expand Down
46 changes: 41 additions & 5 deletions packages/react-native-node-api-modules/src/node/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (e) {
throw new Error(`Found an unreadable module ${fileName}: ${e}`);
}
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.
*/
Expand Down Expand Up @@ -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).replace(/\\/g, "/");
}

export function escapePath(modulePath: string) {
Expand Down Expand Up @@ -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) => {
Expand Down