[bugfix] xmldb:store: parse binary content stored under an XML mime type - #6497
[bugfix] xmldb:store: parse binary content stored under an XML mime type#6497joewiz wants to merge 1 commit into
Conversation
…gacy-name read-compat in GET Cleanup surfaced once eXist core (eXist-db/exist#6497) fixed the raw binary PUT, which had been masking these in the cypress suite. - create-collection: strict by default — a missing parent is now a clean 409 (was a raw exception). Pass `recursive: true` to create intermediate collections (mkdir -p). api.json gains the `recursive` body property and the 409 response; db-core gains the recursive arity + dbc:ensure-collection-path. - db:error-response now pins the response media-type to application/json. An error mapped to a status the route doesn't declare in api.json otherwise hits roaster's application/xml fallback and the error map fails to serialize (SENR0001). Hardens every db error path. See eeditiones/roaster#127. - db:get-resource resolved its existence check / binary streaming with dbc:to-stored, so it 404'd on legacy full-encoded names that dbc:get-resource / dbc:properties resolve. Made dbc:resolve-stored public and use it in the wrapper. - tests: migrate the remaining store call-sites (permissions setup; query.cy.js, query_pool_reuse, query_scope before-hooks) off the pre-consolidation {path,content,mime-type} envelope to the raw transport; make the raw-transport block self-sufficient; add strict-409 + recursive create coverage. Full cypress suite green (194/194) against an eXist build carrying #6497. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // The content is binary but the target mime type is an XML type: parse the | ||
| // binary's bytes as an XML document. Setting the BinaryValue directly would | ||
| // leave the (XML) resource with no character/byte stream, and the store would | ||
| // later fail with an NPE when it tried to parse a null string as XML. Feed the | ||
| // bytes through an InputSource so the parser reads (and encoding-detects) them; | ||
| // storing XML parses the whole document into a DOM anyway, so reading the bytes | ||
| // into a buffer here adds no asymptotic memory cost over the parse itself. | ||
| final byte[] xmlBytes; | ||
| try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { | ||
| ((BinaryValue) item).streamBinaryTo(baos); | ||
| xmlBytes = baos.toByteArray(); | ||
| } catch (final IOException e) { | ||
| throw new XPathException(this, "Unable to read binary content to store as XML: " + e.getMessage(), e); | ||
| } | ||
| // StringInputSource(byte[]) is re-readable (the store may open the | ||
| // stream more than once) and lets the parser detect the encoding from | ||
| // the bytes; a plain InputSource over a single ByteArrayInputStream would | ||
| // be drained on the first read. | ||
| resource.setContent(new StringInputSource(xmlBytes)); |
There was a problem hiding this comment.
Directly set a input source using the getInputStream() from BinaryValue as all returned implementation will support mark() in case a partial re-read is needed.
| // The content is binary but the target mime type is an XML type: parse the | |
| // binary's bytes as an XML document. Setting the BinaryValue directly would | |
| // leave the (XML) resource with no character/byte stream, and the store would | |
| // later fail with an NPE when it tried to parse a null string as XML. Feed the | |
| // bytes through an InputSource so the parser reads (and encoding-detects) them; | |
| // storing XML parses the whole document into a DOM anyway, so reading the bytes | |
| // into a buffer here adds no asymptotic memory cost over the parse itself. | |
| final byte[] xmlBytes; | |
| try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { | |
| ((BinaryValue) item).streamBinaryTo(baos); | |
| xmlBytes = baos.toByteArray(); | |
| } catch (final IOException e) { | |
| throw new XPathException(this, "Unable to read binary content to store as XML: " + e.getMessage(), e); | |
| } | |
| // StringInputSource(byte[]) is re-readable (the store may open the | |
| // stream more than once) and lets the parser detect the encoding from | |
| // the bytes; a plain InputSource over a single ByteArrayInputStream would | |
| // be drained on the first read. | |
| resource.setContent(new StringInputSource(xmlBytes)); | |
| BinaryValue binaryValue = (BinaryValue)item; | |
| resource.setContent(new InputSource(binaryValue.getInputStream())); |
Also let BinaryValueFromInputStream return a InputStream.nullInputStream() instead of null in the failure case
There was a problem hiding this comment.
[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]
Thanks — I've made the getInputStream() change: BinaryValueFromInputStream.getInputStream() now returns InputStream.nullInputStream() instead of null on its internal-failure path.
On setting the input source directly from getInputStream(): I tried it, and it doesn't survive the xmldb:store path. The store parses the source twice — once to validate, once to store (MutableCollection.storeXmlDocument → validator pass, then store pass) — and it does not reset() the source between the two passes; each pass just re-invokes getByteStream() and parses. A plain InputSource wrapping a single getInputStream() is therefore drained by the validate pass, and the store pass sees an empty stream (fatal error … Premature end of file). So the mark()/reset() support isn't exercised on this route — what the route needs is a source that hands back a fresh stream on each read.
I settled on buffering into a re-readable StringInputSource(byte[]), whose getByteStream() returns a new UnsynchronizedByteArrayInputStream per call. For the XML branch specifically the buffer is asymptotically free — storing XML parses the whole document into a DOM regardless — so this doesn't reintroduce the OOM risk the non-XML branch avoids by streaming. Verified against the four XQSuite cases (explicit and inferred XML mime, encoding honored, stored-as-XML-not-binary) plus the malformed-bytes case; all green.
…gacy-name read-compat in GET Cleanup surfaced once eXist core (eXist-db/exist#6497) fixed the raw binary PUT, which had been masking these in the cypress suite. - create-collection: strict by default — a missing parent is now a clean 409 (was a raw exception). Pass `recursive: true` to create intermediate collections (mkdir -p). api.json gains the `recursive` body property and the 409 response; db-core gains the recursive arity + dbc:ensure-collection-path. - db:error-response now pins the response media-type to application/json. An error mapped to a status the route doesn't declare in api.json otherwise hits roaster's application/xml fallback and the error map fails to serialize (SENR0001). Hardens every db error path. See eeditiones/roaster#127. - db:get-resource resolved its existence check / binary streaming with dbc:to-stored, so it 404'd on legacy full-encoded names that dbc:get-resource / dbc:properties resolve. Made dbc:resolve-stored public and use it in the wrapper. - tests: migrate the remaining store call-sites (permissions setup; query.cy.js, query_pool_reuse, query_scope before-hooks) off the pre-consolidation {path,content,mime-type} envelope to the raw transport; make the raw-transport block self-sufficient; add strict-409 + recursive create coverage. Full cypress suite green (194/194) against an eXist build carrying #6497. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gacy-name read-compat in GET Cleanup surfaced once eXist core (eXist-db/exist#6497) fixed the raw binary PUT, which had been masking these in the cypress suite. - create-collection: strict by default — a missing parent is now a clean 409 (was a raw exception). Pass `recursive: true` to create intermediate collections (mkdir -p). api.json gains the `recursive` body property and the 409 response; db-core gains the recursive arity + dbc:ensure-collection-path. - db:error-response now pins the response media-type to application/json. An error mapped to a status the route doesn't declare in api.json otherwise hits roaster's application/xml fallback and the error map fails to serialize (SENR0001). Hardens every db error path. See eeditiones/roaster#127. - db:get-resource resolved its existence check / binary streaming with dbc:to-stored, so it 404'd on legacy full-encoded names that dbc:get-resource / dbc:properties resolve. Made dbc:resolve-stored public and use it in the wrapper. - tests: migrate the remaining store call-sites (permissions setup; query.cy.js, query_pool_reuse, query_scope before-hooks) off the pre-consolidation {path,content,mime-type} envelope to the raw transport; make the raw-transport block self-sufficient; add strict-409 + recursive create coverage. Full cypress suite green (194/194) against an eXist build carrying #6497. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gacy-name read-compat in GET Cleanup surfaced once eXist core (eXist-db/exist#6497) fixed the raw binary PUT, which had been masking these in the cypress suite. - create-collection: strict by default — a missing parent is now a clean 409 (was a raw exception). Pass `recursive: true` to create intermediate collections (mkdir -p). api.json gains the `recursive` body property and the 409 response; db-core gains the recursive arity + dbc:ensure-collection-path. - db:error-response now pins the response media-type to application/json. An error mapped to a status the route doesn't declare in api.json otherwise hits roaster's application/xml fallback and the error map fails to serialize (SENR0001). Hardens every db error path. See eeditiones/roaster#127. - db:get-resource resolved its existence check / binary streaming with dbc:to-stored, so it 404'd on legacy full-encoded names that dbc:get-resource / dbc:properties resolve. Made dbc:resolve-stored public and use it in the wrapper. - tests: migrate the remaining store call-sites (permissions setup; query.cy.js, query_pool_reuse, query_scope before-hooks) off the pre-consolidation {path,content,mime-type} envelope to the raw transport; make the raw-transport block self-sufficient; add strict-409 + recursive create coverage. Full cypress suite green (194/194) against an eXist build carrying #6497. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gacy-name read-compat in GET Cleanup surfaced once eXist core (eXist-db/exist#6497) fixed the raw binary PUT, which had been masking these in the cypress suite. - create-collection: strict by default — a missing parent is now a clean 409 (was a raw exception). Pass `recursive: true` to create intermediate collections (mkdir -p). api.json gains the `recursive` body property and the 409 response; db-core gains the recursive arity + dbc:ensure-collection-path. - db:error-response now pins the response media-type to application/json. An error mapped to a status the route doesn't declare in api.json otherwise hits roaster's application/xml fallback and the error map fails to serialize (SENR0001). Hardens every db error path. See eeditiones/roaster#127. - db:get-resource resolved its existence check / binary streaming with dbc:to-stored, so it 404'd on legacy full-encoded names that dbc:get-resource / dbc:properties resolve. Made dbc:resolve-stored public and use it in the wrapper. - tests: migrate the remaining store call-sites (permissions setup; query.cy.js, query_pool_reuse, query_scope before-hooks) off the pre-consolidation {path,content,mime-type} envelope to the raw transport; make the raw-transport block self-sufficient; add strict-409 + recursive create coverage. Full cypress suite green (194/194) against an eXist build carrying #6497. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gacy-name read-compat in GET Cleanup surfaced once eXist core (eXist-db/exist#6497) fixed the raw binary PUT, which had been masking these in the cypress suite. - create-collection: strict by default — a missing parent is now a clean 409 (was a raw exception). Pass `recursive: true` to create intermediate collections (mkdir -p). api.json gains the `recursive` body property and the 409 response; db-core gains the recursive arity + dbc:ensure-collection-path. - db:error-response now pins the response media-type to application/json. An error mapped to a status the route doesn't declare in api.json otherwise hits roaster's application/xml fallback and the error map fails to serialize (SENR0001). Hardens every db error path. See eeditiones/roaster#127. - db:get-resource resolved its existence check / binary streaming with dbc:to-stored, so it 404'd on legacy full-encoded names that dbc:get-resource / dbc:properties resolve. Made dbc:resolve-stored public and use it in the wrapper. - tests: migrate the remaining store call-sites (permissions setup; query.cy.js, query_pool_reuse, query_scope before-hooks) off the pre-consolidation {path,content,mime-type} envelope to the raw transport; make the raw-transport block self-sufficient; add strict-409 + recursive create coverage. Full cypress suite green (194/194) against an eXist build carrying #6497. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@joewiz please could you revise? |
| */ | ||
| package org.exist.xquery.functions.xmldb; | ||
|
|
||
| import java.io.ByteArrayOutputStream; |
There was a problem hiding this comment.
Better to use apache's BAOS, or better we use frequenlty a variant that does not do locking/blocking/...
There was a problem hiding this comment.
[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]
Good call — switched to org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream for collecting the bytes, so the write side takes no lock. The read side was already lock-free: StringInputSource hands back an UnsynchronizedByteArrayInputStream. The JDK java.io.ByteArrayOutputStream import is gone.
xmldb:store($collection, $name, $content, $mime) threw a NullPointerException
("Cannot invoke \"String.length()\" because \"<parameter1>\" is null") whenever
$content was an xs:base64Binary value and the target mime type was an XML type
(an explicit mime="application/xml", or one inferred from an .xml resource name).
getResource() creates an XMLResource for an XML mime type, but the binary was
then bound to it via resource.setContent((BinaryValue) item). A BinaryValue is
an AtomicValue, so it landed in the resource's value slot, leaving the XML
resource with no character or byte stream. The store then built a
StringInputSource from the resource's null string content and parsing failed
with the NPE (new StringReader(null)).
A binary value declared as XML should be parsed as an XML document. Buffer the
bytes into a re-readable StringInputSource(byte[]) so the parser reads them and
detects the encoding from the XML declaration. The bytes are collected with an
UnsynchronizedByteArrayOutputStream, and StringInputSource yields a fresh
UnsynchronizedByteArrayInputStream on each read, so neither the collect nor the
replay path takes a lock. A re-readable source is required because the store
parses it twice — once to validate, once to store — without resetting it
between passes; the buffer costs nothing asymptotically, since storing XML
parses the whole document into a DOM regardless. Non-XML mime types are
unchanged: the BinaryValue is still streamed straight to a binary resource
without materializing it. Malformed bytes under an XML mime now produce a clean
parse error instead of an NPE.
Also harden BinaryValueFromInputStream.getInputStream() to return an empty
stream (InputStream.nullInputStream()) rather than null on its internal failure
path, so callers cannot NPE on the result.
This surfaced through existdb-openapi's binary-safe PUT /api/db/resource:
roaster hands the raw request body, which request:get-data() returns as an
xs:base64Binary for an application/octet-stream upload, to xmldb:store with the
resource's natural (XML) mime. (request:get-data() itself was never at fault;
it delivers the octet-stream body correctly.)
Adds XQSuite coverage in store-binary-tests.xql.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5b3ac8a to
f89a80a
Compare
[This PR was co-authored with Claude Code. -Joe]
Summary
xmldb:store($collection, $name, $content, $mime)threw aNullPointerException—Cannot invoke "String.length()" because "<parameter1>" is null— whenever$contentwas anxs:base64Binaryvalue and the target mime type was an XML type (an explicitmime="application/xml", or one inferred from an.xmlresource name). The store failed for every binary-content + XML-mime combination, regardless of how the binary value was produced.Minimal repro:
Root cause
getResource(mimeType, …)creates anXMLResourcefor an XML mime type. The binary was then bound to it withresource.setContent((BinaryValue) item). ABinaryValueis anAtomicValue, soLocalXMLResource.setContentrouted it into the resource'svalueslot, leaving the XML resource with no character or byte stream. The store path then built the parse source asObjects.requireNonNullElseGet(res.inputSource, () -> new StringInputSource(res.content))(LocalCollection.storeXMLResource); with bothinputSourceandcontentnull, this producednew StringInputSource(null), and parsing failed atnew StringReader(null)→ the NPE.What changed
XMLDBStorenow branches on the mime type for binary content:StringInputSource(byte[]), so the parser reads the bytes and detects the encoding from the XML declaration (the bytes are parsed and stored as an XML document).StringInputSourceis used because the store may open the source stream more than once — a plainInputSourceover a singleByteArrayInputStreamwould be drained on the first read.BinaryValueis still streamed straight to a binary resource without materializing it.Malformed bytes under an XML mime now produce a clean parse error instead of an NPE. Reading the bytes into a buffer for the XML case adds no asymptotic memory cost, since storing XML parses the whole document into a DOM anyway.
How it surfaced
This was found via existdb-openapi's binary-safe
PUT /api/db/resource. roaster hands the raw request body straight through, andrequest:get-data()returns it as anxs:base64Binaryfor anapplication/octet-streamupload; the handler then callsxmldb:storewith the resource's natural (XML) mime, hitting the untested binary-content + XML-mime pairing.request:get-data()itself was never at fault — it delivers the octet-stream body correctly through the controller-forward path; the bug is entirely inxmldb:store.Test plan
exist-core/src/test/xquery/xmldb/store-binary-tests.xql, run byXMLDBTests): binary stored under an explicitapplication/xmlmime and under an inferred.xmlmime is parsed and re-readable as XML; the stored doc is a real XML document (not a binary resource); the XML encoding declaration in the bytes is honored; binary + a binary mime still stores byte-for-byte (control); malformed bytes under an XML mime produce a clean store/parse error, not an NPE.XMLDBTestsgreen (36/36, including the 30 pre-existing xmldb tests).xmldb:storecall on a running instance.