Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ Versioning].

## [Unreleased]

- Expand support for `--enable-experimental-regexp-engine`. ([#2358])
- Resolve links recursively. ([#2388])
- Add support for `bsd-csh` as a C shell (`csh`) identifier. ([#2388])
- Improve rejecting non-array inputs to `escapeAll` & `quoteAll`. ([#2363],
[#2382])
- Expand support for `--enable-experimental-regexp-engine`. ([#2358])

## [2.1.8] - 2026-01-25

Expand Down Expand Up @@ -385,6 +387,7 @@ Versioning].
[#2358]: https://github.com/ericcornelissen/shescape/pull/2358
[#2363]: https://github.com/ericcornelissen/shescape/pull/2363
[#2382]: https://github.com/ericcornelissen/shescape/pull/2382
[#2388]: https://github.com/ericcornelissen/shescape/pull/2388
[552e8ea]: https://github.com/ericcornelissen/shescape/commit/552e8eab56861720b1d4e5474fb65741643358f9
[keep a changelog]: https://keepachangelog.com/en/1.0.0/
[semantic versioning]: https://semver.org/spec/v2.0.0.html
2 changes: 1 addition & 1 deletion config/eslint.js
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ export default [
// https://github.com/avajs/eslint-plugin-ava#readme
"ava/assertion-arguments": ["error"],
"ava/hooks-order": ["error"],
"ava/max-asserts": ["error", 5],
"ava/max-asserts": ["error", 6],
"ava/no-async-fn-without-await": ["error"],
"ava/no-duplicate-modifiers": ["error"],
"ava/no-identical-title": ["error"],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
"test": "npm-run-all test:*",
"test:unit": "ava test/unit/**/*.test.js",
"test:integration": "npm run transpile && ava test/integration/**/*.test.js --timeout 2m",
"test:e2e": "node script/busybox-sh.js && ava test/e2e/**/*.test.js --timeout 1m",
"test:e2e": "node script/busybox-sh.js && node script/double-link-sh.js && ava test/e2e/**/*.test.js --timeout 1m",
Comment thread
ericcornelissen marked this conversation as resolved.
"test:compat": "npm-run-all test:compat:*",
"test:compat:runtime": "node test/compat/runtime/runner.js",
"test:compat:runtime:all": "nve 14.18.0,16.13.0,18.0.0,19.0.0,20.0.0,22.0.0 npm run test:compat",
Expand Down
29 changes: 29 additions & 0 deletions script/double-link-sh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @overview Creates a link to a link to the "sh" binary. Used to test the link
* resolver in this library.
* @license MIT-0
*/

import fs from "node:fs";
import path from "node:path";
import { exit } from "node:process";

import which from "which";

const root = path.resolve(import.meta.dirname, "..");
const temp = path.resolve(root, ".temp", "double-link");
if (!fs.existsSync(temp)) {
fs.mkdirSync(temp, { recursive: true });
}

const shell = which.sync("sh", { nothrow: true });
if (shell === null) {
exit(0);
}

const linkToShell = path.resolve(temp, "link-to-shell");
const linkToLink = path.resolve(temp, "link-to-link");
if (!fs.existsSync(linkToLink)) {
fs.symlinkSync(shell, linkToShell);
fs.symlinkSync(`.${path.sep}${path.basename(linkToShell)}`, linkToLink);
}
Comment thread
ericcornelissen marked this conversation as resolved.
17 changes: 13 additions & 4 deletions src/internal/executables.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* @license MPL-2.0
*/

import * as path from "node:path";

import { hasOwn } from "./reflection.js";

/**
Expand Down Expand Up @@ -54,11 +56,18 @@ export function resolveExecutable(
}

try {
resolved = readlink(resolved);
const seen = {};
while (!hasOwn(seen, resolved)) {
seen[resolved] = null;
const link = readlink(resolved);
const base = path.dirname(resolved);
resolved = path.resolve(base, link);
}
} catch {
// An error will be thrown if the executable is not a (sym)link, this is not
// a problem so the error is ignored
// An error is thrown if the argument is not a (sym)link, this is what we
// want so we return.
return resolved;
}

return resolved;
throw new Error(`${executable} points to a link loop, cannot resolve shell`);
}
11 changes: 10 additions & 1 deletion src/internal/unix.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ const binBusyBox = "busybox";
*/
const binCsh = "csh";

/**
* An alternative name for the C shell (csh) binary.
*
* @constant
* @type {string}
*/
const binCshBsd = "bsd-csh";

/**
* The name of the Debian Almquist shell (Dash) binary.
*
Expand Down Expand Up @@ -86,7 +94,8 @@ export function getShellHelpers(shellName) {
case binBusyBox: {
return busybox;
}
case binCsh: {
case binCsh:
case binCshBsd: {
return csh;
}
case binDash: {
Expand Down
2 changes: 2 additions & 0 deletions test/_constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const echoScript = "test/_echo.js";

export const isMacOS = os.platform() === "darwin";
export const isWindows = os.platform() === "win32";
export const isLinux = !isMacOS && !isWindows;
Comment thread
ericcornelissen marked this conversation as resolved.

/* Illegal arguments */
export const illegalArguments = [
Expand Down Expand Up @@ -75,6 +76,7 @@ export const osTypes = [ostypeCygwin, ostypeMsys];
export const binBash = "bash";
export const binBusyBox = "busybox";
export const binCsh = "csh";
export const binCshBsd = "bsd-csh";
export const binDash = "dash";
export const binZsh = "zsh";

Expand Down
8 changes: 6 additions & 2 deletions test/e2e/_common.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function getTestFn(shell) {
* @returns {(boolean | string)[]} A list of `shell` option values.
*/
export function getTestShells() {
const temp = path.resolve(import.meta.dirname, "..", "..", ".temp");
const systemShells = constants.isWindows
? constants.shellsWindows
: constants.shellsUnix;
Expand All @@ -57,12 +58,15 @@ export function getTestShells() {
if (constants.isMacOS) {
shells.splice(busyboxIndex, 1);
} else {
const root = path.resolve(import.meta.dirname, "..", "..");
const temp = path.resolve(root, ".temp");
shells[busyboxIndex] = path.resolve(temp, "busybox", "sh");
}
}

if (constants.isLinux) {
const doubleLinkedShell = path.resolve(temp, "double-link", "link-to-link");
shells.push(doubleLinkedShell);
}

if (!constants.isMacOS) {
shells.push(true);
}
Expand Down
84 changes: 80 additions & 4 deletions test/unit/executables/resolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ test.before((t) => {

test.beforeEach((t) => {
const exists = sinon.stub().returns(true);
const readlink = sinon.stub();
const readlink = sinon.stub().throws();
const which = sinon.stub();

t.context.deps = { exists, readlink, which };
Expand Down Expand Up @@ -187,15 +187,91 @@ test("the executable exists and is a (sym)link", (t) => {
const args = { env, executable };

t.context.deps.exists.returns(true);
t.context.deps.readlink.returns(linkedExecutable);
t.context.deps.which.returns(resolvedExecutable);

t.context.deps.readlink.onCall(0).returns(linkedExecutable);
t.context.deps.readlink.onCall(1).throws();

const result = resolveExecutable(args, t.context.deps);
t.is(result, linkedExecutable);

t.is(t.context.deps.readlink.callCount, 1);
t.true(t.context.deps.exists.calledWithExactly(resolvedExecutable));
t.is(t.context.deps.readlink.callCount, 2);
t.true(t.context.deps.readlink.calledWithExactly(resolvedExecutable));

t.is(t.context.deps.which.callCount, 1);
t.is(t.context.deps.exists.callCount, 1);
});

test("the executable exists and is a (sym)link to a (sym)link (absolute)", (t) => {
const { env, executable, linkedExecutable, resolvedExecutable } = t.context;
const args = { env, executable };
const intermediaryLink = "/path/to/link-to-link";

t.context.deps.exists.returns(true);
t.context.deps.which.returns(resolvedExecutable);

t.context.deps.readlink.onCall(0).returns(intermediaryLink);
t.context.deps.readlink.onCall(1).returns(linkedExecutable);
t.context.deps.readlink.onCall(2).throws();

const result = resolveExecutable(args, t.context.deps);
t.is(result, linkedExecutable);

t.is(t.context.deps.readlink.callCount, 3);
t.true(t.context.deps.readlink.calledWithExactly(resolvedExecutable));
t.true(t.context.deps.readlink.calledWithExactly(intermediaryLink));

t.is(t.context.deps.which.callCount, 1);
t.is(t.context.deps.exists.callCount, 1);
});

test("the executable exists and is a (sym)link to a (sym)link (relative)", (t) => {
const { env, executable, linkedExecutable, resolvedExecutable } = t.context;
const args = { env, executable };
const intermediaryLink = "./link-to-link";

t.context.deps.exists.returns(true);
t.context.deps.which.returns(resolvedExecutable);

t.context.deps.readlink.onCall(0).returns(intermediaryLink);
t.context.deps.readlink.onCall(1).returns(linkedExecutable);
t.context.deps.readlink.onCall(2).throws();

const result = resolveExecutable(args, t.context.deps);
t.is(result, linkedExecutable);

t.is(t.context.deps.readlink.callCount, 3);
t.true(t.context.deps.readlink.calledWithExactly(resolvedExecutable));
t.true(t.context.deps.readlink.calledWithExactly("/path/to/link-to-link"));

t.is(t.context.deps.which.callCount, 1);
t.is(t.context.deps.exists.callCount, 1);
});

testProp(
"the executable exists but there is a link cycle",
[
fc.array(fc.stringMatching(/^\/[a-z]{2,}$/u), {
minLength: 1,
maxLength: 64,
}),
],
(t, links) => {
const { env, executable, resolvedExecutable } = t.context;
const args = { env, executable };

t.context.deps.exists.returns(true);
t.context.deps.which.returns(resolvedExecutable);

t.context.deps.readlink = sinon.stub();
for (const index in links) {
t.context.deps.readlink.onCall(index).returns(links[index]);
}
t.context.deps.readlink.onCall(links.length).returns(links[0]);

t.throws(() => resolveExecutable(args, t.context.deps), {
instanceOf: Error,
message: `${executable} points to a link loop, cannot resolve shell`,
});
},
);
1 change: 1 addition & 0 deletions test/unit/unix/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const shells = [
{ module: bash, shellName: constants.binBash },
{ module: busybox, shellName: constants.binBusyBox },
{ module: csh, shellName: constants.binCsh },
{ module: csh, shellName: constants.binCshBsd },
{ module: dash, shellName: constants.binDash },
{ module: zsh, shellName: constants.binZsh },
];
Expand Down
Loading