@amxts/core 0.1.0 → 0.2.1
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 +21 -25
- package/README.ru.md +22 -24
- package/as/amxts.d.ts +63 -3
- package/as/constants.ts +684 -116
- package/as/entities.ts +1905 -1364
- package/as/events.ts +1696 -907
- package/as/facade.ts +1240 -220
- package/as/fetch.ts +1519 -0
- package/as/flags.ts +159 -145
- package/as/hlds.ts +1071 -0
- package/as/hooks.ts +5226 -5379
- package/as/kit.ts +15 -5
- package/as/lib/check.ts +1 -1
- package/as/natives.ts +2658 -2499
- package/as/remote.ts +39 -15
- package/as/tsconfig.json +7 -2
- package/as/vector.ts +12 -9
- package/includes/order.txt +22 -29
- package/includes/sources.json +0 -8
- package/node_modules/assemblyscript/std/assembly/index.d.ts +20 -11
- package/package.json +29 -7
- package/runtime/deps/assemblyscript/dist/asc.js +86 -16
- package/runtime/deps/assemblyscript/dist/assemblyscript.js +29 -29
- package/runtime/licenses/MPL-2.0.txt +373 -0
- package/runtime/licenses/README.md +5 -1
- package/runtime/licenses/curl-COPYING +22 -0
- package/runtime/licenses/libssh2-COPYING +43 -0
- package/runtime/licenses/mbedTLS-LICENSE +553 -0
- package/runtime/natives.txt +108 -105
- package/runtime/patches/assemblyscript-0.28.20-amxts.patch +752 -296
- package/runtime/patches/wamr-2.4.5-amxts.patch +148 -0
- package/scripts/apply-docs.ts +2 -2
- package/scripts/auto-imports.ts +2 -2
- package/scripts/build-identity.ts +115 -0
- package/scripts/build-wasm.ts +101 -50
- package/scripts/check.ts +17 -3
- package/scripts/client-messages.ts +246 -0
- package/scripts/compile-cache.ts +4 -2
- package/scripts/compile-pool.ts +128 -0
- package/scripts/compile-worker.ts +23 -0
- package/scripts/compile.ts +156 -59
- package/scripts/docker-server.ts +13 -3
- package/scripts/docs/as/amxts.ts +86 -0
- package/scripts/docs/as/facade.ts +688 -188
- package/scripts/docs/as/fetch.ts +805 -0
- package/scripts/docs/entities.ts +43 -144
- package/scripts/docs/events.ts +17 -28
- package/scripts/docs/game.ts +68 -22
- package/scripts/docs/messages.ts +330 -16
- package/scripts/editor-api.ts +101 -0
- package/scripts/hlds-events.ts +370 -0
- package/scripts/includes.ts +12 -10
- package/scripts/player-fields.ts +16 -15
- package/scripts/prebuilt.ts +65 -23
- package/scripts/prepare.ts +42 -28
- package/scripts/project.ts +120 -20
- package/scripts/reapi-events.ts +98 -0
- package/scripts/shared-modules.ts +85 -23
- package/scripts/source-map.ts +242 -0
- package/scripts/system.ts +1 -1
- package/scripts/typed-commands.ts +263 -0
- package/scripts/typed-configs.ts +23 -12
- package/scripts/ui.ts +3 -1
- package/scripts/upgrade-menus.ts +274 -0
- package/scripts/upgrade-names.ts +384 -0
- package/scripts/upgrade.ts +857 -0
- package/src/cli-api.mjs +37 -4
- package/src/system.mjs +74 -3
- package/src/testing/compile.ts +8 -5
- package/src/testing/index.ts +2 -1
- package/src/testing/natives.ts +199 -5
- package/src/testing/network.ts +531 -0
- package/src/testing/server.ts +572 -48
- package/src/testing/setup.ts +1 -1
- package/src/testing/tables.ts +25 -0
- package/as/modules/http.ts +0 -184
- package/scripts/docs/as/modules/http.ts +0 -66
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// The module's network client (runtime/src/network.h) on the fake server:
|
|
2
|
+
// the net_* externals as/fetch.ts declares. HTTP and HTTPS are answered with
|
|
3
|
+
// Bun's own fetch; FTP and FTPS with basic-ftp, SFTP with ssh2-sftp-client,
|
|
4
|
+
// loaded the first time a test asks for one - so a module's test can start
|
|
5
|
+
// an FTP or SFTP server in its own process (ftp-srv, ssh2) and reach it the
|
|
6
|
+
// way the module's natives reach a real one: the same options, the same
|
|
7
|
+
// kinds of error, the same reply codes, a `file` read from or written to the
|
|
8
|
+
// fake game folder.
|
|
9
|
+
//
|
|
10
|
+
// A request goes out when the plugin sends it; what comes back waits until
|
|
11
|
+
// the test lets the server's frames run (`await server.responses()`), and is
|
|
12
|
+
// handed to the plugin's callback then, as the module hands it over on a
|
|
13
|
+
// frame - never inside the call that sent it.
|
|
14
|
+
import type { FakeServer, PluginInstance } from './server';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { Readable, Writable } from 'node:stream';
|
|
17
|
+
import { normalizePath } from './server';
|
|
18
|
+
|
|
19
|
+
// What net_text reads: NET_TEXT_* in runtime/src/network.h.
|
|
20
|
+
const TEXT_ERROR = 0;
|
|
21
|
+
const TEXT_STATUS = 1;
|
|
22
|
+
const TEXT_URL = 2;
|
|
23
|
+
const TEXT_HEADERS = 3;
|
|
24
|
+
const TEXT_KIND = 4;
|
|
25
|
+
|
|
26
|
+
const SHAPE_NARROW = 0;
|
|
27
|
+
|
|
28
|
+
/** What kind of failure ended a request, as the module names it (NetErrorKind); `''` for none. */
|
|
29
|
+
type Kind = '' | 'login' | 'denied' | 'notFound' | 'refused' | 'timeout' | 'tls' | 'aborted' | 'other';
|
|
30
|
+
|
|
31
|
+
interface Outcome {
|
|
32
|
+
/** The HTTP status, FTP's last reply, 0 for SFTP; -1 when there was no response. */
|
|
33
|
+
status: number;
|
|
34
|
+
statusText: string;
|
|
35
|
+
/** The last response's header lines, `name: value` each. */
|
|
36
|
+
headers: string;
|
|
37
|
+
url: string;
|
|
38
|
+
redirects: number;
|
|
39
|
+
body: Uint8Array;
|
|
40
|
+
error: string;
|
|
41
|
+
kind: Kind;
|
|
42
|
+
/** The protocol's last reply code, a failed request's too. */
|
|
43
|
+
reply: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A request a plugin holds, as the module keeps one. */
|
|
47
|
+
interface FakeRequest {
|
|
48
|
+
plugin: PluginInstance;
|
|
49
|
+
url: string;
|
|
50
|
+
method: string;
|
|
51
|
+
headers: [string, string][];
|
|
52
|
+
body: Uint8Array | null;
|
|
53
|
+
follow: boolean;
|
|
54
|
+
proxy: string;
|
|
55
|
+
ca: string;
|
|
56
|
+
user: string;
|
|
57
|
+
password: string;
|
|
58
|
+
/** A file of the game folder, as the server's files hold it. */
|
|
59
|
+
keyFile: string;
|
|
60
|
+
keyPassphrase: string;
|
|
61
|
+
hostKey: string;
|
|
62
|
+
/** FTP's TLS: '', 'try', 'control' or 'all'. */
|
|
63
|
+
ssl: string;
|
|
64
|
+
upload: boolean;
|
|
65
|
+
list: boolean;
|
|
66
|
+
createDirs: boolean;
|
|
67
|
+
quote: string[];
|
|
68
|
+
postquote: string[];
|
|
69
|
+
timeout: number;
|
|
70
|
+
/** A file of the game folder the answer goes into, or an upload comes from. */
|
|
71
|
+
file: string;
|
|
72
|
+
/** The callback's index in the plugin's table. */
|
|
73
|
+
fn: number;
|
|
74
|
+
state: 'setting' | 'sent' | 'ended';
|
|
75
|
+
cancelled: boolean;
|
|
76
|
+
controller: AbortController;
|
|
77
|
+
/** Settled when the response is in. */
|
|
78
|
+
done: Promise<void> | null;
|
|
79
|
+
outcome: Outcome | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A failure on the way: its kind, its words and the reply code that said it. */
|
|
83
|
+
class Failure extends Error {
|
|
84
|
+
constructor(readonly kind: Kind, message: string, readonly reply = 0) {
|
|
85
|
+
super(message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A path of the game folder as the module takes one - or null when it would
|
|
91
|
+
* leave the folder: absolute, a drive, or a `..` among its parts.
|
|
92
|
+
*/
|
|
93
|
+
function gamePath(path: string): string | null {
|
|
94
|
+
if (!path || /^[/\\]/.test(path) || path.includes(':') || path.split(/[/\\]/).includes('..')) return null;
|
|
95
|
+
return normalizePath(path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function flag(value: string): boolean {
|
|
99
|
+
return value !== '0' && value !== 'false' && value !== '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A client library a test of FTP or SFTP needs, or an error saying how to get it. */
|
|
103
|
+
async function need<T>(name: string): Promise<T> {
|
|
104
|
+
try {
|
|
105
|
+
return await import(name) as T;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Failure('other', `the fake server reaches FTP and SFTP servers through ${name}: npm install -D ${name}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The kind of an error of Bun's fetch, of a socket or of TLS. */
|
|
112
|
+
function kindOf(error: unknown): Kind {
|
|
113
|
+
const text = `${(error as { code?: string }).code ?? ''} ${(error as Error).name} ${(error as Error).message}`;
|
|
114
|
+
if (/Timeout/i.test(text)) return 'timeout';
|
|
115
|
+
if (/refused|ENOTFOUND|EAI_AGAIN|resolve|Unable to connect|ConnectionClosed|ECONNRESET/i.test(text)) return 'refused';
|
|
116
|
+
if (/CERT|TLS|SSL|self[- ]signed/i.test(text)) return 'tls';
|
|
117
|
+
return 'other';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The answer of a request that ended well. */
|
|
121
|
+
function answer(status: number, body = new Uint8Array(), more: Partial<Outcome> = {}): Outcome {
|
|
122
|
+
return { status, statusText: '', headers: '', url: '', redirects: 0, body, error: '', kind: '', reply: status, ...more };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class FakeNetwork {
|
|
126
|
+
/** Every request plugins hold, by id. */
|
|
127
|
+
readonly requests = new Map<number, FakeRequest>();
|
|
128
|
+
/** What came back, in order, for the next frame. */
|
|
129
|
+
private readonly ended: number[] = [];
|
|
130
|
+
private nextId = 1;
|
|
131
|
+
|
|
132
|
+
constructor(private readonly server: FakeServer) {}
|
|
133
|
+
|
|
134
|
+
/** The plugin's request in one of `states`; undefined for another plugin's. */
|
|
135
|
+
private of(plugin: PluginInstance, id: number, ...states: FakeRequest['state'][]): FakeRequest | undefined {
|
|
136
|
+
const request = this.requests.get(id);
|
|
137
|
+
return request && request.plugin === plugin && states.includes(request.state) ? request : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
open(plugin: PluginInstance, url: string): number {
|
|
141
|
+
const id = this.nextId++;
|
|
142
|
+
this.requests.set(id, {
|
|
143
|
+
plugin,
|
|
144
|
+
url,
|
|
145
|
+
method: 'GET',
|
|
146
|
+
headers: [],
|
|
147
|
+
body: null,
|
|
148
|
+
follow: true,
|
|
149
|
+
proxy: '',
|
|
150
|
+
ca: '',
|
|
151
|
+
user: '',
|
|
152
|
+
password: '',
|
|
153
|
+
keyFile: '',
|
|
154
|
+
keyPassphrase: '',
|
|
155
|
+
hostKey: '',
|
|
156
|
+
ssl: '',
|
|
157
|
+
upload: false,
|
|
158
|
+
list: false,
|
|
159
|
+
createDirs: false,
|
|
160
|
+
quote: [],
|
|
161
|
+
postquote: [],
|
|
162
|
+
timeout: 0,
|
|
163
|
+
file: '',
|
|
164
|
+
fn: 0,
|
|
165
|
+
state: 'setting',
|
|
166
|
+
cancelled: false,
|
|
167
|
+
controller: new AbortController(),
|
|
168
|
+
done: null,
|
|
169
|
+
outcome: null,
|
|
170
|
+
});
|
|
171
|
+
return id;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
option(plugin: PluginInstance, id: number, name: string, value: string): number {
|
|
175
|
+
const request = this.of(plugin, id, 'setting');
|
|
176
|
+
if (!request) return 0;
|
|
177
|
+
switch (name) {
|
|
178
|
+
case 'header': {
|
|
179
|
+
const colon = value.indexOf(':');
|
|
180
|
+
request.headers.push([value.slice(0, colon).trim(), value.slice(colon + 1).trim()]);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
case 'follow':
|
|
184
|
+
case 'upload':
|
|
185
|
+
case 'list':
|
|
186
|
+
case 'createDirs':
|
|
187
|
+
request[name] = flag(value);
|
|
188
|
+
return 1;
|
|
189
|
+
case 'method':
|
|
190
|
+
case 'proxy':
|
|
191
|
+
case 'ca':
|
|
192
|
+
case 'user':
|
|
193
|
+
case 'password':
|
|
194
|
+
case 'keyPassphrase':
|
|
195
|
+
case 'hostKey':
|
|
196
|
+
request[name] = value;
|
|
197
|
+
return 1;
|
|
198
|
+
case 'ssl':
|
|
199
|
+
request.ssl = ['try', 'control', 'all'].includes(value) ? value : value !== 'none' && flag(value) ? 'all' : '';
|
|
200
|
+
return 1;
|
|
201
|
+
case 'quote':
|
|
202
|
+
case 'postquote':
|
|
203
|
+
request[name].push(value);
|
|
204
|
+
return 1;
|
|
205
|
+
case 'timeout':
|
|
206
|
+
request.timeout = Number.parseInt(value, 10) || 0;
|
|
207
|
+
return 1;
|
|
208
|
+
case 'keyFile':
|
|
209
|
+
case 'file': {
|
|
210
|
+
const path = gamePath(value);
|
|
211
|
+
if (path === null) return 0;
|
|
212
|
+
request[name] = path;
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
default:
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
body(plugin: PluginInstance, id: number, bytes: Uint8Array): void {
|
|
221
|
+
const request = this.of(plugin, id, 'setting');
|
|
222
|
+
if (request) request.body = bytes;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
send(plugin: PluginInstance, id: number, fn: number): number {
|
|
226
|
+
const request = this.of(plugin, id, 'setting');
|
|
227
|
+
if (!request) return 0;
|
|
228
|
+
request.fn = fn;
|
|
229
|
+
request.state = 'sent';
|
|
230
|
+
request.done = this.run(request).then((outcome) => {
|
|
231
|
+
request.outcome = outcome;
|
|
232
|
+
this.ended.push(id);
|
|
233
|
+
});
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Runs a request by its URL's scheme, with its timeout; a download with `file` lands in the game folder. */
|
|
238
|
+
private async run(request: FakeRequest): Promise<Outcome> {
|
|
239
|
+
let timedOut = false;
|
|
240
|
+
const timer = request.timeout > 0
|
|
241
|
+
? setTimeout(() => {
|
|
242
|
+
timedOut = true;
|
|
243
|
+
request.controller.abort();
|
|
244
|
+
}, request.timeout)
|
|
245
|
+
: null;
|
|
246
|
+
try {
|
|
247
|
+
const scheme = new URL(request.url).protocol;
|
|
248
|
+
const outcome = scheme === 'ftp:' || scheme === 'ftps:' ? await this.ftp(request) : scheme === 'sftp:' ? await this.sftp(request) : await this.http(request);
|
|
249
|
+
if (request.file && !request.upload) {
|
|
250
|
+
if (outcome.status < 400) this.server.files.set(request.file, outcome.body);
|
|
251
|
+
outcome.body = new Uint8Array();
|
|
252
|
+
}
|
|
253
|
+
return outcome;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const failure = error instanceof Failure ? error : null;
|
|
256
|
+
const kind: Kind = timedOut ? 'timeout' : failure?.kind ?? kindOf(error);
|
|
257
|
+
return { ...answer(-1), error: (error as Error).message, kind, reply: failure?.reply ?? 0 };
|
|
258
|
+
} finally {
|
|
259
|
+
if (timer) clearTimeout(timer);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What an upload sends: its file of the game folder, or its body. */
|
|
264
|
+
private uploaded(request: FakeRequest): Uint8Array {
|
|
265
|
+
if (!request.file) return request.body ?? new Uint8Array();
|
|
266
|
+
const bytes = this.server.files.get(request.file);
|
|
267
|
+
if (!bytes) throw new Failure('other', `cannot read ${request.file}`);
|
|
268
|
+
return bytes;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async http(request: FakeRequest): Promise<Outcome> {
|
|
272
|
+
const response = await fetch(request.url, {
|
|
273
|
+
method: request.upload ? 'PUT' : request.method,
|
|
274
|
+
headers: request.headers,
|
|
275
|
+
body: request.upload ? this.uploaded(request) : request.body,
|
|
276
|
+
redirect: request.follow ? 'follow' : 'manual',
|
|
277
|
+
signal: request.controller.signal,
|
|
278
|
+
...(request.proxy ? { proxy: request.proxy } : {}),
|
|
279
|
+
...(request.ca ? { tls: { ca: request.ca } } : {}),
|
|
280
|
+
} as RequestInit);
|
|
281
|
+
const lines = [...response.headers].filter(([name]) => name !== 'set-cookie').map(([name, value]) => `${name}: ${value}`);
|
|
282
|
+
for (const cookie of response.headers.getSetCookie()) lines.push(`set-cookie: ${cookie}`);
|
|
283
|
+
return answer(response.status, new Uint8Array(await response.arrayBuffer()), {
|
|
284
|
+
statusText: response.statusText,
|
|
285
|
+
headers: lines.join('\n'),
|
|
286
|
+
url: response.url,
|
|
287
|
+
redirects: response.redirected ? 1 : 0,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* FTP and FTPS as curl speaks them: log in, the quote commands, into the
|
|
293
|
+
* path's folder (making it, for an upload with createDirs), then the
|
|
294
|
+
* transfer - a listing for a path that ends in `/`.
|
|
295
|
+
*/
|
|
296
|
+
private async ftp(request: FakeRequest): Promise<Outcome> {
|
|
297
|
+
const { Client, FTPError } = await need<any>('basic-ftp');
|
|
298
|
+
const url = new URL(request.url);
|
|
299
|
+
const implicit = url.protocol === 'ftps:';
|
|
300
|
+
const client = new Client();
|
|
301
|
+
request.controller.signal.addEventListener('abort', () => client.close());
|
|
302
|
+
let stage: 'login' | 'quote' | 'folder' | 'transfer' = 'login';
|
|
303
|
+
let last = 0;
|
|
304
|
+
const step = async (response: Promise<{ code: number }>) => {
|
|
305
|
+
last = (await response).code;
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
const access = (secure: boolean | 'implicit') => client.access({
|
|
309
|
+
host: url.hostname,
|
|
310
|
+
port: Number(url.port) || (implicit ? 990 : 21),
|
|
311
|
+
user: request.user || decodeURIComponent(url.username) || 'anonymous',
|
|
312
|
+
password: request.password || decodeURIComponent(url.password),
|
|
313
|
+
secure,
|
|
314
|
+
secureOptions: request.ca ? { ca: request.ca } : undefined,
|
|
315
|
+
});
|
|
316
|
+
try {
|
|
317
|
+
await step(access(implicit ? 'implicit' : request.ssl !== ''));
|
|
318
|
+
} catch (error) {
|
|
319
|
+
// "try": TLS when the server has it, else as it is.
|
|
320
|
+
if (request.ssl !== 'try' || (error as { code?: number }).code === 530) throw error;
|
|
321
|
+
client.close();
|
|
322
|
+
await step(access(false));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
stage = 'quote';
|
|
326
|
+
for (const command of request.quote) await step(client.send(command));
|
|
327
|
+
|
|
328
|
+
// The path is the login folder's: ftp://host/dir/file is dir/file.
|
|
329
|
+
const path = decodeURIComponent(url.pathname).slice(1);
|
|
330
|
+
const slash = path.lastIndexOf('/');
|
|
331
|
+
const dir = path.slice(0, slash + 1);
|
|
332
|
+
const name = path.slice(slash + 1);
|
|
333
|
+
stage = 'folder';
|
|
334
|
+
if (dir && request.upload && request.createDirs) await client.ensureDir(dir);
|
|
335
|
+
else if (dir) await step(client.cd(dir));
|
|
336
|
+
|
|
337
|
+
stage = 'transfer';
|
|
338
|
+
let body = new Uint8Array();
|
|
339
|
+
if (request.method === 'HEAD') {
|
|
340
|
+
// Nothing is transferred: the commands alone.
|
|
341
|
+
} else if (request.upload) {
|
|
342
|
+
await step(client.uploadFrom(Readable.from([Buffer.from(this.uploaded(request))]), name));
|
|
343
|
+
} else if (!name) {
|
|
344
|
+
client.availableListCommands = [request.list ? 'NLST' : 'LIST'];
|
|
345
|
+
client.parseList = (raw: string) => [raw] as never;
|
|
346
|
+
const [raw] = await client.list() as unknown as string[];
|
|
347
|
+
body = new TextEncoder().encode(raw);
|
|
348
|
+
last = 226;
|
|
349
|
+
} else {
|
|
350
|
+
const chunks: Buffer[] = [];
|
|
351
|
+
await step(client.downloadTo(new Writable({ write(chunk, _, done) {
|
|
352
|
+
chunks.push(chunk);
|
|
353
|
+
done();
|
|
354
|
+
} }), name));
|
|
355
|
+
body = new Uint8Array(Buffer.concat(chunks));
|
|
356
|
+
}
|
|
357
|
+
for (const command of request.postquote) await step(client.send(command));
|
|
358
|
+
return answer(last, body);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (!(error instanceof FTPError)) throw error;
|
|
361
|
+
// As curl's codes have it: a refused login, a folder it cannot
|
|
362
|
+
// enter, a file the server does not have.
|
|
363
|
+
const { code, message } = error as { code: number; message: string };
|
|
364
|
+
const kind: Kind = code === 530 ? 'login' : stage === 'folder' ? 'denied' : stage === 'transfer' && !request.upload && code === 550 ? 'notFound' : 'other';
|
|
365
|
+
throw new Failure(kind, message, code);
|
|
366
|
+
} finally {
|
|
367
|
+
client.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* SFTP as curl speaks it: log in (a password, or a key of the game
|
|
373
|
+
* folder), the host key checked against its SHA-256, the quote commands,
|
|
374
|
+
* then the transfer - a listing for a path that ends in `/`.
|
|
375
|
+
*/
|
|
376
|
+
private async sftp(request: FakeRequest): Promise<Outcome> {
|
|
377
|
+
const { default: Sftp } = await need<{ default: new () => any }>('ssh2-sftp-client');
|
|
378
|
+
const url = new URL(request.url);
|
|
379
|
+
const sftp = new Sftp();
|
|
380
|
+
request.controller.signal.addEventListener('abort', () => sftp.end().catch(() => {}));
|
|
381
|
+
const hostKey = request.hostKey.replace(/=+$/, '');
|
|
382
|
+
let hostRefused = false;
|
|
383
|
+
let stage: 'login' | 'quote' | 'transfer' = 'login';
|
|
384
|
+
try {
|
|
385
|
+
const key = request.keyFile ? this.server.files.get(request.keyFile) : undefined;
|
|
386
|
+
if (request.keyFile && !key) throw new Failure('login', `cannot read ${request.keyFile}`);
|
|
387
|
+
await sftp.connect({
|
|
388
|
+
host: url.hostname,
|
|
389
|
+
port: Number(url.port) || 22,
|
|
390
|
+
username: request.user || decodeURIComponent(url.username),
|
|
391
|
+
password: request.password || decodeURIComponent(url.password) || undefined,
|
|
392
|
+
privateKey: key ? Buffer.from(key) : undefined,
|
|
393
|
+
passphrase: request.keyPassphrase || undefined,
|
|
394
|
+
hostVerifier: hostKey
|
|
395
|
+
? (presented: Buffer) => {
|
|
396
|
+
hostRefused = createHash('sha256').update(presented).digest('base64').replace(/=+$/, '') !== hostKey;
|
|
397
|
+
return !hostRefused;
|
|
398
|
+
}
|
|
399
|
+
: undefined,
|
|
400
|
+
readyTimeout: 30_000,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
stage = 'quote';
|
|
404
|
+
for (const command of request.quote) await this.sftpCommand(sftp, command);
|
|
405
|
+
|
|
406
|
+
// sftp://host/path is /path; /~/path is the login folder's.
|
|
407
|
+
const path = decodeURIComponent(url.pathname).replace(/^\/~\//, '');
|
|
408
|
+
stage = 'transfer';
|
|
409
|
+
let body = new Uint8Array();
|
|
410
|
+
if (request.method === 'HEAD') {
|
|
411
|
+
// Nothing is transferred: the commands alone.
|
|
412
|
+
} else if (request.upload) {
|
|
413
|
+
if (request.createDirs && path.includes('/')) await sftp.mkdir(path.slice(0, path.lastIndexOf('/')) || '/', true);
|
|
414
|
+
await sftp.put(Buffer.from(this.uploaded(request)), path);
|
|
415
|
+
} else if (path.endsWith('/') || !path) {
|
|
416
|
+
const entries: { name: string; longname: string }[] = await sftp.list(path || '.');
|
|
417
|
+
body = new TextEncoder().encode(entries.map(entry => `${request.list ? entry.name : entry.longname}\n`).join(''));
|
|
418
|
+
} else {
|
|
419
|
+
body = new Uint8Array(await sftp.get(path));
|
|
420
|
+
}
|
|
421
|
+
for (const command of request.postquote) await this.sftpCommand(sftp, command);
|
|
422
|
+
return answer(0, body);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (error instanceof Failure) throw error;
|
|
425
|
+
const message = (error as Error).message;
|
|
426
|
+
if (hostRefused) throw new Failure('tls', `the host key is not ${request.hostKey}`);
|
|
427
|
+
if (stage === 'login' && /authentication|auth|passphrase|privateKey/i.test(message)) throw new Failure('login', message);
|
|
428
|
+
// SFTP's status, as the server sent it: 2 no such file, 3 permission denied.
|
|
429
|
+
const status = typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 0;
|
|
430
|
+
const kind: Kind = status === 2 || status === 10 ? 'notFound' : status === 3 || status === 12 ? 'denied' : stage === 'login' ? kindOf(error) : 'other';
|
|
431
|
+
throw new Failure(kind, message, status);
|
|
432
|
+
} finally {
|
|
433
|
+
await sftp.end().catch(() => {});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** One of curl's SFTP quote commands. */
|
|
438
|
+
private async sftpCommand(sftp: any, command: string): Promise<void> {
|
|
439
|
+
const [name, ...args] = command.trim().split(/\s+/);
|
|
440
|
+
switch (name) {
|
|
441
|
+
case 'rename':
|
|
442
|
+
return sftp.rename(args[0], args[1]);
|
|
443
|
+
case 'rm':
|
|
444
|
+
return sftp.delete(args[0]);
|
|
445
|
+
case 'mkdir':
|
|
446
|
+
return sftp.mkdir(args[0]);
|
|
447
|
+
case 'rmdir':
|
|
448
|
+
return sftp.rmdir(args[0]);
|
|
449
|
+
case 'chmod':
|
|
450
|
+
return sftp.chmod(args[1], Number.parseInt(args[0], 8));
|
|
451
|
+
default:
|
|
452
|
+
throw new Failure('other', `the fake server's SFTP has no quote command ${name}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Takes a request back: one under way is aborted and never heard of again. */
|
|
457
|
+
cancel(plugin: PluginInstance, id: number): void {
|
|
458
|
+
const request = this.of(plugin, id, 'setting', 'sent', 'ended');
|
|
459
|
+
if (!request) return;
|
|
460
|
+
request.cancelled = true;
|
|
461
|
+
request.controller.abort();
|
|
462
|
+
this.requests.delete(id);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
status(plugin: PluginInstance, id: number): number {
|
|
466
|
+
return this.of(plugin, id, 'ended')?.outcome?.status ?? -1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
reply(plugin: PluginInstance, id: number): number {
|
|
470
|
+
return this.of(plugin, id, 'ended')?.outcome?.reply ?? 0;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
redirects(plugin: PluginInstance, id: number): number {
|
|
474
|
+
return this.of(plugin, id, 'ended')?.outcome?.redirects ?? 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
text(plugin: PluginInstance, id: number, what: number, out: number, max: number): number {
|
|
478
|
+
const outcome = this.of(plugin, id, 'ended')?.outcome;
|
|
479
|
+
if (!outcome) return 0;
|
|
480
|
+
const texts: Record<number, string> = {
|
|
481
|
+
[TEXT_ERROR]: outcome.error,
|
|
482
|
+
[TEXT_STATUS]: outcome.statusText,
|
|
483
|
+
[TEXT_URL]: outcome.url,
|
|
484
|
+
[TEXT_HEADERS]: outcome.headers,
|
|
485
|
+
[TEXT_KIND]: outcome.kind,
|
|
486
|
+
};
|
|
487
|
+
return plugin.memory.setUtf8(out, max, texts[what] ?? '');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
size(plugin: PluginInstance, id: number): number {
|
|
491
|
+
return this.of(plugin, id, 'ended')?.outcome?.body.length ?? 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
read(plugin: PluginInstance, id: number, out: number, max: number): number {
|
|
495
|
+
const body = this.of(plugin, id, 'ended')?.outcome?.body;
|
|
496
|
+
if (!body) return 0;
|
|
497
|
+
if (out && max > 0) plugin.memory.setRaw(out, body.subarray(0, max));
|
|
498
|
+
return body.length;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Whether a request is still out. */
|
|
502
|
+
get busy(): boolean {
|
|
503
|
+
return [...this.requests.values()].some(request => request.state === 'sent' && request.outcome === null);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Waits for the requests that are out, and hands each response to its
|
|
508
|
+
* plugin - until none is out: a callback may send another.
|
|
509
|
+
*/
|
|
510
|
+
async settle(): Promise<void> {
|
|
511
|
+
for (;;) {
|
|
512
|
+
const out = [...this.requests.values()].filter(request => request.state === 'sent' && request.done);
|
|
513
|
+
await Promise.all(out.map(request => request.done));
|
|
514
|
+
this.frame();
|
|
515
|
+
if (!this.busy) return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** One frame: every request that has come back goes to its callback. */
|
|
520
|
+
frame(): void {
|
|
521
|
+
for (const id of this.ended.splice(0)) {
|
|
522
|
+
const request = this.requests.get(id);
|
|
523
|
+
if (!request || request.cancelled || request.plugin.unloaded) {
|
|
524
|
+
this.requests.delete(id);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
request.state = 'ended';
|
|
528
|
+
this.server.call({ plugin: request.plugin, fn: request.fn, shape: SHAPE_NARROW }, [id], 0);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|