Skip to content

feat: opt-in Clark-notation prop keys for namespace disambiguation#410

Open
dschmidt wants to merge 2 commits into
perry-mitchell:masterfrom
dschmidt:feat/preserve-prop-namespaces
Open

feat: opt-in Clark-notation prop keys for namespace disambiguation#410
dschmidt wants to merge 2 commits into
perry-mitchell:masterfrom
dschmidt:feat/preserve-prop-namespaces

Conversation

@dschmidt

@dschmidt dschmidt commented May 21, 2026

Copy link
Copy Markdown
Contributor

Related to #210, though not a full fix for the high-level stat() shape it asks about.

Motivation

I'm building a generic extension metadata mechanism in OpenCloud. Third-party extensions can attach typed metadata to files, and the natural mapping for cross-protocol access turned out to be one custom XML namespace per extension. Multiple extensions can then reuse the same local property name without colliding, because the namespace URI is what disambiguates them. RFC 4918 supports this cleanly, and on the server side it falls out for free.

On the client side it doesn't. Today parseXML strips namespace prefixes unconditionally, so two properties with the same local name from different namespaces (e.g. <value xmlns="proj-ns"> and <value xmlns="rev-ns">) collapse to a single value key (with values merged into an array) and lose their namespace origin entirely. Real-world servers add a second twist: Go's encoding/xml and similar marshalers serialise the standard DAV/oc properties with prefixes (<d:getlastmodified>) but custom-namespace properties with inline default xmlns on the element (<value xmlns="...">), because they have no prefix registered for the custom namespace. The client has to deal with both serialisation styles in the same response.

What I originally tried

My first attempt at this PR was a simple opt-in that just told parseXML to keep namespace prefixes on tags (essentially removeNSPrefix: false). That works for servers that prefix-serialise custom namespaces, but for the inline-xmlns style it just leaked fast-xml-parser's raw object shape into the result ({ "text": "...", "@xmlns": "..." } per element, arrays when collisions occur, and so on). That made the option's output awkward to reason about and inconsistent across server serialisation styles. I'd be shipping a half-feature whose consumers would commit to an unpleasant shape, and any "real" fix later would mean a second parallel opt-in.

So the actual feature is Clark notation: one canonical key per property regardless of how the server expressed the namespace.

What this PR does

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. The structural shape of DAVResult is unaffected: multistatus, response, propstat, prop, status, href all keep their bare names, and the existing normaliseResult runs unchanged.

Downstream helpers like prepareFileFromProps, parseStat and parseSearch assume bare prop keys and are intentionally left untouched; consumers that opt in handle the Clark-notation keys on their side, which is a single property access if they know the namespace they're after.

Implementation notes

The implementation isn't completely trivial and I'd really appreciate review feedback. A single-pass tree walker runs after fast-xml-parser parses with removeNSPrefix: false and does two things in one traversal:

  1. Renames any prefixed structural keys back to their bare local names so normaliseResult can do its job.
  2. Inside each <prop>, walks the children, resolves each one's namespace (from the xmlns scope of the multistatus element, or from an inline xmlns="..." on the element itself), unwraps the { text, "@xmlns" } object shape, and emits a single Clark-notation key per property. Same-local-name collisions in different namespaces become distinct keys.

Edge cases:

  • Unknown prefixes (used but never declared) fall back to the null namespace; that yields a bare local-name key instead of throwing.
  • Multiple elements with the same Clark key (genuine collisions in the same namespace) are kept as an array under that key.
  • The walk extends the prefix-to-URI scope map only when a node actually declares xmlns, to avoid per-node allocations.
  • String references for the xmlns/text attribute keys are computed once at the start of the walk so V8 can inline-cache the hot lookups.

I tried to keep the cost as low as I could by benchmarking alternative walker designs (V8 micro-optimisations, algorithmic restructure with direct navigation of the envelope, and parser-cooperative approaches using fast-xml-parser hooks). The full benchmark suite landed within noise of the current shape on end-to-end parseXML, since the parser dominates the cost and the walker is a small slice on top. I left the walker as a clean generic traversal rather than commit a more complex variant for a wash result. Very open to suggestions if you see a cheaper structural pattern.

Performance

Benchmarked against the current default and a discarded two-pass alternative. Each benchmark fixture is a synthetic multistatus response: one <d:response> per file with a single <d:propstat> (status 200) containing 8 standard properties (d:getlastmodified, d:getcontentlength, d:getetag, d:getcontenttype, d:resourcetype, oc:permissions, oc:id, oc:fileid) plus additional extension properties from two distinct custom namespaces with inline default-xmlns serialisation (the realistic Sabre / Go-encoding/xml shape), rotating, up to the configured prop count per file.

scenario size default (strip) clarkNotationProps overhead
10 files / 10 props 8.5 KB 0.38 ms 0.41 ms +6%
100 files / 15 props 134 KB 4.96 ms 5.96 ms +20%
1k files / 20 props 1.8 MB 69 ms 84 ms +21%
5k files / 30 props 14 MB 514 ms 640 ms +24%
10k files / 50 props 49 MB 1674 ms 2125 ms +27%
20k files / 50 props 99 MB 3343 ms 4279 ms +28%

Overhead settles around 25% on realistic-and-large inputs, paid only by consumers that opt in.

Tests

Three cases under parseXML > clarkNotationProps: documents today's collapse-on-collision behaviour (lossy array of values), verifies that the Clark-notation rewrite yields distinct canonical keys covering both prefix-resolved (DAV/oc) and inline-xmlns (extension) properties, and a malformed-input case where an undeclared prefix falls back to the null namespace rather than crashing.

@dschmidt dschmidt marked this pull request as draft May 21, 2026 13:33
@dschmidt

Copy link
Copy Markdown
Contributor Author

I just realised OpenCloud uses xmlns Attributes on the props - figuring out what this means for this PR :)

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 <prop> 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 perry-mitchell#210
@dschmidt dschmidt force-pushed the feat/preserve-prop-namespaces branch from 3a4a2b4 to b04e568 Compare May 21, 2026 14:58
@dschmidt dschmidt changed the title feat: opt-in preservation of namespace prefixes on prop tags feat: opt-in Clark-notation prop keys for namespace disambiguation May 21, 2026
@dschmidt

Copy link
Copy Markdown
Contributor Author

Feature got a bit bigger than anticipated, but I'm pretty satisfied with the outcome. Appreciate any feedback!

@dschmidt dschmidt marked this pull request as ready for review May 21, 2026 15:07
- 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: `<oc:permissions>` with `xmlns:oc` declared on
  the multistatus root). The existing tests exercised the
  inline-`xmlns` path; this covers the prefix-to-URI lookup path.
@dschmidt

dschmidt commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@perry-mitchell any thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant