gspace 0.1.1 → 0.2.0
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 +72 -31
- package/lib/bundle.js +5 -5
- package/lib/https.js +38 -0
- package/package.json +2 -2
- package/release.json +1 -1
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.
|
|
12
|
+
const VERSION = '0.2.0';
|
|
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;
|
|
@@ -142,12 +161,17 @@ function artifact(value, source) {
|
|
|
142
161
|
const url = new URL(value.url, source);
|
|
143
162
|
if (source.protocol === 'https:' && url.protocol !== 'https:') throw new Error('Remote manifests cannot reference local or insecure artifacts.');
|
|
144
163
|
if (!['https:', 'file:'].includes(url.protocol)) throw new Error('Unsupported artifact URL.');
|
|
164
|
+
if (value.mirrors !== undefined && (!Array.isArray(value.mirrors) || value.mirrors.length > 3)) throw new Error('Invalid artifact mirrors.');
|
|
165
|
+
for (const mirror of value.mirrors || []) {
|
|
166
|
+
const parsed = new URL(mirror);
|
|
167
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new Error('Mirrors require HTTPS without credentials.');
|
|
168
|
+
}
|
|
145
169
|
return { ...value, url: url.href };
|
|
146
170
|
}
|
|
147
171
|
|
|
148
172
|
export function validateManifest(value, source) {
|
|
149
|
-
if (value
|
|
150
|
-
const platform = value.platforms
|
|
173
|
+
if (!value || value.schema_version !== 2 || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(value.version)) throw new Error('Unsupported release manifest.');
|
|
174
|
+
const platform = value.platforms && value.platforms['linux-x86_64'];
|
|
151
175
|
if (!platform || !/^2\.\d+$/.test(platform.glibc_min)) throw new Error('Release lacks a supported Linux x86_64 bundle.');
|
|
152
176
|
const unpacked = validateFiles(platform.files);
|
|
153
177
|
if (unpacked !== platform.unpacked_size) throw new Error('Invalid unpacked size.');
|
|
@@ -160,7 +184,7 @@ export async function sha256(file) {
|
|
|
160
184
|
const info = await handle.stat();
|
|
161
185
|
if (!info.isFile() || info.nlink !== 1 || info.size > MAX_ARTIFACT) throw new Error('Unsafe runtime artifact.');
|
|
162
186
|
const hash = createHash('sha256');
|
|
163
|
-
for await (const chunk of
|
|
187
|
+
for await (const chunk of createReadStream(null, { fd: handle.fd, autoClose: false })) hash.update(chunk);
|
|
164
188
|
return hash.digest('hex');
|
|
165
189
|
} finally { await handle.close(); }
|
|
166
190
|
}
|
|
@@ -170,7 +194,7 @@ const RANGE_SIZE = 1024 * 1024;
|
|
|
170
194
|
async function downloadRanges(value, handle, first, request) {
|
|
171
195
|
async function copy(response, start, end) {
|
|
172
196
|
if (response.status !== 206 || response.headers.get('content-range') !== `bytes ${start}-${end}/${value.size}`) {
|
|
173
|
-
await response
|
|
197
|
+
await cancelBody(response);
|
|
174
198
|
throw new Error('Invalid artifact byte range.');
|
|
175
199
|
}
|
|
176
200
|
let position = start;
|
|
@@ -202,7 +226,16 @@ async function downloadRanges(value, handle, first, request) {
|
|
|
202
226
|
if (failure) throw failure.reason;
|
|
203
227
|
}
|
|
204
228
|
|
|
205
|
-
export async function downloadVerified(value, destination) {
|
|
229
|
+
export async function downloadVerified(value, destination, transport = requestHTTPS) {
|
|
230
|
+
let failure;
|
|
231
|
+
for (const url of [value.url, ...(value.mirrors || [])]) {
|
|
232
|
+
try { return await downloadFromSource({ ...value, url }, destination, transport); }
|
|
233
|
+
catch (error) { failure = error; }
|
|
234
|
+
}
|
|
235
|
+
throw failure;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function downloadFromSource(value, destination, transport) {
|
|
206
239
|
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
240
|
const url = new URL(value.url);
|
|
208
241
|
const temporary = `${destination}.download-${nonce()}`;
|
|
@@ -212,15 +245,15 @@ export async function downloadVerified(value, destination) {
|
|
|
212
245
|
let ranged = false;
|
|
213
246
|
if (url.protocol === 'file:') stream = createReadStream(fileURLToPath(url));
|
|
214
247
|
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);
|
|
248
|
+
const request = (start, end) => fetchHTTPS(url, 600000, transport, { Range: `bytes=${start}-${end}` });
|
|
249
|
+
const response = value.size > RANGE_SIZE ? await request(0, 0) : await fetchHTTPS(url, 600000, transport);
|
|
217
250
|
if (response.status === 206 && value.size > RANGE_SIZE) {
|
|
218
251
|
await downloadRanges(value, handle, response, request);
|
|
219
252
|
ranged = true;
|
|
220
253
|
} else {
|
|
221
254
|
// Servers without range support can return the entire file as HTTP 200.
|
|
222
|
-
if (response.status !== 200) { await response
|
|
223
|
-
stream = Readable.
|
|
255
|
+
if (response.status !== 200) { await cancelBody(response); throw new Error('Unexpected artifact response.'); }
|
|
256
|
+
stream = Readable.from(response.body);
|
|
224
257
|
}
|
|
225
258
|
} else throw new Error('Artifact requires HTTPS or a local file URL.');
|
|
226
259
|
const hash = createHash('sha256');
|
|
@@ -250,13 +283,13 @@ export async function downloadVerified(value, destination) {
|
|
|
250
283
|
|
|
251
284
|
export function run(executable, args, options = {}) {
|
|
252
285
|
return new Promise((resolve, reject) => {
|
|
253
|
-
const child = spawn(executable, args, { shell: false, stdio: options.stdio
|
|
286
|
+
const child = spawn(executable, args, { shell: false, stdio: options.stdio === undefined ? 'inherit' : options.stdio, env: options.env || process.env });
|
|
254
287
|
const forward = signal => { if (!child.killed) child.kill(signal); };
|
|
255
288
|
const onInt = () => forward('SIGINT'), onTerm = () => forward('SIGTERM');
|
|
256
289
|
process.on('SIGINT', onInt); process.on('SIGTERM', onTerm);
|
|
257
290
|
const remove = () => { process.off('SIGINT', onInt); process.off('SIGTERM', onTerm); };
|
|
258
291
|
child.once('error', error => { remove(); reject(new Error(`Cannot start ${executable}: ${error.message}`)); });
|
|
259
|
-
child.once('exit', (code, signal) => { remove(); resolve(code
|
|
292
|
+
child.once('exit', (code, signal) => { remove(); resolve(code === null ? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1) : code); });
|
|
260
293
|
});
|
|
261
294
|
}
|
|
262
295
|
|
|
@@ -268,7 +301,7 @@ async function lock(root) {
|
|
|
268
301
|
if (error.code !== 'EEXIST') throw error;
|
|
269
302
|
await safeDirectory(directory);
|
|
270
303
|
const info = await readJSON(path.join(directory, 'owner.json'));
|
|
271
|
-
if (info
|
|
304
|
+
if (info && info.hostname === os.hostname() && Number.isSafeInteger(info.pid) && info.pid > 1) {
|
|
272
305
|
let alive = true;
|
|
273
306
|
try { process.kill(info.pid, 0); } catch (failure) { if (failure.code === 'ESRCH') alive = false; }
|
|
274
307
|
if (!alive) {
|
|
@@ -296,11 +329,11 @@ async function currentRuntime(root) {
|
|
|
296
329
|
}
|
|
297
330
|
|
|
298
331
|
async function prepareRuntime(context, manifest, manifestHash, logFd) {
|
|
299
|
-
const
|
|
332
|
+
const available = await availableSpace(context.goinfre);
|
|
300
333
|
const libc = process.report.getReport().header.glibcVersionRuntime;
|
|
301
334
|
const minimum = Number(manifest.glibc_min.split('.')[1]);
|
|
302
335
|
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 (
|
|
336
|
+
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
337
|
const id = `${manifest.version}-${manifestHash.slice(0, 16)}`;
|
|
305
338
|
const directory = path.join(context.root, 'runtime', id);
|
|
306
339
|
// An incomplete version stays quarantined; a fresh installation gets a unique name.
|
|
@@ -348,7 +381,8 @@ export async function main(args, env = process.env) {
|
|
|
348
381
|
releaseLock = await lock(context.root);
|
|
349
382
|
runtime = await currentRuntime(context.root);
|
|
350
383
|
const config = JSON.parse(await fs.readFile(new URL('../release.json', import.meta.url), 'utf8'));
|
|
351
|
-
const
|
|
384
|
+
const locations = env.GSPACE_RELEASE_MANIFEST ? [env.GSPACE_RELEASE_MANIFEST] : [config.manifest, ...(config.fallbacks || [])].filter(Boolean);
|
|
385
|
+
const location = locations[0];
|
|
352
386
|
const forceUpdate = args[0] === '--update';
|
|
353
387
|
if (forceUpdate) args = args.slice(1);
|
|
354
388
|
const cacheFile = path.join(context.root, 'runtime', 'update.json');
|
|
@@ -358,9 +392,16 @@ export async function main(args, env = process.env) {
|
|
|
358
392
|
if (!location && !runtime) throw new Error('No release source configured. Set GSPACE_RELEASE_MANIFEST to a local manifest.json or HTTPS URL, or use GSPACE_CORE for a development build.');
|
|
359
393
|
if (location) {
|
|
360
394
|
try {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
395
|
+
let source, raw, manifest, sourceError;
|
|
396
|
+
for (const candidate of locations) {
|
|
397
|
+
try {
|
|
398
|
+
source = asURL(candidate);
|
|
399
|
+
raw = await fetchBytes(source, 1024 * 1024);
|
|
400
|
+
manifest = validateManifest(JSON.parse(raw.toString('utf8')), source);
|
|
401
|
+
break;
|
|
402
|
+
} catch (error) { sourceError = error; }
|
|
403
|
+
}
|
|
404
|
+
if (!manifest) throw sourceError;
|
|
364
405
|
const hash = createHash('sha256').update(raw).digest('hex');
|
|
365
406
|
if (!runtime || runtime.manifest_hash !== hash) {
|
|
366
407
|
if (!runtime) console.error(`Gspace is not installed on this workstation.\nUsing ${context.goinfre}`);
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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"
|
package/release.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"manifest":
|
|
1
|
+
{"manifest":"https://gspace-downloads.pages.dev/manifest.json","fallbacks":["https://github.com/Seraph919/gspace-releases/releases/latest/download/manifest.json"]}
|