ssh-client-wrapper 3.1.0 → 3.1.2

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.
package/README.md CHANGED
@@ -107,6 +107,17 @@ Downloads files or directories from the remote host using `rsync`.
107
107
  * `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in default instead of adding to it. Defaults to `false`.
108
108
  * Returns: `Promise<void>`
109
109
 
110
+ ### Remote paths of `.send()` / `.recv()`
111
+
112
+ A remote path (`dst` of `.send()`, `src` of `.recv()`) is interpreted by the login shell of the remote host, whatever the version or the implementation of the local `rsync` is:
113
+
114
+ * `~`, environment variables (`$HOME/work`), braces (`dir/{a,b}`; the remote shell must support them, e.g. bash), quotes (`'dir/a b'`) and backslash escapes (`dir/a\ b`) are expanded.
115
+ * A space which is neither quoted nor escaped separates two paths.
116
+ * Wildcards (`*`, `?`, `[...]`) are expanded by the remote side as usual.
117
+ * The destination of `.send()` must be expanded to exactly one path. Its directory is created if it does not exist.
118
+
119
+ A path that consists only of letters, digits and `_ @ % + = : , . / -` and wildcards needs no expansion and costs no extra round trip. Otherwise the expansion is done by one extra command over the existing connection.
120
+
110
121
  ### Retry behavior for `.send()` / `.recv()`
111
122
 
112
123
  `.send()` and `.recv()` automatically retry (up to `hostInfo.maxRetry` times, default `3`, waiting `hostInfo.retryDuration` msec between attempts, default `1000`) when:
@@ -202,3 +213,36 @@ If no agent can be used, or `ssh-add` fails, the wrapper silently falls back to
202
213
  **Keyless / browser agents:** with no `keyFile`, set `identityAgent` to the socket of an already-populated agent (for example [`bssh-agent`](https://github.com/so5/browser-ssh-agent), which keeps the private key in a browser tab) and the wrapper will authenticate through it.
203
214
 
204
215
  **Windows:** an already-running agent (the OpenSSH Authentication Agent service, or a supplied `identityAgent`) is used, but the wrapper never spawns one; without a reachable agent it falls back to the legacy path.
216
+
217
+ ## Running the tests
218
+
219
+ The tests are split into two groups:
220
+
221
+ | command | what runs | needs test hosts |
222
+ |---|---|---|
223
+ | `npm run test:unit` | `test/unit`: option building, validation, agent handling, ... | no |
224
+ | `npm run test:integration` | `test/integration`: real `ssh` and `rsync` against test hosts | yes |
225
+ | `npm run mocha` | both | yes |
226
+ | `npm test` | both, with coverage | yes |
227
+
228
+ `npm run test:unit` prints a reminder that **the integration tests were not run**. A green unit run does not say anything about ssh or rsync.
229
+
230
+ The integration tests check the test hosts first. If a required environment variable is missing, or a host can not be logged in, the whole run fails at once with a message that says what to check (it does not wait for timeouts of the tests).
231
+
232
+ ### Test hosts
233
+
234
+ The easiest way is `./run_test_local.sh`. It starts two [`naoso5/test-sshd`](https://hub.docker.com/r/naoso5/test-sshd) containers with Docker Compose, installs a throw-away key, starts an ssh-agent, sets the variables below and runs `npm test`. (If you run it with a different `HOME`, keep the path short: the ssh-agent socket path is limited to about 100 characters.)
235
+
236
+ To use your own hosts, set these environment variables:
237
+
238
+ | variable | meaning |
239
+ |---|---|
240
+ | `TEST_HOST`, `TEST_PORT`, `TEST_USER` | the test host (required: `TEST_HOST`) |
241
+ | `TEST_PW` or `TEST_KEYFILE` | password or private key of the test user (an ssh-agent also works) |
242
+ | `TEST_HOST2`, `TEST_PORT2`, `TEST_USER2` | the second host used by `remoteToRemoteCopy`, **as seen from the first test host** (required: `TEST_HOST2`; `TEST_USER2` defaults to `TEST_USER`) |
243
+ | `TEST_HOST2_VERIFY`, `TEST_PORT2_VERIFY` | the same second host, **as seen from the machine which runs the tests** (default: `TEST_HOST2`, `TEST_PORT2`) |
244
+ | `TEST_KEEP_FILES` | if set, the files created on the test hosts are not removed |
245
+
246
+ The two ways to name the second host differ when the hosts are Docker containers: the first container reaches the second one by its container name (`testbed2:22`), while the test runner reaches it through a published port (`localhost:4101`).
247
+
248
+ The same tests run in GitHub Actions for Node.js 22/24 and for Debian (see `.github/workflows`). To run the Debian workflow locally, see the comment at the top of `run_test_debian.yml`.
@@ -0,0 +1,124 @@
1
+ import crypto from "crypto";
2
+ import Debug from "debug";
3
+
4
+ import { sshExec } from "./sshExec.js";
5
+
6
+ const debug = Debug("sshClientWrapper:debug:remotePath");
7
+
8
+ //characters which need no shell expansion. wildcards are left to the remote side.
9
+ const notSimpleChar = /[^\w@%+=:,./*?[\]-]/;
10
+ const notSimpleCharGlobal = new RegExp(notSimpleChar.source, "g");
11
+
12
+ /**
13
+ * check if the remote rsync arguments are taken literally by rsync.
14
+ * rsync 3.2.4 or later does not pass remote path to the remote shell by default.
15
+ * older rsync, openrsync (reports 2.6.9) and rsync4win (reports 0.x) let the remote shell evaluate it.
16
+ * @param {object} version - rsync version object ({major, minor, patch})
17
+ * @returns {boolean} - true if the remote path is taken literally
18
+ */
19
+ export function hasLiteralRemoteArgs(version) {
20
+ if (!version
21
+ || !Number.isInteger(version.major)
22
+ || !Number.isInteger(version.minor)
23
+ || !Number.isInteger(version.patch)) {
24
+ return false;
25
+ }
26
+ const { major, minor, patch } = version;
27
+ if (major !== 3) {
28
+ return major > 3;
29
+ }
30
+ if (minor !== 2) {
31
+ return minor > 2;
32
+ }
33
+ return patch >= 4;
34
+ }
35
+
36
+ /**
37
+ * check if the remote path contains something which must be evaluated by the remote shell
38
+ * @param {string} pathSpec - remote path given by the caller
39
+ * @returns {boolean} - true if the remote path need to be expanded
40
+ */
41
+ export function needsExpansion(pathSpec) {
42
+ return notSimpleChar.test(pathSpec);
43
+ }
44
+
45
+ /**
46
+ * escape all characters except wildcards with backslash, so that the remote shell passes it as is.
47
+ * @param {string} word - remote path
48
+ * @returns {string} - escaped remote path
49
+ */
50
+ export function escapeRemoteArg(word) {
51
+ return word.replace(notSimpleCharGlobal, "\\$&");
52
+ }
53
+
54
+ /**
55
+ * quote a string with single quote for the remote shell. wildcards are NOT expanded.
56
+ * @param {string} word - string
57
+ * @returns {string} - quoted string
58
+ */
59
+ export function shellQuote(word) {
60
+ return `'${word.replace(/'/g, "'\\''")}'`;
61
+ }
62
+
63
+ /**
64
+ * convert an expanded remote path to the form the local rsync expects.
65
+ * @param {string} word - expanded remote path
66
+ * @param {boolean} literal - true if the local rsync takes remote path literally
67
+ * @returns {string} - remote path for rsync command line
68
+ */
69
+ export function formatRemoteArg(word, literal) {
70
+ return literal ? word : escapeRemoteArg(word);
71
+ }
72
+
73
+ /**
74
+ * make command line which print expanded remote path.
75
+ * filename expansion (wildcards) is disabled. it is left to the remote rsync.
76
+ * @param {string} pathSpec - remote path given by the caller
77
+ * @param {string} token - random string to find the result in output
78
+ * @returns {string} - command line for remote shell
79
+ */
80
+ export function buildExpandCommand(pathSpec, token) {
81
+ return `set -f; echo ${token}B; for f in ${pathSpec}; do printf '%s\\n' "$f"; done; echo ${token}E`;
82
+ }
83
+
84
+ /**
85
+ * pick up expanded words from the output of command which is made by buildExpandCommand
86
+ * @param {string} output - whole output
87
+ * @param {string} token - random string which was given to buildExpandCommand
88
+ * @returns {string[] | null} - expanded words, or null if the output can not be parsed
89
+ */
90
+ export function parseExpandOutput(output, token) {
91
+ const lines = output.split(/\r?\n/);
92
+ const begin = lines.indexOf(`${token}B`);
93
+ const end = lines.lastIndexOf(`${token}E`);
94
+ if (begin < 0 || end < begin) {
95
+ return null;
96
+ }
97
+ return lines.slice(begin + 1, end);
98
+ }
99
+
100
+ /**
101
+ * let the remote shell expand the remote path (tilde, variables, braces, quotes, and backslash escapes).
102
+ * if the remote shell can not do it, the path is returned as is.
103
+ * @param {object} hostInfo - hostinfo object
104
+ * @param {string} pathSpec - remote path given by the caller
105
+ * @param {number} timeout - timeout in seconds
106
+ * @returns {Promise<string[]>} - expanded paths
107
+ */
108
+ export async function expandRemotePath(hostInfo, pathSpec, timeout = 0) {
109
+ if (!needsExpansion(pathSpec)) {
110
+ return [pathSpec];
111
+ }
112
+ const token = `SCW${crypto.randomBytes(8).toString("hex")}`;
113
+ const chunks = [];
114
+ const rt = await sshExec(hostInfo, buildExpandCommand(pathSpec, token), timeout, (data)=>{
115
+ chunks.push(data);
116
+ });
117
+ const words = parseExpandOutput(chunks.join(""), token);
118
+ if (rt !== 0 || words === null || words.length === 0) {
119
+ debug(`can not expand remote path on ${hostInfo.host}, use it as is: ${pathSpec}`);
120
+ return [pathSpec];
121
+ }
122
+ debug(`remote path ${pathSpec} is expanded to`, words);
123
+ return words;
124
+ }
package/lib/rsyncExec.js CHANGED
@@ -7,6 +7,7 @@ import { connect, retryWrapper, sshExec } from "./sshExec.js";
7
7
  import { fork } from "./fork.js";
8
8
 
9
9
  import { sshCmd, rsyncCmd, getSshOption, resolveRetryableExitCodes } from "./util.js";
10
+ import { expandRemotePath, formatRemoteArg, hasLiteralRemoteArgs, shellQuote } from "./remotePath.js";
10
11
 
11
12
  const debug = Debug("sshClientWrapper:debug:rsyncExec");
12
13
  const debugVerbose = Debug("sshClientWrapper:verbose:rsyncExec");
@@ -67,39 +68,47 @@ export async function send(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableEx
67
68
  const sshArgs = getSshOption(hostInfo, true);
68
69
  const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
69
70
 
70
- const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, ...src, `${hostInfo.host}:${dst}`];
71
+ //the remote path is interpreted by the remote shell (~, $VAR, {a,b}, quotes and escapes)
72
+ const remoteDsts = await expandRemotePath(hostInfo, dst);
73
+ if (remoteDsts.length !== 1) {
74
+ throw new Error(`dst must be expanded to exactly one path: ${dst}`);
75
+ }
76
+ const [remoteDst] = remoteDsts;
77
+ const literal = await hasLiteralRemoteArgsOfLocalRsync(hostInfo);
78
+
79
+ const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, ...src, `${hostInfo.host}:${formatRemoteArg(remoteDst, literal)}`];
71
80
  let remoteDir;
72
- if (dst.endsWith("/")) {
73
- remoteDir = dst;
81
+ if (remoteDst.endsWith("/")) {
82
+ remoteDir = remoteDst;
74
83
  } else {
75
- const { dir } = path.parse(dst);
84
+ const { dir } = path.parse(remoteDst);
76
85
  remoteDir = dir;
77
86
  }
78
87
  debug("remoteDir=", remoteDir);
79
88
 
80
89
  if (remoteDir !== "" && remoteDir !== "/") {
81
90
  debug(`mkdir ${remoteDir}`);
82
- sshExec(hostInfo, `mkdir -p ${remoteDir}`, debugVerbose);
91
+ await sshExec(hostInfo, `mkdir -p ${shellQuote(remoteDir)}`, 0, debugVerbose);
83
92
  }
84
- debug(`send ${src} to ${hostInfo.host}:${dst}`);
93
+ debug(`send ${src} to ${hostInfo.host}:${remoteDst}`);
85
94
  debugVerbose(`send: ${rsyncCmd} ${args.join(" ")}`);
86
95
  const exitCodes = resolveRetryableExitCodes(hostInfo, retryableExitCodes, replaceRetryableExitCodes);
87
96
  return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, exitCodes), hostInfo);
88
97
  }
89
98
 
90
99
  /**
91
- * format src array to src string for recv
100
+ * check if the local rsync takes remote path literally (rsync 3.2.4 or later) or lets the remote shell evaluate it
92
101
  * @param {object} hostInfo - hostinfo object
93
- * @returns {boolean} - rsync command version is higher than 3.2.4 or not
102
+ * @returns {boolean} - true if the local rsync takes remote path literally
94
103
  */
95
- async function isNewRsync(hostInfo) {
104
+ async function hasLiteralRemoteArgsOfLocalRsync(hostInfo) {
96
105
  if (!hostInfo.rsyncVersion
97
106
  || !Number.isInteger(hostInfo.rsyncVersion.major)
98
107
  || !Number.isInteger(hostInfo.rsyncVersion.minor)
99
108
  || !Number.isInteger(hostInfo.rsyncVersion.patch)) {
100
109
  hostInfo.rsyncVersion = await checkRsyncVersion(3);
101
110
  }
102
- return hostInfo.rsyncVersion.major >= 3 && hostInfo.rsyncVersion.minor >= 2 && hostInfo.rsyncVersion.patch >= 4;
111
+ return hasLiteralRemoteArgs(hostInfo.rsyncVersion);
103
112
  }
104
113
 
105
114
  /**
@@ -116,19 +125,28 @@ async function isNewRsync(hostInfo) {
116
125
  */
117
126
  export async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableExitCodes, replaceRetryableExitCodes) {
118
127
  await connect(hostInfo);
119
- const src = `${hostInfo.host}:${argSrc.join(" ")}`;
128
+ //pass each source as its own "host:path" argument. Joining them into one
129
+ //space separated argument relies on the remote shell to split it, which
130
+ //rsync >= 3.2.4 (protected args) no longer does.
131
+ //the remote path is interpreted by the remote shell (~, $VAR, {a,b}, quotes and escapes).
132
+ //it is expanded here, so that the result does not depend on how the local rsync handles it.
133
+ const literal = await hasLiteralRemoteArgsOfLocalRsync(hostInfo);
134
+ const remoteSrcs = [];
135
+ for (const e of argSrc) {
136
+ remoteSrcs.push(...await expandRemotePath(hostInfo, e));
137
+ }
138
+ const src = remoteSrcs.map((e)=>{
139
+ return `${hostInfo.host}:${formatRemoteArg(e, literal)}`;
140
+ });
120
141
  const sshArgs = getSshOption(hostInfo, true);
121
142
  const rshOpt = ["-e", `${sshCmd} ${sshArgs.join(" ")}`];
122
- const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, src, dst];
123
- if (await isNewRsync(hostInfo)) {
124
- args.unshift("--old-args");
125
- }
143
+ const args = ["-avv", "--copy-unsafe-links", ...rshOpt, ...rsyncOpt, ...src, dst];
126
144
 
127
145
  //make destination directory
128
146
  const dstDir = argSrc.len > 1 || hasMagic(argSrc[0]) ? dst : path.dirname(dst);
129
147
  await fs.mkdir(dstDir, { recursive: true });
130
148
 
131
- debug(`recv ${src} to ${dst}`);
149
+ debug(`recv ${src.join(" ")} to ${dst}`);
132
150
  debugVerbose(`recv: ${rsyncCmd} ${args.join(" ")}`);
133
151
  const exitCodes = resolveRetryableExitCodes(hostInfo, retryableExitCodes, replaceRetryableExitCodes);
134
152
  return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, exitCodes), hostInfo);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-client-wrapper",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "wrapper library for open ssh client and rsync",
5
5
  "keywords": [
6
6
  "ssh",
@@ -16,6 +16,8 @@
16
16
  "types": "lib/index.d.ts",
17
17
  "scripts": {
18
18
  "mocha": "mocha --recursive test",
19
+ "test:unit": "mocha --recursive test/unit && echo \"NOTE: only unit tests were run. The integration tests (ssh, rsync) were NOT run: use npm run test:integration with test hosts\"",
20
+ "test:integration": "mocha --recursive test/integration",
19
21
  "lint": "eslint --fix ./",
20
22
  "test": "c8 --reporter=html --reporter=text mocha --recursive test",
21
23
  "coverage": "c8 report --reporter=text-lcov > coverage-report.lcov",