laravel-workers 0.4.0 → 0.4.1

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.
@@ -103,4 +103,4 @@ export async function decryptString(payloadBase64, keyBase64) {
103
103
  }
104
104
 
105
105
  // Convenience sync-ish wrappers not possible (crypto.subtle is async). Export async only.
106
- export default { encryptString, decryptString, timingSafeEqual };
106
+ export default { encryptString, decryptString, timingSafeEqual };
package/kernel/hash.mjs CHANGED
@@ -1,113 +1,113 @@
1
- // kernel/hash.mjs � Hash (bcrypt)
2
- // Uses bcryptjs (pure JS, no native).
3
- import bcrypt from 'bcryptjs';
4
-
5
- // constant-time string/Uint8Array compare � does not early-return on first mismatch
6
- export function timingSafeEqual(a, b) {
7
- const toBytes = (v) => {
8
- if (v instanceof Uint8Array) return v;
9
- if (typeof v === 'string') return new TextEncoder().encode(v);
10
- if (typeof Buffer !== 'undefined' && v && Buffer.isBuffer(v)) return new Uint8Array(v);
11
- return new TextEncoder().encode(String(v));
12
- };
13
- const aBytes = toBytes(a);
14
- const bBytes = toBytes(b);
15
- if (aBytes.length !== bBytes.length) return false;
16
- try {
17
- const f = globalThis.crypto && globalThis.crypto.timingSafeEqual;
18
- if (typeof f === 'function' && typeof Buffer !== 'undefined') {
19
- return f(Buffer.from(aBytes), Buffer.from(bBytes));
20
- }
21
- } catch {}
22
- let diff = 0;
23
- for (let i = 0; i < aBytes.length; i++) diff |= aBytes[i] ^ bBytes[i];
24
- return diff === 0;
25
- }
26
-
27
- export async function make(value, { rounds = 10 } = {}) {
28
- const salt = await bcrypt.genSalt(rounds);
29
- const hash = await bcrypt.hash(String(value), salt);
30
- // PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
31
- // PHP prefix (like makeSync below) so password_get_info()-shaped checks
32
- // (isHashed, verifyConfiguration, needsRehash) treat our own hashes as
33
- // bcrypt - exactly like BcryptHasher.
34
- return hash.replace(/^\$2[abxy]\$/, '$2y$');
35
- }
36
-
37
- // Synchronous bcrypt for Eloquent's `hashed` cast. Laravel's
38
- // castAttributeAsHashedString() runs Hash::make() inside setAttribute(), which is
39
- // synchronous in PHP, so the compiled Model#set() must hash before returning.
40
- // bcryptjs' hashSync costs the same CPU as the async form (measured in
41
- // kernel/tests/budget.test.mjs); callers on the free plan should expect the
42
- // configured cost (12 by default) per filled password.
43
- export function makeSync(value, { rounds = 12 } = {}) {
44
- const salt = bcrypt.genSaltSync(Number(rounds));
45
- const hash = bcrypt.hashSync(String(value), salt);
46
- // PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
47
- // PHP prefix so password_get_info()-shaped checks (verifyConfiguration,
48
- // needsRehash) treat our own hashes as bcrypt, exactly like BcryptHasher.
49
- return hash.replace(/^\$2[abxy]\$/, '$2y$');
50
- }
51
-
52
- // PHP 8.3 password_get_info() as measured against the vendor oracle: only a
53
- // full `$2y$NN$` + 53-char digest is bcrypt; `$2a$`/`$2b$` and truncated
54
- // strings report algo null; any `$argon2(id|i|d)$` prefix is argon2.
55
- const BCRYPT_2Y_60_RE = /^\$2y\$(\d{2})\$[./A-Za-z0-9]{53}$/;
56
- const ARGON2_PREFIX_RE = /^\$argon2(id|i|d)\$/;
57
-
58
- export function passwordInfo(value) {
59
- if (typeof value !== 'string') return { algo: null, algoName: 'unknown', options: [] };
60
- const m = BCRYPT_2Y_60_RE.exec(value);
61
- if (m) {
62
- return { algo: '2y', algoName: 'bcrypt', options: { cost: parseInt(m[1], 10) } };
63
- }
64
- const a = ARGON2_PREFIX_RE.exec(value);
65
- if (a) {
66
- return { algo: 'argon2' + a[1], algoName: 'argon2' + a[1], options: [] };
67
- }
68
- return { algo: null, algoName: 'unknown', options: [] };
69
- }
70
-
71
- // Laravel 13.37 HashManager::isHashed(): `driver()->info($value)['algo'] !== null`.
72
- // The `hashed` cast must use this exact check (a `$2a$` digest is re-hashed by
73
- // Laravel on PHP 8.3, while `$2y$` passes through).
74
- export function isHashedDigest(value) {
75
- return passwordInfo(value).algo !== null;
76
- }
77
-
78
- // Laravel 13.37 BcryptHasher::verifyConfiguration():
79
- // isUsingCorrectAlgorithm($value) && isUsingValidOptions($value)
80
- // where PHP's password_get_info() identifies ONLY `$2y$NN$` as bcrypt, and the
81
- // cost must be an integer <= the configured rounds (a lower cost is valid).
82
- export function verifyConfiguration(hashed, { rounds = 12 } = {}) {
83
- const info = passwordInfo(hashed);
84
- if (info.algoName !== 'bcrypt') return false;
85
- return info.options.cost <= Number(rounds);
86
- }
87
-
88
- export async function check(value, hashed) {
89
- if (!hashed || typeof hashed !== 'string') return false;
90
- const ok = await bcrypt.compare(String(value), hashed);
91
- return timingSafeEqual(ok ? '1' : '0', '1');
92
- }
93
-
94
- export function needsRehash(hashed, { rounds = 10 } = {}) {
95
- if (!hashed || typeof hashed !== 'string') return true;
96
- try {
97
- const r = bcrypt.getRounds(hashed);
98
- return r !== rounds;
99
- } catch {
100
- return true;
101
- }
102
- }
103
-
104
- // Laravel `Hash::isHashed()` (HashManager.php:97-100): `$this->driver()->info(
105
- // $value)['algo'] !== null` - the same rule as password_get_info(). The kernel
106
- // used a loose regex, so a `$2a$`/`$2b$` digest or a TRUNCATED one counted as
107
- // hashed where PHP says it is not (Laravel's own isHashedDigest() is the
108
- // faithful rule - the driver checks the digest, not its shape).
109
- export function isHashed(value) {
110
- return isHashedDigest(value);
111
- }
112
-
113
- export default { make, makeSync, check, needsRehash, isHashed, isHashedDigest, passwordInfo, verifyConfiguration, timingSafeEqual };
1
+ // kernel/hash.mjs � Hash (bcrypt)
2
+ // Uses bcryptjs (pure JS, no native).
3
+ import bcrypt from 'bcryptjs';
4
+
5
+ // constant-time string/Uint8Array compare � does not early-return on first mismatch
6
+ export function timingSafeEqual(a, b) {
7
+ const toBytes = (v) => {
8
+ if (v instanceof Uint8Array) return v;
9
+ if (typeof v === 'string') return new TextEncoder().encode(v);
10
+ if (typeof Buffer !== 'undefined' && v && Buffer.isBuffer(v)) return new Uint8Array(v);
11
+ return new TextEncoder().encode(String(v));
12
+ };
13
+ const aBytes = toBytes(a);
14
+ const bBytes = toBytes(b);
15
+ if (aBytes.length !== bBytes.length) return false;
16
+ try {
17
+ const f = globalThis.crypto && globalThis.crypto.timingSafeEqual;
18
+ if (typeof f === 'function' && typeof Buffer !== 'undefined') {
19
+ return f(Buffer.from(aBytes), Buffer.from(bBytes));
20
+ }
21
+ } catch {}
22
+ let diff = 0;
23
+ for (let i = 0; i < aBytes.length; i++) diff |= aBytes[i] ^ bBytes[i];
24
+ return diff === 0;
25
+ }
26
+
27
+ export async function make(value, { rounds = 10 } = {}) {
28
+ const salt = await bcrypt.genSalt(rounds);
29
+ const hash = await bcrypt.hash(String(value), salt);
30
+ // PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
31
+ // PHP prefix (like makeSync below) so password_get_info()-shaped checks
32
+ // (isHashed, verifyConfiguration, needsRehash) treat our own hashes as
33
+ // bcrypt - exactly like BcryptHasher.
34
+ return hash.replace(/^\$2[abxy]\$/, '$2y$');
35
+ }
36
+
37
+ // Synchronous bcrypt for Eloquent's `hashed` cast. Laravel's
38
+ // castAttributeAsHashedString() runs Hash::make() inside setAttribute(), which is
39
+ // synchronous in PHP, so the compiled Model#set() must hash before returning.
40
+ // bcryptjs' hashSync costs the same CPU as the async form (measured in
41
+ // kernel/tests/budget.test.mjs); callers on the free plan should expect the
42
+ // configured cost (12 by default) per filled password.
43
+ export function makeSync(value, { rounds = 12 } = {}) {
44
+ const salt = bcrypt.genSaltSync(Number(rounds));
45
+ const hash = bcrypt.hashSync(String(value), salt);
46
+ // PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
47
+ // PHP prefix so password_get_info()-shaped checks (verifyConfiguration,
48
+ // needsRehash) treat our own hashes as bcrypt, exactly like BcryptHasher.
49
+ return hash.replace(/^\$2[abxy]\$/, '$2y$');
50
+ }
51
+
52
+ // PHP 8.3 password_get_info() as measured against the vendor oracle: only a
53
+ // full `$2y$NN$` + 53-char digest is bcrypt; `$2a$`/`$2b$` and truncated
54
+ // strings report algo null; any `$argon2(id|i|d)$` prefix is argon2.
55
+ const BCRYPT_2Y_60_RE = /^\$2y\$(\d{2})\$[./A-Za-z0-9]{53}$/;
56
+ const ARGON2_PREFIX_RE = /^\$argon2(id|i|d)\$/;
57
+
58
+ export function passwordInfo(value) {
59
+ if (typeof value !== 'string') return { algo: null, algoName: 'unknown', options: [] };
60
+ const m = BCRYPT_2Y_60_RE.exec(value);
61
+ if (m) {
62
+ return { algo: '2y', algoName: 'bcrypt', options: { cost: parseInt(m[1], 10) } };
63
+ }
64
+ const a = ARGON2_PREFIX_RE.exec(value);
65
+ if (a) {
66
+ return { algo: 'argon2' + a[1], algoName: 'argon2' + a[1], options: [] };
67
+ }
68
+ return { algo: null, algoName: 'unknown', options: [] };
69
+ }
70
+
71
+ // Laravel 13.37 HashManager::isHashed(): `driver()->info($value)['algo'] !== null`.
72
+ // The `hashed` cast must use this exact check (a `$2a$` digest is re-hashed by
73
+ // Laravel on PHP 8.3, while `$2y$` passes through).
74
+ export function isHashedDigest(value) {
75
+ return passwordInfo(value).algo !== null;
76
+ }
77
+
78
+ // Laravel 13.37 BcryptHasher::verifyConfiguration():
79
+ // isUsingCorrectAlgorithm($value) && isUsingValidOptions($value)
80
+ // where PHP's password_get_info() identifies ONLY `$2y$NN$` as bcrypt, and the
81
+ // cost must be an integer <= the configured rounds (a lower cost is valid).
82
+ export function verifyConfiguration(hashed, { rounds = 12 } = {}) {
83
+ const info = passwordInfo(hashed);
84
+ if (info.algoName !== 'bcrypt') return false;
85
+ return info.options.cost <= Number(rounds);
86
+ }
87
+
88
+ export async function check(value, hashed) {
89
+ if (!hashed || typeof hashed !== 'string') return false;
90
+ const ok = await bcrypt.compare(String(value), hashed);
91
+ return timingSafeEqual(ok ? '1' : '0', '1');
92
+ }
93
+
94
+ export function needsRehash(hashed, { rounds = 10 } = {}) {
95
+ if (!hashed || typeof hashed !== 'string') return true;
96
+ try {
97
+ const r = bcrypt.getRounds(hashed);
98
+ return r !== rounds;
99
+ } catch {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ // Laravel `Hash::isHashed()` (HashManager.php:97-100): `$this->driver()->info(
105
+ // $value)['algo'] !== null` - the same rule as password_get_info(). The kernel
106
+ // used a loose regex, so a `$2a$`/`$2b$` digest or a TRUNCATED one counted as
107
+ // hashed where PHP says it is not (Laravel's own isHashedDigest() is the
108
+ // faithful rule - the driver checks the digest, not its shape).
109
+ export function isHashed(value) {
110
+ return isHashedDigest(value);
111
+ }
112
+
113
+ export default { make, makeSync, check, needsRehash, isHashed, isHashedDigest, passwordInfo, verifyConfiguration, timingSafeEqual };
@@ -1,161 +1,161 @@
1
- // kernel/multipart.mjs — the multipart/form-data parser (the edge has no PHP).
2
- //
3
- // The body is the raw bytes; the parts are split on the boundary. A part with a
4
- // `filename` is an uploaded file (the bytes as they are), the other parts are
5
- // the form fields.
6
- const enc = new TextEncoder();
7
- const dec = new TextDecoder('utf-8', { fatal: false });
8
-
9
- function indexOfBytes(hay, needle, from) {
10
- outer: for (let i = Math.max(0, from); i + needle.length <= hay.length; i++) {
11
- for (let j = 0; j < needle.length; j++) {
12
- if (hay[i + j] !== needle[j]) continue outer;
13
- }
14
- return i;
15
- }
16
- return -1;
17
- }
18
-
19
- export function parseMultipart(bytes, contentType) {
20
- if (!(bytes instanceof Uint8Array)) {
21
- throw new Error('multipart/form-data: the body must be the raw bytes');
22
- }
23
- const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
24
- if (!m) throw new Error('multipart/form-data: the boundary is missing');
25
- const boundary = (m[1] || m[2] || '').trim();
26
- const delim = enc.encode('--' + boundary);
27
- const fields = {};
28
- const files = {};
29
- // PHP's rfc1867 parser (ext/standard/rfc1867.c): the delimiter is
30
- // CRLF + "--boundary" - a file whose CONTENT contains the boundary bytes
31
- // must not end the part (the edge searched the bare bytes, so such a file
32
- // truncated the upload). The FIRST boundary starts the body (no CRLF before
33
- // it).
34
- const findDelimiter = (from) => {
35
- let at = from;
36
- for (;;) {
37
- const found = indexOfBytes(bytes, delim, at);
38
- if (found === -1) return -1;
39
- if (found === 0 || (bytes[found - 1] === 10 && bytes[found - 2] === 13)) return found;
40
- at = found + 1;
41
- }
42
- };
43
- let pos = findDelimiter(0);
44
- if (pos === -1) throw new Error('multipart/form-data: the body has no boundary');
45
- pos += delim.length;
46
- for (;;) {
47
- // the closing delimiter is "--boundary--"
48
- if (bytes[pos] === 45 && bytes[pos + 1] === 45) break;
49
- if (bytes[pos] === 13 && bytes[pos + 1] === 10) pos += 2;
50
- const headEnd = indexOfBytes(bytes, enc.encode('\r\n\r\n'), pos);
51
- if (headEnd === -1) break;
52
- const head = dec.decode(bytes.subarray(pos, headEnd));
53
- const bodyStart = headEnd + 4;
54
- const next = findDelimiter(bodyStart);
55
- if (next === -1) break;
56
- // the part's body ends with a CRLF before the delimiter
57
- let end = next;
58
- if (end >= 2 && bytes[end - 1] === 10 && bytes[end - 2] === 13) end -= 2;
59
- const content = bytes.subarray(bodyStart, end);
60
- let name = null;
61
- let filename = null;
62
- let mime = 'application/octet-stream';
63
- for (const line of head.split('\r\n')) {
64
- const colon = line.indexOf(':');
65
- if (colon === -1) continue;
66
- const key = line.slice(0, colon).trim().toLowerCase();
67
- const value = line.slice(colon + 1).trim();
68
- if (key === 'content-disposition') {
69
- const nm = /name="([^"]*)"/i.exec(value);
70
- if (nm) name = nm[1];
71
- const fn = /filename="([^"]*)"/i.exec(value);
72
- if (fn) filename = fn[1];
73
- } else if (key === 'content-type') {
74
- mime = value;
75
- }
76
- }
77
- if (name !== null) {
78
- if (filename !== null) {
79
- setFile(files, name, {
80
- name,
81
- filename,
82
- mime,
83
- bytes: content,
84
- size: content.length,
85
- __part: true,
86
- });
87
- } else {
88
- fields[name] = dec.decode(content);
89
- }
90
- }
91
- pos = next + delim.length;
92
- }
93
- return { fields, files };
94
- }
95
-
96
- // PHP's $_FILES naming: 'photos[]' is an append, 'photos[0]' is an index,
97
- // 'a[b]' is a key. The parser writes the files into the same shape.
98
- function setFile(files, name, desc) {
99
- const m = /^([^[\]]*)((?:\[[^[\]]*\])*)$/.exec(name);
100
- const base = m ? m[1] : name;
101
- const brackets = m ? m[2] : '';
102
- if (!brackets) {
103
- if (base === '') files[''] = desc;
104
- else files[base] = desc;
105
- return;
106
- }
107
- // The path: the base + every bracket key ('' means "append").
108
- const keys = brackets.slice(1, -1).split('][');
109
- let node = files;
110
- let container = base;
111
- for (let i = 0; i < keys.length; i++) {
112
- const key = keys[i];
113
- if (container === '') {
114
- container = '0';
115
- if (!node[container] || typeof node[container] !== 'object') node[container] = [];
116
- }
117
- let child = node[container];
118
- if (!child || typeof child !== 'object' || child.__uploadedFile || child.__part) {
119
- // The next key decides the shape: '' or a number is a list, else a map.
120
- const next = keys[i + 1];
121
- child = next === undefined || next === '' || /^\d+$/.test(next) ? [] : {};
122
- node[container] = child;
123
- }
124
- if (Array.isArray(child)) {
125
- if (key === '') {
126
- // The last segment: append the file.
127
- if (i === keys.length - 1) {
128
- child.push(desc);
129
- return;
130
- }
131
- const created = [];
132
- child.push(created);
133
- node = created;
134
- container = '';
135
- continue;
136
- }
137
- const idx = Number(key);
138
- if (!Number.isInteger(idx) || idx < 0) continue;
139
- if (i === keys.length - 1) {
140
- child[idx] = desc;
141
- return;
142
- }
143
- if (!child[idx] || typeof child[idx] !== 'object') child[idx] = [];
144
- node = child[idx];
145
- container = '';
146
- continue;
147
- }
148
- if (i === keys.length - 1) {
149
- child[key] = desc;
150
- return;
151
- }
152
- const next = keys[i + 1];
153
- if (!child[key] || typeof child[key] !== 'object') {
154
- child[key] = next === '' || /^\d+$/.test(next) ? [] : {};
155
- }
156
- node = child[key];
157
- container = key;
158
- }
159
- }
160
-
161
- export default { parseMultipart };
1
+ // kernel/multipart.mjs — the multipart/form-data parser (the edge has no PHP).
2
+ //
3
+ // The body is the raw bytes; the parts are split on the boundary. A part with a
4
+ // `filename` is an uploaded file (the bytes as they are), the other parts are
5
+ // the form fields.
6
+ const enc = new TextEncoder();
7
+ const dec = new TextDecoder('utf-8', { fatal: false });
8
+
9
+ function indexOfBytes(hay, needle, from) {
10
+ outer: for (let i = Math.max(0, from); i + needle.length <= hay.length; i++) {
11
+ for (let j = 0; j < needle.length; j++) {
12
+ if (hay[i + j] !== needle[j]) continue outer;
13
+ }
14
+ return i;
15
+ }
16
+ return -1;
17
+ }
18
+
19
+ export function parseMultipart(bytes, contentType) {
20
+ if (!(bytes instanceof Uint8Array)) {
21
+ throw new Error('multipart/form-data: the body must be the raw bytes');
22
+ }
23
+ const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
24
+ if (!m) throw new Error('multipart/form-data: the boundary is missing');
25
+ const boundary = (m[1] || m[2] || '').trim();
26
+ const delim = enc.encode('--' + boundary);
27
+ const fields = {};
28
+ const files = {};
29
+ // PHP's rfc1867 parser (ext/standard/rfc1867.c): the delimiter is
30
+ // CRLF + "--boundary" - a file whose CONTENT contains the boundary bytes
31
+ // must not end the part (the edge searched the bare bytes, so such a file
32
+ // truncated the upload). The FIRST boundary starts the body (no CRLF before
33
+ // it).
34
+ const findDelimiter = (from) => {
35
+ let at = from;
36
+ for (;;) {
37
+ const found = indexOfBytes(bytes, delim, at);
38
+ if (found === -1) return -1;
39
+ if (found === 0 || (bytes[found - 1] === 10 && bytes[found - 2] === 13)) return found;
40
+ at = found + 1;
41
+ }
42
+ };
43
+ let pos = findDelimiter(0);
44
+ if (pos === -1) throw new Error('multipart/form-data: the body has no boundary');
45
+ pos += delim.length;
46
+ for (;;) {
47
+ // the closing delimiter is "--boundary--"
48
+ if (bytes[pos] === 45 && bytes[pos + 1] === 45) break;
49
+ if (bytes[pos] === 13 && bytes[pos + 1] === 10) pos += 2;
50
+ const headEnd = indexOfBytes(bytes, enc.encode('\r\n\r\n'), pos);
51
+ if (headEnd === -1) break;
52
+ const head = dec.decode(bytes.subarray(pos, headEnd));
53
+ const bodyStart = headEnd + 4;
54
+ const next = findDelimiter(bodyStart);
55
+ if (next === -1) break;
56
+ // the part's body ends with a CRLF before the delimiter
57
+ let end = next;
58
+ if (end >= 2 && bytes[end - 1] === 10 && bytes[end - 2] === 13) end -= 2;
59
+ const content = bytes.subarray(bodyStart, end);
60
+ let name = null;
61
+ let filename = null;
62
+ let mime = 'application/octet-stream';
63
+ for (const line of head.split('\r\n')) {
64
+ const colon = line.indexOf(':');
65
+ if (colon === -1) continue;
66
+ const key = line.slice(0, colon).trim().toLowerCase();
67
+ const value = line.slice(colon + 1).trim();
68
+ if (key === 'content-disposition') {
69
+ const nm = /name="([^"]*)"/i.exec(value);
70
+ if (nm) name = nm[1];
71
+ const fn = /filename="([^"]*)"/i.exec(value);
72
+ if (fn) filename = fn[1];
73
+ } else if (key === 'content-type') {
74
+ mime = value;
75
+ }
76
+ }
77
+ if (name !== null) {
78
+ if (filename !== null) {
79
+ setFile(files, name, {
80
+ name,
81
+ filename,
82
+ mime,
83
+ bytes: content,
84
+ size: content.length,
85
+ __part: true,
86
+ });
87
+ } else {
88
+ fields[name] = dec.decode(content);
89
+ }
90
+ }
91
+ pos = next + delim.length;
92
+ }
93
+ return { fields, files };
94
+ }
95
+
96
+ // PHP's $_FILES naming: 'photos[]' is an append, 'photos[0]' is an index,
97
+ // 'a[b]' is a key. The parser writes the files into the same shape.
98
+ function setFile(files, name, desc) {
99
+ const m = /^([^[\]]*)((?:\[[^[\]]*\])*)$/.exec(name);
100
+ const base = m ? m[1] : name;
101
+ const brackets = m ? m[2] : '';
102
+ if (!brackets) {
103
+ if (base === '') files[''] = desc;
104
+ else files[base] = desc;
105
+ return;
106
+ }
107
+ // The path: the base + every bracket key ('' means "append").
108
+ const keys = brackets.slice(1, -1).split('][');
109
+ let node = files;
110
+ let container = base;
111
+ for (let i = 0; i < keys.length; i++) {
112
+ const key = keys[i];
113
+ if (container === '') {
114
+ container = '0';
115
+ if (!node[container] || typeof node[container] !== 'object') node[container] = [];
116
+ }
117
+ let child = node[container];
118
+ if (!child || typeof child !== 'object' || child.__uploadedFile || child.__part) {
119
+ // The next key decides the shape: '' or a number is a list, else a map.
120
+ const next = keys[i + 1];
121
+ child = next === undefined || next === '' || /^\d+$/.test(next) ? [] : {};
122
+ node[container] = child;
123
+ }
124
+ if (Array.isArray(child)) {
125
+ if (key === '') {
126
+ // The last segment: append the file.
127
+ if (i === keys.length - 1) {
128
+ child.push(desc);
129
+ return;
130
+ }
131
+ const created = [];
132
+ child.push(created);
133
+ node = created;
134
+ container = '';
135
+ continue;
136
+ }
137
+ const idx = Number(key);
138
+ if (!Number.isInteger(idx) || idx < 0) continue;
139
+ if (i === keys.length - 1) {
140
+ child[idx] = desc;
141
+ return;
142
+ }
143
+ if (!child[idx] || typeof child[idx] !== 'object') child[idx] = [];
144
+ node = child[idx];
145
+ container = '';
146
+ continue;
147
+ }
148
+ if (i === keys.length - 1) {
149
+ child[key] = desc;
150
+ return;
151
+ }
152
+ const next = keys[i + 1];
153
+ if (!child[key] || typeof child[key] !== 'object') {
154
+ child[key] = next === '' || /^\d+$/.test(next) ? [] : {};
155
+ }
156
+ node = child[key];
157
+ container = key;
158
+ }
159
+ }
160
+
161
+ export default { parseMultipart };