@gesslar/toolkit 5.6.0 → 5.7.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/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "gesslar",
6
6
  "url": "https://gesslar.dev"
7
7
  },
8
- "version": "5.6.0",
8
+ "version": "5.7.0",
9
9
  "license": "0BSD",
10
10
  "homepage": "https://github.com/gesslar/toolkit#readme",
11
11
  "repository": {
@@ -27,6 +27,77 @@ const fdType = Object.freeze(
27
27
  await Collection.allocateObject(upperFdTypes, fdTypes)
28
28
  )
29
29
 
30
+ // Characters that are illegal in filenames on common operating systems.
31
+ // Windows is the strictest, forbidding < > : " / \ | ? * along with the
32
+ // control characters (0x00-0x1F); POSIX is a subset of these. Matching the
33
+ // strictest set keeps sanitized names portable everywhere.
34
+ const illegalFilenameChars = /[<>:"/\\|?*\u0000-\u001F]/
35
+ const illegalFilenameCharsGlobal = /[<>:"/\\|?*\u0000-\u001F]/g
36
+
37
+ // Windows trims trailing dots and spaces, silently changing the name, so a
38
+ // portable filename must not end with either. This also rejects the relative
39
+ // path indicators "." and "..", since both end with a dot. A name ends with a
40
+ // run of these characters exactly when its final character is one, so a
41
+ // single-character class (no `+`) detects the condition without the
42
+ // super-linear backtracking that an anchored `/[. ]+$/` exhibits on
43
+ // adversarial input. Stripping the whole run is handled by
44
+ // stripTrailingDotsSpaces, a linear scan, for the same reason.
45
+ const trailingDotOrSpace = /[. ]$/
46
+
47
+ // Device names reserved by Windows, illegal as a filename with or without an
48
+ // extension (e.g. "CON", "con.txt", "LPT1"). The match is case-insensitive.
49
+ const reservedFilenames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
50
+
51
+ // Maximum length, in bytes, of a single path component. ext4, APFS, NTFS and
52
+ // exFAT all cap a name at 255 bytes — note bytes, not characters, so a single
53
+ // multi-byte UTF-8 codepoint counts for more than one against the budget.
54
+ const maxFilenameBytes = 255
55
+
56
+ /**
57
+ * Truncate a string to at most `maxBytes` UTF-8 bytes without splitting a
58
+ * multi-byte codepoint. When the byte limit lands in the middle of a
59
+ * character, that whole character is dropped rather than left half-encoded.
60
+ *
61
+ * @private
62
+ * @param {string} str - The string to truncate
63
+ * @param {number} maxBytes - The maximum length in UTF-8 bytes
64
+ * @returns {string} The truncated string, never exceeding `maxBytes` bytes
65
+ */
66
+ function truncateToBytes(str, maxBytes) {
67
+ const buf = Buffer.from(str, "utf8")
68
+
69
+ if(buf.length <= maxBytes)
70
+ return str
71
+
72
+ // Back up over UTF-8 continuation bytes (0b10xxxxxx) so the cut never lands
73
+ // inside a multi-byte sequence, dropping the straddling character entirely.
74
+ let end = maxBytes
75
+
76
+ while(end > 0 && (buf[end] & 0xC0) === 0x80)
77
+ end--
78
+
79
+ return buf.subarray(0, end).toString("utf8")
80
+ }
81
+
82
+ /**
83
+ * Strip any trailing dots and spaces from a string. Windows trims these
84
+ * silently, so a portable name must not end with them. Implemented as a linear
85
+ * scan rather than an anchored `/[. ]+$/` replace, which can backtrack
86
+ * super-linearly on adversarial input.
87
+ *
88
+ * @private
89
+ * @param {string} str - The string to trim
90
+ * @returns {string} The string with any trailing dots and spaces removed
91
+ */
92
+ function stripTrailingDotsSpaces(str) {
93
+ let end = str.length
94
+
95
+ while(end > 0 && (str[end - 1] === "." || str[end - 1] === " "))
96
+ end--
97
+
98
+ return str.slice(0, end)
99
+ }
100
+
30
101
  /**
31
102
  * File system utility class for path operations and file discovery.
32
103
  */
@@ -414,6 +485,139 @@ export default class FileSystem {
414
485
  return path.parse(pathName)
415
486
  }
416
487
 
488
+ /**
489
+ * Determine whether a string is safe to use as a filename on every common
490
+ * operating system.
491
+ *
492
+ * A name is sane only when it would be legal everywhere, so the checks span
493
+ * the union of platform rules rather than any single OS:
494
+ *
495
+ * - No characters that are illegal on common filesystems. Windows is the
496
+ * strictest, forbidding `< > : " / \ | ? *` and the control characters
497
+ * (0x00-0x1F); POSIX is a subset of these.
498
+ * - No trailing dot or space (Windows silently trims them).
499
+ * - Not a Windows reserved device name, with or without an extension
500
+ * (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`).
501
+ * - Not the relative path indicators `.` or `..`.
502
+ * - No longer than 255 bytes — the per-component limit on ext4, APFS, NTFS
503
+ * and exFAT. Length is counted in UTF-8 bytes, not characters, so a single
504
+ * multi-byte codepoint costs more than one toward the limit.
505
+ *
506
+ * @static
507
+ * @param {string} str - The candidate filename to test
508
+ * @returns {boolean} True if the string is a legal filename on every common OS
509
+ * @throws {Sass} If str is not a non-empty string
510
+ * @example
511
+ * FS.sane("report.txt") // true
512
+ * FS.sane("a/b:c.txt") // false (illegal character)
513
+ * FS.sane("name ") // false (trailing space)
514
+ * FS.sane("CON") // false (reserved on Windows)
515
+ * FS.sane("x".repeat(256)) // false (exceeds 255 bytes)
516
+ */
517
+ static sane(str) {
518
+ Valid.type(str, "String", {allowEmpty: false})
519
+
520
+ return !illegalFilenameChars.test(str)
521
+ && !trailingDotOrSpace.test(str)
522
+ && !reservedFilenames.test(str)
523
+ && Buffer.byteLength(str) <= maxFilenameBytes
524
+ }
525
+
526
+ /**
527
+ * Rewrite a string into a filename that is legal on every common operating
528
+ * system.
529
+ *
530
+ * Applies the union of platform rules (see {@link FileSystem.sane}) so the
531
+ * result is portable regardless of where it is used:
532
+ *
533
+ * - Every character illegal on common filesystems is replaced with
534
+ * `replacement` (defaults to an underscore).
535
+ * - Trailing dots and spaces are stripped (Windows trims them anyway).
536
+ * - Windows reserved device names are suffixed with `replacement` so they
537
+ * are no longer reserved (e.g. `CON` becomes `CON_`, `CON.txt` becomes
538
+ * `CON_.txt`).
539
+ * - Names longer than 255 bytes are truncated to fit that limit. The base
540
+ * name is shortened while the extension is preserved where it fits, and
541
+ * truncation never splits a multi-byte UTF-8 codepoint.
542
+ *
543
+ * A custom `replacement` is itself validated: it must contain no illegal
544
+ * characters, otherwise the result could remain unsafe.
545
+ *
546
+ * Note that degenerate inputs can sanitize to an empty string — for example
547
+ * an empty `replacement` applied to a name of only illegal characters, or a
548
+ * relative indicator such as `"."` or `".."` whose trailing dots are
549
+ * stripped. The empty string is not itself a legal filename, so callers that
550
+ * need a guaranteed-usable name should treat an empty result as a signal to
551
+ * fall back to a default of their own.
552
+ *
553
+ * @static
554
+ * @param {string} str - The filename to sanitize
555
+ * @param {string} [replacement] - The substitute for illegal characters (defaults to "_")
556
+ * @returns {string} A filename legal on every common OS, or "" when the input sanitizes to nothing
557
+ * @throws {Sass} If str is not a non-empty string
558
+ * @throws {Sass} If replacement is not a string, or itself contains OS-illegal characters
559
+ * @example
560
+ * FS.sanitize("a/b:c.txt") // "a_b_c.txt"
561
+ * FS.sanitize("a/b:c.txt", "-") // "a-b-c.txt"
562
+ * FS.sanitize("name. ") // "name"
563
+ * FS.sanitize("CON.txt") // "CON_.txt"
564
+ * FS.sanitize("..") // "" (caller should supply a fallback)
565
+ */
566
+ static sanitize(str, replacement="_") {
567
+ Valid.type(str, "String", {allowEmpty: false})
568
+ Valid.type(replacement, "String")
569
+
570
+ Valid.assert(
571
+ !illegalFilenameChars.test(replacement),
572
+ `replacement must not contain OS-illegal characters, got: ${replacement}`
573
+ )
574
+
575
+ // Swap illegal characters, then drop trailing dots/spaces that Windows
576
+ // would silently strip.
577
+ const cleaned = stripTrailingDotsSpaces(
578
+ str.replace(illegalFilenameCharsGlobal, replacement)
579
+ )
580
+
581
+ // Defuse Windows reserved device names by suffixing the reserved portion,
582
+ // preserving any extension (e.g. "CON" -> "CON_"). A replacement that
583
+ // cannot actually change the name -- an empty string, or dots/spaces that
584
+ // get stripped straight back off -- leaves it reserved; like any other
585
+ // input that cannot be made safe, fall back to the empty string.
586
+ let defused = cleaned
587
+
588
+ if(reservedFilenames.test(cleaned)) {
589
+ defused = stripTrailingDotsSpaces(
590
+ cleaned.replace(/^([^.]*)/, `$1${replacement}`)
591
+ )
592
+
593
+ if(reservedFilenames.test(defused))
594
+ return ""
595
+ }
596
+
597
+ // Enforce the 255-byte component limit. Truncate the base name while
598
+ // keeping the extension where it fits; a cut can re-expose a trailing dot
599
+ // or space, so strip those again before reattaching the extension.
600
+ if(Buffer.byteLength(defused) <= maxFilenameBytes)
601
+ return defused
602
+
603
+ const dot = defused.lastIndexOf(".")
604
+ const ext = dot > 0 ? defused.slice(dot) : ""
605
+ const extBytes = Buffer.byteLength(ext)
606
+
607
+ // An extension that alone blows the budget cannot be preserved; truncate
608
+ // the whole name instead. The cut can land on a dot or space, so strip any
609
+ // the same way the base-truncation path below does.
610
+ if(extBytes >= maxFilenameBytes)
611
+ return stripTrailingDotsSpaces(truncateToBytes(defused, maxFilenameBytes))
612
+
613
+ const base = dot > 0 ? defused.slice(0, dot) : defused
614
+ const truncatedBase = stripTrailingDotsSpaces(
615
+ truncateToBytes(base, maxFilenameBytes - extBytes)
616
+ )
617
+
618
+ return truncatedBase + ext
619
+ }
620
+
417
621
  /**
418
622
  * Returns the current working directory as a string.
419
623
  *
@@ -180,6 +180,77 @@ export default class FileSystem {
180
180
  */
181
181
  name: string;
182
182
  };
183
+ /**
184
+ * Determine whether a string is safe to use as a filename on every common
185
+ * operating system.
186
+ *
187
+ * A name is sane only when it would be legal everywhere, so the checks span
188
+ * the union of platform rules rather than any single OS:
189
+ *
190
+ * - No characters that are illegal on common filesystems. Windows is the
191
+ * strictest, forbidding `< > : " / \ | ? *` and the control characters
192
+ * (0x00-0x1F); POSIX is a subset of these.
193
+ * - No trailing dot or space (Windows silently trims them).
194
+ * - Not a Windows reserved device name, with or without an extension
195
+ * (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`).
196
+ * - Not the relative path indicators `.` or `..`.
197
+ * - No longer than 255 bytes — the per-component limit on ext4, APFS, NTFS
198
+ * and exFAT. Length is counted in UTF-8 bytes, not characters, so a single
199
+ * multi-byte codepoint costs more than one toward the limit.
200
+ *
201
+ * @static
202
+ * @param {string} str - The candidate filename to test
203
+ * @returns {boolean} True if the string is a legal filename on every common OS
204
+ * @throws {Sass} If str is not a non-empty string
205
+ * @example
206
+ * FS.sane("report.txt") // true
207
+ * FS.sane("a/b:c.txt") // false (illegal character)
208
+ * FS.sane("name ") // false (trailing space)
209
+ * FS.sane("CON") // false (reserved on Windows)
210
+ * FS.sane("x".repeat(256)) // false (exceeds 255 bytes)
211
+ */
212
+ static sane(str: string): boolean;
213
+ /**
214
+ * Rewrite a string into a filename that is legal on every common operating
215
+ * system.
216
+ *
217
+ * Applies the union of platform rules (see {@link FileSystem.sane}) so the
218
+ * result is portable regardless of where it is used:
219
+ *
220
+ * - Every character illegal on common filesystems is replaced with
221
+ * `replacement` (defaults to an underscore).
222
+ * - Trailing dots and spaces are stripped (Windows trims them anyway).
223
+ * - Windows reserved device names are suffixed with `replacement` so they
224
+ * are no longer reserved (e.g. `CON` becomes `CON_`, `CON.txt` becomes
225
+ * `CON_.txt`).
226
+ * - Names longer than 255 bytes are truncated to fit that limit. The base
227
+ * name is shortened while the extension is preserved where it fits, and
228
+ * truncation never splits a multi-byte UTF-8 codepoint.
229
+ *
230
+ * A custom `replacement` is itself validated: it must contain no illegal
231
+ * characters, otherwise the result could remain unsafe.
232
+ *
233
+ * Note that degenerate inputs can sanitize to an empty string — for example
234
+ * an empty `replacement` applied to a name of only illegal characters, or a
235
+ * relative indicator such as `"."` or `".."` whose trailing dots are
236
+ * stripped. The empty string is not itself a legal filename, so callers that
237
+ * need a guaranteed-usable name should treat an empty result as a signal to
238
+ * fall back to a default of their own.
239
+ *
240
+ * @static
241
+ * @param {string} str - The filename to sanitize
242
+ * @param {string} [replacement] - The substitute for illegal characters (defaults to "_")
243
+ * @returns {string} A filename legal on every common OS, or "" when the input sanitizes to nothing
244
+ * @throws {Sass} If str is not a non-empty string
245
+ * @throws {Sass} If replacement is not a string, or itself contains OS-illegal characters
246
+ * @example
247
+ * FS.sanitize("a/b:c.txt") // "a_b_c.txt"
248
+ * FS.sanitize("a/b:c.txt", "-") // "a-b-c.txt"
249
+ * FS.sanitize("name. ") // "name"
250
+ * FS.sanitize("CON.txt") // "CON_.txt"
251
+ * FS.sanitize("..") // "" (caller should supply a fallback)
252
+ */
253
+ static sanitize(str: string, replacement?: string): string;
183
254
  /**
184
255
  * Returns the current working directory as a string.
185
256
  *
@@ -1 +1 @@
1
- {"version":3,"file":"FileSystem.d.ts","sourceRoot":"","sources":["../../../src/node/lib/FileSystem.js"],"names":[],"mappings":"AA6BA;;GAEG;AACH;IACE,kCAAwB;IACxB,uCAAkC;IAClC,mBAAsB;IAiEtB;;;;;;OAMG;IACH,4BAHW,MAAM,GACJ,MAAM,CAIlB;IAED;;;;;;OAMG;IACH,2BAHW,MAAM,GACJ,MAAM,CAQlB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,GACJ,MAAM,CAUlB;IAED;;;;;;;;;;OAUG;IACH,gCAJW,OAAO,iBAAiB,EAAE,OAAO,GAAC,OAAO,sBAAsB,EAAE,OAAO,MACxE,OAAO,iBAAiB,EAAE,OAAO,GAAC,OAAO,sBAAsB,EAAE,OAAO,GACtE,MAAM,CAYlB;IAED;;;;;;;;;;OAUG;IACH,oCAJW,MAAM,MACN,MAAM,GACJ,MAAM,CAQlB;IAED;;;;;;;;;OASG;IACH,oCALW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CA8BlB;IAED;;;;;;;;OAQG;IACH,6BAJW,MAAM,UACN,MAAM,GACJ,MAAM,CAmClB;IAED;;;;;;;;;;;;OAYG;IACH,+BATW,MAAM,aACN,MAAM,GACJ,OAAO,CAcnB;IAED;;;;;;;;;;;;;;OAcG;IACH,iCATW,MAAM,MACN,MAAM,QACN,MAAM,GACJ,MAAM,GAAC,IAAI,CAwBvB;IAED;;;;;;;;;;;;;;OAcG;IACH,4BARW,MAAM,MACN,MAAM,GACJ,MAAM,CAYlB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,+BAXW,MAAM,MACN,MAAM,QACN,MAAM,GACJ,MAAM,GAAC,IAAI,CA+BvB;IAED;;;;;;;OAOG;IAEH;;;;;;;OAOG;IACH,2BAJW,MAAM;;;;cAXH,MAAM;;;;aACN,MAAM;;;;aACN,MAAM;;;;cACN,MAAM;;;;cACN,MAAM;MAenB;IAED;;;;OAIG;IACH,kBAFa,MAAM,CAIlB;IAhYD;;;;;;;;;OASG;IACH,kCAJW,OAAO,iBAAiB,EAAE,OAAO,GAAC,OAAO,sBAAsB,EAAE,OAAO,GACtE,MAAM,CAWlB;IAED;;;;;;;;OAQG;IACH,gBALG;QAA2B,QAAQ;QACV,UAAU,GAA3B,MAAM;QACY,UAAU,GAA5B,OAAO;KACf,GAAU,OAAO,CAAC,SAAS,CAAC,CAwB9B;IAED;;OAEG;IACH,qBAGC;;CAsUF"}
1
+ {"version":3,"file":"FileSystem.d.ts","sourceRoot":"","sources":["../../../src/node/lib/FileSystem.js"],"names":[],"mappings":"AAoGA;;GAEG;AACH;IACE,kCAAwB;IACxB,uCAAkC;IAClC,mBAAsB;IAiEtB;;;;;;OAMG;IACH,4BAHW,MAAM,GACJ,MAAM,CAIlB;IAED;;;;;;OAMG;IACH,2BAHW,MAAM,GACJ,MAAM,CAQlB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,GACJ,MAAM,CAUlB;IAED;;;;;;;;;;OAUG;IACH,gCAJW,OAAO,iBAAiB,EAAE,OAAO,GAAC,OAAO,sBAAsB,EAAE,OAAO,MACxE,OAAO,iBAAiB,EAAE,OAAO,GAAC,OAAO,sBAAsB,EAAE,OAAO,GACtE,MAAM,CAYlB;IAED;;;;;;;;;;OAUG;IACH,oCAJW,MAAM,MACN,MAAM,GACJ,MAAM,CAQlB;IAED;;;;;;;;;OASG;IACH,oCALW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CA8BlB;IAED;;;;;;;;OAQG;IACH,6BAJW,MAAM,UACN,MAAM,GACJ,MAAM,CAmClB;IAED;;;;;;;;;;;;OAYG;IACH,+BATW,MAAM,aACN,MAAM,GACJ,OAAO,CAcnB;IAED;;;;;;;;;;;;;;OAcG;IACH,iCATW,MAAM,MACN,MAAM,QACN,MAAM,GACJ,MAAM,GAAC,IAAI,CAwBvB;IAED;;;;;;;;;;;;;;OAcG;IACH,4BARW,MAAM,MACN,MAAM,GACJ,MAAM,CAYlB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,+BAXW,MAAM,MACN,MAAM,QACN,MAAM,GACJ,MAAM,GAAC,IAAI,CA+BvB;IAED;;;;;;;OAOG;IAEH;;;;;;;OAOG;IACH,2BAJW,MAAM;;;;cAXH,MAAM;;;;aACN,MAAM;;;;aACN,MAAM;;;;cACN,MAAM;;;;cACN,MAAM;MAenB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,iBAVW,MAAM,GACJ,OAAO,CAgBnB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,qBAZW,MAAM,gBACN,MAAM,GACJ,MAAM,CA+DlB;IAED;;;;OAIG;IACH,kBAFa,MAAM,CAIlB;IArgBD;;;;;;;;;OASG;IACH,kCAJW,OAAO,iBAAiB,EAAE,OAAO,GAAC,OAAO,sBAAsB,EAAE,OAAO,GACtE,MAAM,CAWlB;IAED;;;;;;;;OAQG;IACH,gBALG;QAA2B,QAAQ;QACV,UAAU,GAA3B,MAAM;QACY,UAAU,GAA5B,OAAO;KACf,GAAU,OAAO,CAAC,SAAS,CAAC,CAwB9B;IAED;;OAEG;IACH,qBAGC;;CA2cF"}
@@ -1,4 +1,4 @@
1
- // @gesslar/toolkit v5.6.0 - ES module bundle
1
+ // @gesslar/toolkit v5.7.0 - ES module bundle
2
2
  /**
3
3
  * @file Tantrum.js
4
4
  *
@@ -1,4 +1,4 @@
1
- // @gesslar/toolkit v5.6.0 - UMD bundle
1
+ // @gesslar/toolkit v5.7.0 - UMD bundle
2
2
  (function (global, factory) {
3
3
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
4
4
  typeof define === 'function' && define.amd ? define(['exports'], factory) :