@agent-flow-board/bridge 0.2.0 → 0.3.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.
Files changed (51) hide show
  1. package/README.md +16 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +3 -2
  5. package/dist/checks/bind.js +3 -2
  6. package/dist/checks/broker.js +439 -0
  7. package/dist/checks/browser.js +137 -32
  8. package/dist/checks/browserpool.js +221 -0
  9. package/dist/checks/cache.js +710 -0
  10. package/dist/checks/command.js +75 -88
  11. package/dist/checks/commands.js +219 -0
  12. package/dist/checks/connections.js +4 -3
  13. package/dist/checks/copy.js +320 -0
  14. package/dist/checks/dbpool.js +710 -0
  15. package/dist/checks/doctor.js +649 -0
  16. package/dist/checks/envPolicy.js +50 -1
  17. package/dist/checks/envrules.js +322 -0
  18. package/dist/checks/evidence.js +114 -0
  19. package/dist/checks/fsretry.js +29 -0
  20. package/dist/checks/importlegacy.js +300 -0
  21. package/dist/checks/install.js +150 -0
  22. package/dist/checks/live.js +76 -0
  23. package/dist/checks/lock.js +28 -4
  24. package/dist/checks/login.js +203 -0
  25. package/dist/checks/mailguard-hook.cjs +216 -0
  26. package/dist/checks/mailguard.js +98 -0
  27. package/dist/checks/paths.js +24 -0
  28. package/dist/checks/preview.js +418 -191
  29. package/dist/checks/profiles.js +110 -0
  30. package/dist/checks/provision.js +1 -1
  31. package/dist/checks/recipe.js +19 -5
  32. package/dist/checks/registry.js +548 -0
  33. package/dist/checks/repoenv.js +162 -0
  34. package/dist/checks/roots.js +144 -0
  35. package/dist/checks/routing.js +68 -0
  36. package/dist/checks/run.js +243 -53
  37. package/dist/checks/sandbox.js +32 -10
  38. package/dist/checks/scheduler.js +383 -0
  39. package/dist/checks/scope.js +119 -30
  40. package/dist/checks/sealedNames.js +39 -0
  41. package/dist/checks/secrets.js +452 -0
  42. package/dist/checks/secretscmd.js +80 -0
  43. package/dist/checks/service.js +589 -0
  44. package/dist/checks/signin.js +7 -1
  45. package/dist/checks/singleflight.js +307 -0
  46. package/dist/checks/store.js +92 -0
  47. package/dist/checks/surface.js +124 -0
  48. package/dist/checks/warm.js +342 -0
  49. package/dist/checks/watch.js +190 -63
  50. package/dist/checks/wire.js +87 -0
  51. package/package.json +1 -1
@@ -6,6 +6,10 @@
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { existsSync, readFileSync, statSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { databaseProblems } from './envrules.js';
11
+ import { enforceMailSet } from './mailguard.js';
12
+ import { adoptRepoEnv } from './repoenv.js';
9
13
  // -- CONSTANTS -- //
10
14
  /** Read in this order, a later file overriding an earlier one (the order a production build of Vite and SvelteKit uses). */
11
15
  const FILES = ['.env', '.env.local', '.env.production', '.env.production.local'];
@@ -108,7 +112,7 @@ export function buildPreviewEnv(input) {
108
112
  env[k] = v;
109
113
  }
110
114
  // -- THE REPO'S OWN ENV FILES -- //
111
- const fromFiles = readRepoEnv(input.repo);
115
+ const fromFiles = input.readRepoEnv === false ? new Map() : readRepoEnv(input.repo);
112
116
  let adopted = 0;
113
117
  let mailStripped = false;
114
118
  for (const [name, value] of fromFiles) {
@@ -161,3 +165,48 @@ export function buildPreviewEnv(input) {
161
165
  env.DATABASE_OWNER_URL = input.databaseOwnerUrl;
162
166
  return { env, stripped, notes };
163
167
  }
168
+ /**
169
+ * The environment of a registration that adopts its repo's whole .env. `env` is the START environment (system, adopted, recipe, run's own; later wins), `buildEnv` is
170
+ * the same WITHOUT the adopted values. Rules: the deny list is never adopted (repoenv.ts), every adopted database or address value is checked by databaseProblems and a
171
+ * problem fails the run (the caller refuses to start when `problems` is not empty), and mail settings that did not come from the sealed store are stripped.
172
+ * A registration that did not opt in uses buildPreviewEnv, whose stricter default strips database and mail variables.
173
+ */
174
+ export function buildAdoptedEnv(input) {
175
+ const base = buildPreviewEnv({ ...input, readRepoEnv: false });
176
+ const adopted = adoptRepoEnv({ repo: input.repo, appDir: input.appDir }, { baseUrl: input.baseUrl });
177
+ const notes = [...base.notes];
178
+ const stripped = [...base.stripped];
179
+ // The database rule runs over what the files said, before the run's own settings are laid over it
180
+ let throwawayHost;
181
+ try {
182
+ if (input.databaseUrl !== undefined)
183
+ throwawayHost = new URL(input.databaseUrl).host;
184
+ }
185
+ catch {
186
+ throwawayHost = undefined;
187
+ }
188
+ const problems = databaseProblems(adopted.startEnv, { testHost: input.testHost, throwawayHost });
189
+ // Mail: only a setting from the sealed store stays
190
+ const mail = enforceMailSet(adopted.startEnv, input.sealedNames ?? [], 'strip');
191
+ stripped.push(...mail.stripped);
192
+ if (adopted.files.length)
193
+ notes.push(`Adopted ${Object.keys(mail.env).length} variable(s) from ${adopted.files.join(', ')} for the start step only.`);
194
+ if (adopted.denied.length)
195
+ notes.push(`Not adopted (the deny list): ${adopted.denied.join(', ')}.`);
196
+ if (mail.stripped.length)
197
+ notes.push(`Stripped ${mail.stripped.length} mail variable(s) that did not come from the sealed store: ${mail.stripped.join(', ')}.`);
198
+ for (const p of problems)
199
+ notes.push(p);
200
+ // Later wins: system, adopted, then the recipe's and the run's own settings
201
+ const env = { ...mail.env, ...base.env };
202
+ // The system variables come first in base.env; an adopted variable never replaces one of them (the deny list already covers the dangerous names)
203
+ return {
204
+ env,
205
+ buildEnv: base.env,
206
+ stripped,
207
+ notes,
208
+ redactions: adopted.redactions,
209
+ problems,
210
+ names: Object.keys(mail.env)
211
+ };
212
+ }
@@ -0,0 +1,322 @@
1
+ // THE DATABASE RULE OF A PREVIEW ENVIRONMENT THAT ADOPTED THE REPO'S OWN .env (STEP 3.7). It FAILS THE RUN, it does not strip. Every variable that is named like a database, and
2
+ // every value that is a URL or host:port, is parsed after ${VAR} expansion, URL-decoding and case-folding. Then:
3
+ // 1. a loopback host (localhost, 127.x, ::1, 0.0.0.0, host.docker.internal and its kin, in any spelling) on port 5433, 5180 or 5190 is refused, whatever the variable is called
4
+ // (5433 is the local Docker PostgreSQL with real data; 5180 and 5190 are the person's servers). The port is the URL port, or the elided default completed from a sibling
5
+ // *_PORT / PGPORT variable or a ?port= parameter;
6
+ // 2. a database or Supabase host is allowed only when it is the sealed test project's host or the run's own throwaway database; anything else fails the run.
7
+ // Every sentence names the VARIABLE and never its value.
8
+ // -- CONSTANTS -- //
9
+ /** Ports a check never reaches: the person's dev server, the build preview and the local Docker PostgreSQL. Same set as guardrails.ts, kept here so this file imports nothing. */
10
+ const FORBIDDEN = new Set([5180, 5190, 5433]);
11
+ /** Name tokens that make a variable a database setting (split on anything that is not a letter or digit). */
12
+ const DB_TOKENS = new Set([
13
+ 'DB',
14
+ 'PG',
15
+ 'SQL',
16
+ 'SUPABASE',
17
+ 'NEON',
18
+ 'TURSO',
19
+ 'LIBSQL',
20
+ 'POSTGRES',
21
+ 'POSTGRESQL',
22
+ 'MYSQL',
23
+ 'MARIADB',
24
+ 'MONGO',
25
+ 'MONGODB',
26
+ 'REDIS',
27
+ 'DATABASE'
28
+ ]);
29
+ /** A name that holds one of these as a substring (DATABASEURL, PGHOST, POSTGRESQL_URL) is a database setting too. */
30
+ const DB_SUBSTRING = /DATABASE|POSTGRES|MYSQL|MONGO|REDIS|^PG(HOST|PORT|HOSTADDR|DATABASE|SERVICE)/;
31
+ const HOST_NAME = /(HOST|HOSTNAME|HOSTADDR|ADDR|ADDRESS|SERVER|ENDPOINT)$/;
32
+ const PORT_NAME = /(^|[^A-Z0-9])PORT$|^PGPORT$/;
33
+ const DB_SCHEME = /^(postgres(ql)?|mysql|mariadb|mongodb(\+srv)?|redis|rediss|libsql|sqlserver|mssql|cockroachdb):/;
34
+ /** Hosted database and Supabase domains: a value that names one is a database host whatever the variable is called. */
35
+ const DB_HOST_SUFFIX = /(^|\.)(supabase\.(co|com|in)|neon\.tech|planetscale\.com|psdb\.cloud|rds\.amazonaws\.com|database\.azure\.com|cockroachlabs\.cloud|mongodb\.net|upstash\.io|redislabs\.com|elephantsql\.com|aivencloud\.com|turso\.io)$/;
36
+ const LOOPBACK_NAMES = new Set([
37
+ 'localhost',
38
+ 'ip6-localhost',
39
+ 'ip6-loopback',
40
+ 'host.docker.internal',
41
+ 'gateway.docker.internal',
42
+ 'kubernetes.docker.internal',
43
+ 'host.containers.internal',
44
+ 'host.lima.internal',
45
+ 'host.internal'
46
+ ]);
47
+ // -- FUNCTIONS -- //
48
+ /** `${NAME}`, `${NAME:-default}` and `${NAME-default}` filled from the same environment (names compared without case). Unknown names become empty. Nested up to 5 levels. */
49
+ export function expandRefs(value, env, depth = 0) {
50
+ if (depth > 5 || !value.includes('${'))
51
+ return value;
52
+ const upper = new Map(Object.entries(env).map(([k, v]) => [k.toUpperCase(), v]));
53
+ const out = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}/g, (_all, name, fallback) => {
54
+ const v = upper.get(name.toUpperCase());
55
+ return v !== undefined && v !== '' ? v : (fallback ?? '');
56
+ });
57
+ return out === value ? out : expandRefs(out, env, depth + 1);
58
+ }
59
+ /** Percent-decoding up to three rounds (a doubly encoded value is still read). A broken escape stops the decoding and keeps what was read. */
60
+ function decodeRounds(value) {
61
+ const seen = [value];
62
+ let cur = value;
63
+ for (let i = 0; i < 3; i++) {
64
+ let next;
65
+ try {
66
+ next = decodeURIComponent(cur);
67
+ }
68
+ catch {
69
+ break;
70
+ }
71
+ if (next === cur)
72
+ break;
73
+ seen.push(next);
74
+ cur = next;
75
+ }
76
+ return seen;
77
+ }
78
+ /** The host with its spelling folded: lower case, no brackets, no trailing dot, no zone id, and number-like IPv4 spellings (2130706433, 0x7f.1, 127.1) as dotted quads. */
79
+ export function normalizeHost(raw) {
80
+ let h = raw.trim().toLowerCase();
81
+ if (h.startsWith('[') && h.endsWith(']'))
82
+ h = h.slice(1, -1);
83
+ h = h
84
+ .replace(/%25.*$/, '')
85
+ .replace(/%.*$/, '')
86
+ .replace(/\.+$/, '');
87
+ if (/^[0-9a-fx.]+$/.test(h) && /[0-9]/.test(h) && !h.includes(':')) {
88
+ try {
89
+ return new URL(`http://${h}`).hostname;
90
+ }
91
+ catch {
92
+ return h;
93
+ }
94
+ }
95
+ return h;
96
+ }
97
+ /** True when the host reaches this machine: localhost and its kin, 127.0.0.0/8, 0.0.0.0, the IPv6 loopback and unspecified addresses (also as IPv4-mapped), the Docker host names. */
98
+ export function isLoopbackHost(raw) {
99
+ const h = normalizeHost(raw);
100
+ if (!h)
101
+ return false;
102
+ if (LOOPBACK_NAMES.has(h) || h.endsWith('.localhost'))
103
+ return true;
104
+ if (/^127\.\d+\.\d+\.\d+$/.test(h) || /^0\.\d+\.\d+\.\d+$/.test(h))
105
+ return true;
106
+ if (h.includes(':')) {
107
+ // The URL parser gives the one canonical spelling of an IPv6 address (::1, ::, ::ffff:7f00:1)
108
+ let canon;
109
+ try {
110
+ canon = new URL(`http://[${h}]`).hostname.slice(1, -1);
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ if (canon === '::1' || canon === '::')
116
+ return true;
117
+ const mapped = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(canon);
118
+ if (mapped) {
119
+ const hi = parseInt(mapped[1] ?? '1', 16);
120
+ return hi >> 8 === 127 || hi === 0;
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+ /** The host and port of one authority part, `user:pw@host:port` or `[::1]:port` or `host`. */
126
+ function splitAuthority(part) {
127
+ const at = part.lastIndexOf('@');
128
+ const hp = at >= 0 ? part.slice(at + 1) : part;
129
+ if (hp.startsWith('[')) {
130
+ const end = hp.indexOf(']');
131
+ const host = end >= 0 ? hp.slice(0, end + 1) : hp;
132
+ const port = /^:(\d{1,5})$/.exec(end >= 0 ? hp.slice(end + 1) : '');
133
+ return { host, port: port?.[1] ? Number(port[1]) : null };
134
+ }
135
+ const colon = hp.lastIndexOf(':');
136
+ if (colon >= 0 && /^\d{1,5}$/.test(hp.slice(colon + 1)) && hp.indexOf(':') === colon)
137
+ return { host: hp.slice(0, colon), port: Number(hp.slice(colon + 1)) };
138
+ return { host: hp, port: null };
139
+ }
140
+ /** Every host the value names, with the ports it states itself. `schemeOnly` is true for a URL or a connection string, false for a bare host:port. */
141
+ function hostsOf(text) {
142
+ const out = [];
143
+ const url = /^([a-z][a-z0-9+.-]*):\/\/([^\s]*)$/.exec(text);
144
+ if (url) {
145
+ const dbScheme = DB_SCHEME.test(`${url[1]}:`);
146
+ const rest = url[2] ?? '';
147
+ const cut = rest.search(/[/?#]/);
148
+ const authority = cut >= 0 ? rest.slice(0, cut) : rest;
149
+ const query = rest.indexOf('?') >= 0 ? (rest.slice(rest.indexOf('?') + 1).split('#')[0] ?? '') : '';
150
+ const params = new URLSearchParams(query);
151
+ const qPort = params.get('port');
152
+ const qPorts = qPort && /^\d{1,5}$/.test(qPort) ? [Number(qPort)] : [];
153
+ // The host list is what follows the last @ (a password may hold a comma): mongodb://h1:1,h2:2
154
+ const at = authority.lastIndexOf('@');
155
+ const parts = authority.slice(at + 1).split(',');
156
+ for (const part of parts) {
157
+ if (!part)
158
+ continue;
159
+ const { host, port } = splitAuthority(part);
160
+ if (host)
161
+ out.push({
162
+ host,
163
+ ports: port !== null ? [port] : qPorts,
164
+ portKnown: port !== null || qPorts.length > 0,
165
+ dbScheme,
166
+ urlLike: true
167
+ });
168
+ }
169
+ for (const key of ['host', 'hostaddr', 'sslhost']) {
170
+ for (const h of (params.get(key) ?? '').split(',').filter(Boolean))
171
+ out.push({ host: h, ports: qPorts, portKnown: qPorts.length > 0, dbScheme, urlLike: true });
172
+ }
173
+ return out;
174
+ }
175
+ // A libpq key/value connection string: `host=localhost port=5433 dbname=app`, a value may be single quoted, host and port may be lists
176
+ const pairs = [...text.matchAll(/(?:^|\s)(host|hostaddr|port)\s*=\s*('(?:[^'\\]|\\.)*'|\S*)/gi)];
177
+ if (pairs.length) {
178
+ const valuesOf = (key) => pairs
179
+ .filter((m) => m[1]?.toLowerCase() === key)
180
+ .flatMap((m) => (m[2] ?? '').replace(/^'|'$/g, '').split(','))
181
+ .map((v) => v.trim())
182
+ .filter(Boolean);
183
+ const hosts = [...valuesOf('host'), ...valuesOf('hostaddr')];
184
+ const ports = valuesOf('port')
185
+ .filter((p) => /^\d{1,5}$/.test(p))
186
+ .map(Number);
187
+ for (const host of hosts)
188
+ out.push({ host, ports, portKnown: ports.length > 0, dbScheme: true, urlLike: true });
189
+ if (out.length)
190
+ return out;
191
+ }
192
+ // A bare host:port, with or without a path
193
+ const bare = /^(\[[0-9a-f:.%a-z]+\]|[a-z0-9.-]+):(\d{1,5})(?:[/?#].*)?$/.exec(text);
194
+ if (bare && bare[1] && bare[2]) {
195
+ out.push({
196
+ host: bare[1],
197
+ ports: [Number(bare[2])],
198
+ portKnown: true,
199
+ dbScheme: false,
200
+ urlLike: false
201
+ });
202
+ }
203
+ return out;
204
+ }
205
+ const isDbName = (name) => {
206
+ const upper = name.toUpperCase();
207
+ return DB_SUBSTRING.test(upper) || upper.split(/[^A-Z0-9]+/).some((t) => DB_TOKENS.has(t));
208
+ };
209
+ /** The ports that sibling variables give to a database variable whose own port was elided. */
210
+ function siblingPorts(name, env) {
211
+ const upper = name.toUpperCase();
212
+ const prefix = upper.replace(/[_]?(URL|URI|DSN|CONNECTION_STRING|CONNECTION|HOSTNAME|HOSTADDR|HOST|ADDRESS|ADDR|SERVER|ENDPOINT)$/, '');
213
+ const wanted = new Set([
214
+ 'PGPORT',
215
+ prefix ? `${prefix}_PORT` : '',
216
+ prefix ? `${prefix}PORT` : '',
217
+ 'DB_PORT',
218
+ 'DATABASE_PORT',
219
+ 'POSTGRES_PORT',
220
+ 'POSTGRESQL_PORT',
221
+ 'MYSQL_PORT',
222
+ 'REDIS_PORT',
223
+ 'MONGO_PORT'
224
+ ].filter(Boolean));
225
+ const out = [];
226
+ for (const [k, v] of env)
227
+ if (wanted.has(k) && /^\d{1,5}$/.test(v.trim()))
228
+ out.push({ var: k, port: Number(v.trim()) });
229
+ return out;
230
+ }
231
+ /** The throwaway database written as host or host:port. */
232
+ function allowedBy(host, ports, allowed) {
233
+ if (!allowed)
234
+ return false;
235
+ const probe = hostsOf(`x://${allowed}`)[0] ?? { host: allowed, ports: [] };
236
+ const a = normalizeHost(probe.host);
237
+ const h = normalizeHost(host);
238
+ const sameHost = a === h || (isLoopbackHost(a) && isLoopbackHost(h));
239
+ if (!sameHost)
240
+ return false;
241
+ return (probe.ports.length === 0 || ports.length === 0 || ports.some((p) => probe.ports.includes(p)));
242
+ }
243
+ /**
244
+ * Every problem of the database and address settings in `env`, as plain sentences that name VARIABLES only. An empty list means the environment may be used.
245
+ * `env` is the adopted environment (plus anything else the start step is given); the run's own throwaway database and the sealed test host are the only databases allowed.
246
+ */
247
+ export function databaseProblems(env, { testHost, throwawayHost } = {}) {
248
+ const problems = [];
249
+ const add = (s) => {
250
+ if (!problems.includes(s))
251
+ problems.push(s);
252
+ };
253
+ // Expanded copy of the whole environment, names folded to upper case (a Windows environment has no case)
254
+ const expanded = new Map();
255
+ for (const [k, v] of Object.entries(env))
256
+ expanded.set(k.toUpperCase(), expandRefs(String(v), env));
257
+ for (const name of Object.keys(env)) {
258
+ const upper = name.toUpperCase();
259
+ const dbNamed = isDbName(name);
260
+ const folded = (expanded.get(upper) ?? '').trim();
261
+ if (!folded)
262
+ continue;
263
+ // A database port variable on its own
264
+ if (dbNamed &&
265
+ PORT_NAME.test(upper) &&
266
+ /^\d{1,5}$/.test(folded) &&
267
+ FORBIDDEN.has(Number(folded))) {
268
+ add(forbiddenSentence(name, Number(folded)));
269
+ continue;
270
+ }
271
+ const spellings = decodeRounds(folded).map((s) => s.toLowerCase());
272
+ const candidates = [];
273
+ for (const s of spellings) {
274
+ for (const h of hostsOf(s))
275
+ candidates.push({ ...h, spelling: s });
276
+ // A database host variable holds a bare host name (PGHOST, DB_HOST)
277
+ if (dbNamed &&
278
+ HOST_NAME.test(upper) &&
279
+ !s.includes('://') &&
280
+ !/^\[?[a-z0-9.:-]+\]?:\d{1,5}(?:[/?#].*)?$/.test(s))
281
+ candidates.push({
282
+ host: s,
283
+ ports: [],
284
+ portKnown: false,
285
+ dbScheme: false,
286
+ urlLike: false,
287
+ spelling: s
288
+ });
289
+ }
290
+ for (const c0 of candidates) {
291
+ // A host written with percent escapes (local%68ost) is read as it will be resolved
292
+ const c = { ...c0, host: decodeRounds(c0.host).at(-1) ?? c0.host };
293
+ if (c.host.length === 0)
294
+ continue;
295
+ let ports = c.ports;
296
+ if (!c.portKnown) {
297
+ const sib = siblingPorts(name, expanded);
298
+ ports = sib.map((s) => s.port);
299
+ }
300
+ const loop = isLoopbackHost(c.host);
301
+ const dbish = dbNamed || c.dbScheme || DB_HOST_SUFFIX.test(normalizeHost(c.host)) || /^pg/.test(upper);
302
+ const bad = ports.find((p) => FORBIDDEN.has(p));
303
+ if (loop && bad !== undefined) {
304
+ add(forbiddenSentence(name, bad));
305
+ continue;
306
+ }
307
+ if (!dbish)
308
+ continue;
309
+ if (loop) {
310
+ if (!allowedBy(c.host, ports, throwawayHost))
311
+ add(`${name} names a database on this machine that this run did not make, so the run does not start. Remove it; the run uses its own throwaway database.`);
312
+ continue;
313
+ }
314
+ const h = normalizeHost(c.host);
315
+ const ok = (testHost && h === normalizeHost(testHost)) || allowedBy(c.host, ports, throwawayHost);
316
+ if (!ok)
317
+ add(`${name} names a database or Supabase host that is not this registration's sealed test project or the run's own throwaway database, so the run does not start.`);
318
+ }
319
+ }
320
+ return problems;
321
+ }
322
+ const forbiddenSentence = (name, port) => `${name} points at port ${port} on this machine, which a check never uses (the local database that holds real data, or a server the person runs), so the run does not start.`;
@@ -0,0 +1,114 @@
1
+ // READING WHAT THE SANDBOXED CHECK WROTE: THE CHILD OWNS ITS RUN FOLDER, SO EVERY FILE IN IT IS HOSTILE UNTIL PROVEN OTHERWISE.
2
+ // A check can plant a link where the tool expects a picture (shots/01.jpg -> a key file) and the tool, reading it, would upload that key to the board. So a file is
3
+ // read only when it is a regular file (not a link, not a hard link), its real path is inside the folder it should be in, it is small enough, and it starts with the
4
+ // bytes of its kind. It is opened without following a link where the platform allows it, and the handle is compared with the first look.
5
+ // -- DEP-MODULES -- //
6
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ // -- MODULES -- //
9
+ import { isInside } from './paths.js';
10
+ // -- CONSTANTS -- //
11
+ /** A picture over this is not uploaded. */
12
+ export const MAX_SHOT_BYTES = 5 * 1024 * 1024;
13
+ /** result.json is a short report. */
14
+ export const MAX_RESULT_BYTES = 2 * 1024 * 1024;
15
+ export const JPEG_MAGIC = [0xff, 0xd8, 0xff];
16
+ // -- FUNCTIONS -- //
17
+ /** True when the bytes start with the magic bytes. */
18
+ const startsWith = (buf, magic) => magic.every((b, i) => buf[i] === b);
19
+ /** Reads `name` from `dir` only when it is a plain file of the kind expected. Never throws; says why when it refuses. */
20
+ export function readPlainFile(dir, name, opts) {
21
+ const file = join(dir, name);
22
+ let st;
23
+ try {
24
+ st = lstatSync(file);
25
+ }
26
+ catch {
27
+ return { refused: 'it is missing' };
28
+ }
29
+ if (st.isSymbolicLink())
30
+ return { refused: 'it is a link' };
31
+ if (!st.isFile())
32
+ return { refused: 'it is not a regular file' };
33
+ if (st.nlink > 1)
34
+ return { refused: 'it has more than one name on disk' };
35
+ if (st.size > opts.maxBytes)
36
+ return { refused: 'it is too big' };
37
+ if (!isInside(dir, file))
38
+ return { refused: 'it is not inside its folder' };
39
+ let fd = null;
40
+ try {
41
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
42
+ const fst = fstatSync(fd);
43
+ if (!fst.isFile() || fst.size !== st.size || (st.ino && fst.ino !== st.ino))
44
+ return { refused: 'it changed while it was opened' };
45
+ const bytes = Buffer.alloc(fst.size);
46
+ let at = 0;
47
+ while (at < bytes.length) {
48
+ const n = readSync(fd, bytes, at, bytes.length - at, at);
49
+ if (n <= 0)
50
+ break;
51
+ at += n;
52
+ }
53
+ if (at !== bytes.length)
54
+ return { refused: 'it changed while it was read' };
55
+ if (opts.magic && !startsWith(bytes, opts.magic))
56
+ return { refused: 'it is not the kind of file it should be' };
57
+ return { bytes };
58
+ }
59
+ catch {
60
+ return { refused: 'it could not be opened' };
61
+ }
62
+ finally {
63
+ if (fd !== null)
64
+ closeSync(fd);
65
+ }
66
+ }
67
+ /** True when `name` in `dir` is a plain file (what cleanResult keeps). */
68
+ export const isPlainFile = (dir, name) => {
69
+ try {
70
+ const st = lstatSync(join(dir, name));
71
+ return st.isFile() && !st.isSymbolicLink();
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ };
77
+ /**
78
+ * The first link (symbolic link, junction or file with several names) below a folder, or null. Node's permission model checks the path it is given, not where a
79
+ * link leads, so a link inside a folder the child may read or write reaches files outside it (checked on this Node: both a file link and a junction are followed).
80
+ * The folders given to a check are walked before and after the run, and a run that finds a link is refused.
81
+ */
82
+ export function findLink(root, maxEntries = 20_000) {
83
+ let seen = 0;
84
+ const walk = (dir) => {
85
+ let names;
86
+ try {
87
+ names = readdirSync(dir);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ for (const name of names) {
93
+ if (++seen > maxEntries)
94
+ return join(dir, name);
95
+ const full = join(dir, name);
96
+ let st;
97
+ try {
98
+ st = lstatSync(full);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ if (st.isSymbolicLink() || (st.isFile() && st.nlink > 1))
104
+ return full;
105
+ if (st.isDirectory()) {
106
+ const hit = walk(full);
107
+ if (hit)
108
+ return hit;
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+ return walk(root);
114
+ }
@@ -0,0 +1,29 @@
1
+ // RETRIES FOR A FILE ANOTHER PROCESS HOLDS FOR A MOMENT (WINDOWS ANTIVIRUS, INDEXERS, A RENAME IN PROGRESS): EPERM, EBUSY AND EACCES ARE TRIED AGAIN.
2
+ // Every atomic rename of this folder goes through here, so no module keeps a copy of the loop.
3
+ // -- DEP-MODULES -- //
4
+ import { renameSync } from 'node:fs';
5
+ // -- CONSTANTS -- //
6
+ /** Errors that mean "another process has the file right now". */
7
+ const BUSY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
8
+ /** Waits between tries, growing: about 2.4 seconds in all. */
9
+ export const RETRY_WAITS_MS = [10, 20, 40, 80, 120, 160, 240, 320, 400, 500, 600];
10
+ // -- FUNCTIONS -- //
11
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12
+ /** Runs `fn` and tries it again while it fails with a busy-file error. Any other error is thrown at once. */
13
+ export function withRetry(fn, waits = RETRY_WAITS_MS) {
14
+ for (let i = 0;; i++) {
15
+ try {
16
+ return fn();
17
+ }
18
+ catch (e) {
19
+ const code = e.code;
20
+ if (!code || !BUSY_CODES.has(code) || i >= waits.length)
21
+ throw e;
22
+ nap(waits[i]);
23
+ }
24
+ }
25
+ }
26
+ /** An atomic rename that waits out a busy file. `rename` is replaceable so a test can simulate the errors Windows gives. */
27
+ export function renameRetry(from, to, rename = renameSync) {
28
+ withRetry(() => rename(from, to));
29
+ }