@helping-ai-workflow/md2doc 3.0.2 → 3.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.
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+ /* UMD, same shape as convert-md.js: `require`-able in node for the unit
3
+ tests, and injected into the editor page as `window.md2docAsset`
4
+ (lib/editor/server.js) for the parts the client UI needs (sanitizeName /
5
+ extFor / uniqueName / relPath). client.js is inlined into the page as a
6
+ plain <script>, not bundled, so a bare top-level require(...) here would
7
+ be an undefined identifier in the browser and take down the whole
8
+ injected script -- see the comment at the top of convert-md.js.
9
+
10
+ isWithin is NODE/SERVER ONLY: the browser side never calls it (there is
11
+ no filesystem on that side to check containment against; only
12
+ lib/editor/server.js, guarding the real write, calls it). Even so it does
13
+ NOT `require('path')` -- per the ruling for this module, every function
14
+ here is plain string arithmetic, so the file stays safe to inject
15
+ unconditionally regardless of which functions a given caller uses. */
16
+ (function (root, factory) {
17
+ if (typeof module === 'object' && module.exports) module.exports = factory();
18
+ else root.md2docAsset = factory();
19
+ })(typeof self !== 'undefined' ? self : this, function () {
20
+
21
+ // The image-drop endpoint accepts exactly these encoded raster formats.
22
+ // image/svg+xml is deliberately NOT here: an SVG file can carry an inline
23
+ // <script>/<foreignObject> payload, and this whitelist is the only gate
24
+ // between an attacker-controlled MIME string and a file written to disk --
25
+ // widening it to vector formats trades a decode convenience for a stored-XSS
26
+ // vector, which is not a trade this endpoint gets to make silently.
27
+ var EXT_FOR_MIME = {
28
+ 'image/png': '.png',
29
+ 'image/jpeg': '.jpg',
30
+ 'image/gif': '.gif',
31
+ 'image/webp': '.webp',
32
+ };
33
+
34
+ // A stem is "reserved" -- unusable, must fall back -- if it is exactly '.',
35
+ // exactly '..', or empty. This check is re-run after EVERY transformation
36
+ // that can change the string, not just once at the top: fix-round-1 found
37
+ // that the extension-stripping slice below can manufacture a fresh '..' out
38
+ // of an input the top-of-function check never saw (`'...'` -> base `'...'`,
39
+ // which is not '.'/'..'/'' so it passes unmodified, then
40
+ // `stem = base.slice(0, base.lastIndexOf('.'))` = `'..'`, returned
41
+ // unchecked). The function whose entire job is rejecting '..' must not be
42
+ // able to manufacture one partway through its own body.
43
+ function isReservedStem(s) {
44
+ return s === '.' || s === '..' || s === '';
45
+ }
46
+
47
+ // The longest extension this module ever attaches (see EXT_FOR_MIME) is
48
+ // '.webp' at 5 bytes; reserving that much headroom out of the 255-byte
49
+ // filename budget guarantees stem + extension never exceeds it, without
50
+ // this function needing to know which mime the caller will pick.
51
+ var MAX_NAME_BYTES = 255;
52
+ var MAX_EXT_BYTES = Object.keys(EXT_FOR_MIME).reduce(function (m, k) {
53
+ return Math.max(m, EXT_FOR_MIME[k].length);
54
+ }, 0);
55
+ var MAX_STEM_BYTES = MAX_NAME_BYTES - MAX_EXT_BYTES;
56
+
57
+ // UTF-8 byte length of one Unicode CODE POINT (not a UTF-16 code unit).
58
+ function utf8ByteLen(codePoint) {
59
+ if (codePoint <= 0x7f) return 1;
60
+ if (codePoint <= 0x7ff) return 2;
61
+ if (codePoint <= 0xffff) return 3;
62
+ return 4;
63
+ }
64
+
65
+ // Truncates `s` to at most `maxBytes` UTF-8 bytes without splitting a
66
+ // Unicode code point (iterates via Array.from, which walks code points, not
67
+ // UTF-16 code units -- load-bearing for CJK/astral characters, both
68
+ // realistic in a pasted screenshot filename). No Buffer / TextEncoder
69
+ // dependency, so this stays safe to run in the browser build of this
70
+ // module too, same as everything else in this file.
71
+ function truncateUtf8(s, maxBytes) {
72
+ var out = '';
73
+ var used = 0;
74
+ var chars = Array.from(s);
75
+ for (var i = 0; i < chars.length; i++) {
76
+ var len = utf8ByteLen(chars[i].codePointAt(0));
77
+ if (used + len > maxBytes) break;
78
+ out += chars[i];
79
+ used += len;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // Only the last path segment survives, and only its stem (the caller
85
+ // re-attaches whatever extFor(mime) decides -- see extFor below -- so
86
+ // whatever extension the ORIGINAL name carried, spoofed or not, is
87
+ // discarded here, not preserved).
88
+ //
89
+ // Three separator families are stripped, not just '/':
90
+ // '/' -- the traversal vector this endpoint obviously has to defend
91
+ // ('../../etc/passwd', 'a/b.png').
92
+ // '\\' -- Windows is a supported target for this tool even though this
93
+ // process may run on Linux; 'C:\Windows\x.png' must not survive
94
+ // as a literal filename component either.
95
+ // ':' -- NTFS Alternate Data Stream syntax ('x.png:evil' writes into a
96
+ // stream named 'evil' *on* the real file 'x.png') and the drive
97
+ // letter ('C:'). Both are neutralized by taking only what
98
+ // precedes the first colon found in the segment that already
99
+ // survived the '/' and '\\' split.
100
+ //
101
+ // The result is also capped to MAX_STEM_BYTES (UTF-8 bytes), truncating
102
+ // only the stem -- there is no extension left to truncate at this point,
103
+ // it has already been stripped -- so a long pasted-screenshot filename
104
+ // cannot reach fs.writeFileSync and fail with ENAMETOOLONG.
105
+ function sanitizeName(filename) {
106
+ var s = String(filename === null || filename === undefined ? '' : filename);
107
+ s = s.split('\\').join('/');
108
+ var parts = s.split('/');
109
+ var base = parts[parts.length - 1];
110
+ var colonIdx = base.indexOf(':');
111
+ if (colonIdx !== -1) base = base.slice(0, colonIdx);
112
+ // '..' and '.' are meaningful only as path SEGMENTS; once nothing but the
113
+ // last segment is left, they carry no traversal power any more, but they
114
+ // are still not usable file stems, so map them (and the empty string, the
115
+ // "no filename at all" case) onto one defined fallback.
116
+ if (isReservedStem(base)) base = 'file';
117
+ // Strip the extension: the last '.' in the segment, as long as it is not
118
+ // the segment's very first character (that makes '.png' a dotfile stem,
119
+ // not an extension marker with nothing in front of it -- still a defined,
120
+ // deterministic result, just not a stripped one).
121
+ var dot = base.lastIndexOf('.');
122
+ var stem = dot > 0 ? base.slice(0, dot) : base;
123
+ // Re-validate: this slice can manufacture '.', '..' or '' out of an
124
+ // all-dots (or dot-prefixed) `base` that the check above never caught.
125
+ if (isReservedStem(stem)) stem = 'file';
126
+ stem = truncateUtf8(stem, MAX_STEM_BYTES);
127
+ // Re-validate again: belt-and-suspenders against a future MAX_STEM_BYTES
128
+ // shrinking below 2 and cutting a legitimate '..foo'-shaped stem down to
129
+ // a bare '..'. Not reachable at the current 250-byte budget, but the
130
+ // point of this whole fix round is that "not reachable today" is not the
131
+ // same guarantee as "provably never produces '.'/'..'/''".
132
+ if (isReservedStem(stem)) stem = 'file';
133
+ return stem;
134
+ }
135
+
136
+ // mime -> extension, or null when it is outside EXT_FOR_MIME. Never guesses
137
+ // from the filename -- that is the whole point of gating on the whitelist.
138
+ function extFor(mime) {
139
+ var key = String(mime === null || mime === undefined ? '' : mime);
140
+ return Object.prototype.hasOwnProperty.call(EXT_FOR_MIME, key) ? EXT_FOR_MIME[key] : null;
141
+ }
142
+
143
+ // CONTRACT (fix-round-1 ruling): `base` is a FULL FILENAME, extension
144
+ // included (e.g. 'a.png'), and `existing` is a raw directory listing of the
145
+ // same shape (e.g. fs.readdir()'s output -- also full filenames). The
146
+ // integration task composes the candidate as
147
+ // `uniqueName(sanitizeName(name) + extFor(mime), existing)` BEFORE calling
148
+ // this function, not `uniqueName(stem, existing)` with the extension
149
+ // appended after: a bare stem checked against an extension-bearing listing
150
+ // silently defeats collision detection ('x' !== 'x.png' as far as
151
+ // indexOf() is concerned, even though 'x.png' really is taken). This
152
+ // function's own contract does not change to accommodate that -- it stays
153
+ // the plain, already-tested extension-aware suffixer pinned by
154
+ // uniqueName('a.png', ['a.png','a-2.png']) -> 'a-3.png'; only the caller's
155
+ // composition order does.
156
+ //
157
+ // Appends '-2', '-3', ... immediately before the extension until `base`
158
+ // (or a suffixed variant of it) is absent from `existing`.
159
+ function uniqueName(base, existing) {
160
+ var list = Array.isArray(existing) ? existing : [];
161
+ var s = String(base === null || base === undefined ? '' : base);
162
+ var dot = s.lastIndexOf('.');
163
+ var stem = dot > 0 ? s.slice(0, dot) : s;
164
+ var ext = dot > 0 ? s.slice(dot) : '';
165
+ if (list.indexOf(s) === -1) return s;
166
+ var n = 2;
167
+ for (;;) {
168
+ var candidate = stem + '-' + n + ext;
169
+ if (list.indexOf(candidate) === -1) return candidate;
170
+ n += 1;
171
+ }
172
+ }
173
+
174
+ // Pure formatter: the caller is expected to have already run `name` through
175
+ // sanitizeName / extFor / uniqueName. relPath performs no validation of its
176
+ // own -- it is not the safety boundary; sanitizeName and isWithin are.
177
+ function relPath(name) {
178
+ return 'assets/' + String(name === null || name === undefined ? '' : name);
179
+ }
180
+
181
+ // POSIX-style '.'/'..' resolution over a '/'-joined string, without pulling
182
+ // in node's `path` module (see the file-header comment for why). Good
183
+ // enough for isWithin's job: `dir` and `target` are both server-side
184
+ // absolute filesystem paths built by lib/editor/server.js, not user input
185
+ // carrying Windows separators -- sanitizeName is what stands between user
186
+ // input and either argument here.
187
+ function normalizeSegments(p) {
188
+ var s = String(p === null || p === undefined ? '' : p);
189
+ var abs = s.indexOf('/') === 0;
190
+ var parts = s.split('/');
191
+ var out = [];
192
+ for (var i = 0; i < parts.length; i++) {
193
+ var seg = parts[i];
194
+ if (seg === '' || seg === '.') continue;
195
+ if (seg === '..') {
196
+ if (out.length && out[out.length - 1] !== '..') out.pop();
197
+ else if (!abs) out.push('..');
198
+ // an absolute path's '..' past the root has nowhere to go; drop it
199
+ continue;
200
+ }
201
+ out.push(seg);
202
+ }
203
+ return (abs ? '/' : '') + out.join('/');
204
+ }
205
+
206
+ // true iff `target`, once '.'/'..' is resolved away, is a strict descendant
207
+ // of `dir` (also resolved). This is the third and final boundary: even if
208
+ // sanitizeName's basename-only contract were somehow bypassed upstream, a
209
+ // resolved target that escapes `dir` -- or merely shares its string prefix
210
+ // without a path separator between them ('/x' vs '/xy/...') -- is refused.
211
+ // `target === dir` is also refused: every real caller appends a filename to
212
+ // `dir` before checking, so equality is never the legitimate case, and
213
+ // accepting it would just be a hole with no corresponding valid use.
214
+ //
215
+ // CONTRACT (fix-round-1 ruling, binding): `dir` and `target` MUST already
216
+ // be '/'-normalized POSIX paths when passed in. This function does not
217
+ // understand '\\' as a separator -- ruled POSIX-only on purpose, so this
218
+ // file keeps avoiding node's `path` module; normalizing a native-Windows
219
+ // path to '/' before calling this is the CALLER's job (the integration
220
+ // task's server-side boundary, not this module). A '\\'-separated `target`
221
+ // is not silently accepted as "within" -- normalizeSegments treats the
222
+ // whole backslash-joined string as one path segment, so it can never match
223
+ // the '/'-joined `dir` prefix, and isWithin FAILS CLOSED (returns false)
224
+ // rather than wrongly permitting a traversal. Confirmed by the fix-round-1
225
+ // review: this is a functional bug on native Windows (a legitimate target
226
+ // gets refused), not a security hole.
227
+ function isWithin(dir, target) {
228
+ var d = normalizeSegments(dir);
229
+ var t = normalizeSegments(target);
230
+ if (t === d) return false;
231
+ return t.indexOf(d + '/') === 0;
232
+ }
233
+
234
+ return { sanitizeName: sanitizeName, extFor: extFor, uniqueName: uniqueName, relPath: relPath, isWithin: isWithin };
235
+ });