diff --git a/apps/root.immich.app/src/lib/compose/config.test.ts b/apps/root.immich.app/src/lib/compose/config.test.ts index f511e178..51529d7a 100644 --- a/apps/root.immich.app/src/lib/compose/config.test.ts +++ b/apps/root.immich.app/src/lib/compose/config.test.ts @@ -152,6 +152,42 @@ describe('validate', () => { } }); + it('refuses an external library that lands inside the container upload directory', () => { + const config = structuredClone(DEFAULT_CONFIG); + + for (const path of ['/data', '/data/library', '/data/upload', '/data/thumbs', '/data/anything']) { + config.storage.externalLibraries = [{ path, readOnly: true }]; + expect(validate(config)['storage.externalLibraries.0.path'], path).toContain('/data'); + } + }); + + it('refuses the filesystem root as an external library', () => { + const config = structuredClone(DEFAULT_CONFIG); + + for (const path of ['/', '//']) { + config.storage.externalLibraries = [{ path, readOnly: true }]; + expect(validate(config)['storage.externalLibraries.0.path'], path).toContain('root'); + } + }); + + it('flags an external library that reuses an override host path', () => { + const config = structuredClone(DEFAULT_CONFIG); + config.storage.customFolders = true; + config.storage.overrides.thumbs = '/mnt/thumbs'; + config.storage.externalLibraries = [{ path: '/mnt/thumbs', readOnly: true }]; + const errors = validate(config); + expect(errors['storage.overrides.thumbs']).toBeTruthy(); + expect(errors['storage.externalLibraries.0.path']).toBeTruthy(); + }); + + it('still accepts an ordinary external library alongside overrides', () => { + const config = structuredClone(DEFAULT_CONFIG); + config.storage.customFolders = true; + config.storage.overrides.thumbs = '/mnt/fast/thumbs'; + config.storage.externalLibraries = [{ path: '/mnt/media/photos', readOnly: true }]; + expect(validate(config)).toEqual({}); + }); + it('flags an external library that overlaps another mount', () => { const config = structuredClone(DEFAULT_CONFIG); config.storage.uploadLocation = '/mnt/media'; diff --git a/apps/root.immich.app/src/lib/compose/config.ts b/apps/root.immich.app/src/lib/compose/config.ts index 685a5dfe..87fdfd9e 100644 --- a/apps/root.immich.app/src/lib/compose/config.ts +++ b/apps/root.immich.app/src/lib/compose/config.ts @@ -264,6 +264,8 @@ type ValidationErrors = Record; const normalizePath = (path: string) => path.trim().replace(/\/+$/, ''); +const UPLOAD_TARGET = '/data'; + type Mount = { path: string[]; location: string }; const collectMounts = (config: ImmichConfig): Mount[] => { @@ -301,11 +303,18 @@ const validationSchema = immichConfigSchema.superRefine((config, ctx) => { } for (const [index, { path }] of config.storage.externalLibraries.entries()) { - const location = path.trim(); - if (!location) { - fail(['storage', 'externalLibraries', String(index), 'path'], 'A path is required.'); + const field = ['storage', 'externalLibraries', String(index), 'path']; + const trimmed = path.trim(); + const location = normalizePath(path); + + if (!trimmed) { + fail(field, 'A path is required.'); + } else if (!location) { + fail(field, 'The filesystem root cannot be mounted as a library.'); } else if (!location.startsWith('/')) { - fail(['storage', 'externalLibraries', String(index), 'path'], 'Enter an absolute path, e.g. /mnt/media/photos.'); + fail(field, 'Enter an absolute path, e.g. /mnt/media/photos.'); + } else if (location === UPLOAD_TARGET || location.startsWith(`${UPLOAD_TARGET}/`)) { + fail(field, `Immich manages ${UPLOAD_TARGET} inside the container, so a library cannot live there.`); } }