ssh-client-wrapper 2.2.0 → 2.2.3
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/Dockerfile +0 -1
- package/README.md +6 -0
- package/lib/fork.js +2 -2
- package/lib/sshExec.js +13 -11
- package/lib/util.js +101 -9
- package/package.json +6 -4
- package/test/interface.js +2 -6
- package/test/rsyncExec.js +2 -2
- package/test/sshExec.js +4 -4
- package/test/{util → testUtil}/hostInfo.js +1 -1
- package/test/util.js +76 -0
- /package/test/{util → testUtil}/testFiles.js +0 -0
package/Dockerfile
CHANGED
package/README.md
CHANGED
|
@@ -4,3 +4,9 @@
|
|
|
4
4
|
[](https://codeclimate.com/github/so5/ssh-client-wrapper/maintainability)
|
|
5
5
|
# ssh-client-wrapper
|
|
6
6
|
open ssh client wrapper library for nodejs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# how to test on local machine
|
|
10
|
+
```
|
|
11
|
+
docker-compose up --build
|
|
12
|
+
```
|
package/lib/fork.js
CHANGED
|
@@ -58,7 +58,7 @@ function sshLoginCallback(output, ptyProcess, pw, ph, logger = debugSendPty) {
|
|
|
58
58
|
/**
|
|
59
59
|
* create pty for master ssh session and store to hostInfo.masterPty
|
|
60
60
|
*/
|
|
61
|
-
function
|
|
61
|
+
function createMasterPty(hostInfo) {
|
|
62
62
|
debug("fork pty for master connection");
|
|
63
63
|
|
|
64
64
|
if (hostInfo.masterPty) {
|
|
@@ -125,6 +125,6 @@ async function fork(hostInfo, cmd, args, timeout, outputCallback) {
|
|
|
125
125
|
}
|
|
126
126
|
module.exports = {
|
|
127
127
|
fork,
|
|
128
|
-
|
|
128
|
+
createMasterPty,
|
|
129
129
|
sshLoginCallback
|
|
130
130
|
};
|
package/lib/sshExec.js
CHANGED
|
@@ -5,7 +5,7 @@ const debug = require("debug")("sshClientWrapper:debug:sshExec");
|
|
|
5
5
|
const debugVerbose = require("debug")("sshClientWrapper:verbose:sshExec");
|
|
6
6
|
const debugSendMasterPty = require("debug")("sshClientWrapper:insecure:sshExec_masterPty");
|
|
7
7
|
const { sendPty, sshCmd, getSshOption, sanityCheck, watchDogTimer } = require("./util.js");
|
|
8
|
-
const { fork,
|
|
8
|
+
const { fork, createMasterPty, sshLoginCallback } = require("./fork.js");
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Execute command on remote host.
|
|
@@ -15,6 +15,7 @@ const { fork, createMastePty, sshLoginCallback } = require("./fork.js");
|
|
|
15
15
|
* @param {Function} outputCallback - Callback routine for stdout and stderr.
|
|
16
16
|
*/
|
|
17
17
|
async function sshExec(hostInfo, cmd, timeout, outputCallback) {
|
|
18
|
+
debug("sshExec called");
|
|
18
19
|
await connect(hostInfo);
|
|
19
20
|
debug("exec", cmd, "on remote server");
|
|
20
21
|
const args = getSshOption(hostInfo, false);
|
|
@@ -46,16 +47,13 @@ async function sshExec(hostInfo, cmd, timeout, outputCallback) {
|
|
|
46
47
|
* @param {Integer} timeout - timeout in secconds
|
|
47
48
|
*/
|
|
48
49
|
async function canConnect(hostInfo, timeout = 60) {
|
|
49
|
-
debug("
|
|
50
|
+
debug("canConnect called");
|
|
50
51
|
sanityCheck(hostInfo);
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
} finally {
|
|
57
|
-
await disconnect(hostInfo);
|
|
58
|
-
}
|
|
53
|
+
await connect(hostInfo, timeout);
|
|
54
|
+
await disconnect(hostInfo);
|
|
55
|
+
//if connection fail, Error is throwed.
|
|
56
|
+
//so, if we reach here, connection must be succeeded
|
|
59
57
|
return true;
|
|
60
58
|
}
|
|
61
59
|
|
|
@@ -97,16 +95,17 @@ async function existsMaster(hostInfo, argTimeout) {
|
|
|
97
95
|
* @param {Object} hostInfo - hostinfo object
|
|
98
96
|
*/
|
|
99
97
|
async function connect(hostInfo, timeout = 60) {
|
|
98
|
+
debug("connect called");
|
|
99
|
+
|
|
100
100
|
if (await existsMaster(hostInfo, timeout)) {
|
|
101
101
|
debugVerbose("master connection exists");
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
debug(`create master pty for ${hostInfo.host}`);
|
|
106
|
-
|
|
106
|
+
createMasterPty(hostInfo, debugSendMasterPty);
|
|
107
107
|
|
|
108
108
|
debug(`create master ssh session to ${hostInfo.host}`);
|
|
109
|
-
|
|
110
109
|
const words = crypto.randomUUID();
|
|
111
110
|
const re = new RegExp(`^${words}`);
|
|
112
111
|
|
|
@@ -177,6 +176,8 @@ async function connect(hostInfo, timeout = 60) {
|
|
|
177
176
|
* @param {Object} hostInfo - hostinfo object
|
|
178
177
|
*/
|
|
179
178
|
async function disconnect(hostInfo) {
|
|
179
|
+
debug("disconnect called");
|
|
180
|
+
|
|
180
181
|
if (await existsMaster(hostInfo)) {
|
|
181
182
|
const args = getSshOption(hostInfo);
|
|
182
183
|
args.push("-Oexit");
|
|
@@ -218,6 +219,7 @@ async function disconnect(hostInfo) {
|
|
|
218
219
|
* @returns {string[]} - output from ls
|
|
219
220
|
*/
|
|
220
221
|
async function ls(hostInfo, target, lsOpt, timeout) {
|
|
222
|
+
debug("ls called");
|
|
221
223
|
const outputArray = [];
|
|
222
224
|
try {
|
|
223
225
|
await sshExec(hostInfo, `ls ${lsOpt.join(" ")} ${target}`, timeout, (data)=>{
|
package/lib/util.js
CHANGED
|
@@ -11,6 +11,53 @@ const rePwPrompt = /password:/;
|
|
|
11
11
|
const rePhPrompt = /Enter passphrase for key/;
|
|
12
12
|
const reNewHostPrompt = /Are you sure you want to continue connecting/;
|
|
13
13
|
|
|
14
|
+
const Ajv = require("ajv");
|
|
15
|
+
|
|
16
|
+
const ajv = new Ajv({
|
|
17
|
+
allErrors: true,
|
|
18
|
+
removeAdditional: "all",
|
|
19
|
+
coerceTypes: true,
|
|
20
|
+
logger: {
|
|
21
|
+
log: debug,
|
|
22
|
+
warn: debug,
|
|
23
|
+
error: debug
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
require("ajv-keywords")(ajv, "transform");
|
|
27
|
+
|
|
28
|
+
//never validate password and passphrase value to avoid security insident
|
|
29
|
+
const hostInfoSchema = {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
host: { type: "string", pattern: "\\S+", transform: ["trim"] },
|
|
33
|
+
user: { type: "string", pattern: "\\S+", transform: ["trim"] },
|
|
34
|
+
port: { type: "number", minimum: 0, maximum: 65535 },
|
|
35
|
+
keyFile: { type: "string", pattern: "\\S+", transform: ["trim"] },
|
|
36
|
+
noStrictHostkeyChecking: { type: "boolean" },
|
|
37
|
+
ControlPersist: { type: "number", minimum: 0 },
|
|
38
|
+
ConnectTimeout: { type: "number", minimum: 0 },
|
|
39
|
+
maxRetry: { type: "number", minimum: 0 },
|
|
40
|
+
retryMinTimeout: { type: "number", minimum: 0 },
|
|
41
|
+
retryMaxTimeout: { type: "number", minimum: 0 },
|
|
42
|
+
sshOpt: { type: "array", minItems: 1, items: { type: "string", pattern: "\\S+", transform: ["trim"] } }
|
|
43
|
+
},
|
|
44
|
+
required: ["host"],
|
|
45
|
+
additionalProperties: false
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const stringOptions = [
|
|
49
|
+
"user",
|
|
50
|
+
"keyFile"
|
|
51
|
+
];
|
|
52
|
+
const numberOptions = [
|
|
53
|
+
"ControlPersist",
|
|
54
|
+
"ConnectTimeout",
|
|
55
|
+
"maxRetry",
|
|
56
|
+
"retryMinTimeout",
|
|
57
|
+
"retryMaxTimeout"
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const validate = ajv.compile(hostInfoSchema);
|
|
14
61
|
|
|
15
62
|
/**
|
|
16
63
|
* bridge function to functions which need string
|
|
@@ -23,18 +70,62 @@ const buff2String = (func, data)=>{
|
|
|
23
70
|
|
|
24
71
|
/**
|
|
25
72
|
* check if given hostInfo object is OK
|
|
26
|
-
* @param { Object} hostInfo - host information object defined in index.js
|
|
73
|
+
* @param { Object } hostInfo - host information object defined in index.js
|
|
27
74
|
*/
|
|
28
75
|
const sanityCheck = (hostInfo)=>{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
76
|
+
debug("sanityCheck called", hostInfo);
|
|
77
|
+
//keep password, passphrase, and masterPty
|
|
78
|
+
const { password, passphrase, masterPty } = hostInfo;
|
|
79
|
+
validate(hostInfo);
|
|
80
|
+
|
|
81
|
+
if (validate !== null && Array.isArray(validate.errors)) {
|
|
82
|
+
for (const e of validate.errors) {
|
|
83
|
+
debug("validation error:", e);
|
|
84
|
+
const prop = e.instancePath.replace(/^\//, "");
|
|
85
|
+
if (e.keyword === "required" && e.params.missingProperty === "host") {
|
|
86
|
+
throw new Error("host is required");
|
|
87
|
+
}
|
|
88
|
+
if (prop === "host") {
|
|
89
|
+
if (hostInfo.host === "") {
|
|
90
|
+
throw new Error("empty host is not allowed");
|
|
91
|
+
}
|
|
92
|
+
const err = new Error("invalid host specified");
|
|
93
|
+
err.hostInfo = hostInfo;
|
|
94
|
+
err.validationError = e;
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
if (prop.startsWith("sshOpt")) {
|
|
98
|
+
debug("remove empty member of sshOpt");
|
|
99
|
+
hostInfo.sshOpt = hostInfo.sshOpt.filter((opt)=>{
|
|
100
|
+
return opt !== "";
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
} else if (stringOptions.includes(prop) && hostInfo[prop] === "") {
|
|
104
|
+
debug("remove empty string option", prop, hostInfo[prop]);
|
|
105
|
+
delete hostInfo[prop];
|
|
106
|
+
continue;
|
|
107
|
+
} else if (numberOptions.includes(prop) && ["minimum", "maximum"].includes(e.keyword)) {
|
|
108
|
+
debug("remove out of range number option", prop, hostInfo[prop]);
|
|
109
|
+
delete hostInfo[prop];
|
|
110
|
+
continue;
|
|
111
|
+
} else {
|
|
112
|
+
const err = new Error(`invalid ${prop} specified ${hostInfo[prop]}`);
|
|
113
|
+
err.hostInfo = hostInfo;
|
|
114
|
+
err.validationError = e;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
36
117
|
}
|
|
37
118
|
}
|
|
119
|
+
if (["string", "function"].includes(typeof password)) {
|
|
120
|
+
hostInfo.password = password;
|
|
121
|
+
}
|
|
122
|
+
if (["string", "function"].includes(typeof passphrase)) {
|
|
123
|
+
hostInfo.password = password;
|
|
124
|
+
}
|
|
125
|
+
if (masterPty === null || typeof masterPty === "function") {
|
|
126
|
+
hostInfo.masterPty = masterPty;
|
|
127
|
+
}
|
|
128
|
+
return hostInfo;
|
|
38
129
|
};
|
|
39
130
|
|
|
40
131
|
/**
|
|
@@ -43,6 +134,7 @@ const sanityCheck = (hostInfo)=>{
|
|
|
43
134
|
* @param {boolean} withoutDestination - return except for hostname
|
|
44
135
|
*/
|
|
45
136
|
const getSshOption = (hostInfo, withoutDestination)=>{
|
|
137
|
+
debug("getSshOption called");
|
|
46
138
|
const args = [];
|
|
47
139
|
|
|
48
140
|
if (!withoutDestination) {
|
|
@@ -140,7 +232,7 @@ const watchDogTimer = (timeout, label, cb)=>{
|
|
|
140
232
|
})
|
|
141
233
|
.catch((err)=>{
|
|
142
234
|
if (err.name === "AbortError") {
|
|
143
|
-
debug(`timeout was aborted because ${label} has
|
|
235
|
+
debug(`timeout was aborted because ${label} has finished or canceled`);
|
|
144
236
|
} else {
|
|
145
237
|
throw err;
|
|
146
238
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-client-wrapper",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "wrapper library for open ssh client and rsync",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ssh",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"*.js": "eslint --fix"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"ajv": "^8.12.0",
|
|
38
|
+
"ajv-keywords": "^5.1.0",
|
|
37
39
|
"debug": "^4.3.4",
|
|
38
40
|
"glob": "^10.3.10",
|
|
39
41
|
"node-pty": "^0.10.1",
|
|
40
|
-
"
|
|
41
|
-
"promise-retry": "^2.0.1",
|
|
42
|
-
"rewire": "^7.0.0"
|
|
42
|
+
"promise-retry": "^2.0.1"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"chai": "^4.3.7",
|
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
"lint-staged": "^13.2.1",
|
|
57
57
|
"mocha": "^10.2.0",
|
|
58
58
|
"sinon": "^15.0.3",
|
|
59
|
+
"rewire": "^7.0.0",
|
|
60
|
+
"nyc": "^15.1.0",
|
|
59
61
|
"sinon-chai": "^3.7.0"
|
|
60
62
|
}
|
|
61
63
|
}
|
package/test/interface.js
CHANGED
|
@@ -33,15 +33,11 @@ describe("test for interface", ()=>{
|
|
|
33
33
|
expect(()=>{
|
|
34
34
|
//eslint-disable-next-line no-new
|
|
35
35
|
new SshClientWrapper({});
|
|
36
|
-
}).to.throw("host
|
|
37
|
-
expect(()=>{
|
|
38
|
-
//eslint-disable-next-line no-new
|
|
39
|
-
new SshClientWrapper({ host: 1 });
|
|
40
|
-
}).to.throw("host must be non-empty string");
|
|
36
|
+
}).to.throw("host is required");
|
|
41
37
|
expect(()=>{
|
|
42
38
|
//eslint-disable-next-line no-new
|
|
43
39
|
new SshClientWrapper({ host: "" });
|
|
44
|
-
}).to.throw("host
|
|
40
|
+
}).to.throw("empty host is not allowed");
|
|
45
41
|
});
|
|
46
42
|
it("should successfully instanciate only with host prop", ()=>{
|
|
47
43
|
expect(new SshClientWrapper({ host: "hoge" })).to.have.property("exec");
|
package/test/rsyncExec.js
CHANGED
|
@@ -28,7 +28,7 @@ const { checkRsyncVersion, send, recv } = require("../lib/rsyncExec.js");
|
|
|
28
28
|
|
|
29
29
|
//test helpers
|
|
30
30
|
const { sshExec, disconnect } = require("../lib/sshExec.js");
|
|
31
|
-
const hostInfo = require("./
|
|
31
|
+
const hostInfo = require("./testUtil/hostInfo.js");
|
|
32
32
|
const {
|
|
33
33
|
clearLocalTestFiles,
|
|
34
34
|
clearRemoteTestFiles,
|
|
@@ -40,7 +40,7 @@ const {
|
|
|
40
40
|
remoteRoot,
|
|
41
41
|
remoteEmptyDir,
|
|
42
42
|
remoteFiles
|
|
43
|
-
} = require("./
|
|
43
|
+
} = require("./testUtil/testFiles");
|
|
44
44
|
|
|
45
45
|
const formatLsOutput = (array)=>{
|
|
46
46
|
const rt = [];
|
package/test/sshExec.js
CHANGED
|
@@ -17,8 +17,8 @@ const sshout = sinon.stub();
|
|
|
17
17
|
const { sshExec, canConnect, disconnect, ls } = require("../lib/sshExec.js");
|
|
18
18
|
|
|
19
19
|
//test helpers
|
|
20
|
-
const hostInfo = require("./
|
|
21
|
-
const { clearRemoteTestFiles, clearLocalTestFiles, createRemoteFiles, nonExisting, remoteRoot, remoteEmptyDir } = require("./
|
|
20
|
+
const hostInfo = require("./testUtil/hostInfo.js");
|
|
21
|
+
const { clearRemoteTestFiles, clearLocalTestFiles, createRemoteFiles, nonExisting, remoteRoot, remoteEmptyDir } = require("./testUtil/testFiles.js");
|
|
22
22
|
|
|
23
23
|
describe("test for ssh execution", function() {
|
|
24
24
|
this.timeout(65000);//eslint-disable-line no-invalid-this
|
|
@@ -97,11 +97,11 @@ describe("test for ssh execution", function() {
|
|
|
97
97
|
});
|
|
98
98
|
it("should be rejected if port number is out of range(-1)", async ()=>{
|
|
99
99
|
hostInfo2.port = -1;
|
|
100
|
-
return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith(
|
|
100
|
+
return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith("invalid port specified -1");
|
|
101
101
|
});
|
|
102
102
|
it("should be rejected if port number is out of range(65536)", async ()=>{
|
|
103
103
|
hostInfo2.port = 65536;
|
|
104
|
-
return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith(
|
|
104
|
+
return expect(canConnect(hostInfo2, 2)).to.be.rejectedWith("invalid port specified 65536");
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
describe("#ls", ()=>{
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module.exports = {
|
|
3
3
|
host: process.env.TEST_HOST || "localhost",
|
|
4
4
|
user: process.env.TEST_USER || null,
|
|
5
|
-
port: process.env.TEST_PORT ||
|
|
5
|
+
port: process.env.TEST_PORT || 22,
|
|
6
6
|
password: process.env.TEST_PW,
|
|
7
7
|
passphrase: process.env.TEST_PH,
|
|
8
8
|
keyFile: process.env.TEST_PRIVATE_KEY,
|
package/test/util.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
process.on("unhandledRejection", console.dir); //eslint-disable-line no-console
|
|
3
|
+
|
|
4
|
+
//setup test framework
|
|
5
|
+
const chai = require("chai");
|
|
6
|
+
const { expect } = require("chai");
|
|
7
|
+
chai.use(require("chai-as-promised"));
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
//testee
|
|
11
|
+
const { sanityCheck } = require("../lib/util.js");
|
|
12
|
+
|
|
13
|
+
describe("test for sanityCheck", ()=>{
|
|
14
|
+
const host = "testHostName";
|
|
15
|
+
it("should throw error for empty host", ()=>{
|
|
16
|
+
expect(sanityCheck.bind(null, { host: " ", user: " ", port: 22 })).to.throw(/empty host is not allowed/);
|
|
17
|
+
});
|
|
18
|
+
it("should throw error if host is not in argument object", ()=>{
|
|
19
|
+
expect(sanityCheck.bind(null, { user: " ", port: 22 })).to.throw(/host is required/);
|
|
20
|
+
});
|
|
21
|
+
it("should just remove empty string props", ()=>{
|
|
22
|
+
expect(sanityCheck({ host, user: " ", port: 22 })).to.deep.equal({ host, port: 22 });
|
|
23
|
+
});
|
|
24
|
+
it("should remove empty string member in sshOpt", ()=>{
|
|
25
|
+
expect(sanityCheck({ host, sshOpt: ["foo", " ", "bar"], user: "user", port: 33 })).to.deep.equal({ host, sshOpt: ["foo", "bar"], user: "user", port: 33 });
|
|
26
|
+
});
|
|
27
|
+
it("should just chage type if string value specified for number", ()=>{
|
|
28
|
+
expect(sanityCheck({
|
|
29
|
+
host,
|
|
30
|
+
port: "11",
|
|
31
|
+
ControlPersist: "22",
|
|
32
|
+
ConnectTimeout: "33",
|
|
33
|
+
maxRetry: "44",
|
|
34
|
+
retryMinTimeout: "55",
|
|
35
|
+
retryMaxTimeout: "66"
|
|
36
|
+
})).to.deep.equal({
|
|
37
|
+
host,
|
|
38
|
+
port: 11,
|
|
39
|
+
ControlPersist: 22,
|
|
40
|
+
ConnectTimeout: 33,
|
|
41
|
+
maxRetry: 44,
|
|
42
|
+
retryMinTimeout: 55,
|
|
43
|
+
retryMaxTimeout: 66
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
it("should remove out of range members", ()=>{
|
|
47
|
+
expect(sanityCheck({
|
|
48
|
+
host,
|
|
49
|
+
ControlPersist: "-1",
|
|
50
|
+
ConnectTimeout: "-1",
|
|
51
|
+
maxRetry: "-1",
|
|
52
|
+
retryMinTimeout: "-1",
|
|
53
|
+
retryMaxTimeout: "-1"
|
|
54
|
+
})).to.deep.equal({
|
|
55
|
+
host
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it("should just chage type if string value specified for boolean member", ()=>{
|
|
59
|
+
expect(sanityCheck({
|
|
60
|
+
host,
|
|
61
|
+
noStrictHostkeyChecking: "true"
|
|
62
|
+
})).to.deep.equal({
|
|
63
|
+
host,
|
|
64
|
+
noStrictHostkeyChecking: true
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
it("should just chage type if number value specified for boolean member", ()=>{
|
|
68
|
+
expect(sanityCheck({
|
|
69
|
+
host,
|
|
70
|
+
noStrictHostkeyChecking: 0
|
|
71
|
+
})).to.deep.equal({
|
|
72
|
+
host,
|
|
73
|
+
noStrictHostkeyChecking: false
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
File without changes
|