From 7715c182deaa6902ad284e075741c4e825bed57a Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 9 Jun 2026 22:51:22 -0400 Subject: [PATCH 1/2] fix(db): encode new leaf names in create/rename so spaces are accepted The create and rename handlers took the new collection/resource name raw from the request body and passed it straight to xmldb:create-collection / xmldb:rename, while every path elsewhere in the module goes through db:encode-path. Those functions reject a raw space ("Illegal character in path", FORG0001), so creating a collection named "My Folder" or renaming to "report final.xml" failed with HTTP 400. Encode the leaf name with db:encode-path, like every other path in the module. Verified against a live instance: create "My Folder" and rename to "renamed file.xml" now return 200 (stored My%20Folder / renamed%20file.xml, displayed decoded in the listing). Non-ASCII names were already handled by the xmldb:* functions; this closes the space case and keeps the leaf consistent with the module's existing full-encode convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/api/db.xqm | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/api/db.xqm b/modules/api/db.xqm index 2bfafc61..472bae3b 100644 --- a/modules/api/db.xqm +++ b/modules/api/db.xqm @@ -81,7 +81,10 @@ declare function db:post($request as map(*)) { switch ($action) case "create" return try { - let $name := $body?name + (: Encode the new collection name the same way every path in this module is + : encoded. xmldb:create-collection rejects a raw space ("Illegal character + : in path", FORG0001), so an un-encoded leaf name made e.g. "My Folder" fail. :) + let $name := db:encode-path($body?name) let $created := xmldb:create-collection($path, $name) return map { "status": "ok", "path": $created } } catch * { @@ -126,7 +129,9 @@ declare function db:post($request as map(*)) { } case "rename" return try { - let $target := $body?target + (: Encode the new leaf name; xmldb:rename rejects a raw space (FORG0001), + : so an un-encoded target made spaces in renames fail. :) + let $target := db:encode-path($body?target) return if (xmldb:collection-available($path)) then ( xmldb:rename($path, $target), From 436456beb7782b307fa5f4cdee34089c98489eeb Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Tue, 4 Aug 2026 10:58:42 -0400 Subject: [PATCH 2/2] test(db): guard create/rename against names containing spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duncan asked for a regression guard on this PR (eXide#820). Before the fix, `db:post` passed the new leaf name to xmldb:create-collection / xmldb:rename un-encoded while every path in the module already went through db:encode-path, so a space came back as 400 "Illegal character in path". Nothing in the suite covered it: dbmanager_spec renames to AéB, and the xmldb:* functions escape non-ASCII internally, so only a space (or another URI-illegal ASCII character) exposes the gap. The new spec covers all three call sites the fix touches — create, the collection branch of rename, and the resource branch of rename — plus a create/delete round trip through the DB manager UI, and asserts the listing shows the decoded name so a space survives storage as %20 and comes back. Verified against eXist-db 7.0.0-SNAPSHOT with existdb-openapi 0.10.0: 3 of the 4 tests fail on develop without this PR's fix (the create assertion reports the exact "Illegal character in path" 400), all 4 pass with it, and the full suite is unaffected. Requires develop's cy.execXQuery support command, hence the merge of develop into this branch. Co-Authored-By: Claude Opus 5 (1M context) --- cypress/e2e/db_names_with_spaces_spec.cy.js | 162 ++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 cypress/e2e/db_names_with_spaces_spec.cy.js diff --git a/cypress/e2e/db_names_with_spaces_spec.cy.js b/cypress/e2e/db_names_with_spaces_spec.cy.js new file mode 100644 index 00000000..d5d2957c --- /dev/null +++ b/cypress/e2e/db_names_with_spaces_spec.cy.js @@ -0,0 +1,162 @@ +/** + * Regression guard for eXide#820: a new leaf name containing a space must be + * accepted by the create and rename actions of POST /api/storage/{path}. + * + * Before the fix, `db:post` passed `$body?name` / `$body?target` to + * xmldb:create-collection / xmldb:rename un-encoded, while every *path* in the + * module already went through db:encode-path. Those functions reject a raw + * space, so the request came back as: + * + * 400 "failed to convert My Folder into an XmldbURI: + * Illegal character in path at index 2: My Folder" + * + * (Non-ASCII names such as AéB happened to work, because the xmldb:* functions + * escape those internally — which is why dbmanager_spec never caught this.) + * + * The assertions below cover all three call sites the fix touches: create, the + * collection branch of rename, and the resource branch of rename. Each one also + * checks the round trip — the listing must show the *decoded* name, so a space + * survives storage as %20 and comes back as a space. + */ +describe('Names containing spaces (eXide#820)', () => { + const base = '/db/cypress-test-spaces' + const collWithSpace = 'My Folder' + const renamedColl = 'My Renamed Folder' + const resource = 'plain.xml' + const renamedResource = 'renamed file.xml' + const uiColl = 'UI Made Folder' + + function removeTestCollections() { + cy.execXQuery( + 'xquery version "3.1"; ' + + 'for $c in ("' + base + '", "/db/' + uiColl.replace(/ /g, '%20') + '") ' + + 'where xmldb:collection-available($c) return xmldb:remove($c)' + ) + } + + function storagePost(path, body) { + return cy.request({ + method: 'POST', + url: '/eXide' + '/api/storage' + path, + headers: { 'Content-Type': 'application/json' }, + body: body, + failOnStatusCode: false + }) + } + + function listing(path) { + return cy.request({ + method: 'GET', + url: '/eXide/api/storage' + path, + failOnStatusCode: false + }) + } + + function names(response) { + return (response.body.items || []).map((item) => item.name) + } + + before(() => { + cy.loginXHR('admin', '') + removeTestCollections() + cy.execXQuery('xquery version "3.1"; xmldb:create-collection("/db", "cypress-test-spaces")') + }) + + beforeEach(() => { + cy.loginXHR('admin', '') + }) + + after(() => { + cy.loginXHR('admin', '') + removeTestCollections() + }) + + it('creates a collection whose name contains a space', () => { + storagePost(base, { action: 'create', name: collWithSpace }).then((response) => { + // The pre-fix failure mode was a 400 carrying "Illegal character in path". + expect(JSON.stringify(response.body)).to.not.contain('Illegal character in path') + expect(response.status).to.eq(200) + expect(response.body).to.have.property('status', 'ok') + // Stored encoded — the leaf is escaped, not rejected. + expect(response.body.path).to.contain('My%20Folder') + }) + + // …and the listing shows it decoded, so the user sees the name they typed. + listing(base).then((response) => { + expect(response.status).to.eq(200) + expect(names(response)).to.include(collWithSpace) + }) + }) + + it('renames a collection to a name containing a space', () => { + storagePost(base + '/My%20Folder', { action: 'rename', target: renamedColl }) + .then((response) => { + expect(JSON.stringify(response.body)).to.not.contain('Illegal character in path') + expect(response.status).to.eq(200) + expect(response.body).to.have.property('status', 'ok') + }) + + listing(base).then((response) => { + expect(names(response)).to.include(renamedColl) + expect(names(response)).to.not.include(collWithSpace) + }) + }) + + it('renames a resource to a name containing a space', () => { + // Store a resource with a space-free name first, so the rename is the only + // thing under test. + cy.request({ + method: 'PUT', + url: '/eXide/api/storage' + base + '/' + resource, + headers: { 'Content-Type': 'application/xml' }, + body: '' + }).its('status').should('eq', 200) + + storagePost(base + '/' + resource, { action: 'rename', target: renamedResource }) + .then((response) => { + expect(JSON.stringify(response.body)).to.not.contain('Illegal character in path') + expect(response.status).to.eq(200) + expect(response.body).to.have.property('status', 'ok') + }) + + listing(base).then((response) => { + expect(names(response)).to.include(renamedResource) + expect(names(response)).to.not.include(resource) + }) + }) + + it('creates and deletes a collection with a space through the DB manager UI', () => { + cy.visit('/eXide/index.html', { + onBeforeLoad(win) { + win.localStorage.setItem('eXide.firstTime', '0') + } + }) + cy.dismissDialog() + // Open the DB manager from the File menu, the way dbmanager_spec does. + cy.get('#fullscreen > div.editor-header > div > ul > li:nth-child(1) > a').click() + cy.get('#fullscreen > div.editor-header > div > ul > li:nth-child(1) > ul') + .find('#menu-file-manager').click() + cy.get('div.eXide-browse-main', { timeout: 10000 }).should('be.visible') + + // Create in the default collection (/db), as the manager opens there. + cy.get('#eXide-browse-toolbar-create').click() + cy.get('#eXide-browse-collection-name').type(uiColl) + cy.get('dialog.eXide-dialog[open] .eXide-dialog-buttons button:first-of-type').click() + + cy.get('div.eXide-browse-main').within(() => { + cy.contains('.browse-table tbody td.col-name', uiColl, { timeout: 10000 }) + .should('exist') + }) + + // Delete it again through the UI, so the round trip covers a path whose + // encoded and displayed forms differ. + cy.get('div.eXide-browse-main').within(() => { + cy.contains('.browse-table tbody td.col-name', uiColl).click() + }) + cy.get('#eXide-browse-toolbar-delete-resource').click() + cy.get('dialog.eXide-dialog[open] .eXide-dialog-buttons button:first-of-type').click() + cy.get('div.eXide-browse-main').within(() => { + cy.contains('.browse-table tbody td.col-name', uiColl).should('not.exist') + }) + }) +})