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 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.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 = 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;
@@ -146,8 +165,8 @@ function artifact(value, source) {
146
165
  }
147
166
 
148
167
  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'];
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 handle.createReadStream({ autoClose: false })) hash.update(chunk);
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.body?.cancel();
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, fetch, { Range: `bytes=${start}-${end}` });
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.body?.cancel(); throw new Error('Unexpected artifact response.'); }
223
- stream = Readable.fromWeb(response.body);
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 ?? 'inherit', env: options.env ?? process.env });
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 ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1)); });
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?.hostname === os.hostname() && Number.isSafeInteger(info.pid) && info.pid > 1) {
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 space = await fs.statfs(context.goinfre);
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 (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.');
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 '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.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": ">=20"
21
+ "node": ">=12.22.9"
22
22
  },
23
23
  "os": [
24
24
  "linux"