@mason-data/cli 0.10.0 → 0.11.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.
@@ -19,6 +19,10 @@
19
19
  *
20
20
  * - ITS WEB PAGE ON LOOPBACK ALONE (src/cli/run-server.ts `listenOnLoopback`): the run's server, MASON_UI_PORT, reached
21
21
  * from a developer's machine with `mason ui --ssh`, never published. No setting moves it to another address.
22
+ * MASON_UI_SOCKET serves it on a Unix socket too, for a process on this machine (src/cli/ui-socket.ts): mode 0600, in
23
+ * a folder only Mason's user can write, behind the same guard as `mason ui --ssh`'s requests. MASON_UI_RUN_FILE
24
+ * writes the page's port and the run's token to a file for a supervisor on this machine (src/cli/ui-run-file.ts),
25
+ * held to the same bar, once the run answers; /healthz says from the start where the page is served (`PageServed`).
22
26
  *
23
27
  * The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
24
28
  */
@@ -36,7 +40,10 @@ import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/d
36
40
  import { appEnvValue } from "../lib/dotenv.js";
37
41
  import { imagePinProblem, PIN_FILE } from "../lib/project-version.js";
38
42
  import { commandPinProblem } from "./pin.js";
43
+ import { listenOnUiSocket, prepareUiSocket, uiSocketSetting } from "./ui-socket.js";
44
+ import { prepareUiRunFile, uiRunFileSetting, UI_RUN_FILE_SETTING, writeUiRunFile } from "./ui-run-file.js";
39
45
  import { recordRefusal } from "./replication-mode.js";
46
+ import { UI_READ_ONLY_SETTING, uiReadOnlySetting } from "./ui-read-only.js";
40
47
  import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
41
48
  import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
42
49
  import { DEFAULT_MEMORY_RULE, memoryProblems, memoryProblemText, memorySize, MEMORY_FLOOR_BYTES } from "../duck/store.js";
@@ -81,6 +88,18 @@ export const SERVE = {
81
88
  MASON_UI_PORT the web page's and the commands' port, on 127.0.0.1 alone: no setting moves the address, and
82
89
  mason ui --ssh <user@server> opens the page from your machine (default ${DEFAULT_UI_PORT}, or the next
83
90
  free one; a port named here is that port, or the start is refused)
91
+ MASON_UI_SOCKET a Unix socket the web page is served on too, for a process on this machine (a supervisor,
92
+ a local reverse proxy): made mode 0600 in a folder only Mason's user can write (else the
93
+ start is refused), a stale one replaced, removed on exit; a request on it passes the same
94
+ guard as mason ui --ssh's (default unset: none)
95
+ MASON_UI_RUN_FILE a file Mason writes once it answers, for a process on this machine that supervises it:
96
+ { "port", "token" }, the web page's port and the run's token, which grants what the token
97
+ grants — the commands' writes (mason add, mason sync, mason user) as well as a tunnel to
98
+ the page; written whole, mode 0600, in a folder only Mason's user can write (else the
99
+ start is refused), a stale one replaced, removed on exit (default unset: none)
100
+ MASON_UI_READ_ONLY 1 when the web page should only show: it offers no control that changes anything, and
101
+ refuses every change it is sent, through mason ui --ssh or the socket alike; the mason
102
+ commands beside it are unchanged (default 0)
84
103
  MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
85
104
  MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
86
105
  for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
@@ -164,6 +183,19 @@ export function serveSettings(env, cwd) {
164
183
  else if (env.MASON_UI_PORT && uiPort === pgPortOf(env))
165
184
  wrong.push(`MASON_UI_PORT=${uiText} is the Postgres API's port too (MASON_PG_PORT): two ports`);
166
185
  // a host would only be believed: the page has no address but loopback, and running on while one is asked for would hide that
186
+ const socket = uiSocketSetting(env, cwd);
187
+ if (socket !== null && typeof socket !== "string")
188
+ wrong.push(socket.problem);
189
+ // the run's file (src/cli/ui-run-file.ts): never the socket's own path
190
+ const runFile = uiRunFileSetting(env, cwd);
191
+ if (runFile !== null && typeof runFile !== "string")
192
+ wrong.push(runFile.problem);
193
+ else if (runFile !== null && runFile === socket)
194
+ wrong.push(`${UI_RUN_FILE_SETTING} names the path MASON_UI_SOCKET names: two paths`);
195
+ // the read-only page (src/cli/ui-read-only.ts): 1 or 0, anything else refused here, never read as off
196
+ const pageReadOnly = uiReadOnlySetting(env);
197
+ if (typeof pageReadOnly !== "boolean")
198
+ wrong.push(pageReadOnly.problem);
167
199
  if (env.MASON_UI_HOST)
168
200
  wrong.push("MASON_UI_HOST is set, and the web page has no address but 127.0.0.1: remove it (mason ui --ssh <user@server> opens the page from your machine)");
169
201
  if (wrong.length) {
@@ -176,7 +208,7 @@ export function serveSettings(env, cwd) {
176
208
  name, apiKey, selfSignedTls: selfSigned === "1",
177
209
  project, data,
178
210
  health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
179
- ui: { port: uiPort, named: !!env.MASON_UI_PORT },
211
+ ui: { port: uiPort, named: !!env.MASON_UI_PORT, socket: typeof socket === "string" ? socket : null, runFile: typeof runFile === "string" ? runFile : null },
180
212
  };
181
213
  }
182
214
  /**
@@ -331,6 +363,7 @@ export function serveRecordRefusal(s, existing, env) {
331
363
  return null;
332
364
  return recordRefusal(existing.config, file, env, sourcesTop(path.dirname(s.project), s.project).shown, "mason serve");
333
365
  }
366
+ export const NO_PAGE_SERVED = { socket: null, readOnly: false };
334
367
  /** seconds since the serve started: /healthz's `uptimeSeconds` and /metrics' `mason_uptime_seconds`, one number */
335
368
  const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
336
369
  /**
@@ -338,8 +371,8 @@ const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
338
371
  * leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
339
372
  * endpoint takes no credential.
340
373
  */
341
- export function healthBody(state, startedAt, now = Date.now()) {
342
- const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now) };
374
+ export function healthBody(state, startedAt, now = Date.now(), page = NO_PAGE_SERVED) {
375
+ const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now), page: { socket: page.socket, readOnly: page.readOnly } };
343
376
  if (state.phase !== "ready")
344
377
  return { status: 503, body: base };
345
378
  return { status: 200, body: { ...base, tables: phaseCounts(state.tables()) } };
@@ -355,7 +388,7 @@ export async function metricsBody(state, startedAt, now = Date.now()) {
355
388
  return metricsText({ version: version(), run: { ...run, uptimeSeconds: uptimeSeconds(startedAt, now) } });
356
389
  }
357
390
  /** The health server: GET /healthz and GET /metrics, nothing else. `log` says a scrape whose numbers could not be read. */
358
- export function healthServer(state, startedAt, log = say) {
391
+ export function healthServer(state, startedAt, log = say, page = NO_PAGE_SERVED) {
359
392
  return http.createServer((req, res) => {
360
393
  const url = new URL(req.url ?? "/", "http://health");
361
394
  if (url.pathname !== "/healthz" && url.pathname !== "/metrics") {
@@ -381,7 +414,7 @@ export function healthServer(state, startedAt, log = say) {
381
414
  });
382
415
  return;
383
416
  }
384
- const h = healthBody(state(), startedAt);
417
+ const h = healthBody(state(), startedAt, Date.now(), page);
385
418
  res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
386
419
  });
387
420
  }
@@ -421,14 +454,57 @@ export async function serve(argv) {
421
454
  const held = s.ui.named ? await portHolder(s.ui.port) : null;
422
455
  if (held !== null)
423
456
  return failed("serve", { message: `MASON_UI_PORT: ${heldText(s.ui.port, held)}`, code: "refused", fix: "name another port, or leave MASON_UI_PORT unset for the next free one" }, { json });
457
+ // the page's socket, before anything listens: its folder and its path held to the bar, a stale one replaced, then made
458
+ let page = null;
459
+ // what making it says, under the start's heading below
460
+ const socketSaid = [];
461
+ if (s.ui.socket !== null) {
462
+ const prepared = await prepareUiSocket(s.ui.socket, (l) => socketSaid.push(l));
463
+ if (prepared !== null)
464
+ return failed("serve", prepared, { json });
465
+ const opened = await listenOnUiSocket(s.ui.socket, (l) => say(` ${l}`));
466
+ if ("message" in opened)
467
+ return failed("serve", opened, { json });
468
+ page = opened;
469
+ }
470
+ // the run's file: written once the run answers (asserted wide: they are set in the run's hooks, which the compiler
471
+ // does not follow)
472
+ let runFile = null;
473
+ let runFileFailed = null;
474
+ // a SIGTERM before the run has its own stop (the run file's check, the run still connecting) ends the process by its
475
+ // default action, which runs no finally: the socket is removed first, then the signal raised again as it was
476
+ const early = (signal) => {
477
+ if (process.listenerCount(signal) > 1)
478
+ return; // the run's stop is in place: it stops, and the finally removes it
479
+ page?.close();
480
+ runFile?.remove();
481
+ unhookEarly();
482
+ process.kill(process.pid, signal);
483
+ };
484
+ const unhookEarly = () => { process.removeListener("SIGTERM", early); process.removeListener("SIGINT", early); };
485
+ if (page !== null) {
486
+ process.on("SIGTERM", early);
487
+ process.on("SIGINT", early);
488
+ }
489
+ // its folder and its path held to the bar now, a stale one removed (its run asked, up to 2 s: the handler is in place)
490
+ if (s.ui.runFile !== null) {
491
+ const prepared = await prepareUiRunFile(s.ui.runFile, (l) => socketSaid.push(l));
492
+ if (prepared !== null) {
493
+ page?.close();
494
+ unhookEarly();
495
+ return failed("serve", prepared, { json });
496
+ }
497
+ }
424
498
  const startedAt = Date.now();
425
499
  let state = { phase: "starting" };
426
- const health = healthServer(() => state, startedAt);
500
+ const health = healthServer(() => state, startedAt, say, { socket: page?.file ?? null, readOnly: uiReadOnlySetting(process.env) === true });
427
501
  const listening = await new Promise((resolve) => {
428
502
  health.once("error", resolve);
429
503
  health.listen(s.health.port, s.health.host, () => resolve(null));
430
504
  });
431
505
  if (listening !== null) {
506
+ page?.close();
507
+ unhookEarly();
432
508
  return failed("serve", { message: `the health endpoint cannot listen on ${s.health.host}:${s.health.port}: ${listening.message}`, code: "refused",
433
509
  fix: "MASON_HEALTH_PORT names a port free on this machine" }, { json });
434
510
  }
@@ -440,18 +516,52 @@ export async function serve(argv) {
440
516
  say(` database ${made.database}, what a tool connects with`);
441
517
  say(` health http://${s.health.host}:${s.health.port}/healthz`);
442
518
  say(` metrics http://${s.health.host}:${s.health.port}/metrics`);
519
+ for (const l of socketSaid)
520
+ say(` ${l}`);
521
+ if (s.ui.runFile !== null)
522
+ say(` run file ${s.ui.runFile}: written once Mason answers (mode 0600; it holds the run's token)`);
523
+ if (page !== null)
524
+ say(` web page ${page.file} (a Unix socket, mode 0600: a request on it passes the same guard as mason ui --ssh's)`);
525
+ if (uiReadOnlySetting(process.env) === true)
526
+ say(` web page read-only (${UI_READ_ONLY_SETTING}=1): it refuses every change it is sent; the mason commands beside it are unchanged`);
443
527
  // the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
444
528
  process.chdir(s.data);
445
529
  let code;
446
530
  try {
447
531
  code = await dev({ port: s.ui.port, exactPort: s.ui.named, version: version(), selfSignedTls: s.selfSignedTls,
448
- serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables, metrics: run.metrics }; }, stopping: () => { state = { phase: "stopping" }; } } });
532
+ serve: { log: say,
533
+ ready: (run) => {
534
+ // the run's file before /healthz says ready: a supervisor that sees ready finds it
535
+ if (s.ui.runFile !== null) {
536
+ const w = writeUiRunFile(s.ui.runFile, run.page, (l) => say(` ${l}`));
537
+ if ("message" in w) {
538
+ // fail closed: a supervisor would wait on a file that never comes, so the run stops, never says it is
539
+ // ready, records why, and the serve exits 1
540
+ runFileFailed = w;
541
+ say(` ${w.message}: Mason stops (${w.fix})`);
542
+ state = { phase: "stopping" };
543
+ return `stopped: the run file (${UI_RUN_FILE_SETTING}) could not be written: ${w.message.slice(`${UI_RUN_FILE_SETTING}=`.length)}`;
544
+ }
545
+ runFile = w;
546
+ say(` run file ${w.file} (the web page's port and the run's token, mode 0600)`);
547
+ }
548
+ state = { phase: "ready", tables: run.tables, metrics: run.metrics };
549
+ return null;
550
+ },
551
+ stopping: () => { state = { phase: "stopping" }; page?.close(); runFile?.remove(); },
552
+ ...(page !== null ? { page: page.attach, socket: page.file } : {}) } });
449
553
  }
450
554
  finally {
555
+ // removed whatever ended the run: a stop, a crash of the engine's thread, or a start that failed
556
+ page?.close();
557
+ runFile?.remove();
558
+ unhookEarly();
451
559
  // a platform's health check may hold a keep-alive connection open, which close() alone would wait for
452
560
  health.closeAllConnections();
453
561
  await new Promise((resolve) => health.close(() => resolve()));
454
562
  }
563
+ if (runFileFailed !== null)
564
+ return failed("serve", runFileFailed, { json: false });
455
565
  if (code !== 0)
456
566
  return failed("serve", { message: "Mason did not start: what stopped it is said above", code: "failed",
457
567
  fix: "fix what it says, then start it again (a platform restarts it by itself)" }, { json: false });
@@ -0,0 +1,23 @@
1
+ export const UI_READ_ONLY_SETTING = "MASON_UI_READ_ONLY";
2
+ /** MASON_UI_READ_ONLY as the environment says it: 1 true, 0 or unset false, anything else what is wrong with it. */
3
+ export function uiReadOnlySetting(env) {
4
+ const v = env[UI_READ_ONLY_SETTING];
5
+ if (v === undefined || v === "" || v === "0")
6
+ return false;
7
+ if (v === "1")
8
+ return true;
9
+ return { problem: `${UI_READ_ONLY_SETTING}=${JSON.stringify(v)}: 1 (the web page only shows, and changes nothing) or 0 (the default)` };
10
+ }
11
+ /** Why the page offers no control that changes anything: `/api/state` `pageReadOnly`, the page's words. */
12
+ export const PAGE_READ_ONLY_WHY = `the web page is read-only here (${UI_READ_ONLY_SETTING}=1)`;
13
+ /** The answer to every change the page asks for under the setting. */
14
+ export const PAGE_READ_ONLY = {
15
+ message: `${PAGE_READ_ONLY_WHY}: it shows what Mason syncs, and changes nothing`, code: "refused",
16
+ why: `Mason was started with ${UI_READ_ONLY_SETTING}=1, so the page refuses every change, whatever it asks`,
17
+ fix: "make the change with the mason command where Mason runs, or ask whoever runs it",
18
+ };
19
+ /**
20
+ * Whether a request the guard let in is a change the page asks for: anything but a GET or a HEAD, sent without the
21
+ * run's token (a request with it is the commands', and the guard has checked that the token is the run's).
22
+ */
23
+ export const pageWrite = (method, authorization) => method !== "GET" && method !== "HEAD" && authorization === undefined;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `MASON_UI_RUN_FILE` (mason serve): THE RUN'S PAGE PORT AND TOKEN IN A FILE THE OPERATOR NAMES, for a process on the
3
+ * same machine that supervises the run (a supervisor, a local reverse proxy beside MASON_UI_SOCKET) and opens a tunnel
4
+ * (`POST /api/ui/tunnel`) without reading `.mason/config.json`, which is the run's own and holds more.
5
+ *
6
+ * WHAT IT HOLDS: `{ "port", "token" }` and nothing else. The token is the run's: it grants the commands' writes (mason
7
+ * add, mason sync, mason cancel, the users) as well as a tunnel, so the file is held to the bar the page's socket is
8
+ * (src/cli/ui-socket.ts): in a folder only Mason's user can write (`ownFolderProblem`), mode 0600 from the moment it
9
+ * exists, never through a symbolic link. The socket's path is not in it: whoever reads the file named the socket, and
10
+ * /healthz says it.
11
+ *
12
+ * WRITTEN WHOLE, ONCE THE RUN ANSWERS: a 0600 file beside it, written and flushed, then linked to the path (a link
13
+ * never replaces what is there), so a reader finds no file or the whole of it. It is removed when the run stops, and only
14
+ * when it is still the one written. A file already at the path is replaced only when it is a run file of Mason's user
15
+ * (a regular file, 0600, of exactly those two keys) whose run does not answer with its token; anything else — a link,
16
+ * a folder, another user's file, a file of other content, a run that answers — refuses the start and is left as it is.
17
+ */
18
+ import * as crypto from "node:crypto";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { octal, ownFolderProblem } from "./ui-socket.js";
22
+ export const UI_RUN_FILE_SETTING = "MASON_UI_RUN_FILE";
23
+ /** How long the run a stale file names has to answer before whether it runs is taken as unknown. */
24
+ const PROBE_MS = 2_000;
25
+ /** The file MASON_UI_RUN_FILE names, absolute; null when it is unset (or set empty); or what is wrong with it. */
26
+ export function uiRunFileSetting(env, cwd, platform = process.platform) {
27
+ const set = env[UI_RUN_FILE_SETTING];
28
+ if (!set)
29
+ return null;
30
+ // a file mode is how the token is kept to Mason's user, and Windows keeps none
31
+ if (platform === "win32")
32
+ return { problem: `${UI_RUN_FILE_SETTING} is set, and on Windows a file cannot be kept to Mason's user by its mode: unset it` };
33
+ return path.resolve(cwd, set);
34
+ }
35
+ const refused = (file, problem, fix) => ({ message: `${UI_RUN_FILE_SETTING}=${file}: ${problem}`, code: "refused", why: "the file holds the run's token, which grants the commands' writes as well as a tunnel to the page, so only Mason's user may make or replace it", fix });
36
+ /** The content of a run file Mason wrote, or null when `text` is anything else. */
37
+ function runFileContent(text) {
38
+ let v;
39
+ try {
40
+ v = JSON.parse(text);
41
+ }
42
+ catch {
43
+ return null;
44
+ } // not JSON: not a run file, which the caller refuses
45
+ if (typeof v !== "object" || v === null || Array.isArray(v))
46
+ return null;
47
+ const o = v;
48
+ if (Object.keys(o).sort().join(",") !== "port,token")
49
+ return null;
50
+ if (typeof o.port !== "number" || !Number.isInteger(o.port) || o.port < 1 || o.port > 65_535 || typeof o.token !== "string")
51
+ return null;
52
+ return { port: o.port, token: o.token };
53
+ }
54
+ /** Whether the run a file names answers with its token: true, false (nothing that is that run), or the error that left it unknown. */
55
+ async function runAnswers(c) {
56
+ try {
57
+ const res = await fetch(`http://127.0.0.1:${c.port}/api/ping`, { headers: { Authorization: `Bearer ${c.token}` }, signal: AbortSignal.timeout(PROBE_MS) });
58
+ if (res.status !== 200) {
59
+ await res.body?.cancel();
60
+ return false;
61
+ }
62
+ let body;
63
+ try {
64
+ body = await res.json();
65
+ }
66
+ catch (e) {
67
+ // a 200 whose body is not JSON is no run's ping (a body cut off midway is not knowing, said as such)
68
+ if (e instanceof SyntaxError)
69
+ return false;
70
+ throw e;
71
+ }
72
+ return typeof body?.pid === "number";
73
+ }
74
+ catch (e) {
75
+ const code = e.cause?.code;
76
+ if (code === "ECONNREFUSED")
77
+ return false;
78
+ return e instanceof Error ? e : new Error(String(e));
79
+ }
80
+ }
81
+ /**
82
+ * The folder and the path held to the bar (the header): null when the file may be written, after a stale one is
83
+ * removed (`said` is told), or the refusal. Fails closed: a fact it cannot read refuses.
84
+ */
85
+ export async function prepareUiRunFile(file, said, uid = process.getuid()) {
86
+ const folder = ownFolderProblem(file, "run file", uid);
87
+ if (folder !== null)
88
+ return refused(file, folder.problem, folder.fix);
89
+ let f;
90
+ try {
91
+ f = fs.lstatSync(file);
92
+ }
93
+ catch (e) {
94
+ if (e.code === "ENOENT")
95
+ return null;
96
+ return refused(file, `it cannot be read: ${e.message}`, "name another path");
97
+ }
98
+ const notMine = "remove it yourself if it is not needed, or name another path";
99
+ if (f.isSymbolicLink())
100
+ return refused(file, "it is a symbolic link, and Mason never replaces one", notMine);
101
+ if (!f.isFile())
102
+ return refused(file, `it is ${f.isDirectory() ? "a folder" : "not a regular file"}, and Mason replaces only a run file a run of its own left`, notMine);
103
+ if (f.uid !== uid)
104
+ return refused(file, `it is a file of uid ${f.uid}, not of Mason's user (uid ${uid})`, notMine);
105
+ if ((f.mode & 0o777) !== 0o600)
106
+ return refused(file, `it is a file of mode ${octal(f.mode)}, and a run file Mason writes is 0600`, notMine);
107
+ let text;
108
+ try {
109
+ // never through a link that appeared since: O_NOFOLLOW
110
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
111
+ try {
112
+ text = f.size > 4096 ? "" : fs.readFileSync(fd, "utf8");
113
+ }
114
+ finally {
115
+ fs.closeSync(fd);
116
+ }
117
+ }
118
+ catch (e) {
119
+ return refused(file, `it cannot be read: ${e.message}`, notMine);
120
+ }
121
+ const content = runFileContent(text);
122
+ if (content === null)
123
+ return refused(file, "it is a file of other content than a run file Mason writes ({ port, token })", notMine);
124
+ const live = await runAnswers(content);
125
+ if (live === true)
126
+ return refused(file, "the run it names answers with its token: another Mason writes it now", "stop that Mason, or name another path");
127
+ if (live instanceof Error)
128
+ return refused(file, `whether the run it names still runs could not be told (${live.message})`, notMine);
129
+ try {
130
+ fs.unlinkSync(file);
131
+ }
132
+ catch (e) {
133
+ return refused(file, `the stale run file could not be removed: ${e.message}`, notMine);
134
+ }
135
+ said(`replaced the run file a run that did not stop left at ${file}`);
136
+ return null;
137
+ }
138
+ /** Whether `file` holds `text`, read never through a link. */
139
+ function readsAs(file, text) {
140
+ let fd;
141
+ try {
142
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
143
+ }
144
+ catch {
145
+ return false;
146
+ } // gone, or a link since: not the file written, which the caller leaves
147
+ try {
148
+ return fs.readFileSync(fd, "utf8") === text;
149
+ }
150
+ finally {
151
+ fs.closeSync(fd);
152
+ }
153
+ }
154
+ /**
155
+ * Writes `content` to `file` whole (the header): a 0600 file beside it, written and flushed, linked to the path, which
156
+ * fails when anything is there, then checked. `said` is told of a removal that failed.
157
+ */
158
+ export function writeUiRunFile(file, content, said, uid = process.getuid()) {
159
+ const folder = ownFolderProblem(file, "run file", uid);
160
+ if (folder !== null)
161
+ return refused(file, folder.problem, folder.fix);
162
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}`);
163
+ const text = `${JSON.stringify({ port: content.port, token: content.token })}\n`;
164
+ let fd = null;
165
+ let linked = false;
166
+ const cleanUp = () => {
167
+ if (fd !== null) {
168
+ try {
169
+ fs.closeSync(fd);
170
+ }
171
+ catch (e) {
172
+ said(`the run file's descriptor could not be closed: ${e.message}`);
173
+ }
174
+ }
175
+ try {
176
+ fs.unlinkSync(temp);
177
+ }
178
+ catch (e) {
179
+ if (e.code !== "ENOENT")
180
+ said(`the run file's temporary ${temp} could not be removed: ${e.message}`);
181
+ }
182
+ };
183
+ let made;
184
+ try {
185
+ // made 0600 by open itself (O_EXCL: never a file that was there; O_NOFOLLOW: never through a link), then set
186
+ // exactly 0600 whatever the umask, before a byte is written
187
+ fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
188
+ fs.fchmodSync(fd, 0o600);
189
+ fs.writeFileSync(fd, text);
190
+ fs.fsyncSync(fd);
191
+ made = fs.fstatSync(fd);
192
+ fs.closeSync(fd);
193
+ fd = null;
194
+ // a link never replaces what is at the path: EEXIST when something appeared there since the start checked it
195
+ fs.linkSync(temp, file);
196
+ linked = true;
197
+ }
198
+ catch (e) {
199
+ cleanUp();
200
+ const code = e.code;
201
+ return refused(file, code === "EEXIST" && !linked ? "something was put at the path after the start checked it, and Mason writes over nothing" : `it cannot be written: ${e.message}`, code === "EEXIST" ? "remove it yourself if it is not needed, or name another path" : "name a path in a folder only Mason's user can write, on a file system that keeps hard links");
202
+ }
203
+ cleanUp();
204
+ let now;
205
+ try {
206
+ now = fs.lstatSync(file);
207
+ }
208
+ catch (e) {
209
+ return refused(file, `it cannot be read once written: ${e.message}`, "name a path in a folder only Mason's user can write");
210
+ }
211
+ // the file written: its inode, and its text too, since a file made at the path after it was removed may be handed
212
+ // the same inode number
213
+ const same = (s) => s.isFile() && s.ino === made.ino && s.dev === made.dev && s.size === Buffer.byteLength(text) && readsAs(file, text);
214
+ if (!same(now) || now.uid !== uid || (now.mode & 0o777) !== 0o600) {
215
+ if (same(now)) {
216
+ try {
217
+ fs.unlinkSync(file);
218
+ }
219
+ catch (e) {
220
+ said(`the run file ${file} could not be removed: ${e.message}`);
221
+ }
222
+ }
223
+ return refused(file, `it is not Mason's alone once written (mode ${octal(now.mode)}, uid ${now.uid})`, "name a path in a folder only Mason's user can write");
224
+ }
225
+ let removed = false;
226
+ return {
227
+ file,
228
+ remove: () => {
229
+ if (removed)
230
+ return;
231
+ removed = true;
232
+ // removed only while it is the file written: another's at the path since is left as it is
233
+ try {
234
+ if (same(fs.lstatSync(file)))
235
+ fs.unlinkSync(file);
236
+ else
237
+ return;
238
+ }
239
+ catch (e) {
240
+ if (e.code !== "ENOENT")
241
+ said(`the run file ${file} could not be removed: ${e.message}`);
242
+ return;
243
+ }
244
+ said(`removed the run file ${file}`);
245
+ },
246
+ };
247
+ }