fix(storage): parse S3 responses as XML

- Replace regex-based list and error parsing with xmldom

- Decode entities through the parser and support namespaced pagination responses

- Treat malformed listing documents as empty pages
This commit is contained in:
Danila Poyarkov 2026-07-27 19:04:50 +03:00
parent 42d3122323
commit 858e6451de
5 changed files with 112 additions and 65 deletions

View file

@ -35,6 +35,7 @@
"@tauri-apps/plugin-updater": "^2.10.1",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"@xmldom/xmldom": "^0.9.10",
"ai": "^6.0.174",
"aws4fetch": "^1.0.20",
"canvaskit-wasm": "^0.40.0",

View file

@ -88,6 +88,7 @@
"@tauri-apps/plugin-updater": "^2.10.1",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"@xmldom/xmldom": "^0.9.10",
"ai": "^6.0.174",
"aws4fetch": "^1.0.20",
"canvaskit-wasm": "^0.40.0",

View file

@ -3,6 +3,11 @@ import { AwsClient } from 'aws4fetch'
import { storageFetch } from '@/app/integrations/storage/s3/fetch'
import { inferS3Region } from '@/app/integrations/storage/s3/region'
import type { S3CompatibleConfig } from '@/app/integrations/storage/s3/types'
import {
parseListObjectsV2Page,
parseS3ErrorXml,
type ListedObject
} from '@/app/integrations/storage/s3/xml'
export function resolveS3Region(config: S3CompatibleConfig): string {
const explicit = config.region?.trim()
@ -50,13 +55,7 @@ export function createAwsClient(config: S3CompatibleConfig): AwsClient {
async function readErrorBody(res: Response): Promise<{ message: string; code: string | null }> {
const text = await res.text().catch(() => '')
const codeMatch = text.match(/<Code>([^<]+)<\/Code>/i)
const messageMatch = text.match(/<Message>([^<]+)<\/Message>/i)
const code = codeMatch?.[1] ?? null
const message =
messageMatch?.[1] ??
(text.trim() ? text.trim().slice(0, 200) : `S3 request failed with status ${res.status}`)
return { message, code }
return parseS3ErrorXml(text, res.status)
}
/**
@ -237,59 +236,6 @@ export async function deleteObject(config: S3CompatibleConfig, key: string): Pro
}
}
export type ListedObject = {
key: string
lastModified: string | null
size: number | null
}
export type ListObjectsPage = {
objects: ListedObject[]
isTruncated: boolean
nextContinuationToken: string | null
}
/** Parse ListObjectsV2 XML into key entries. Pure for unit tests. */
export function parseListObjectsV2Xml(xml: string): ListedObject[] {
return parseListObjectsV2Page(xml).objects
}
/** Parse ListObjectsV2 XML including pagination fields. */
export function parseListObjectsV2Page(xml: string): ListObjectsPage {
const contents = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/gi)]
const items: ListedObject[] = []
for (const match of contents) {
const block = match[1] ?? ''
const key = block.match(/<Key>([^<]*)<\/Key>/i)?.[1]
if (!key) continue
const lastModified = block.match(/<LastModified>([^<]*)<\/LastModified>/i)?.[1] ?? null
const sizeRaw = block.match(/<Size>([^<]*)<\/Size>/i)?.[1]
const size = sizeRaw != null && sizeRaw !== '' ? Number(sizeRaw) : null
items.push({
key: decodeXmlEntities(key),
lastModified,
size: Number.isFinite(size) ? size : null
})
}
const truncatedRaw = xml.match(/<IsTruncated>([^<]*)<\/IsTruncated>/i)?.[1]
const isTruncated = truncatedRaw?.trim().toLowerCase() === 'true'
const tokenRaw = xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/i)?.[1]
return {
objects: items,
isTruncated,
nextContinuationToken: tokenRaw ? decodeXmlEntities(tokenRaw) : null
}
}
function decodeXmlEntities(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
export async function listObjects(
config: S3CompatibleConfig,
prefix: string

View file

@ -0,0 +1,76 @@
import { DOMParser, type Document, type Element } from '@xmldom/xmldom'
export type ListedObject = {
key: string
lastModified: string | null
size: number | null
}
export type ListObjectsPage = {
objects: ListedObject[]
isTruncated: boolean
nextContinuationToken: string | null
}
function parseXML(source: string): Document | null {
try {
return new DOMParser({
onError: (level, message) => {
if (level !== 'warning') throw new Error(message)
}
}).parseFromString(source, 'application/xml')
} catch {
return null
}
}
function elementsByName(root: Document | Element, name: string): Element[] {
return Array.from(root.getElementsByTagNameNS('*', name))
}
function firstText(root: Document | Element, name: string): string | null {
return elementsByName(root, name)[0]?.textContent ?? null
}
export function parseS3ErrorXml(
source: string,
status: number
): { message: string; code: string | null } {
const xmlDocument = parseXML(source)
const code = xmlDocument ? firstText(xmlDocument, 'Code') : null
const xmlMessage = xmlDocument ? firstText(xmlDocument, 'Message') : null
const message =
xmlMessage ??
(source.trim() ? source.trim().slice(0, 200) : `S3 request failed with status ${status}`)
return { message, code }
}
export function parseListObjectsV2Xml(source: string): ListedObject[] {
return parseListObjectsV2Page(source).objects
}
export function parseListObjectsV2Page(source: string): ListObjectsPage {
const xmlDocument = parseXML(source)
if (!xmlDocument) {
return { objects: [], isTruncated: false, nextContinuationToken: null }
}
const objects = elementsByName(xmlDocument, 'Contents').flatMap((content) => {
const key = firstText(content, 'Key')
if (!key) return []
const sizeText = firstText(content, 'Size')
const size = sizeText ? Number(sizeText) : null
return [
{
key,
lastModified: firstText(content, 'LastModified'),
size: Number.isFinite(size) ? size : null
}
]
})
return {
objects,
isTruncated: firstText(xmlDocument, 'IsTruncated')?.trim().toLowerCase() === 'true',
nextContinuationToken: firstText(xmlDocument, 'NextContinuationToken')
}
}

View file

@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { documentIdFromFigKey } from '@/app/integrations/storage/namespace'
import { parseListObjectsV2Xml } from '@/app/integrations/storage/s3/client'
import {
parseListObjectsV2Page,
parseListObjectsV2Xml,
parseS3ErrorXml
} from '@/app/integrations/storage/s3/xml'
describe('parseListObjectsV2Xml', () => {
test('extracts keys and ignores objects outside canvas fig pattern when filtered', () => {
@ -36,9 +40,28 @@ describe('parseListObjectsV2Xml', () => {
expect(canvasIds).toEqual(['a1'])
})
test('decodes basic XML entities in keys', () => {
const xml = `<ListBucketResult><Contents><Key>open_pencil_storage/canvases/a&amp;b.fig</Key><Size>1</Size></Contents></ListBucketResult>`
const listed = parseListObjectsV2Xml(xml)
expect(listed[0]?.key).toBe('open_pencil_storage/canvases/a&b.fig')
test('decodes entities and reads namespaced pagination fields', () => {
const xml = `<s3:ListBucketResult xmlns:s3="urn:s3"><s3:Contents><s3:Key>open_pencil_storage/canvases/a&amp;b.fig</s3:Key><s3:Size>1</s3:Size></s3:Contents><s3:IsTruncated>true</s3:IsTruncated><s3:NextContinuationToken>a&amp;b</s3:NextContinuationToken></s3:ListBucketResult>`
const page = parseListObjectsV2Page(xml)
expect(page.objects[0]?.key).toBe('open_pencil_storage/canvases/a&b.fig')
expect(page.isTruncated).toBe(true)
expect(page.nextContinuationToken).toBe('a&b')
})
test('parses S3 error XML without interpreting markup-like text', () => {
expect(
parseS3ErrorXml(
`<Error><Code>AccessDenied</Code><Message>Key contains &lt;Code&gt;fake&lt;/Code&gt;</Message></Error>`,
403
)
).toEqual({ code: 'AccessDenied', message: 'Key contains <Code>fake</Code>' })
})
test('returns an empty page for malformed XML', () => {
expect(parseListObjectsV2Page('<ListBucketResult><Contents>')).toEqual({
objects: [],
isTruncated: false,
nextContinuationToken: null
})
})
})