gspace 0.1.1 → 0.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/lib/bootstrap.js +46 -27
- package/lib/bundle.js +5 -5
- package/lib/https.js +38 -0
- package/package.json +2 -2
package/lib/bootstrap.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
|
-
import { constants, promises as fs, createReadStream } from '
|
|
2
|
-
import path from '
|
|
3
|
-
import os from '
|
|
4
|
-
import { fileURLToPath, pathToFileURL } from '
|
|
5
|
-
import { createHash, randomBytes } from '
|
|
6
|
-
import { spawn } from '
|
|
7
|
-
import {
|
|
1
|
+
import { constants, promises as fs, createReadStream } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
|
+
import { createHash, randomBytes } from 'crypto';
|
|
6
|
+
import { spawn, execFile } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { Readable } from 'stream';
|
|
9
|
+
import { requestHTTPS, cancelBody } from './https.js';
|
|
8
10
|
import { extractBundle, validateFiles, verifyBundle } from './bundle.js';
|
|
9
11
|
|
|
10
|
-
const VERSION = '0.1.
|
|
12
|
+
const VERSION = '0.1.2';
|
|
11
13
|
const DAY = 24 * 60 * 60 * 1000;
|
|
12
14
|
const MAX_ARTIFACT = 256 * 1024 * 1024;
|
|
13
15
|
const nonce = () => randomBytes(12).toString('hex');
|
|
14
16
|
const uid = () => process.getuid();
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
export async function availableSpace(directory, statfs = fs.statfs) {
|
|
20
|
+
if (typeof statfs === 'function') {
|
|
21
|
+
const space = await statfs(directory);
|
|
22
|
+
return space.bavail * space.bsize;
|
|
23
|
+
}
|
|
24
|
+
// Ubuntu 22.04's Node 12 predates fs.statfs. GNU df is part of the base
|
|
25
|
+
// workstation; use an absolute path and argument vector, never a shell.
|
|
26
|
+
const result = await execFileAsync('/usr/bin/df', ['--output=avail', '--block-size=1', '--', directory], {
|
|
27
|
+
env: { ...process.env, LC_ALL: 'C' }, timeout: 10000, maxBuffer: 65536,
|
|
28
|
+
});
|
|
29
|
+
const match = /^\s*Avail\s*\n\s*(\d+)\s*$/.exec(result.stdout);
|
|
30
|
+
const bytes = match && Number(match[1]);
|
|
31
|
+
if (!match || !Number.isSafeInteger(bytes)) throw new Error('Cannot determine available goinfre space.');
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
|
15
34
|
|
|
16
35
|
async function exists(file) {
|
|
17
36
|
try { await fs.lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
@@ -97,21 +116,21 @@ function asURL(location) {
|
|
|
97
116
|
return pathToFileURL(path.resolve(location));
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
export async function fetchHTTPS(url, timeout = 60000, request =
|
|
101
|
-
const
|
|
119
|
+
export async function fetchHTTPS(url, timeout = 60000, request = requestHTTPS, headers = {}) {
|
|
120
|
+
const deadline = Date.now() + timeout;
|
|
102
121
|
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
103
122
|
url = new URL(url);
|
|
104
123
|
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Release downloads require HTTPS without embedded credentials.');
|
|
105
|
-
const response = await request(url, {
|
|
124
|
+
const response = await request(url, { timeout: Math.max(1, deadline - Date.now()), redirect: 'manual', headers });
|
|
106
125
|
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
107
126
|
const location = response.headers.get('location');
|
|
108
|
-
await response
|
|
127
|
+
await cancelBody(response);
|
|
109
128
|
if (!location) throw new Error('Release redirect has no destination.');
|
|
110
129
|
url = new URL(location, url);
|
|
111
130
|
continue;
|
|
112
131
|
}
|
|
113
132
|
if (!response.ok) {
|
|
114
|
-
await response
|
|
133
|
+
await cancelBody(response);
|
|
115
134
|
throw new Error(`Release download failed: HTTP ${response.status}`);
|
|
116
135
|
}
|
|
117
136
|
return response;
|
|
@@ -146,8 +165,8 @@ function artifact(value, source) {
|
|
|
146
165
|
}
|
|
147
166
|
|
|
148
167
|
export function validateManifest(value, source) {
|
|
149
|
-
if (value
|
|
150
|
-
const platform = value.platforms
|
|
168
|
+
if (!value || value.schema_version !== 2 || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(value.version)) throw new Error('Unsupported release manifest.');
|
|
169
|
+
const platform = value.platforms && value.platforms['linux-x86_64'];
|
|
151
170
|
if (!platform || !/^2\.\d+$/.test(platform.glibc_min)) throw new Error('Release lacks a supported Linux x86_64 bundle.');
|
|
152
171
|
const unpacked = validateFiles(platform.files);
|
|
153
172
|
if (unpacked !== platform.unpacked_size) throw new Error('Invalid unpacked size.');
|
|
@@ -160,7 +179,7 @@ export async function sha256(file) {
|
|
|
160
179
|
const info = await handle.stat();
|
|
161
180
|
if (!info.isFile() || info.nlink !== 1 || info.size > MAX_ARTIFACT) throw new Error('Unsafe runtime artifact.');
|
|
162
181
|
const hash = createHash('sha256');
|
|
163
|
-
for await (const chunk of
|
|
182
|
+
for await (const chunk of createReadStream(null, { fd: handle.fd, autoClose: false })) hash.update(chunk);
|
|
164
183
|
return hash.digest('hex');
|
|
165
184
|
} finally { await handle.close(); }
|
|
166
185
|
}
|
|
@@ -170,7 +189,7 @@ const RANGE_SIZE = 1024 * 1024;
|
|
|
170
189
|
async function downloadRanges(value, handle, first, request) {
|
|
171
190
|
async function copy(response, start, end) {
|
|
172
191
|
if (response.status !== 206 || response.headers.get('content-range') !== `bytes ${start}-${end}/${value.size}`) {
|
|
173
|
-
await response
|
|
192
|
+
await cancelBody(response);
|
|
174
193
|
throw new Error('Invalid artifact byte range.');
|
|
175
194
|
}
|
|
176
195
|
let position = start;
|
|
@@ -202,7 +221,7 @@ async function downloadRanges(value, handle, first, request) {
|
|
|
202
221
|
if (failure) throw failure.reason;
|
|
203
222
|
}
|
|
204
223
|
|
|
205
|
-
export async function downloadVerified(value, destination) {
|
|
224
|
+
export async function downloadVerified(value, destination, transport = requestHTTPS) {
|
|
206
225
|
if (!Number.isSafeInteger(value.size) || value.size <= 0 || value.size > MAX_ARTIFACT || !/^[a-f0-9]{64}$/.test(value.sha256)) throw new Error('Invalid release artifact metadata.');
|
|
207
226
|
const url = new URL(value.url);
|
|
208
227
|
const temporary = `${destination}.download-${nonce()}`;
|
|
@@ -212,15 +231,15 @@ export async function downloadVerified(value, destination) {
|
|
|
212
231
|
let ranged = false;
|
|
213
232
|
if (url.protocol === 'file:') stream = createReadStream(fileURLToPath(url));
|
|
214
233
|
else if (url.protocol === 'https:') {
|
|
215
|
-
const request = (start, end) => fetchHTTPS(url, 600000,
|
|
216
|
-
const response = value.size > RANGE_SIZE ? await request(0, 0) : await fetchHTTPS(url, 600000);
|
|
234
|
+
const request = (start, end) => fetchHTTPS(url, 600000, transport, { Range: `bytes=${start}-${end}` });
|
|
235
|
+
const response = value.size > RANGE_SIZE ? await request(0, 0) : await fetchHTTPS(url, 600000, transport);
|
|
217
236
|
if (response.status === 206 && value.size > RANGE_SIZE) {
|
|
218
237
|
await downloadRanges(value, handle, response, request);
|
|
219
238
|
ranged = true;
|
|
220
239
|
} else {
|
|
221
240
|
// Servers without range support can return the entire file as HTTP 200.
|
|
222
|
-
if (response.status !== 200) { await response
|
|
223
|
-
stream = Readable.
|
|
241
|
+
if (response.status !== 200) { await cancelBody(response); throw new Error('Unexpected artifact response.'); }
|
|
242
|
+
stream = Readable.from(response.body);
|
|
224
243
|
}
|
|
225
244
|
} else throw new Error('Artifact requires HTTPS or a local file URL.');
|
|
226
245
|
const hash = createHash('sha256');
|
|
@@ -250,13 +269,13 @@ export async function downloadVerified(value, destination) {
|
|
|
250
269
|
|
|
251
270
|
export function run(executable, args, options = {}) {
|
|
252
271
|
return new Promise((resolve, reject) => {
|
|
253
|
-
const child = spawn(executable, args, { shell: false, stdio: options.stdio
|
|
272
|
+
const child = spawn(executable, args, { shell: false, stdio: options.stdio === undefined ? 'inherit' : options.stdio, env: options.env || process.env });
|
|
254
273
|
const forward = signal => { if (!child.killed) child.kill(signal); };
|
|
255
274
|
const onInt = () => forward('SIGINT'), onTerm = () => forward('SIGTERM');
|
|
256
275
|
process.on('SIGINT', onInt); process.on('SIGTERM', onTerm);
|
|
257
276
|
const remove = () => { process.off('SIGINT', onInt); process.off('SIGTERM', onTerm); };
|
|
258
277
|
child.once('error', error => { remove(); reject(new Error(`Cannot start ${executable}: ${error.message}`)); });
|
|
259
|
-
child.once('exit', (code, signal) => { remove(); resolve(code
|
|
278
|
+
child.once('exit', (code, signal) => { remove(); resolve(code === null ? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1) : code); });
|
|
260
279
|
});
|
|
261
280
|
}
|
|
262
281
|
|
|
@@ -268,7 +287,7 @@ async function lock(root) {
|
|
|
268
287
|
if (error.code !== 'EEXIST') throw error;
|
|
269
288
|
await safeDirectory(directory);
|
|
270
289
|
const info = await readJSON(path.join(directory, 'owner.json'));
|
|
271
|
-
if (info
|
|
290
|
+
if (info && info.hostname === os.hostname() && Number.isSafeInteger(info.pid) && info.pid > 1) {
|
|
272
291
|
let alive = true;
|
|
273
292
|
try { process.kill(info.pid, 0); } catch (failure) { if (failure.code === 'ESRCH') alive = false; }
|
|
274
293
|
if (!alive) {
|
|
@@ -296,11 +315,11 @@ async function currentRuntime(root) {
|
|
|
296
315
|
}
|
|
297
316
|
|
|
298
317
|
async function prepareRuntime(context, manifest, manifestHash, logFd) {
|
|
299
|
-
const
|
|
318
|
+
const available = await availableSpace(context.goinfre);
|
|
300
319
|
const libc = process.report.getReport().header.glibcVersionRuntime;
|
|
301
320
|
const minimum = Number(manifest.glibc_min.split('.')[1]);
|
|
302
321
|
if (!libc || Number(libc.split('.')[0]) !== 2 || Number(libc.split('.')[1]) < minimum) throw new Error(`This release requires Linux glibc ${manifest.glibc_min} or newer.`);
|
|
303
|
-
if (
|
|
322
|
+
if (available < manifest.bundle.size + manifest.unpacked_size + 64 * 1024 * 1024) throw new Error('Not enough goinfre space to download and unpack the runtime safely.');
|
|
304
323
|
const id = `${manifest.version}-${manifestHash.slice(0, 16)}`;
|
|
305
324
|
const directory = path.join(context.root, 'runtime', id);
|
|
306
325
|
// An incomplete version stays quarantined; a fresh installation gets a unique name.
|
package/lib/bundle.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { constants, createReadStream, promises as fs } from '
|
|
2
|
-
import path from '
|
|
3
|
-
import { createHash } from '
|
|
4
|
-
import { createGunzip } from '
|
|
1
|
+
import { constants, createReadStream, promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { createGunzip } from 'zlib';
|
|
5
5
|
|
|
6
6
|
export const MAX_UNPACKED = 512 * 1024 * 1024;
|
|
7
7
|
const digest = /^[a-f0-9]{64}$/;
|
|
@@ -141,7 +141,7 @@ export async function verifyBundle(directory, files) {
|
|
|
141
141
|
const handle = await fs.open(location, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
142
142
|
try {
|
|
143
143
|
const hash = createHash('sha256');
|
|
144
|
-
for await (const bytes of
|
|
144
|
+
for await (const bytes of createReadStream(null, { fd: handle.fd, autoClose: false })) hash.update(bytes);
|
|
145
145
|
if (hash.digest('hex') !== file.sha256) throw new Error('Installed runtime checksum mismatch; refusing execution.');
|
|
146
146
|
} finally { await handle.close(); }
|
|
147
147
|
count++;
|
package/lib/https.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
|
|
3
|
+
// Node 12 has HTTPS and async iterable streams, but no fetch/AbortSignal API.
|
|
4
|
+
// Keep TLS verification enabled and leave all redirect policy to the caller.
|
|
5
|
+
export function requestHTTPS(url, options = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
let incoming;
|
|
8
|
+
const request = https.get(url, { headers: options.headers || {} }, response => {
|
|
9
|
+
incoming = response;
|
|
10
|
+
response.once('end', clear);
|
|
11
|
+
response.once('close', clear);
|
|
12
|
+
response.once('error', clear);
|
|
13
|
+
// Match the small response interface used by the bootstrap and tests.
|
|
14
|
+
response.cancel = async () => { response.destroy(); clear(); };
|
|
15
|
+
resolve({
|
|
16
|
+
status: response.statusCode,
|
|
17
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
18
|
+
headers: { get: name => {
|
|
19
|
+
const value = response.headers[name.toLowerCase()];
|
|
20
|
+
return Array.isArray(value) ? value.join(', ') : value || null;
|
|
21
|
+
} },
|
|
22
|
+
body: response,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
const error = new Error('Release download timed out.');
|
|
27
|
+
request.destroy(error);
|
|
28
|
+
if (incoming) incoming.destroy(error);
|
|
29
|
+
}, options.timeout || 60000);
|
|
30
|
+
timer.unref();
|
|
31
|
+
function clear() { clearTimeout(timer); }
|
|
32
|
+
request.once('error', error => { clear(); reject(error); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function cancelBody(response) {
|
|
37
|
+
if (response.body) await response.body.cancel();
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gspace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Gspace launcher for 1337/42 Linux workstations",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"THIRD_PARTY_NOTICES.txt"
|
|
19
19
|
],
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=12.22.9"
|
|
22
22
|
},
|
|
23
23
|
"os": [
|
|
24
24
|
"linux"
|