From b04e5686e28141e345caf17888979064baa6d347 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 21 May 2026 16:03:58 +0200 Subject: [PATCH 1/2] feat: opt-in Clark-notation prop keys for namespace disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `clarkNotationProps` field to `WebDAVParsingContext`, defaulting to `false`. When set to `true`, every property key inside each `propstat.prop` is rewritten to Clark notation `{namespaceURI}localName`. This lets consumers calling parseXML directly disambiguate properties that share a local name across different XML namespaces — RFC 4918 allows this and the default unconditional prefix stripping today collapses such properties (into an array under one bare key, with the namespace origin lost). With Clark notation each property carries a single canonical key regardless of how the server serialised the namespace (prefix or inline default xmlns). The structural envelope of DAVResult (multistatus/response/propstat/prop/ status/href) is unaffected: a small walker renames any prefixed structural key back to its bare local name before normaliseResult runs, then rewrites prop content to Clark notation. The walker stops at each so prop contents do not cascade into the structural rename pass. xmlns declarations on the multistatus element are kept as attributes and used to resolve prop-key namespaces; inline xmlns="..." on individual property elements overrides scope. Unknown prefixes fall back to the null namespace rather than throwing. Default behaviour, normaliseResult, normaliseResponse and the downstream helpers (prepareFileFromProps, parseStat, parseSearch) are unchanged. Refs #210 --- source/tools/dav.ts | 223 +++++++++++++++++++++++++++++++++++- source/types.ts | 7 ++ test/node/tools/dav.spec.ts | 100 ++++++++++++++++ 3 files changed, 327 insertions(+), 3 deletions(-) diff --git a/source/tools/dav.ts b/source/tools/dav.ts index d2d2c41..7ea3727 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,222 @@ 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 localName(key: string): string { + const colonIdx = key.indexOf(":"); + return colonIdx === -1 ? key : key.slice(colonIdx + 1); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** - * Parse an XML response from a WebDAV service, - * converting it to an internal DAV result + * 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 values + * of the form `{ "@xmlns": ..., text: "..." }` are unwrapped to just + * their text content. + * + * Pre-computes the xmlns attribute keys once and extends the prefix scope + * map only when a node actually declares xmlns, so the walk stays close to + * a plain tree traversal in cost. + */ +function applyClarkNotation(root: unknown, attrPrefix: string): void { + if (!isPlainObject(root) && !Array.isArray(root)) return; + + // Stable string references for the duration of the walk; lets V8 + // inline-cache the property accesses against the same key shapes. + 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; + } + + function unwrapXmlnsWrappedValue(raw: unknown): unknown { + if (!isPlainObject(raw)) return raw; + const text = raw[TEXT_KEY]; + // If the only non-text keys are xmlns attrs, the element is a simple + // text node with namespace metadata: extract the text directly. + let onlyXmlnsAndText = true; + for (const k of Object.keys(raw)) { + if (k === TEXT_KEY) continue; + if (!isXmlnsAttr(k)) { + onlyXmlnsAndText = false; + break; + } + } + if (onlyXmlnsAndText && text !== undefined) return text; + // Complex element: clone with xmlns attrs stripped, keep child content. + const out: Record = {}; + for (const k of Object.keys(raw)) { + if (!isXmlnsAttr(k)) out[k] = raw[k]; + } + return out; + } + + 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 ln = localName(key); + 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. + * + * 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 +395,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..7b0caf9 100644 --- a/test/node/tools/dav.spec.ts +++ b/test/node/tools/dav.spec.ts @@ -150,6 +150,106 @@ 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("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 = ` From 00677463fa8dbf2315b438ae907aa7c003b08894 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 21 May 2026 18:13:07 +0200 Subject: [PATCH 2/2] refactor: tighten Clark walker after review feedback - Inline the single-use `localName` helper into `walk` (top-level function no longer needed). - Simplify `unwrapXmlnsWrappedValue`: the "complex element clone with xmlns attrs stripped" branch was never reached by tests and would have returned the value half-rewritten anyway (grandchild keys unchanged), so it is removed. Complex elements are now returned as-is and the limitation is documented in the JSDoc. - Drop the V8 inline-cache comment from `applyClarkNotation`; the code is straightforward enough to speak for itself. - Add a `clarkNotationProps` test for prefix-only serialisation (Nextcloud-style: `` with `xmlns:oc` declared on the multistatus root). The existing tests exercised the inline-`xmlns` path; this covers the prefix-to-URI lookup path. --- source/tools/dav.ts | 43 ++++++++++++------------------------- test/node/tools/dav.spec.ts | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/source/tools/dav.ts b/source/tools/dav.ts index 7ea3727..2ecdbd8 100644 --- a/source/tools/dav.ts +++ b/source/tools/dav.ts @@ -187,11 +187,6 @@ const STRUCTURAL_KEYS = new Set([ "responsedescription" ]); -function localName(key: string): string { - const colonIdx = key.indexOf(":"); - return colonIdx === -1 ? key : key.slice(colonIdx + 1); -} - function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -204,19 +199,15 @@ function isPlainObject(value: unknown): value is Record { * 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 values - * of the form `{ "@xmlns": ..., text: "..." }` are unwrapped to just - * their text content. - * - * Pre-computes the xmlns attribute keys once and extends the prefix scope - * map only when a node actually declares xmlns, so the walk stays close to - * a plain tree traversal in cost. + * `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; - // Stable string references for the duration of the walk; lets V8 - // inline-cache the property accesses against the same key shapes. const xmlnsKey = `${attrPrefix}xmlns`; const xmlnsPrefixedKey = `${xmlnsKey}:`; const xmlnsPrefixedKeyLen = xmlnsPrefixedKey.length; @@ -249,26 +240,19 @@ function applyClarkNotation(root: unknown, attrPrefix: string): void { 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]; - // If the only non-text keys are xmlns attrs, the element is a simple - // text node with namespace metadata: extract the text directly. - let onlyXmlnsAndText = true; for (const k of Object.keys(raw)) { if (k === TEXT_KEY) continue; - if (!isXmlnsAttr(k)) { - onlyXmlnsAndText = false; - break; - } + if (!isXmlnsAttr(k)) return raw; } - if (onlyXmlnsAndText && text !== undefined) return text; - // Complex element: clone with xmlns attrs stripped, keep child content. - const out: Record = {}; - for (const k of Object.keys(raw)) { - if (!isXmlnsAttr(k)) out[k] = raw[k]; - } - return out; + return text !== undefined ? text : raw; } function emitClarkForChild( @@ -335,7 +319,8 @@ function applyClarkNotation(root: unknown, attrPrefix: string): void { const childScope = extendScope(node, scope); for (const key of Object.keys(node)) { - const ln = localName(key); + const colonIdx = key.indexOf(":"); + const ln = colonIdx === -1 ? key : key.slice(colonIdx + 1); const value = node[key]; // Rename structural keys to bare local name. diff --git a/test/node/tools/dav.spec.ts b/test/node/tools/dav.spec.ts index 7b0caf9..8ec8930 100644 --- a/test/node/tools/dav.spec.ts +++ b/test/node/tools/dav.spec.ts @@ -218,6 +218,44 @@ describe("parseXML", function () { ).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 = `