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 CHANGED
@@ -1,17 +1,36 @@
1
- import { constants, promises as fs, createReadStream } from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { fileURLToPath, pathToFileURL } from 'node:url';
5
- import { createHash, randomBytes } from 'node:crypto';
6
- import { spawn } from 'node:child_process';
7
- import { Readable } from 'node:stream';
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.1';
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 = fetch, headers = {}) {
101
- const signal = AbortSignal.timeout(timeout);
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, { signal, redirect: 'manual', headers });
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.body?.cancel();
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.body?.cancel();
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?.schema_version !== 2 || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(value.version)) throw new Error('Unsupported release manifest.');
150
- const platform = value.platforms?.['linux-x86_64'];
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 handle.createReadStream({ autoClose: false })) hash.update(chunk);
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.body?.cancel();
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, fetch, { Range: `bytes=${start}-${end}` });
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.body?.cancel(); throw new Error('Unexpected artifact response.'); }
223
- stream = Readable.fromWeb(response.body);
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 ?? 'inherit', env: options.env ?? process.env });
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 ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)); });
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?.hostname === os.hostname() && Number.isSafeInteger(info.pid) && info.pid > 1) {
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 space = await fs.statfs(context.goinfre);
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 (space.bavail * space.bsize < manifest.bundle.size + manifest.unpacked_size + 64 * 1024 * 1024) throw new Error('Not enough goinfre space to download and unpack the runtime safely.');
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 location = env.GSPACE_RELEASE_MANIFEST || config.manifest;
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
- const source = asURL(location);
362
- const raw = await fetchBytes(source, 1024 * 1024);
363
- const manifest = validateManifest(JSON.parse(raw.toString('utf8')), source);
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 'node:fs';
2
- import path from 'node:path';
3
- import { createHash } from 'node:crypto';
4
- import { createGunzip } from 'node:zlib';
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 handle.createReadStream({ autoClose: false })) hash.update(bytes);
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.1",
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": ">=20"
21
+ "node": ">=12.22.9"
22
22
  },
23
23
  "os": [
24
24
  "linux"
package/release.json CHANGED
@@ -1 +1 @@
1
- {"manifest": "https://github.com/Seraph919/gspace-releases/releases/latest/download/manifest.json"}
1
+ {"manifest":"https://gspace-downloads.pages.dev/manifest.json","fallbacks":["https://github.com/Seraph919/gspace-releases/releases/latest/download/manifest.json"]}