@felipedsvit/s3node 0.1.2

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/src/xml.js ADDED
@@ -0,0 +1,205 @@
1
+ import { S3Error } from './errors.js'
2
+
3
+ export const S3_XMLNS = 'http://s3.amazonaws.com/doc/2006-03-01/'
4
+
5
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }
6
+
7
+ /**
8
+ * Element text needs only &, < and > escaped. S3 leaves quotes and apostrophes
9
+ * literal in element content, and matching that keeps keys byte-identical to
10
+ * what clients sent.
11
+ */
12
+ export function escapeXml(value) {
13
+ if (value === null || value === undefined) return ''
14
+ return String(value).replace(/[&<>]/g, (c) => ESCAPES[c])
15
+ }
16
+
17
+ /** Attribute values additionally need quotes escaped. */
18
+ export function escapeAttribute(value) {
19
+ if (value === null || value === undefined) return ''
20
+ return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c])
21
+ }
22
+
23
+ /** Build an element. `children` may be a string (text) or pre-rendered markup. */
24
+ export function el(name, children, attrs) {
25
+ const attr = attrs
26
+ ? Object.entries(attrs)
27
+ .filter(([, v]) => v !== undefined && v !== null)
28
+ .map(([k, v]) => ` ${k}="${escapeAttribute(v)}"`)
29
+ .join('')
30
+ : ''
31
+ if (children === undefined || children === null || children === '') return `<${name}${attr}/>`
32
+ return `<${name}${attr}>${children}</${name}>`
33
+ }
34
+
35
+ export function text(name, value) {
36
+ if (value === undefined || value === null) return ''
37
+ return `<${name}>${escapeXml(value)}</${name}>`
38
+ }
39
+
40
+ export function document(rootName, body) {
41
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<${rootName} xmlns="${S3_XMLNS}">${body}</${rootName}>`
42
+ }
43
+
44
+ export function errorDocument({ code, message, resource, requestId, hostId }) {
45
+ return (
46
+ '<?xml version="1.0" encoding="UTF-8"?>\n<Error>' +
47
+ text('Code', code) +
48
+ text('Message', message) +
49
+ text('Resource', resource) +
50
+ text('RequestId', requestId) +
51
+ text('HostId', hostId) +
52
+ '</Error>'
53
+ )
54
+ }
55
+
56
+ /* ------------------------------------------------------------------ *
57
+ * Parsing
58
+ *
59
+ * Deliberately minimal: no DTD, no entity declarations, no CDATA, no
60
+ * external references. That makes XXE and billion-laughs impossible by
61
+ * construction rather than by configuration (docs/plan.md section 7).
62
+ * ------------------------------------------------------------------ */
63
+
64
+ const NAMED_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" }
65
+ const NAME_RE = /[A-Za-z_:][A-Za-z0-9._:-]*/y
66
+
67
+ function decodeEntities(raw) {
68
+ return raw.replace(/&(#x?[0-9A-Fa-f]+|[A-Za-z]+);/g, (match, body) => {
69
+ if (body[0] === '#') {
70
+ const code = body[1] === 'x' || body[1] === 'X'
71
+ ? Number.parseInt(body.slice(2), 16)
72
+ : Number.parseInt(body.slice(1), 10)
73
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) {
74
+ throw new S3Error('MalformedXML', 'Invalid character reference')
75
+ }
76
+ return String.fromCodePoint(code)
77
+ }
78
+ const named = NAMED_ENTITIES[body]
79
+ if (named === undefined) throw new S3Error('MalformedXML', `Unsupported entity &${body};`)
80
+ return named
81
+ })
82
+ }
83
+
84
+ export function parseXml(input, { maxSize = 1024 * 1024, maxDepth = 32, maxNodes = 100_000 } = {}) {
85
+ const src = Buffer.isBuffer(input) ? input.toString('utf8') : String(input)
86
+ if (src.length > maxSize) throw new S3Error('MalformedXML', 'XML body too large')
87
+ if (/<!\s*(DOCTYPE|ENTITY|ATTLIST|ELEMENT|NOTATION)/i.test(src)) {
88
+ throw new S3Error('MalformedXML', 'Document type declarations are not accepted')
89
+ }
90
+ if (src.includes('<![CDATA[')) throw new S3Error('MalformedXML', 'CDATA sections are not accepted')
91
+
92
+ let pos = 0
93
+ let nodes = 0
94
+
95
+ const fail = (msg) => { throw new S3Error('MalformedXML', msg) }
96
+
97
+ const skipTrivia = () => {
98
+ for (;;) {
99
+ while (pos < src.length && /\s/.test(src[pos])) pos++
100
+ if (src.startsWith('<?', pos)) {
101
+ const end = src.indexOf('?>', pos)
102
+ if (end === -1) fail('Unterminated processing instruction')
103
+ pos = end + 2
104
+ continue
105
+ }
106
+ if (src.startsWith('<!--', pos)) {
107
+ const end = src.indexOf('-->', pos)
108
+ if (end === -1) fail('Unterminated comment')
109
+ pos = end + 3
110
+ continue
111
+ }
112
+ return
113
+ }
114
+ }
115
+
116
+ const readName = () => {
117
+ NAME_RE.lastIndex = pos
118
+ const m = NAME_RE.exec(src)
119
+ if (!m) fail('Expected element name')
120
+ pos = NAME_RE.lastIndex
121
+ return m[0]
122
+ }
123
+
124
+ const readAttributes = () => {
125
+ const attrs = {}
126
+ for (;;) {
127
+ while (pos < src.length && /\s/.test(src[pos])) pos++
128
+ if (src[pos] === '>' || src.startsWith('/>', pos)) return attrs
129
+ const name = readName()
130
+ while (pos < src.length && /\s/.test(src[pos])) pos++
131
+ if (src[pos] !== '=') fail('Expected "=" in attribute')
132
+ pos++
133
+ while (pos < src.length && /\s/.test(src[pos])) pos++
134
+ const quote = src[pos]
135
+ if (quote !== '"' && quote !== "'") fail('Unquoted attribute value')
136
+ const end = src.indexOf(quote, pos + 1)
137
+ if (end === -1) fail('Unterminated attribute value')
138
+ attrs[name] = decodeEntities(src.slice(pos + 1, end))
139
+ pos = end + 1
140
+ }
141
+ }
142
+
143
+ const parseElement = (depth) => {
144
+ if (depth > maxDepth) fail('XML nesting too deep')
145
+ if (++nodes > maxNodes) fail('XML has too many nodes')
146
+ if (src[pos] !== '<') fail('Expected "<"')
147
+ pos++
148
+ const name = readName()
149
+ const attrs = readAttributes()
150
+ if (src.startsWith('/>', pos)) {
151
+ pos += 2
152
+ return { name, attrs, text: '', children: [] }
153
+ }
154
+ if (src[pos] !== '>') fail('Malformed start tag')
155
+ pos++
156
+
157
+ const children = []
158
+ let textParts = ''
159
+ for (;;) {
160
+ if (pos >= src.length) fail('Unexpected end of document')
161
+ if (src.startsWith('</', pos)) {
162
+ pos += 2
163
+ const closing = readName()
164
+ if (closing !== name) fail(`Mismatched closing tag </${closing}>`)
165
+ while (pos < src.length && /\s/.test(src[pos])) pos++
166
+ if (src[pos] !== '>') fail('Malformed end tag')
167
+ pos++
168
+ return { name, attrs, text: decodeEntities(textParts).trim(), children }
169
+ }
170
+ if (src.startsWith('<!--', pos)) {
171
+ const end = src.indexOf('-->', pos)
172
+ if (end === -1) fail('Unterminated comment')
173
+ pos = end + 3
174
+ continue
175
+ }
176
+ if (src[pos] === '<') {
177
+ children.push(parseElement(depth + 1))
178
+ continue
179
+ }
180
+ const next = src.indexOf('<', pos)
181
+ if (next === -1) fail('Unexpected end of document')
182
+ textParts += src.slice(pos, next)
183
+ pos = next
184
+ }
185
+ }
186
+
187
+ skipTrivia()
188
+ if (pos >= src.length) fail('Empty XML document')
189
+ const root = parseElement(0)
190
+ skipTrivia()
191
+ if (pos !== src.length) fail('Trailing content after root element')
192
+ return root
193
+ }
194
+
195
+ export function childrenNamed(node, name) {
196
+ return node ? node.children.filter((c) => c.name === name) : []
197
+ }
198
+
199
+ export function childNamed(node, name) {
200
+ return node ? node.children.find((c) => c.name === name) : undefined
201
+ }
202
+
203
+ export function childText(node, name) {
204
+ return childNamed(node, name)?.text
205
+ }