Skip to content

Commit c60f530

Browse files
Merge branch 'main' into lregexp-cmd
2 parents 70e7854 + c37bde8 commit c60f530

File tree

10 files changed

+147
-14
lines changed

10 files changed

+147
-14
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ Versioning].
99

1010
## [Unreleased]
1111

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

1618
## [2.1.8] - 2026-01-25
1719

@@ -385,6 +387,7 @@ Versioning].
385387
[#2358]: https://github.com/ericcornelissen/shescape/pull/2358
386388
[#2363]: https://github.com/ericcornelissen/shescape/pull/2363
387389
[#2382]: https://github.com/ericcornelissen/shescape/pull/2382
390+
[#2388]: https://github.com/ericcornelissen/shescape/pull/2388
388391
[552e8ea]: https://github.com/ericcornelissen/shescape/commit/552e8eab56861720b1d4e5474fb65741643358f9
389392
[keep a changelog]: https://keepachangelog.com/en/1.0.0/
390393
[semantic versioning]: https://semver.org/spec/v2.0.0.html

config/eslint.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ export default [
10861086
// https://github.com/avajs/eslint-plugin-ava#readme
10871087
"ava/assertion-arguments": ["error"],
10881088
"ava/hooks-order": ["error"],
1089-
"ava/max-asserts": ["error", 5],
1089+
"ava/max-asserts": ["error", 6],
10901090
"ava/no-async-fn-without-await": ["error"],
10911091
"ava/no-duplicate-modifiers": ["error"],
10921092
"ava/no-identical-title": ["error"],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
"test": "npm-run-all test:*",
145145
"test:unit": "ava test/unit/**/*.test.js",
146146
"test:integration": "npm run transpile && ava test/integration/**/*.test.js --timeout 2m",
147-
"test:e2e": "node script/busybox-sh.js && ava test/e2e/**/*.test.js --timeout 1m",
147+
"test:e2e": "node script/busybox-sh.js && node script/double-link-sh.js && ava test/e2e/**/*.test.js --timeout 1m",
148148
"test:compat": "npm-run-all test:compat:*",
149149
"test:compat:runtime": "node test/compat/runtime/runner.js",
150150
"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",

script/double-link-sh.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @overview Creates a link to a link to the "sh" binary. Used to test the link
3+
* resolver in this library.
4+
* @license MIT-0
5+
*/
6+
7+
import fs from "node:fs";
8+
import path from "node:path";
9+
import { exit } from "node:process";
10+
11+
import which from "which";
12+
13+
const root = path.resolve(import.meta.dirname, "..");
14+
const temp = path.resolve(root, ".temp", "double-link");
15+
if (!fs.existsSync(temp)) {
16+
fs.mkdirSync(temp, { recursive: true });
17+
}
18+
19+
const shell = which.sync("sh", { nothrow: true });
20+
if (shell === null) {
21+
exit(0);
22+
}
23+
24+
const linkToShell = path.resolve(temp, "link-to-shell");
25+
const linkToLink = path.resolve(temp, "link-to-link");
26+
if (!fs.existsSync(linkToLink)) {
27+
fs.symlinkSync(shell, linkToShell);
28+
fs.symlinkSync(`.${path.sep}${path.basename(linkToShell)}`, linkToLink);
29+
}

src/internal/executables.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* @license MPL-2.0
44
*/
55

6+
import * as path from "node:path";
7+
68
import { hasOwn } from "./reflection.js";
79

810
/**
@@ -54,11 +56,18 @@ export function resolveExecutable(
5456
}
5557

5658
try {
57-
resolved = readlink(resolved);
59+
const seen = {};
60+
while (!hasOwn(seen, resolved)) {
61+
seen[resolved] = null;
62+
const link = readlink(resolved);
63+
const base = path.dirname(resolved);
64+
resolved = path.resolve(base, link);
65+
}
5866
} catch {
59-
// An error will be thrown if the executable is not a (sym)link, this is not
60-
// a problem so the error is ignored
67+
// An error is thrown if the argument is not a (sym)link, this is what we
68+
// want so we return.
69+
return resolved;
6170
}
6271

63-
return resolved;
72+
throw new Error(`${executable} points to a link loop, cannot resolve shell`);
6473
}

src/internal/unix.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ const binBusyBox = "busybox";
4040
*/
4141
const binCsh = "csh";
4242

43+
/**
44+
* An alternative name for the C shell (csh) binary.
45+
*
46+
* @constant
47+
* @type {string}
48+
*/
49+
const binCshBsd = "bsd-csh";
50+
4351
/**
4452
* The name of the Debian Almquist shell (Dash) binary.
4553
*
@@ -86,7 +94,8 @@ export function getShellHelpers(shellName) {
8694
case binBusyBox: {
8795
return busybox;
8896
}
89-
case binCsh: {
97+
case binCsh:
98+
case binCshBsd: {
9099
return csh;
91100
}
92101
case binDash: {

test/_constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const echoScript = "test/_echo.js";
99

1010
export const isMacOS = os.platform() === "darwin";
1111
export const isWindows = os.platform() === "win32";
12+
export const isLinux = !isMacOS && !isWindows;
1213

1314
/* Illegal arguments */
1415
export const illegalArguments = [
@@ -75,6 +76,7 @@ export const osTypes = [ostypeCygwin, ostypeMsys];
7576
export const binBash = "bash";
7677
export const binBusyBox = "busybox";
7778
export const binCsh = "csh";
79+
export const binCshBsd = "bsd-csh";
7880
export const binDash = "dash";
7981
export const binZsh = "zsh";
8082

test/e2e/_common.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function getTestFn(shell) {
4646
* @returns {(boolean | string)[]} A list of `shell` option values.
4747
*/
4848
export function getTestShells() {
49+
const temp = path.resolve(import.meta.dirname, "..", "..", ".temp");
4950
const systemShells = constants.isWindows
5051
? constants.shellsWindows
5152
: constants.shellsUnix;
@@ -57,12 +58,15 @@ export function getTestShells() {
5758
if (constants.isMacOS) {
5859
shells.splice(busyboxIndex, 1);
5960
} else {
60-
const root = path.resolve(import.meta.dirname, "..", "..");
61-
const temp = path.resolve(root, ".temp");
6261
shells[busyboxIndex] = path.resolve(temp, "busybox", "sh");
6362
}
6463
}
6564

65+
if (constants.isLinux) {
66+
const doubleLinkedShell = path.resolve(temp, "double-link", "link-to-link");
67+
shells.push(doubleLinkedShell);
68+
}
69+
6670
if (!constants.isMacOS) {
6771
shells.push(true);
6872
}

test/unit/executables/resolve.test.js

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test.before((t) => {
3030

3131
test.beforeEach((t) => {
3232
const exists = sinon.stub().returns(true);
33-
const readlink = sinon.stub();
33+
const readlink = sinon.stub().throws();
3434
const which = sinon.stub();
3535

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

189189
t.context.deps.exists.returns(true);
190-
t.context.deps.readlink.returns(linkedExecutable);
191190
t.context.deps.which.returns(resolvedExecutable);
192191

192+
t.context.deps.readlink.onCall(0).returns(linkedExecutable);
193+
t.context.deps.readlink.onCall(1).throws();
194+
193195
const result = resolveExecutable(args, t.context.deps);
194196
t.is(result, linkedExecutable);
195197

196-
t.is(t.context.deps.readlink.callCount, 1);
197-
t.true(t.context.deps.exists.calledWithExactly(resolvedExecutable));
198+
t.is(t.context.deps.readlink.callCount, 2);
199+
t.true(t.context.deps.readlink.calledWithExactly(resolvedExecutable));
198200

199201
t.is(t.context.deps.which.callCount, 1);
200202
t.is(t.context.deps.exists.callCount, 1);
201203
});
204+
205+
test("the executable exists and is a (sym)link to a (sym)link (absolute)", (t) => {
206+
const { env, executable, linkedExecutable, resolvedExecutable } = t.context;
207+
const args = { env, executable };
208+
const intermediaryLink = "/path/to/link-to-link";
209+
210+
t.context.deps.exists.returns(true);
211+
t.context.deps.which.returns(resolvedExecutable);
212+
213+
t.context.deps.readlink.onCall(0).returns(intermediaryLink);
214+
t.context.deps.readlink.onCall(1).returns(linkedExecutable);
215+
t.context.deps.readlink.onCall(2).throws();
216+
217+
const result = resolveExecutable(args, t.context.deps);
218+
t.is(result, linkedExecutable);
219+
220+
t.is(t.context.deps.readlink.callCount, 3);
221+
t.true(t.context.deps.readlink.calledWithExactly(resolvedExecutable));
222+
t.true(t.context.deps.readlink.calledWithExactly(intermediaryLink));
223+
224+
t.is(t.context.deps.which.callCount, 1);
225+
t.is(t.context.deps.exists.callCount, 1);
226+
});
227+
228+
test("the executable exists and is a (sym)link to a (sym)link (relative)", (t) => {
229+
const { env, executable, linkedExecutable, resolvedExecutable } = t.context;
230+
const args = { env, executable };
231+
const intermediaryLink = "./link-to-link";
232+
233+
t.context.deps.exists.returns(true);
234+
t.context.deps.which.returns(resolvedExecutable);
235+
236+
t.context.deps.readlink.onCall(0).returns(intermediaryLink);
237+
t.context.deps.readlink.onCall(1).returns(linkedExecutable);
238+
t.context.deps.readlink.onCall(2).throws();
239+
240+
const result = resolveExecutable(args, t.context.deps);
241+
t.is(result, linkedExecutable);
242+
243+
t.is(t.context.deps.readlink.callCount, 3);
244+
t.true(t.context.deps.readlink.calledWithExactly(resolvedExecutable));
245+
t.true(t.context.deps.readlink.calledWithExactly("/path/to/link-to-link"));
246+
247+
t.is(t.context.deps.which.callCount, 1);
248+
t.is(t.context.deps.exists.callCount, 1);
249+
});
250+
251+
testProp(
252+
"the executable exists but there is a link cycle",
253+
[
254+
fc.array(fc.stringMatching(/^\/[a-z]{2,}$/u), {
255+
minLength: 1,
256+
maxLength: 64,
257+
}),
258+
],
259+
(t, links) => {
260+
const { env, executable, resolvedExecutable } = t.context;
261+
const args = { env, executable };
262+
263+
t.context.deps.exists.returns(true);
264+
t.context.deps.which.returns(resolvedExecutable);
265+
266+
t.context.deps.readlink = sinon.stub();
267+
for (const index in links) {
268+
t.context.deps.readlink.onCall(index).returns(links[index]);
269+
}
270+
t.context.deps.readlink.onCall(links.length).returns(links[0]);
271+
272+
t.throws(() => resolveExecutable(args, t.context.deps), {
273+
instanceOf: Error,
274+
message: `${executable} points to a link loop, cannot resolve shell`,
275+
});
276+
},
277+
);

test/unit/unix/index.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const shells = [
2525
{ module: bash, shellName: constants.binBash },
2626
{ module: busybox, shellName: constants.binBusyBox },
2727
{ module: csh, shellName: constants.binCsh },
28+
{ module: csh, shellName: constants.binCshBsd },
2829
{ module: dash, shellName: constants.binDash },
2930
{ module: zsh, shellName: constants.binZsh },
3031
];

0 commit comments

Comments
 (0)