zerogterm 0.6.0-alpha.1 → 0.7.0-alpha2
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/LICENSE +21 -21
- package/README.md +44 -3
- package/bin/zerogterm.cjs +23 -23
- package/dist/main/main/external-links.js +79 -0
- package/dist/main/main/local-fs.js +115 -0
- package/dist/main/main/main.js +91 -1
- package/dist/main/main/preload.cjs +26 -1
- package/dist/main/main/session-service.js +38 -20
- package/dist/main/main/sftp-protocol.js +279 -0
- package/dist/main/main/sftp-service.js +465 -0
- package/dist/main/main/shell-catalog.js +45 -0
- package/dist/main/shared/ansi.js +34 -0
- package/dist/main/shared/files.js +88 -0
- package/dist/renderer/assets/index-BjXtNztF.css +1 -0
- package/dist/renderer/assets/index-Ogyjvi0e.js +19 -0
- package/dist/renderer/index.html +4 -4
- package/package.json +2 -2
- package/dist/renderer/assets/index-BJsoL2QL.js +0 -17
- package/dist/renderer/assets/index-BPfYUrvm.css +0 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Reading and writing OpenSSH's interactive sftp client.
|
|
2
|
+
//
|
|
3
|
+
// ZeroG drives the system `sftp` binary rather than speaking the SFTP protocol
|
|
4
|
+
// itself, for the same reason it drives system `ssh` for terminals: the client
|
|
5
|
+
// already honours ~/.ssh/config, ProxyJump, the agent, and known_hosts. The
|
|
6
|
+
// price is that its answers arrive as text written for a person, so everything
|
|
7
|
+
// in this module is about turning that text back into data — and about never
|
|
8
|
+
// letting a filename be read as anything but a filename.
|
|
9
|
+
//
|
|
10
|
+
// Kept free of state and of node-pty so the parsing can be tested directly.
|
|
11
|
+
import { stripAnsi } from '../shared/ansi.js';
|
|
12
|
+
import { parseSshTarget } from './session-service.js';
|
|
13
|
+
import { findOpenSshTool } from './shell-catalog.js';
|
|
14
|
+
/** What the client prints when it is ready for another command. */
|
|
15
|
+
const PROMPT = 'sftp> ';
|
|
16
|
+
/**
|
|
17
|
+
* Characters a path may not contain.
|
|
18
|
+
*
|
|
19
|
+
* `sftp` has a command interpreter: it splits a line into words, strips one
|
|
20
|
+
* layer of quoting, and then hands file arguments to a *glob* expander. Two
|
|
21
|
+
* unescaping passes with different rules means a filename containing a quote,
|
|
22
|
+
* a backslash or a glob character has no encoding that is provably correct for
|
|
23
|
+
* every command — and being approximately right about which file to delete is
|
|
24
|
+
* not good enough. Such names are refused with a message instead, which costs
|
|
25
|
+
* the user very little: these characters are rare in real filenames and illegal
|
|
26
|
+
* in Windows ones.
|
|
27
|
+
*
|
|
28
|
+
* Every control character goes with them. NUL terminates the path for the C
|
|
29
|
+
* library underneath; carriage return and newline would end the command, since
|
|
30
|
+
* a command is one line; and tab is what the client's own argument splitter
|
|
31
|
+
* breaks words on. A quoted tab does survive that splitter today — but the whole
|
|
32
|
+
* point of this list is to avoid betting a delete on the client's undocumented
|
|
33
|
+
* parsing rules, and a filename containing an escape character or a bell is not
|
|
34
|
+
* a case worth taking that bet for.
|
|
35
|
+
*/
|
|
36
|
+
const UNSAFE_PATH = /[\u0000-\u001f\u007f-\u009f"'\\*?[\]]/;
|
|
37
|
+
/** The same control characters again, for telling the user which rule they hit. */
|
|
38
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
|
39
|
+
const MAX_PATH = 4096;
|
|
40
|
+
export function isSafeRemotePath(path) {
|
|
41
|
+
return path.length > 0 && path.length <= MAX_PATH && !UNSAFE_PATH.test(path);
|
|
42
|
+
}
|
|
43
|
+
function describeUnsafe(path) {
|
|
44
|
+
if (!path)
|
|
45
|
+
return 'the path is empty';
|
|
46
|
+
if (path.length > MAX_PATH)
|
|
47
|
+
return 'the path is too long';
|
|
48
|
+
if (CONTROL_CHARACTER.test(path))
|
|
49
|
+
return 'names containing control characters are not supported';
|
|
50
|
+
return 'names containing quotes, backslashes, or the wildcard characters * ? [ ] are not supported yet';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A path as one quoted word for the sftp command line.
|
|
54
|
+
*
|
|
55
|
+
* Only spaces and ordinary punctuation survive validation, so double quotes are
|
|
56
|
+
* enough on their own: the interpreter strips them and the glob pass sees a
|
|
57
|
+
* literal name with no metacharacters left in it.
|
|
58
|
+
*/
|
|
59
|
+
export function quoteRemotePath(path) {
|
|
60
|
+
if (!isSafeRemotePath(path)) {
|
|
61
|
+
throw new Error(`Cannot use this path over SFTP: ${describeUnsafe(path)}`);
|
|
62
|
+
}
|
|
63
|
+
return `"${path}"`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A local path the sftp client will accept as one word.
|
|
67
|
+
*
|
|
68
|
+
* Windows paths are separator-translated rather than escaped: `C:\Users\me`
|
|
69
|
+
* would lose its backslashes to the interpreter, while Windows itself accepts
|
|
70
|
+
* forward slashes everywhere, so `C:/Users/me` reaches the same file with
|
|
71
|
+
* nothing left for the interpreter to eat.
|
|
72
|
+
*/
|
|
73
|
+
export function quoteLocalPath(path) {
|
|
74
|
+
return quoteRemotePath(path.replace(/\\/g, '/'));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The absolute path to the OpenSSH sftp client.
|
|
78
|
+
*
|
|
79
|
+
* A bare command name is not enough: node-pty hands the file straight to
|
|
80
|
+
* CreateProcess on Windows, which does not append `.exe`, so a pty asked for
|
|
81
|
+
* `sftp` dies with "File not found" on a machine that plainly has it. Where
|
|
82
|
+
* OpenSSH is looked for lives in findOpenSshTool, shared with the terminal
|
|
83
|
+
* side's sshExecutable — the two tools ship together, so they should be found
|
|
84
|
+
* the same way.
|
|
85
|
+
*/
|
|
86
|
+
export function sftpExecutable(options = {}) {
|
|
87
|
+
const file = findOpenSshTool('sftp', options);
|
|
88
|
+
if (!file) {
|
|
89
|
+
throw new Error('The OpenSSH sftp client was not found. Install the OpenSSH client tools to transfer files.');
|
|
90
|
+
}
|
|
91
|
+
return file;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The sftp client invocation for an SSH target.
|
|
95
|
+
*
|
|
96
|
+
* Reuses the terminal side's target parser so a destination that ssh would read
|
|
97
|
+
* as an option cannot reach sftp either. ConnectTimeout is set because the panel
|
|
98
|
+
* has to be able to report a dead host rather than sit on a spinner; everything
|
|
99
|
+
* else — keys, proxies, host key policy — is left to the user's ssh config.
|
|
100
|
+
*/
|
|
101
|
+
export function buildSftpArgs(target, options = {}) {
|
|
102
|
+
const { destination, port } = parseSshTarget(target);
|
|
103
|
+
const args = ['-o', 'ConnectTimeout=20'];
|
|
104
|
+
if (port)
|
|
105
|
+
args.push('-P', port);
|
|
106
|
+
args.push(destination);
|
|
107
|
+
return { file: sftpExecutable(options), args };
|
|
108
|
+
}
|
|
109
|
+
/** Has the client finished answering and asked for the next command? */
|
|
110
|
+
export function endsWithPrompt(buffer) {
|
|
111
|
+
return stripAnsi(buffer).trimEnd().endsWith(PROMPT.trimEnd());
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The client's answer, with the echoed command and trailing prompt removed.
|
|
115
|
+
*
|
|
116
|
+
* The client runs on a pty so that it can ask for a password, and a pty echoes
|
|
117
|
+
* what was typed into it — so the first thing in every response is the command
|
|
118
|
+
* itself.
|
|
119
|
+
*/
|
|
120
|
+
export function responseBody(buffer, command) {
|
|
121
|
+
const text = stripAnsi(buffer).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
122
|
+
const wanted = command.trim();
|
|
123
|
+
const lines = text.split('\n');
|
|
124
|
+
const start = lines.findIndex((line) => line.trim() === wanted || line.trim().endsWith(PROMPT + wanted));
|
|
125
|
+
const body = start >= 0 ? lines.slice(start + 1) : lines;
|
|
126
|
+
return body
|
|
127
|
+
.filter((line) => line.trim() !== PROMPT.trim())
|
|
128
|
+
.join('\n')
|
|
129
|
+
.replace(/sftp>\s*$/, '')
|
|
130
|
+
.trimEnd();
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A `ls -l` record, as loosely as it can be matched while still being one.
|
|
134
|
+
*
|
|
135
|
+
* The mode and link-count columns are deliberately permissive: the format is
|
|
136
|
+
* the *server's* choice, and servers do vary. OpenSSH's MSYS build, for one,
|
|
137
|
+
* prints `drwx******` for a mode and `?` where the link count goes. Being strict
|
|
138
|
+
* about columns nothing here reads would turn a directory of files into an empty
|
|
139
|
+
* pane. What the panel actually needs — kind, size, date, name — is matched
|
|
140
|
+
* exactly.
|
|
141
|
+
*/
|
|
142
|
+
const LS_LINE = /^([dlbcps-][rwxsStT*?+-]{9}[.+@]?)\s+[\d?]+\s+\S+\s+\S+\s+(\d+)\s+(\w{3}\s+\d{1,2}\s+(?:\d{4}|\d{1,2}:\d{2}))\s+(.+)$/;
|
|
143
|
+
/**
|
|
144
|
+
* One `ls -l` record, or null when the line is something else — a banner, a
|
|
145
|
+
* blank, or an error. The name is taken as the whole remainder of the line so
|
|
146
|
+
* that spaces in it survive; the columns before it are fixed in number.
|
|
147
|
+
*/
|
|
148
|
+
export function parseLsLine(line) {
|
|
149
|
+
const match = line.trimEnd().match(LS_LINE);
|
|
150
|
+
if (!match)
|
|
151
|
+
return null;
|
|
152
|
+
const [, permissions, size, modified, rest] = match;
|
|
153
|
+
const kind = permissions.startsWith('d') ? 'directory' : permissions.startsWith('l') ? 'symlink' : 'file';
|
|
154
|
+
// A symlink record ends with " -> target"; the arrow is not part of the name.
|
|
155
|
+
const arrow = kind === 'symlink' ? rest.lastIndexOf(' -> ') : -1;
|
|
156
|
+
const name = arrow > 0 ? rest.slice(0, arrow) : rest;
|
|
157
|
+
const linkTarget = arrow > 0 ? rest.slice(arrow + 4) : undefined;
|
|
158
|
+
if (!name || name === '.' || name === '..')
|
|
159
|
+
return null;
|
|
160
|
+
return { name, kind, size: Number(size), modified, permissions, ...(linkTarget ? { linkTarget } : {}) };
|
|
161
|
+
}
|
|
162
|
+
export function parseListing(text) {
|
|
163
|
+
return text.split('\n').flatMap((line) => {
|
|
164
|
+
const entry = parseLsLine(line);
|
|
165
|
+
return entry ? [entry] : [];
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** The directory `pwd` reported, which is the server's canonical form of it. */
|
|
169
|
+
export function parsePwd(text) {
|
|
170
|
+
const match = text.match(/Remote working directory:\s*(.+)$/m);
|
|
171
|
+
return match ? match[1].trim() : null;
|
|
172
|
+
}
|
|
173
|
+
const ERROR_LINE = [
|
|
174
|
+
/^(?:couldn't|can't|cannot|unable to)\b/i,
|
|
175
|
+
/^remote (?:readdir|open|stat|lstat|mkdir|rmdir|unlink|rename)\b/i,
|
|
176
|
+
/:\s*(?:no such file or directory|permission denied|failure|not a directory|is a directory|file exists|operation unsupported|no space left on device|connection closed)\.?$/i,
|
|
177
|
+
/\bnot found\.?$/i,
|
|
178
|
+
/^permission denied/i,
|
|
179
|
+
/^connection closed/i,
|
|
180
|
+
/^host key verification failed/i,
|
|
181
|
+
/^ssh: /i,
|
|
182
|
+
/^invalid command/i
|
|
183
|
+
];
|
|
184
|
+
/**
|
|
185
|
+
* The first line of a response that reports a failure.
|
|
186
|
+
*
|
|
187
|
+
* The client's exit status is not available — it stays alive for the next
|
|
188
|
+
* command — so a failed operation is only distinguishable by what it printed.
|
|
189
|
+
* Lines that parse as directory records are skipped first, so a file whose name
|
|
190
|
+
* happens to read like an error message cannot be mistaken for one.
|
|
191
|
+
*/
|
|
192
|
+
export function findError(text) {
|
|
193
|
+
for (const raw of text.split('\n')) {
|
|
194
|
+
const line = raw.trim();
|
|
195
|
+
if (!line || parseLsLine(raw))
|
|
196
|
+
continue;
|
|
197
|
+
if (ERROR_LINE.some((pattern) => pattern.test(line)))
|
|
198
|
+
return line;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A frame of the transfer progress meter, which the client only prints because
|
|
204
|
+
* it is talking to a pty. Meter frames overwrite each other with carriage
|
|
205
|
+
* returns, so the caller passes the latest chunk and takes the last frame in it.
|
|
206
|
+
*/
|
|
207
|
+
export function parseProgress(chunk) {
|
|
208
|
+
const frames = stripAnsi(chunk).split(/[\r\n]/).reverse();
|
|
209
|
+
for (const frame of frames) {
|
|
210
|
+
const match = frame.match(/^(.*?)\s{2,}(\d{1,3})%\s+(.*?)\s*$/);
|
|
211
|
+
if (!match)
|
|
212
|
+
continue;
|
|
213
|
+
const percent = Number(match[2]);
|
|
214
|
+
if (percent > 100 || !match[1].trim())
|
|
215
|
+
continue;
|
|
216
|
+
return { name: match[1].trim(), percent, detail: match[3].trim() };
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A question the connection is waiting on, if the tail of the output is one.
|
|
222
|
+
*
|
|
223
|
+
* Detection runs on the tail rather than line-by-line because these prompts are
|
|
224
|
+
* written without a trailing newline — the cursor is left on the prompt line,
|
|
225
|
+
* which is precisely what makes them a question rather than a message.
|
|
226
|
+
*/
|
|
227
|
+
export function detectPrompt(buffer) {
|
|
228
|
+
const text = stripAnsi(buffer).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
229
|
+
const tail = text.slice(-600);
|
|
230
|
+
if (/\(yes\/no(?:\/\[fingerprint\])?\)\?\s*$/i.test(tail) || /type 'yes', 'no' or the fingerprint:\s*$/i.test(tail)) {
|
|
231
|
+
// Carry the whole authenticity notice, fingerprint included: the user is
|
|
232
|
+
// being asked to vouch for a key, and cannot do that from the last line.
|
|
233
|
+
const notice = tail.lastIndexOf('The authenticity of host');
|
|
234
|
+
return { kind: 'confirm', text: (notice >= 0 ? tail.slice(notice) : tail).trim() };
|
|
235
|
+
}
|
|
236
|
+
const lastLine = tail.split('\n').pop() ?? '';
|
|
237
|
+
if (/passphrase for key/i.test(lastLine) && /:\s*$/.test(lastLine)) {
|
|
238
|
+
return { kind: 'passphrase', text: lastLine.trim() };
|
|
239
|
+
}
|
|
240
|
+
if (/(?:password|verification code|one-time password):\s*$/i.test(lastLine)) {
|
|
241
|
+
return { kind: 'password', text: lastLine.trim() };
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The last thing the client actually said, for putting in an error.
|
|
247
|
+
*
|
|
248
|
+
* A timeout can only report that nothing arrived, which is the least useful
|
|
249
|
+
* sentence available: whether the host was never reached, asked something
|
|
250
|
+
* unrecognised, or answered and was misparsed are three different problems with
|
|
251
|
+
* the same symptom. The client's own last line separates them.
|
|
252
|
+
*/
|
|
253
|
+
export function lastClientMessage(buffer) {
|
|
254
|
+
const lines = stripAnsi(buffer)
|
|
255
|
+
.replaceAll('\r\n', '\n')
|
|
256
|
+
.replaceAll('\r', '\n')
|
|
257
|
+
.split('\n')
|
|
258
|
+
.map((line) => line.trim())
|
|
259
|
+
.filter((line) => line && line !== PROMPT.trim());
|
|
260
|
+
return lines.at(-1) ?? '';
|
|
261
|
+
}
|
|
262
|
+
/** A failure that ends the connection rather than one command. */
|
|
263
|
+
export function detectFatal(buffer) {
|
|
264
|
+
const text = stripAnsi(buffer);
|
|
265
|
+
const patterns = [
|
|
266
|
+
/^ssh: .*$/m,
|
|
267
|
+
/^Permission denied \(.*\)\.?$/m,
|
|
268
|
+
/^Host key verification failed\.?$/m,
|
|
269
|
+
/^.*Connection (?:refused|timed out|closed by remote host).*$/m,
|
|
270
|
+
/^Too many authentication failures.*$/m,
|
|
271
|
+
/^Connection to .* closed by remote host\.?$/m
|
|
272
|
+
];
|
|
273
|
+
for (const pattern of patterns) {
|
|
274
|
+
const match = text.match(pattern);
|
|
275
|
+
if (match)
|
|
276
|
+
return match[0].trim();
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|