@ix-xs/node-comfort 2.1.0 → 2.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/Arr.js +13 -5
  5. package/src/Checker.js +56 -8
  6. package/src/Cli.js +60 -18
  7. package/src/Color.js +32 -6
  8. package/src/Crypto.js +429 -78
  9. package/src/Emitter.js +9 -9
  10. package/src/Env.js +90 -8
  11. package/src/FS.js +369 -59
  12. package/src/Func.js +1 -1
  13. package/src/Http.js +266 -75
  14. package/src/Id.js +53 -15
  15. package/src/Logger.js +100 -22
  16. package/src/Num.js +3 -2
  17. package/src/Obj.js +225 -36
  18. package/src/SQLite.js +155 -98
  19. package/src/Schema.js +281 -40
  20. package/src/Str.js +77 -29
  21. package/src/Sys.js +124 -23
  22. package/src/Time.js +98 -24
  23. package/src/Utils.js +18 -3
  24. package/src/errors.js +75 -4
  25. package/src/internal/shared.js +68 -14
  26. package/src/internal/terminal.js +49 -0
  27. package/types/index.d.ts +28 -5
  28. package/types/src/Arr.d.ts +4 -2
  29. package/types/src/Arr.d.ts.map +1 -1
  30. package/types/src/Checker.d.ts +29 -3
  31. package/types/src/Checker.d.ts.map +1 -1
  32. package/types/src/Cli.d.ts +6 -4
  33. package/types/src/Cli.d.ts.map +1 -1
  34. package/types/src/Color.d.ts +2 -2
  35. package/types/src/Color.d.ts.map +1 -1
  36. package/types/src/Crypto.d.ts +126 -28
  37. package/types/src/Crypto.d.ts.map +1 -1
  38. package/types/src/Emitter.d.ts +6 -6
  39. package/types/src/Emitter.d.ts.map +1 -1
  40. package/types/src/Env.d.ts +19 -1
  41. package/types/src/Env.d.ts.map +1 -1
  42. package/types/src/FS.d.ts +30 -23
  43. package/types/src/FS.d.ts.map +1 -1
  44. package/types/src/Func.d.ts +1 -1
  45. package/types/src/Http.d.ts +45 -8
  46. package/types/src/Http.d.ts.map +1 -1
  47. package/types/src/Id.d.ts.map +1 -1
  48. package/types/src/Logger.d.ts +11 -3
  49. package/types/src/Logger.d.ts.map +1 -1
  50. package/types/src/Num.d.ts +1 -1
  51. package/types/src/Num.d.ts.map +1 -1
  52. package/types/src/Obj.d.ts +15 -2
  53. package/types/src/Obj.d.ts.map +1 -1
  54. package/types/src/SQLite.d.ts +105 -13
  55. package/types/src/SQLite.d.ts.map +1 -1
  56. package/types/src/Schema.d.ts +61 -5
  57. package/types/src/Schema.d.ts.map +1 -1
  58. package/types/src/Str.d.ts +32 -7
  59. package/types/src/Str.d.ts.map +1 -1
  60. package/types/src/Sys.d.ts +43 -10
  61. package/types/src/Sys.d.ts.map +1 -1
  62. package/types/src/Time.d.ts +12 -7
  63. package/types/src/Time.d.ts.map +1 -1
  64. package/types/src/Utils.d.ts +16 -6
  65. package/types/src/Utils.d.ts.map +1 -1
  66. package/types/src/errors.d.ts +6 -2
  67. package/types/src/errors.d.ts.map +1 -1
  68. package/types/src/internal/shared.d.ts +7 -0
  69. package/types/src/internal/shared.d.ts.map +1 -1
  70. package/types/src/internal/terminal.d.ts +13 -0
  71. package/types/src/internal/terminal.d.ts.map +1 -0
package/src/Emitter.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  const { TimeoutError, AbortError } = require("./errors.js");
18
- const { toMs, setLongTimeout } = require("./internal/shared.js");
18
+ const { toMs, setLongTimeout, disposable } = require("./internal/shared.js");
19
19
 
20
20
  /**
21
21
  * Event names mapped to their arguments, like
@@ -65,11 +65,11 @@ class Emitter {
65
65
  * @param {keyof Events} event
66
66
  * @param {_StoredListener} stored
67
67
  * @param {ListenOptions | undefined} options
68
- * @returns {() => void}
68
+ * @returns {(() => void) & Disposable}
69
69
  */
70
70
  #add(event, stored, options) {
71
71
  const signal = options?.signal;
72
- if (signal?.aborted) return () => {};
72
+ if (signal?.aborted) return disposable(() => {});
73
73
  const current = this.#listeners.get(event) ?? NONE;
74
74
  if (!current.includes(stored)) this.#listeners.set(event, [...current, stored]);
75
75
  const off = () => {
@@ -77,7 +77,7 @@ class Emitter {
77
77
  this.#drop(event, stored);
78
78
  };
79
79
  signal?.addEventListener("abort", off, { once: true });
80
- return off;
80
+ return disposable(off);
81
81
  }
82
82
 
83
83
  /**
@@ -109,7 +109,7 @@ class Emitter {
109
109
  * @param {K} event
110
110
  * @param {(...args: Events[K]) => unknown} listener
111
111
  * @param {ListenOptions} [options]
112
- * @returns {() => void} Removes the listener.
112
+ * @returns {(() => void) & Disposable} Removes the listener. With `using`, at the end of the block.
113
113
  */
114
114
  on(event, listener, options) {
115
115
  return this.#add(event, listener, options);
@@ -122,7 +122,7 @@ class Emitter {
122
122
  * @param {K} event
123
123
  * @param {(...args: Events[K]) => unknown} listener
124
124
  * @param {ListenOptions} [options]
125
- * @returns {() => void} Removes the listener if it hasn't run yet.
125
+ * @returns {(() => void) & Disposable} Removes the listener if it hasn't run yet. With `using`, at the end of the block.
126
126
  */
127
127
  once(event, listener, options) {
128
128
  /** @type {_StoredListener} */
@@ -143,13 +143,13 @@ class Emitter {
143
143
  * emitter.onAny((event, ...args) => nc.debug(event, args));
144
144
  *
145
145
  * @param {(event: keyof Events & string, ...args: any[]) => unknown} listener
146
- * @returns {() => void} Removes the listener.
146
+ * @returns {(() => void) & Disposable} Removes the listener. With `using`, at the end of the block.
147
147
  */
148
148
  onAny(listener) {
149
149
  if (!this.#any.includes(listener)) this.#any = [...this.#any, listener];
150
- return () => {
150
+ return disposable(() => {
151
151
  this.#any = this.#any.filter((stored) => stored !== listener);
152
- };
152
+ });
153
153
  }
154
154
 
155
155
  /**
package/src/Env.js CHANGED
@@ -38,7 +38,7 @@ const { parseDuration } = require("./internal/shared.js");
38
38
  * Options for `parse()`.
39
39
  * @typedef {object} EnvParseOptions
40
40
  * @property {boolean} [expand] Replace `$VAR`, `${VAR}` and `${VAR:-default}`. Defaults to `true`.
41
- * @property {Record<string, string | undefined>} [env] Other variables usable in expansions. Defaults to `process.env`.
41
+ * @property {Record<string, string | undefined>} [env] Other variables usable in expansions. Defaults to none: a reference resolves only from the text itself, so parsing a file you don't trust can't copy your secrets into it. Pass `process.env` to read from it.
42
42
  */
43
43
 
44
44
  /**
@@ -197,20 +197,27 @@ const _REFERENCE = /\\\$|\$\{([\w.]+)(?::?-([^}]*))?\}|\$([A-Za-z_]\w*)/g;
197
197
  /** @type {Record<string, string>} */
198
198
  const _ESCAPED = { n: "\n", r: "\r", t: "\t" };
199
199
 
200
+ /** How long one value may grow through `${VAR}` references. */
201
+ const _MAX_EXPANDED = 64 * 1024;
202
+
200
203
  /**
201
204
  * Parses the text of a `.env` file: quotes, multi-line values, `export`,
202
205
  * comments, escapes, and `${VAR}` expansion (except in single quotes).
206
+ * References resolve from the text itself and from `env`, never from
207
+ * `process.env` unless you pass it.
203
208
  *
204
209
  * @example
205
210
  * env.parse('PORT=3000\nURL="http://localhost:${PORT}"');
206
211
  * // { PORT: "3000", URL: "http://localhost:3000" }
212
+ * env.parse("HOME_COPY=${HOME}", { env: process.env });
207
213
  *
208
214
  * @param {string} content
209
215
  * @param {EnvParseOptions} [options]
210
216
  * @returns {Record<string, string>}
217
+ * @throws {RangeError} If references make one value grow past 64 KB, as with variables that each use the one before several times.
211
218
  */
212
219
  function parse(content, options = {}) {
213
- return _parse(content, options);
220
+ return _parse(content, { ...options, env: options.env ?? {} });
214
221
  }
215
222
 
216
223
  /**
@@ -235,13 +242,18 @@ function _parse(content, options, wins) {
235
242
  if (quote === "'") literal.add(key);
236
243
  else literal.delete(key);
237
244
  } else {
238
- value = (bare ?? "").replace(/(?:^|[ \t]+)#.*$/, "").trim();
245
+ // One blank before the #, not a run of them: a long run would make the regex quadratic.
246
+ value = (bare ?? "").replace(/(?:^|[ \t])#.*$/, "").trim();
239
247
  literal.delete(key);
240
248
  }
241
249
  out.set(key, value);
242
250
  }
243
251
  if (options.expand !== false) {
244
- const base = options.env ?? process.env;
252
+ const base = options.env ?? {};
253
+ // Each variable is expanded once: variables that repeat each other would
254
+ // otherwise take exponential time.
255
+ /** @type {Map<string, string>} */
256
+ const done = new Map();
245
257
  /**
246
258
  * @param {string} name
247
259
  * @param {string[]} seen The variables being expanded, to stop at a loop.
@@ -251,8 +263,20 @@ function _parse(content, options, wins) {
251
263
  if (seen.includes(name)) return "";
252
264
  const winner = wins?.(name);
253
265
  if (winner !== undefined) return winner;
254
- const own = out.get(name);
255
- return own !== undefined ? expand(own, [...seen, name], name) : _raw(base, name) ?? "";
266
+ return out.has(name) ? final(name, [...seen, name]) : _raw(base, name) ?? "";
267
+ };
268
+ /**
269
+ * @param {string} key
270
+ * @param {string[]} seen
271
+ * @returns {string}
272
+ */
273
+ const final = (key, seen) => {
274
+ let value = done.get(key);
275
+ if (value === undefined) {
276
+ value = expand(/** @type {string} */ (out.get(key)), seen, key);
277
+ done.set(key, value);
278
+ }
279
+ return value;
256
280
  };
257
281
  /**
258
282
  * @param {string} value
@@ -262,13 +286,19 @@ function _parse(content, options, wins) {
262
286
  */
263
287
  const expand = (value, seen, key) => {
264
288
  if (literal.has(key) || !value.includes("$")) return value;
289
+ const limit = Math.max(_MAX_EXPANDED, value.length);
290
+ let size = value.length;
265
291
  return value.replace(_REFERENCE, (match, braced, fallback, plain) => {
266
292
  if (match === "\\$") return "$";
267
293
  const resolved = resolve(braced ?? plain, seen);
268
- return resolved === "" && fallback !== undefined ? fallback : resolved;
294
+ const result = resolved === "" && fallback !== undefined ? fallback : resolved;
295
+ size += result.length - match.length;
296
+ if (size > limit) throw new RangeError(`Environment variable ${key} expands to more than 64 KB: check for variables that each use the one before several times`);
297
+ return result;
269
298
  });
270
299
  };
271
- for (const [key, value] of out) out.set(key, expand(value, [key], key));
300
+ for (const key of [...out.keys()]) final(key, [key]);
301
+ for (const [key, value] of done) out.set(key, value);
272
302
  }
273
303
  return Object.fromEntries(out);
274
304
  }
@@ -282,6 +312,34 @@ function _parse(content, options, wins) {
282
312
  const _automatic = new Map();
283
313
  let _loadingAutomatically = false;
284
314
 
315
+ /**
316
+ * Variables the automatic load never takes from a `.env` file: they run
317
+ * code in the programs started afterwards, or turn off TLS checks. A `.env`
318
+ * in a cloned repository is not to be trusted with those.
319
+ */
320
+ const _DANGEROUS = new Set([
321
+ "NODE_OPTIONS", "NODE_TLS_REJECT_UNAUTHORIZED", "NODE_EXTRA_CA_CERTS", "NODE_PATH", "NODE_REDIRECT_WARNINGS", "NODE_REPL_EXTERNAL_MODULE",
322
+ "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT",
323
+ "NODE_USE_ENV_PROXY",
324
+ "GIT_SSH_COMMAND", "GIT_SSH", "GIT_ASKPASS", "GIT_EXEC_PATH", "GIT_CONFIG", "GIT_PROXY_COMMAND", "SSH_ASKPASS",
325
+ "GIT_EXTERNAL_DIFF", "GIT_TEMPLATE_DIR", "GIT_PAGER", "GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "PAGER", "EDITOR", "VISUAL",
326
+ "PATH", "PATHEXT", "ComSpec", "COMSPEC", "BASH_ENV", "PROMPT_COMMAND",
327
+ "PERL5OPT", "PYTHONSTARTUP", "PYTHONPATH", "RUBYOPT", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS",
328
+ ]);
329
+ const _DANGEROUS_UPPER = new Set([..._DANGEROUS].map((name) => name.toUpperCase()));
330
+
331
+ /**
332
+ * @param {string} key
333
+ * @returns {boolean}
334
+ */
335
+ const _dangerous = (key) => {
336
+ // Windows reads Path and PATH as one variable.
337
+ const name = process.platform === "win32" ? key.toUpperCase() : key;
338
+ return (process.platform === "win32" ? _DANGEROUS_UPPER : _DANGEROUS).has(name) || name.startsWith("DYLD_") || name.startsWith("GIT_CONFIG_");
339
+ };
340
+
341
+ const _WARNED = Symbol.for("@ix-xs/node-comfort/env-dangerous-warned");
342
+
285
343
  /**
286
344
  * The files that go with a `.env` file, most specific first.
287
345
  * @param {string} file
@@ -296,6 +354,20 @@ const _cascade = (file, mode) => [`${file}.${mode}.local`, ...(mode === "test" ?
296
354
  * `override`. The `.env` loaded by itself at startup doesn't count as
297
355
  * already set: `.env.local` wins over it, as you'd expect.
298
356
  *
357
+ * The load at startup leaves out the variables that can run code or weaken
358
+ * TLS, and says so on stderr: `NODE_OPTIONS`, `NODE_TLS_REJECT_UNAUTHORIZED`,
359
+ * `NODE_EXTRA_CA_CERTS`, `NODE_PATH`, `NODE_REDIRECT_WARNINGS`,
360
+ * `NODE_REPL_EXTERNAL_MODULE`, `NODE_USE_ENV_PROXY`, `LD_PRELOAD`,
361
+ * `LD_LIBRARY_PATH`, `LD_AUDIT`,
362
+ * `DYLD_*`, `GIT_SSH_COMMAND`, `GIT_SSH`, `GIT_ASKPASS`, `GIT_EXEC_PATH`,
363
+ * `GIT_CONFIG*`, `GIT_PROXY_COMMAND`, `GIT_EXTERNAL_DIFF`, `GIT_TEMPLATE_DIR`,
364
+ * `GIT_PAGER`, `GIT_EDITOR`, `GIT_SEQUENCE_EDITOR`, `PAGER`, `EDITOR`,
365
+ * `VISUAL`, `SSH_ASKPASS`, `PATH`, `PATHEXT`,
366
+ * `ComSpec`, `BASH_ENV`, `PROMPT_COMMAND`, `PERL5OPT`,
367
+ * `PYTHONSTARTUP`, `PYTHONPATH`, `RUBYOPT`, `JAVA_TOOL_OPTIONS` and
368
+ * `_JAVA_OPTIONS`. A call to `load()` loads them all: don't make one on a
369
+ * file you don't trust, such as the `.env` of a repository someone sent you.
370
+ *
299
371
  * @example
300
372
  * env.load(); // ./.env
301
373
  * env.load([".env.local", ".env"]); // local values win
@@ -344,13 +416,23 @@ function load(files = ".env", options = {}) {
344
416
  }
345
417
  }
346
418
  }
419
+ /** @type {string[]} */
420
+ const skipped = [];
347
421
  for (const [key, value] of loaded) {
348
422
  if (kept(key) !== undefined) continue;
423
+ if (own && _loadingAutomatically && _dangerous(key)) {
424
+ skipped.push(key);
425
+ continue;
426
+ }
349
427
  target[key] = value;
350
428
  if (!own) continue;
351
429
  if (_loadingAutomatically) _automatic.set(key, value);
352
430
  else _automatic.delete(key);
353
431
  }
432
+ if (skipped.length && !(/** @type {any} */ (process)[_WARNED])) {
433
+ /** @type {any} */ (process)[_WARNED] = true;
434
+ process.stderr.write(`[@ix-xs/node-comfort] Left out of the automatic .env load, as they can run code or weaken TLS: ${skipped.join(", ")}. Call env.load() to load them on purpose.\n`);
435
+ }
354
436
  return Object.fromEntries(loaded);
355
437
  }
356
438