diff --git a/bun.lock b/bun.lock index 9deb7f694..6b86ca8ba 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/package.json b/package.json index df912d973..b621f7177 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/integrations/storage/s3/client.ts b/src/app/integrations/storage/s3/client.ts index a56633ba6..dc4a09410 100644 --- a/src/app/integrations/storage/s3/client.ts +++ b/src/app/integrations/storage/s3/client.ts @@ -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>/i) - const messageMatch = text.match(/([^<]+)<\/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(/([\s\S]*?)<\/Contents>/gi)] - const items: ListedObject[] = [] - for (const match of contents) { - const block = match[1] ?? '' - const key = block.match(/([^<]*)<\/Key>/i)?.[1] - if (!key) continue - const lastModified = block.match(/([^<]*)<\/LastModified>/i)?.[1] ?? null - const sizeRaw = block.match(/([^<]*)<\/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>/i)?.[1] - const isTruncated = truncatedRaw?.trim().toLowerCase() === 'true' - const tokenRaw = xml.match(/([^<]*)<\/NextContinuationToken>/i)?.[1] - return { - objects: items, - isTruncated, - nextContinuationToken: tokenRaw ? decodeXmlEntities(tokenRaw) : null - } -} - -function decodeXmlEntities(value: string): string { - return value - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') -} - export async function listObjects( config: S3CompatibleConfig, prefix: string diff --git a/src/app/integrations/storage/s3/xml.ts b/src/app/integrations/storage/s3/xml.ts new file mode 100644 index 000000000..a1360b570 --- /dev/null +++ b/src/app/integrations/storage/s3/xml.ts @@ -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') + } +} diff --git a/tests/engine/app/integrations/storage/list-objects.test.ts b/tests/engine/app/integrations/storage/list-objects.test.ts index 315f38519..13f7ebcbb 100644 --- a/tests/engine/app/integrations/storage/list-objects.test.ts +++ b/tests/engine/app/integrations/storage/list-objects.test.ts @@ -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 = `open_pencil_storage/canvases/a&b.fig1` - 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 = `open_pencil_storage/canvases/a&b.fig1truea&b` + 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( + `AccessDeniedKey contains <Code>fake</Code>`, + 403 + ) + ).toEqual({ code: 'AccessDenied', message: 'Key contains fake' }) + }) + + test('returns an empty page for malformed XML', () => { + expect(parseListObjectsV2Page('')).toEqual({ + objects: [], + isTruncated: false, + nextContinuationToken: null + }) }) })