From 56688a4ffafd20176f3a22ad9115f35281c7cce4 Mon Sep 17 00:00:00 2001 From: timgent Date: Fri, 17 Apr 2026 22:37:19 +0100 Subject: [PATCH] fix: handle ENOTDIR in FileDataAccessor.getStats to prevent 500 on duplicate slug When a file already exists at a given path, stat()-ing that path with a trailing slash (e.g. test.json/) causes the OS to return ENOTDIR instead of ENOENT. This error was not caught by getStats, causing it to propagate as a raw OS error and result in a 500 InternalServerError on the second POST with the same Slug header to a file-system-backed server. Treating ENOTDIR the same as ENOENT (i.e. converting it to NotFoundHttpError) means hasResource correctly returns false for the container-style path check, allowing createSafeUri to fall back to a UUID-based name for the duplicate upload. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + eslint.config.mjs | 2 +- src/storage/accessors/FileDataAccessor.ts | 2 +- test/integration/FileBackend.test.ts | 28 +++++++++++++++++++ .../accessors/FileDataAccessor.test.ts | 9 ++++++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ccac280382..3ce56b44cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.acl +/.data /.eslintcache /componentsjs-error-state.json /coverage diff --git a/eslint.config.mjs b/eslint.config.mjs index 36e9587ae5..96e110e0c7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,5 +2,5 @@ import opinionated from 'opinionated-eslint-config'; export default opinionated().append({ // Don't want to lint test assets - ignores: [ 'test/assets/*', 'componentsjs-error-state.json' ], + ignores: [ 'test/assets/*', 'componentsjs-error-state.json', '.data/**' ], }); diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index d34800878b..16a57c02dc 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -148,7 +148,7 @@ export class FileDataAccessor implements DataAccessor { try { return await stat(path); } catch (error: unknown) { - if (isSystemError(error) && error.code === 'ENOENT') { + if (isSystemError(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) { throw new NotFoundHttpError('', { cause: error }); } throw error; diff --git a/test/integration/FileBackend.test.ts b/test/integration/FileBackend.test.ts index da5c7cd92f..43e865a955 100644 --- a/test/integration/FileBackend.test.ts +++ b/test/integration/FileBackend.test.ts @@ -149,6 +149,34 @@ describe('A server with a file backend storage', (): void => { }, ); + it( + 'returns 201 (not 500) when POSTing with the same Slug twice to a file-backend container.', + async(): Promise => { + const slug = 'duplicate-slug-test.json'; + const postOptions = { + method: 'POST', + headers: { + 'content-type': 'application/json', + slug, + }, + body: '{}', + }; + + // First POST with the slug — should create the resource. + const res1 = await fetch(baseUrl, postOptions); + expect(res1.status).toBe(201); + const location1 = res1.headers.get('location'); + expect(location1).toBe(`${baseUrl}${slug}`); + + // Second POST with the same slug — must NOT return 500. + // The server should fall back to a UUID-based name instead. + const res2 = await fetch(baseUrl, postOptions); + expect(res2.status).toBe(201); + const location2 = res2.headers.get('location'); + expect(location2).not.toBe(location1); + }, + ); + it( 'supports content types for which no extension mapping can be found (and falls back to using .unknown).', async(): Promise => { diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts index c200f1963e..ed3b9e5e13 100644 --- a/test/unit/storage/accessors/FileDataAccessor.test.ts +++ b/test/unit/storage/accessors/FileDataAccessor.test.ts @@ -99,6 +99,15 @@ describe('A FileDataAccessor', (): void => { await expect(accessor.getMetadata({ path: base })).rejects.toThrow('error'); }); + it('throws a 404 if stat returns ENOTDIR (container path over an existing file).', async(): Promise => { + cache.data = { resource: 'data' }; + const enotdir = Object.assign(new Error('ENOTDIR: not a directory, stat'), { code: 'ENOTDIR', syscall: 'stat' }); + jest.requireMock('fs-extra').stat = (): never => { + throw enotdir; + }; + await expect(accessor.getMetadata({ path: `${base}resource/` })).rejects.toThrow(NotFoundHttpError); + }); + it('throws a 404 if the trailing slash does not match its type.', async(): Promise => { cache.data = { resource: 'data' }; await expect(accessor.getMetadata({ path: `${base}resource/` })).rejects.toThrow(NotFoundHttpError);