s3-test-server 0.0.0-stage → 1.0.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,391 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const fsp = require('fs/promises');
6
+ const path = require('path');
7
+ const { pipeline } = require('stream/promises');
8
+ const { DigestCalculator } = require('../checksums');
9
+ const { compareKeys } = require('../utils');
10
+
11
+ const BUCKET_META_FILE = '.bucket.json';
12
+ const OBJECTS_DIR = 'objects';
13
+ const UPLOADS_DIR = 'uploads';
14
+
15
+ /**
16
+ * Filesystem-backed store.
17
+ *
18
+ * Layout:
19
+ * <root>/<bucket>/.bucket.json bucket metadata + configs
20
+ * <root>/<bucket>/objects/<h2>/<sha256(key)> object content
21
+ * <root>/<bucket>/objects/<h2>/<sha256(key)>.json object metadata (incl. real key)
22
+ * <root>/<bucket>/uploads/<uploadId>/upload.json multipart upload metadata
23
+ * <root>/<bucket>/uploads/<uploadId>/part.<n> part content
24
+ * <root>/<bucket>/uploads/<uploadId>/part.<n>.json part metadata
25
+ *
26
+ * Object content is addressed by a hash of the key, so any legal S3 key
27
+ * (slashes, unicode, 1024 chars) maps to a safe filename on every platform.
28
+ * A per-bucket in-memory index (rebuilt lazily from the metadata files) backs
29
+ * listings, so data survives restarts.
30
+ */
31
+ class FilesystemStore {
32
+ constructor(root) {
33
+ this.root = root;
34
+ this.indexes = new Map(); // bucket → Map(key → meta)
35
+ }
36
+
37
+ async initialize() {
38
+ await fsp.mkdir(this.root, { recursive: true });
39
+ }
40
+
41
+ _bucketPath(bucket) {
42
+ return path.join(this.root, bucket);
43
+ }
44
+
45
+ _keyHash(key) {
46
+ return crypto.createHash('sha256').update(key, 'utf8').digest('hex');
47
+ }
48
+
49
+ _contentPath(bucket, key) {
50
+ const hash = this._keyHash(key);
51
+ return path.join(
52
+ this._bucketPath(bucket),
53
+ OBJECTS_DIR,
54
+ hash.slice(0, 2),
55
+ hash
56
+ );
57
+ }
58
+
59
+ // ---------- buckets ----------
60
+
61
+ async listBuckets() {
62
+ const entries = await fsp
63
+ .readdir(this.root, { withFileTypes: true })
64
+ .catch(() => []);
65
+ const buckets = [];
66
+ for (const entry of entries) {
67
+ if (!entry.isDirectory()) continue;
68
+ const meta = await this.getBucket(entry.name);
69
+ if (meta) buckets.push(meta);
70
+ }
71
+ buckets.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
72
+ return buckets;
73
+ }
74
+
75
+ async getBucket(bucket) {
76
+ try {
77
+ const raw = await fsp.readFile(
78
+ path.join(this._bucketPath(bucket), BUCKET_META_FILE),
79
+ 'utf8'
80
+ );
81
+ return { name: bucket, ...JSON.parse(raw) };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async createBucket(bucket) {
88
+ const existing = await this.getBucket(bucket);
89
+ if (existing) return { created: false, meta: existing };
90
+ const dir = this._bucketPath(bucket);
91
+ await fsp.mkdir(path.join(dir, OBJECTS_DIR), { recursive: true });
92
+ await fsp.mkdir(path.join(dir, UPLOADS_DIR), { recursive: true });
93
+ const meta = { creationDate: Date.now(), configs: {} };
94
+ await this._writeJson(path.join(dir, BUCKET_META_FILE), meta);
95
+ this.indexes.set(bucket, new Map());
96
+ return { created: true, meta: { name: bucket, ...meta } };
97
+ }
98
+
99
+ async deleteBucket(bucket) {
100
+ const index = await this._index(bucket);
101
+ if (index.size > 0) return { deleted: false, reason: 'objects' };
102
+ const uploads = await this.listUploads(bucket);
103
+ if (uploads.length > 0) return { deleted: false, reason: 'uploads' };
104
+ await fsp.rm(this._bucketPath(bucket), { recursive: true, force: true });
105
+ this.indexes.delete(bucket);
106
+ return { deleted: true };
107
+ }
108
+
109
+ async putBucketConfig(bucket, type, value) {
110
+ const meta = await this.getBucket(bucket);
111
+ if (!meta) return false;
112
+ const { name, ...rest } = meta;
113
+ rest.configs = rest.configs || {};
114
+ if (value === null) delete rest.configs[type];
115
+ else rest.configs[type] = value;
116
+ await this._writeJson(
117
+ path.join(this._bucketPath(bucket), BUCKET_META_FILE),
118
+ rest
119
+ );
120
+ return true;
121
+ }
122
+
123
+ // ---------- object index ----------
124
+
125
+ async _index(bucket) {
126
+ let index = this.indexes.get(bucket);
127
+ if (index) return index;
128
+ index = new Map();
129
+ const objectsDir = path.join(this._bucketPath(bucket), OBJECTS_DIR);
130
+ const shards = await fsp.readdir(objectsDir).catch(() => []);
131
+ for (const shard of shards) {
132
+ const files = await fsp
133
+ .readdir(path.join(objectsDir, shard))
134
+ .catch(() => []);
135
+ for (const file of files) {
136
+ if (!file.endsWith('.json')) continue;
137
+ try {
138
+ const meta = JSON.parse(
139
+ await fsp.readFile(path.join(objectsDir, shard, file), 'utf8')
140
+ );
141
+ index.set(meta.key, meta);
142
+ } catch {
143
+ // skip unreadable metadata
144
+ }
145
+ }
146
+ }
147
+ this.indexes.set(bucket, index);
148
+ return index;
149
+ }
150
+
151
+ async listObjects(bucket) {
152
+ const index = await this._index(bucket);
153
+ return [...index.values()].sort((a, b) => compareKeys(a.key, b.key));
154
+ }
155
+
156
+ async getObject(bucket, key) {
157
+ const index = await this._index(bucket);
158
+ return index.get(key) || null;
159
+ }
160
+
161
+ // ---------- object content ----------
162
+
163
+ /**
164
+ * Streams `readable` into a temp file while computing digests. Returns a
165
+ * staging handle: inspect `size`/`digests`, then `commit(key, fields)` or
166
+ * `abort()`.
167
+ */
168
+ async stageObject(bucket, readable, algorithms = []) {
169
+ const bucketDir = this._bucketPath(bucket);
170
+ const tmpPath = path.join(
171
+ bucketDir,
172
+ `.tmp-${crypto.randomBytes(8).toString('hex')}`
173
+ );
174
+ const calculator = new DigestCalculator(algorithms);
175
+ const writable = fs.createWriteStream(tmpPath, { flags: 'wx' });
176
+ try {
177
+ await pipeline(
178
+ readable,
179
+ async function* (source) {
180
+ for await (const chunk of source) {
181
+ calculator.update(chunk);
182
+ yield chunk;
183
+ }
184
+ },
185
+ writable
186
+ );
187
+ } catch (err) {
188
+ await fsp.rm(tmpPath, { force: true });
189
+ throw err;
190
+ }
191
+ const digests = calculator.digest();
192
+ const store = this;
193
+ return {
194
+ size: digests.size,
195
+ digests,
196
+ async commit(key, fields) {
197
+ const contentPath = store._contentPath(bucket, key);
198
+ await fsp.mkdir(path.dirname(contentPath), { recursive: true });
199
+ await fsp.rename(tmpPath, contentPath);
200
+ const meta = {
201
+ key,
202
+ size: digests.size,
203
+ etag: fields.etag || digests.md5Hex,
204
+ lastModified: Date.now(),
205
+ ...fields,
206
+ };
207
+ await store._writeJson(`${contentPath}.json`, meta);
208
+ const index = await store._index(bucket);
209
+ index.set(key, meta);
210
+ return meta;
211
+ },
212
+ async abort() {
213
+ await fsp.rm(tmpPath, { force: true });
214
+ },
215
+ };
216
+ }
217
+
218
+ createObjectReadStream(bucket, key, range) {
219
+ const contentPath = this._contentPath(bucket, key);
220
+ const opts = {};
221
+ if (range) {
222
+ opts.start = range.start;
223
+ opts.end = range.end;
224
+ }
225
+ return fs.createReadStream(contentPath, opts);
226
+ }
227
+
228
+ async updateObjectMeta(bucket, key, meta) {
229
+ const contentPath = this._contentPath(bucket, key);
230
+ await this._writeJson(`${contentPath}.json`, meta);
231
+ const index = await this._index(bucket);
232
+ index.set(key, meta);
233
+ }
234
+
235
+ async deleteObject(bucket, key) {
236
+ const index = await this._index(bucket);
237
+ const existed = index.delete(key);
238
+ const contentPath = this._contentPath(bucket, key);
239
+ await fsp.rm(contentPath, { force: true });
240
+ await fsp.rm(`${contentPath}.json`, { force: true });
241
+ return existed;
242
+ }
243
+
244
+ // ---------- multipart uploads ----------
245
+
246
+ _uploadDir(bucket, uploadId) {
247
+ return path.join(this._bucketPath(bucket), UPLOADS_DIR, uploadId);
248
+ }
249
+
250
+ async createUpload(bucket, key, fields) {
251
+ const uploadId = crypto.randomBytes(24).toString('hex');
252
+ const dir = this._uploadDir(bucket, uploadId);
253
+ await fsp.mkdir(dir, { recursive: true });
254
+ const meta = { uploadId, key, initiated: Date.now(), ...fields };
255
+ await this._writeJson(path.join(dir, 'upload.json'), meta);
256
+ return meta;
257
+ }
258
+
259
+ async getUpload(bucket, uploadId) {
260
+ if (!/^[0-9a-f]+$/.test(uploadId)) return null;
261
+ try {
262
+ const raw = await fsp.readFile(
263
+ path.join(this._uploadDir(bucket, uploadId), 'upload.json'),
264
+ 'utf8'
265
+ );
266
+ return JSON.parse(raw);
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ async listUploads(bucket) {
273
+ const uploadsDir = path.join(this._bucketPath(bucket), UPLOADS_DIR);
274
+ const ids = await fsp.readdir(uploadsDir).catch(() => []);
275
+ const uploads = [];
276
+ for (const id of ids) {
277
+ const meta = await this.getUpload(bucket, id);
278
+ if (meta) uploads.push(meta);
279
+ }
280
+ uploads.sort(
281
+ (a, b) => compareKeys(a.key, b.key) || a.initiated - b.initiated
282
+ );
283
+ return uploads;
284
+ }
285
+
286
+ async putPart(bucket, uploadId, partNumber, readable, algorithms = []) {
287
+ const dir = this._uploadDir(bucket, uploadId);
288
+ const tmpPath = path.join(
289
+ dir,
290
+ `.tmp-${crypto.randomBytes(8).toString('hex')}`
291
+ );
292
+ const calculator = new DigestCalculator(algorithms);
293
+ try {
294
+ await pipeline(
295
+ readable,
296
+ async function* (source) {
297
+ for await (const chunk of source) {
298
+ calculator.update(chunk);
299
+ yield chunk;
300
+ }
301
+ },
302
+ fs.createWriteStream(tmpPath, { flags: 'wx' })
303
+ );
304
+ } catch (err) {
305
+ await fsp.rm(tmpPath, { force: true });
306
+ throw err;
307
+ }
308
+ const digests = calculator.digest();
309
+ const store = this;
310
+ return {
311
+ digests,
312
+ async commit() {
313
+ const partPath = path.join(dir, `part.${partNumber}`);
314
+ await fsp.rename(tmpPath, partPath);
315
+ const meta = {
316
+ partNumber,
317
+ size: digests.size,
318
+ etag: digests.md5Hex,
319
+ lastModified: Date.now(),
320
+ checksums: digests.checksums,
321
+ };
322
+ await store._writeJson(`${partPath}.json`, meta);
323
+ return meta;
324
+ },
325
+ async abort() {
326
+ await fsp.rm(tmpPath, { force: true });
327
+ },
328
+ };
329
+ }
330
+
331
+ async listParts(bucket, uploadId) {
332
+ const dir = this._uploadDir(bucket, uploadId);
333
+ const files = await fsp.readdir(dir).catch(() => []);
334
+ const parts = [];
335
+ for (const file of files) {
336
+ if (!/^part\.\d+\.json$/.test(file)) continue;
337
+ try {
338
+ parts.push(
339
+ JSON.parse(await fsp.readFile(path.join(dir, file), 'utf8'))
340
+ );
341
+ } catch {
342
+ // skip unreadable part metadata
343
+ }
344
+ }
345
+ parts.sort((a, b) => a.partNumber - b.partNumber);
346
+ return parts;
347
+ }
348
+
349
+ createPartReadStream(bucket, uploadId, partNumber) {
350
+ return fs.createReadStream(
351
+ path.join(this._uploadDir(bucket, uploadId), `part.${partNumber}`)
352
+ );
353
+ }
354
+
355
+ async removeUpload(bucket, uploadId) {
356
+ await fsp.rm(this._uploadDir(bucket, uploadId), {
357
+ recursive: true,
358
+ force: true,
359
+ });
360
+ }
361
+
362
+ // ---------- maintenance ----------
363
+
364
+ async reset() {
365
+ const entries = await fsp
366
+ .readdir(this.root, { withFileTypes: true })
367
+ .catch(() => []);
368
+ await Promise.all(
369
+ entries.map((entry) =>
370
+ fsp.rm(path.join(this.root, entry.name), {
371
+ recursive: true,
372
+ force: true,
373
+ })
374
+ )
375
+ );
376
+ this.indexes.clear();
377
+ }
378
+
379
+ async destroy() {
380
+ await fsp.rm(this.root, { recursive: true, force: true });
381
+ this.indexes.clear();
382
+ }
383
+
384
+ async _writeJson(filePath, value) {
385
+ const tmpPath = `${filePath}.tmp-${crypto.randomBytes(4).toString('hex')}`;
386
+ await fsp.writeFile(tmpPath, JSON.stringify(value));
387
+ await fsp.rename(tmpPath, filePath);
388
+ }
389
+ }
390
+
391
+ module.exports = { FilesystemStore };
package/lib/utils.js ADDED
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { S3Error } = require('./models/error');
5
+
6
+ /** RFC 3986 encoding as used by AWS signatures and list encoding-type=url. */
7
+ function awsUriEncode(value, encodeSlash = true) {
8
+ let out = '';
9
+ for (const char of String(value)) {
10
+ if (/[A-Za-z0-9\-._~]/.test(char) || (char === '/' && !encodeSlash)) {
11
+ out += char;
12
+ } else {
13
+ for (const byte of Buffer.from(char, 'utf8')) {
14
+ out += '%' + byte.toString(16).toUpperCase().padStart(2, '0');
15
+ }
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function generateRequestId() {
22
+ return crypto.randomBytes(8).toString('hex').toUpperCase();
23
+ }
24
+
25
+ function generateId2() {
26
+ return crypto.randomBytes(32).toString('base64');
27
+ }
28
+
29
+ function isoDate(ms) {
30
+ return new Date(ms).toISOString().replace(/(\.\d{3})\d*Z$/, '$1Z');
31
+ }
32
+
33
+ function httpDate(ms) {
34
+ return new Date(ms).toUTCString();
35
+ }
36
+
37
+ function timingSafeEqualStr(a, b) {
38
+ const bufA = Buffer.from(String(a));
39
+ const bufB = Buffer.from(String(b));
40
+ if (bufA.length !== bufB.length) return false;
41
+ return crypto.timingSafeEqual(bufA, bufB);
42
+ }
43
+
44
+ const BUCKET_NAME_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
45
+
46
+ function validateBucketName(name) {
47
+ if (
48
+ !BUCKET_NAME_RE.test(name) ||
49
+ name.includes('..') ||
50
+ /^\d{1,3}(\.\d{1,3}){3}$/.test(name)
51
+ ) {
52
+ throw new S3Error(
53
+ 'InvalidBucketName',
54
+ 'The specified bucket is not valid.',
55
+ { BucketName: name }
56
+ );
57
+ }
58
+ }
59
+
60
+ /** Read an entire request body (for XML / form endpoints), with a size cap. */
61
+ function readBody(req, { limit = 64 * 1024 * 1024 } = {}) {
62
+ return new Promise((resolve, reject) => {
63
+ const chunks = [];
64
+ let total = 0;
65
+ req.on('data', (chunk) => {
66
+ total += chunk.length;
67
+ if (total > limit) {
68
+ reject(
69
+ new S3Error(
70
+ 'EntityTooLarge',
71
+ 'Your proposed upload exceeds the maximum allowed size'
72
+ )
73
+ );
74
+ req.destroy();
75
+ return;
76
+ }
77
+ chunks.push(chunk);
78
+ });
79
+ req.on('end', () => resolve(Buffer.concat(chunks)));
80
+ req.on('error', reject);
81
+ });
82
+ }
83
+
84
+ /** Compare keys the way S3 orders them: by UTF-8 byte value. */
85
+ function compareKeys(a, b) {
86
+ return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
87
+ }
88
+
89
+ function xmlEncodeKey(key, encodingType) {
90
+ return encodingType === 'url' ? awsUriEncode(key, false) : key;
91
+ }
92
+
93
+ function escapeRegExp(value) {
94
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
95
+ }
96
+
97
+ const DUMMY_OWNER = {
98
+ ID: '123456789012345678901234567890123456789012345678901234567890abcd',
99
+ DisplayName: 's3-test-server',
100
+ };
101
+
102
+ module.exports = {
103
+ awsUriEncode,
104
+ generateRequestId,
105
+ generateId2,
106
+ isoDate,
107
+ httpDate,
108
+ timingSafeEqualStr,
109
+ validateBucketName,
110
+ readBody,
111
+ compareKeys,
112
+ xmlEncodeKey,
113
+ escapeRegExp,
114
+ DUMMY_OWNER,
115
+ };
package/lib/xml.js ADDED
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ function escapeXml(value) {
4
+ return String(value)
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/\r/g, '&#13;')
10
+ .replace(/\n/g, '&#10;');
11
+ }
12
+
13
+ function decodeEntities(text) {
14
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
15
+ if (entity[0] === '#') {
16
+ const code =
17
+ entity[1] === 'x' || entity[1] === 'X'
18
+ ? parseInt(entity.slice(2), 16)
19
+ : parseInt(entity.slice(1), 10);
20
+ return Number.isNaN(code) ? match : String.fromCodePoint(code);
21
+ }
22
+ switch (entity) {
23
+ case 'amp':
24
+ return '&';
25
+ case 'lt':
26
+ return '<';
27
+ case 'gt':
28
+ return '>';
29
+ case 'quot':
30
+ return '"';
31
+ case 'apos':
32
+ return "'";
33
+ default:
34
+ return match;
35
+ }
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Serialize a plain JS structure to XML.
41
+ * buildXml('Foo', { Bar: 1, Baz: ['a', 'b'] })
42
+ * → <?xml ...?><Foo><Bar>1</Bar><Baz>a</Baz><Baz>b</Baz></Foo>
43
+ * null/undefined values are skipped; `{}` renders an empty element;
44
+ * Date values render as ISO strings.
45
+ */
46
+ function buildXml(rootName, content, { xmlns } = {}) {
47
+ const serialize = (name, value) => {
48
+ if (value === undefined || value === null) return '';
49
+ if (Array.isArray(value)) {
50
+ return value.map((item) => serialize(name, item)).join('');
51
+ }
52
+ let inner;
53
+ if (value instanceof Date) {
54
+ inner = value.toISOString();
55
+ } else if (typeof value === 'object') {
56
+ inner = Object.entries(value)
57
+ .map(([childName, childValue]) => serialize(childName, childValue))
58
+ .join('');
59
+ } else {
60
+ inner = escapeXml(value);
61
+ }
62
+ return `<${name}>${inner}</${name}>`;
63
+ };
64
+
65
+ let innerXml;
66
+ if (
67
+ content &&
68
+ typeof content === 'object' &&
69
+ !(content instanceof Date) &&
70
+ !Array.isArray(content)
71
+ ) {
72
+ innerXml = Object.entries(content)
73
+ .map(([name, value]) => serialize(name, value))
74
+ .join('');
75
+ } else {
76
+ innerXml =
77
+ content === undefined || content === null ? '' : escapeXml(content);
78
+ }
79
+ const nsAttr = xmlns ? ` xmlns="${xmlns}"` : '';
80
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<${rootName}${nsAttr}>${innerXml}</${rootName}>`;
81
+ }
82
+
83
+ const S3_XMLNS = 'http://s3.amazonaws.com/doc/2006-03-01/';
84
+
85
+ /**
86
+ * A deliberately small XML parser sufficient for the element-only documents
87
+ * S3 clients send (CompleteMultipartUpload, Delete, Tagging, CORS, Website…).
88
+ * Attributes are ignored. Repeated sibling elements become arrays.
89
+ * Returns `{ [rootName]: value }` where value is a string (text-only element)
90
+ * or a nested object. Throws on malformed input.
91
+ */
92
+ function parseXml(input) {
93
+ const xml = String(input);
94
+ const tokenRe =
95
+ /<!\[CDATA\[([\s\S]*?)\]\]>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>|<\?[\s\S]*?\?>|<\/\s*([^>\s]+)\s*>|<([^>/\s]+)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
96
+
97
+ const root = { name: null, children: [], text: '' };
98
+ const stack = [root];
99
+ let match;
100
+ let consumed = 0;
101
+
102
+ while ((match = tokenRe.exec(xml)) !== null) {
103
+ if (match.index !== consumed) {
104
+ throw new Error(`Unexpected content in XML at offset ${consumed}`);
105
+ }
106
+ consumed = tokenRe.lastIndex;
107
+ const [, cdata, closeName, openName, , selfClose, text] = match;
108
+ const top = stack[stack.length - 1];
109
+ if (cdata !== undefined) {
110
+ top.text += cdata;
111
+ } else if (text !== undefined) {
112
+ top.text += decodeEntities(text);
113
+ } else if (openName !== undefined) {
114
+ const node = { name: stripNs(openName), children: [], text: '' };
115
+ top.children.push(node);
116
+ if (!selfClose) stack.push(node);
117
+ } else if (closeName !== undefined) {
118
+ const node = stack.pop();
119
+ if (!node || node.name !== stripNs(closeName)) {
120
+ throw new Error(`Mismatched closing tag </${closeName}>`);
121
+ }
122
+ }
123
+ }
124
+ if (consumed !== xml.length || stack.length !== 1) {
125
+ throw new Error('Malformed XML document');
126
+ }
127
+ const rootNode = root.children.find((child) => child.name);
128
+ if (!rootNode) throw new Error('XML document has no root element');
129
+ return { [rootNode.name]: collapse(rootNode) };
130
+ }
131
+
132
+ function stripNs(name) {
133
+ const idx = name.indexOf(':');
134
+ return idx === -1 ? name : name.slice(idx + 1);
135
+ }
136
+
137
+ function collapse(node) {
138
+ if (node.children.length === 0) {
139
+ return node.text;
140
+ }
141
+ const result = {};
142
+ for (const child of node.children) {
143
+ const value = collapse(child);
144
+ if (Object.prototype.hasOwnProperty.call(result, child.name)) {
145
+ if (!Array.isArray(result[child.name])) {
146
+ result[child.name] = [result[child.name]];
147
+ }
148
+ result[child.name].push(value);
149
+ } else {
150
+ result[child.name] = value;
151
+ }
152
+ }
153
+ return result;
154
+ }
155
+
156
+ /** Coerce a parsed XML field to an array (missing → [], single → [x]). */
157
+ function asArray(value) {
158
+ if (value === undefined || value === null) return [];
159
+ return Array.isArray(value) ? value : [value];
160
+ }
161
+
162
+ module.exports = { escapeXml, buildXml, parseXml, asArray, S3_XMLNS };