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.
@@ -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
+ }