diff --git a/source/tools/dav.ts b/source/tools/dav.ts index d2d2c41..2ecdbd8 100644 --- a/source/tools/dav.ts +++ b/source/tools/dav.ts @@ -35,6 +35,7 @@ function toJPathString( function getParser({ attributeNamePrefix, attributeParsers, + clarkNotationProps, entityDecoder: entityDecoderOptions, tagParsers }: WebDAVParsingContext): XMLParser { @@ -43,7 +44,7 @@ function getParser({ attributeNamePrefix, textNodeName: "text", ignoreAttributes: false, - removeNSPrefix: true, + removeNSPrefix: !clarkNotationProps, jPath: false, numberParseOptions: { hex: true, @@ -165,9 +166,207 @@ function normaliseResult(result: DAVResultRaw): DAVResult { return output as DAVResult; } +// Structural keys of the WebDAV multistatus envelope (RFC 4918). When +// `clarkNotationProps` is enabled the parser keeps namespace prefixes on +// every tag, so these structural keys arrive prefixed (e.g. `d:multistatus`) +// and have to be renamed back to their bare local name so the existing +// `normaliseResult` continues to work and `DAVResult` keeps its shape. +// +// Keep this list in sync with the structural fields exposed on `DAVResult`, +// `DAVResultResponse`, `DAVResultPropstatResponse`, `DAVResultStatusResponse` +// and `DAVPropStat` in `source/types.ts`. If a new structural field is added +// to those interfaces, it must be added here too or the prefixed variant +// will leak through into the result. +const STRUCTURAL_KEYS = new Set([ + "multistatus", + "response", + "propstat", + "prop", + "status", + "href", + "responsedescription" +]); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Transform a parsed (prefix-preserving) tree in place to: + * + * 1. Rename structural keys (`multistatus`/`response`/...) back to their + * bare local names so `DAVResult`'s shape is preserved. + * 2. Rewrite every key inside each `` to Clark notation + * `{namespaceURI}localName`, resolving the namespace from the xmlns + * scope of the surrounding multistatus and from any inline + * `xmlns="..."` declared on the property element itself. Wrapped + * simple-text elements of the form `{ "@xmlns": ..., text: "..." }` + * are unwrapped to their text content. Property values with their own + * nested element children are returned as-is (the keys of those + * grandchildren are not rewritten to Clark notation). + */ +function applyClarkNotation(root: unknown, attrPrefix: string): void { + if (!isPlainObject(root) && !Array.isArray(root)) return; + + const xmlnsKey = `${attrPrefix}xmlns`; + const xmlnsPrefixedKey = `${xmlnsKey}:`; + const xmlnsPrefixedKeyLen = xmlnsPrefixedKey.length; + const TEXT_KEY = "text"; + const EMPTY_SCOPE: Map = new Map(); + + function isXmlnsAttr(key: string): boolean { + return ( + key === xmlnsKey || + (key.length > xmlnsPrefixedKeyLen && key.startsWith(xmlnsPrefixedKey)) + ); + } + + // Return an extended scope if `obj` declares any xmlns; otherwise reuse + // the parent scope to avoid allocating a Map for every node. + function extendScope( + obj: Record, + parent: Map + ): Map { + let scope: Map | null = null; + for (const key of Object.keys(obj)) { + if (key === xmlnsKey) { + if (!scope) scope = new Map(parent); + scope.set("", obj[key] as string); + } else if (key.length > xmlnsPrefixedKeyLen && key.startsWith(xmlnsPrefixedKey)) { + if (!scope) scope = new Map(parent); + scope.set(key.slice(xmlnsPrefixedKeyLen), obj[key] as string); + } + } + return scope ?? parent; + } + + // Unwrap the `{ "@xmlns": ..., text: "..." }` shape that fast-xml-parser + // produces for elements with both an xmlns attribute and text content. + // Complex elements with their own nested children are returned as-is: + // their keys are NOT rewritten to Clark notation, since the walker only + // resolves namespaces at the `` child level. + function unwrapXmlnsWrappedValue(raw: unknown): unknown { + if (!isPlainObject(raw)) return raw; + const text = raw[TEXT_KEY]; + for (const k of Object.keys(raw)) { + if (k === TEXT_KEY) continue; + if (!isXmlnsAttr(k)) return raw; + } + return text !== undefined ? text : raw; + } + + function emitClarkForChild( + rawKey: string, + rawValue: unknown, + scope: Map, + out: Record + ): void { + const colonIdx = rawKey.indexOf(":"); + const prefix = colonIdx === -1 ? "" : rawKey.slice(0, colonIdx); + const local = colonIdx === -1 ? rawKey : rawKey.slice(colonIdx + 1); + // Unknown prefix falls back to the null namespace; that yields a bare + // `local` key rather than throwing or losing the data. + const scopeNs = scope.get(prefix) ?? ""; + + const assign = (ns: string, value: unknown): void => { + const key = ns ? `{${ns}}${local}` : local; + const existing = out[key]; + if (existing === undefined) { + out[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + out[key] = [existing, value]; + } + }; + + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + const itemNs = + isPlainObject(item) && xmlnsKey in item ? (item[xmlnsKey] as string) : scopeNs; + assign(itemNs, unwrapXmlnsWrappedValue(item)); + } + return; + } + + const ns = + isPlainObject(rawValue) && xmlnsKey in rawValue + ? (rawValue[xmlnsKey] as string) + : scopeNs; + assign(ns, unwrapXmlnsWrappedValue(rawValue)); + } + + function rewritePropToClark( + propObj: Record, + parentScope: Map + ): Record { + const scope = extendScope(propObj, parentScope); + const out: Record = {}; + for (const key of Object.keys(propObj)) { + if (isXmlnsAttr(key)) continue; + emitClarkForChild(key, propObj[key], scope, out); + } + return out; + } + + function walk(node: unknown, scope: Map): void { + if (Array.isArray(node)) { + for (const item of node) walk(item, scope); + return; + } + if (!isPlainObject(node)) return; + + const childScope = extendScope(node, scope); + + for (const key of Object.keys(node)) { + const colonIdx = key.indexOf(":"); + const ln = colonIdx === -1 ? key : key.slice(colonIdx + 1); + const value = node[key]; + + // Rename structural keys to bare local name. + let actualKey = key; + if (STRUCTURAL_KEYS.has(ln) && ln !== key) { + delete node[key]; + node[ln] = value; + actualKey = ln; + } + + if (ln === "prop") { + if (isPlainObject(value)) { + node[actualKey] = rewritePropToClark(value, childScope); + } else if (Array.isArray(value)) { + node[actualKey] = value.map(v => + isPlainObject(v) ? rewritePropToClark(v, childScope) : v + ); + } + // Stop here; the prop content was rewritten in one shot. + } else { + walk(value, childScope); + } + } + } + + walk(root, EMPTY_SCOPE); +} + /** - * Parse an XML response from a WebDAV service, - * converting it to an internal DAV result + * Parse an XML response from a WebDAV service, converting it to an internal + * DAV result. + * + * When `context.clarkNotationProps` is `true`, every property key inside + * each `propstat.prop` is rewritten to Clark notation + * `{namespaceURI}localName`. This lets consumers disambiguate properties + * that share a local name across different XML namespaces (RFC 4918) and + * uses a single canonical key per property regardless of how the server + * serialised the namespace (prefix or inline default xmlns). + * + * The structural shape of `DAVResult` (`multistatus`/`response`/`propstat`/ + * `prop`/`status`/`href`) is unaffected by this option; only the keys + * inside each `propstat.prop` change. Downstream helpers like + * `prepareFileFromProps`, `parseStat` and `parseSearch` assume bare prop + * keys and will not work with Clark-notation ones; consumers that opt in + * are expected to address the Clark keys on their side. + * * @param xml The raw XML string * @param context The current client context * @returns A parsed and processed DAV result @@ -181,6 +380,9 @@ export function parseXML(xml: string, context?: WebDAVParsingContext): Promise { const result = getParser(context).parse(xml); + if (context.clarkNotationProps) { + applyClarkNotation(result, context.attributeNamePrefix ?? "@"); + } resolve(normaliseResult(result)); }); } diff --git a/source/types.ts b/source/types.ts index ba606ab..cdb9ba6 100644 --- a/source/types.ts +++ b/source/types.ts @@ -33,6 +33,12 @@ export interface CreateWriteStreamOptions extends WebDAVMethodOptions { overwrite?: boolean; } +// The bare local names of the structural fields below (`multistatus`, +// `response`, `propstat`, `prop`, `status`, `href`, `responsedescription`) +// are mirrored in the `STRUCTURAL_KEYS` set in `tools/dav.ts`, which strips +// any namespace prefixes from those keys when `clarkNotationProps` is +// enabled. Keep both in sync. + /** as per http://www.webdav.org/specs/rfc2518.html#rfc.section.12.9.1.1 */ interface DAVPropStat { prop: DAVResultResponseProps; @@ -408,6 +414,7 @@ export interface WebDAVEntityDecoderOptions { export interface WebDAVParsingContext { attributeNamePrefix?: string; attributeParsers: WebDAVAttributeParser[]; + clarkNotationProps?: boolean; entityDecoder?: WebDAVEntityDecoderOptions; tagParsers: WebDAVTagParser[]; } diff --git a/test/node/tools/dav.spec.ts b/test/node/tools/dav.spec.ts index c3a80c0..8ec8930 100644 --- a/test/node/tools/dav.spec.ts +++ b/test/node/tools/dav.spec.ts @@ -150,6 +150,144 @@ describe("parseXML", function () { ]); }); + describe("clarkNotationProps", function () { + // Two extensions reuse the local name `value` under different + // namespaces. We use the inline default-namespace serialisation + // (`xmlns="..."` on each element rather than a prefix) because that + // is what Go's `encoding/xml` produces for namespaces it has no + // registered prefix for — and what the OpenCloud server actually + // sends for custom-namespace properties. + const xml = ` + + + /file.txt + + + file.txt + RDNVW + PROJ-123 + high + approved + alice + + HTTP/1.1 200 OK + + +`; + + it("merges same-local-name props from different namespaces into one key by default", async function () { + const parsed = await parseXML(xml); + const props = parsed.multistatus.response[0].propstat.prop; + // The two `value` elements collapse to one `value` key; values + // are preserved as an array but their namespace origin is lost. + expect(props.value).to.deep.equal(["PROJ-123", "approved"]); + expect(props.displayname).to.equal("file.txt"); + }); + + it("rewrites prop keys to Clark notation when clarkNotationProps is true", async function () { + const parsed = await parseXML(xml, { + attributeNamePrefix: "@", + attributeParsers: [], + clarkNotationProps: true, + tagParsers: [] + }); + // Structural envelope unchanged: bare keys, normalised array of responses. + expect(parsed.multistatus.response).to.have.length(1); + const propstat = parsed.multistatus.response[0].propstat; + expect(propstat.status).to.equal("HTTP/1.1 200 OK"); + + // Prop keys are in Clark notation. Standard DAV/oc props resolve + // their prefix against the xmlns scope from the multistatus root; + // inline-default-namespace props resolve from their own xmlns + // attribute. The two same-local-name `value` props now coexist + // as distinct Clark keys. + const props = propstat.prop as unknown as Record; + expect(props["{DAV:}displayname"]).to.equal("file.txt"); + expect(props["{http://owncloud.org/ns}permissions"]).to.equal("RDNVW"); + expect(props["{http://opencloud.eu/ns/extensions/com.example.project}value"]).to.equal( + "PROJ-123" + ); + expect( + props["{http://opencloud.eu/ns/extensions/com.example.project}priority"] + ).to.equal("high"); + expect(props["{http://opencloud.eu/ns/extensions/com.example.review}value"]).to.equal( + "approved" + ); + expect( + props["{http://opencloud.eu/ns/extensions/com.example.review}reviewer"] + ).to.equal("alice"); + }); + + it("resolves prefixed prop tags against the multistatus xmlns scope", async function () { + // The other clarkNotationProps test uses inline `xmlns="..."` on + // each prop element (Sabre's serialisation for custom namespaces); + // this test exercises the other path where the namespace is + // resolved from the prefix-to-URI map declared on the multistatus + // element. Nextcloud and similar servers serialise this way. + const prefixedXml = ` + + + /file.txt + + + file.txt + RDNVW + true + + HTTP/1.1 200 OK + + +`; + const parsed = await parseXML(prefixedXml, { + attributeNamePrefix: "@", + attributeParsers: [], + clarkNotationProps: true, + tagParsers: [] + }); + const props = parsed.multistatus.response[0].propstat.prop as unknown as Record< + string, + string + >; + expect(props["{DAV:}displayname"]).to.equal("file.txt"); + expect(props["{http://owncloud.org/ns}permissions"]).to.equal("RDNVW"); + expect(props["{http://nextcloud.org/ns}has-preview"]).to.equal(true); + }); + + it("falls back to the null namespace for prefixes that are not in scope", async function () { + const xmlMalformed = ` + + + /file.txt + + + file.txt + orphan + + HTTP/1.1 200 OK + + +`; + const parsed = await parseXML(xmlMalformed, { + attributeNamePrefix: "@", + attributeParsers: [], + clarkNotationProps: true, + tagParsers: [] + }); + const props = parsed.multistatus.response[0].propstat.prop as unknown as Record< + string, + string + >; + // Known prefix (d:) resolves normally; unknown prefix (x:) has no + // URI in scope and falls back to the null namespace, yielding a + // bare local-name key rather than throwing or losing the data. + expect(props["{DAV:}displayname"]).to.equal("file.txt"); + expect(props.lonely).to.equal("orphan"); + }); + }); + describe("entityDecoder", function () { it("parses XML with entities when entityDecoder is not set", async function () { const xml = `