diff --git a/server/src/common/utils/path-identity-lookup.test.ts b/server/src/common/utils/path-identity-lookup.test.ts new file mode 100644 index 000000000..d1aca26b6 --- /dev/null +++ b/server/src/common/utils/path-identity-lookup.test.ts @@ -0,0 +1,146 @@ +vi.mock('fs/promises', () => ({ lstat: vi.fn(), readdir: vi.fn() })); + +import { lstat, readdir } from 'fs/promises'; + +import { pathsReferToSameEntry, resolveExistingPathSpelling } from './path-identity.utils'; + +const mockLstat = vi.mocked(lstat); +const mockReaddir = vi.mocked(readdir); + +function seedDirectories(entriesByPath: Record): void { + mockReaddir.mockImplementation((path) => { + const entries = entriesByPath[String(path)]; + if (!entries) return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })) as never; + return Promise.resolve(entries) as never; + }); +} + +describe('resolveExistingPathSpelling', () => { + beforeEach(() => { + mockLstat.mockReset(); + mockReaddir.mockReset(); + mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); + }); + + it('resolves a case-only difference to the spelling stored on disk', async () => { + seedDirectories({ + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub', '/library')).resolves.toBe('/library/bell hooks/Dune.epub'); + }); + + it('resolves a Unicode normalization difference to the spelling stored on disk', async () => { + const storedName = 'José Saramago'.normalize('NFD'); + seedDirectories({ + '/library': [storedName], + [`/library/${storedName}`]: ['Blindness.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/José Saramago/Blindness.epub'.normalize('NFC'), '/library')).resolves.toBe( + `/library/${storedName}/Blindness.epub`, + ); + }); + + it('never lists directories above the given base path', async () => { + seedDirectories({ + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + + await resolveExistingPathSpelling('/library/bell hooks/Dune.epub', '/library'); + + expect(mockReaddir).not.toHaveBeenCalledWith('/'); + expect(mockReaddir).not.toHaveBeenCalledWith('/library', expect.anything()); + expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/library', '/library/bell hooks']); + }); + + it('walks up from the filesystem root when no base path is given', async () => { + seedDirectories({ + '/': ['library'], + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub')).resolves.toBe('/library/bell hooks/Dune.epub'); + expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/', '/library', '/library/bell hooks']); + }); + + it('prefers an exact match when both spellings exist', async () => { + seedDirectories({ + '/library': ['bell hooks', 'Bell Hooks'], + '/library/Bell Hooks': ['Dune.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub', '/library')).resolves.toBe('/library/Bell Hooks/Dune.epub'); + }); + + it('gives up when more than one entry matches case-insensitively', async () => { + seedDirectories({ '/library': ['bell hooks', 'BELL HOOKS'] }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub', '/library')).resolves.toBeNull(); + }); + + it('returns null for a path outside the base path', async () => { + seedDirectories({ '/library': ['bell hooks'] }); + + await expect(resolveExistingPathSpelling('/other/bell hooks/Dune.epub', '/library')).resolves.toBeNull(); + expect(mockReaddir).not.toHaveBeenCalled(); + }); + + it('returns null when the path does not exist', async () => { + mockLstat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); + seedDirectories({ '/library': ['bell hooks'] }); + + await expect(resolveExistingPathSpelling('/library/bell hooks/Dune.epub', '/library')).resolves.toBeNull(); + }); +}); + +describe('pathsReferToSameEntry', () => { + beforeEach(() => { + mockLstat.mockReset(); + mockReaddir.mockReset(); + mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); + seedDirectories({ + '/': ['library'], + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + }); + + it('treats identical paths as the same entry without touching the filesystem', async () => { + await expect(pathsReferToSameEntry('/library/bell hooks/Dune.epub', '/library/bell hooks/Dune.epub')).resolves.toBe(true); + expect(mockLstat).not.toHaveBeenCalled(); + }); + + it('treats a case-only alias of the same inode as the same entry', async () => { + await expect(pathsReferToSameEntry('/library/Bell Hooks/Dune.epub', '/library/bell hooks/Dune.epub')).resolves.toBe(true); + }); + + it('treats a normalization-only alias of the same inode as the same entry', async () => { + const storedName = 'José Saramago'.normalize('NFD'); + seedDirectories({ + '/': ['library'], + '/library': [storedName], + [`/library/${storedName}`]: ['Blindness.epub'], + }); + + await expect( + pathsReferToSameEntry(`/library/${storedName}/Blindness.epub`, '/library/José Saramago/Blindness.epub'.normalize('NFC')), + ).resolves.toBe(true); + }); + + it('treats different inodes behind the same spelling fold as distinct entries', async () => { + mockLstat.mockImplementation((path) => + Promise.resolve(String(path) === '/library/bell hooks/Dune.epub' ? { dev: 1, ino: 42 } : { dev: 1, ino: 43 }), + ); + + await expect(pathsReferToSameEntry('/library/Bell Hooks/Dune.epub', '/library/bell hooks/Dune.epub')).resolves.toBe(false); + }); + + it('does not compare paths that differ by more than case and normalization', async () => { + await expect(pathsReferToSameEntry('/library/bell hooks/Dune.epub', '/library/bell hooks/Other.epub')).resolves.toBe(false); + expect(mockLstat).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/common/utils/path-identity.utils.test.ts b/server/src/common/utils/path-identity.utils.test.ts new file mode 100644 index 000000000..9e27eda7e --- /dev/null +++ b/server/src/common/utils/path-identity.utils.test.ts @@ -0,0 +1,54 @@ +import { link, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { pathsReferToSameEntry, resolveExistingPathSpelling } from './path-identity.utils'; + +describe('path identity utilities', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'bookorbit-path-identity-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('returns the directory-entry spelling for an existing path', async () => { + const actualPath = join(root, 'bell hooks', 'Book.epub'); + await mkdir(join(root, 'bell hooks'), { recursive: true }); + await writeFile(actualPath, 'book'); + + const alternatePath = join(root, 'Bell Hooks', 'Book.epub'); + const resolved = await resolveExistingPathSpelling(alternatePath); + + if (resolved === null) { + expect(await resolveExistingPathSpelling(actualPath)).toBe(actualPath); + return; + } + + expect(resolved).toBe(actualPath); + await expect(pathsReferToSameEntry(actualPath, alternatePath)).resolves.toBe(true); + }); + + it('does not conflate distinct case-sensitive hard links', async () => { + const firstPath = join(root, 'Book.epub'); + const secondPath = join(root, 'book.epub'); + await writeFile(firstPath, 'book'); + + try { + await link(firstPath, secondPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + await expect(pathsReferToSameEntry(firstPath, secondPath)).resolves.toBe(true); + return; + } + + await expect(pathsReferToSameEntry(firstPath, secondPath)).resolves.toBe(false); + }); + + it('returns null for a missing path', async () => { + await expect(resolveExistingPathSpelling(join(root, 'missing.epub'))).resolves.toBeNull(); + }); +}); diff --git a/server/src/common/utils/path-identity.utils.ts b/server/src/common/utils/path-identity.utils.ts new file mode 100644 index 000000000..cb6037eb7 --- /dev/null +++ b/server/src/common/utils/path-identity.utils.ts @@ -0,0 +1,84 @@ +import { lstat, readdir } from 'fs/promises'; +import { isAbsolute, join, normalize, parse, relative, sep } from 'path'; + +function foldPathValue(value: string): string { + return value.normalize('NFC').toLowerCase(); +} + +function splitSegments(value: string): string[] { + return value.split(sep).filter(Boolean); +} + +function resolveWalkStart(normalizedPath: string, basePath: string | undefined): { start: string; segments: string[] } | null { + if (basePath === undefined) { + const { root } = parse(normalizedPath); + if (!root) return null; + return { start: root, segments: splitSegments(normalizedPath.slice(root.length)) }; + } + + const normalizedBase = normalize(basePath); + const relativePath = relative(normalizedBase, normalizedPath); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return null; + + return { start: normalizedBase, segments: splitSegments(relativePath) }; +} + +/** + * Resolves the spelling a path is actually stored under, which can differ from the requested + * spelling on case-insensitive or normalization-insensitive filesystems. Passing basePath keeps + * the walk inside a known-good root instead of listing every directory up from the filesystem root. + */ +export async function resolveExistingPathSpelling(inputPath: string, basePath?: string): Promise { + const normalizedPath = normalize(inputPath); + const walk = resolveWalkStart(normalizedPath, basePath); + if (!walk) return null; + + try { + await lstat(normalizedPath); + } catch { + return null; + } + + let currentPath = walk.start; + + for (const segment of walk.segments) { + let entries: string[]; + try { + entries = await readdir(currentPath); + } catch { + return null; + } + + if (entries.includes(segment)) { + currentPath = join(currentPath, segment); + continue; + } + + const foldedSegment = foldPathValue(segment); + const matches = entries.filter((entry) => foldPathValue(entry) === foldedSegment); + if (matches.length !== 1) return null; + currentPath = join(currentPath, matches[0]!); + } + + return currentPath; +} + +export async function pathsReferToSameEntry(firstPath: string, secondPath: string, basePath?: string): Promise { + const normalizedFirst = normalize(firstPath); + const normalizedSecond = normalize(secondPath); + if (normalizedFirst === normalizedSecond) return true; + if (foldPathValue(normalizedFirst) !== foldPathValue(normalizedSecond)) return false; + + try { + const [firstStat, secondStat, resolvedFirst, resolvedSecond] = await Promise.all([ + lstat(normalizedFirst), + lstat(normalizedSecond), + resolveExistingPathSpelling(normalizedFirst, basePath), + resolveExistingPathSpelling(normalizedSecond, basePath), + ]); + + return firstStat.dev === secondStat.dev && firstStat.ino === secondStat.ino && resolvedFirst !== null && resolvedFirst === resolvedSecond; + } catch { + return false; + } +} diff --git a/server/src/modules/book-dock/book-dock-finalize.service.test.ts b/server/src/modules/book-dock/book-dock-finalize.service.test.ts index 58b4601c1..60c22b74e 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.test.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.test.ts @@ -1,17 +1,21 @@ vi.mock('fs/promises', () => ({ access: vi.fn(), + lstat: vi.fn(), + readdir: vi.fn(), readFile: vi.fn(), stat: vi.fn(), unlink: vi.fn(), })); import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { access, readFile, stat, unlink } from 'fs/promises'; +import { access, lstat, readdir, readFile, stat, unlink } from 'fs/promises'; import type { BookDockMetadata } from '@bookorbit/types'; import { BookDockFinalizeService } from './book-dock-finalize.service'; const mockAccess = vi.mocked(access); +const mockLstat = vi.mocked(lstat); +const mockReaddir = vi.mocked(readdir); const mockReadFile = vi.mocked(readFile); const mockStat = vi.mocked(stat); const mockUnlink = vi.mocked(unlink); @@ -148,7 +152,11 @@ describe('BookDockFinalizeService', () => { mockReadFile.mockReset(); mockStat.mockReset(); mockUnlink.mockReset(); + mockLstat.mockReset(); + mockReaddir.mockReset(); mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockLstat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockReaddir.mockResolvedValue([]); mockReadFile.mockResolvedValue(Buffer.from('cover-bytes')); mockStat.mockResolvedValue({ size: 100 } as never); mockUnlink.mockResolvedValue(undefined); diff --git a/server/src/modules/book-dock/book-dock-finalize.service.ts b/server/src/modules/book-dock/book-dock-finalize.service.ts index 34619b97c..f0a59b498 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -34,6 +34,7 @@ import { NotificationService } from '../notification/notification.service'; import { SeriesIdentityService } from '../../common/services/series-identity.service'; import { SeriesMembershipService } from '../../common/services/series-membership.service'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils'; import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils'; import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; import { DB } from '../../db'; @@ -289,17 +290,18 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots } await this.storage.moveToPath(row.absolutePath, destPath); + const persistedDestPath = (await resolveExistingPathSpelling(destPath, folder.path)) ?? destPath; let bookId: number; try { - const { size } = await stat(destPath); - const bookFolderPath = library.organizationMode === 'book_per_file' ? destPath : dirname(destPath); + const { size } = await stat(persistedDestPath); + const bookFolderPath = library.organizationMode === 'book_per_file' ? persistedDestPath : dirname(persistedDestPath); ({ bookId } = await this.processor.createBookRecord( library.id, folder.id, bookFolderPath, - destPath, - destPath.substring(folder.path.length + 1), + persistedDestPath, + persistedDestPath.substring(folder.path.length + 1), format, size, )); @@ -311,8 +313,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots await this.cleanupBookDockRecord(row); existingDestinations.set(this.destinationKey(library.id, destPath), bookId); + existingDestinations.set(this.destinationKey(library.id, persistedDestPath), bookId); - const newName = destPath.substring(folder.path.length + 1); + const newName = persistedDestPath.substring(folder.path.length + 1); return { fileId: row.id, fileName: row.fileName, newName, success: true, bookId }; } catch (err) { const message = resolveFinalizeErrorMessage(err); diff --git a/server/src/modules/file-write/file-rename.service.test.ts b/server/src/modules/file-write/file-rename.service.test.ts index d13ddcf03..194d607c0 100644 --- a/server/src/modules/file-write/file-rename.service.test.ts +++ b/server/src/modules/file-write/file-rename.service.test.ts @@ -1,34 +1,33 @@ import { ConfigService } from '@nestjs/config'; import { NotificationType } from '@bookorbit/types'; import type { MockedFunction } from 'vitest'; -import { access, lstat, mkdir, readdir, realpath, rename as fsRename, rmdir } from 'fs/promises'; +import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises'; import { SelfWriteRegistry } from '../../common/services/self-write-registry.service'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises'); return { ...actual, access: vi.fn(), - lstat: vi.fn(), mkdir: vi.fn(), readdir: vi.fn(), - realpath: vi.fn(), rename: vi.fn(), rmdir: vi.fn(), }; }); +vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() })); import type { BookRenameData } from './file-rename.repository'; import { FileRenameService } from './file-rename.service'; import { bookOperationLockKey } from './file-lock.service'; const mockAccess = access as MockedFunction; -const mockLstat = lstat as MockedFunction; const mockMkdir = mkdir as MockedFunction; const mockReaddir = readdir as MockedFunction; -const mockRealpath = realpath as MockedFunction; const mockRename = fsRename as MockedFunction; const mockRmdir = rmdir as MockedFunction; +const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction; type RenameDataOverrides = Partial & { file?: Partial; @@ -127,19 +126,16 @@ describe('FileRenameService', () => { vi.clearAllMocks(); vi.useRealTimers(); mockAccess.mockReset(); - mockLstat.mockReset(); mockMkdir.mockReset(); mockReaddir.mockReset(); - mockRealpath.mockReset(); mockRename.mockReset(); mockRmdir.mockReset(); mockAccess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); - mockLstat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); mockMkdir.mockResolvedValue(undefined as never); mockReaddir.mockResolvedValue(['still-here'] as never); - mockRealpath.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); mockRename.mockResolvedValue(undefined as never); mockRmdir.mockResolvedValue(undefined as never); + mockPathsReferToSameEntry.mockImplementation((firstPath, secondPath) => Promise.resolve(firstPath === secondPath)); }); afterEach(() => { @@ -759,8 +755,7 @@ describe('FileRenameService', () => { if (path.toString() === '/library/Frank Herbert/Dune.epub') return Promise.resolve(undefined); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); - mockRealpath.mockResolvedValue('/library/Frank Herbert/Dune.epub'); + mockPathsReferToSameEntry.mockResolvedValue(true); const result = await service.performRename(5, 12); @@ -787,9 +782,6 @@ describe('FileRenameService', () => { }), ); mockAccess.mockResolvedValue(undefined as never); - mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); - mockRealpath.mockImplementation((path: any) => Promise.resolve(path.toString())); - const result = await service.performRename(5, 12); expect(result).toEqual(expect.objectContaining({ status: 'skipped', reason: 'target path already exists on disk' })); @@ -813,8 +805,7 @@ describe('FileRenameService', () => { if (path.toString() === '/library/Frank Herbert/Dune') return Promise.resolve(undefined); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); - mockRealpath.mockResolvedValue('/library/Frank Herbert/Dune'); + mockPathsReferToSameEntry.mockResolvedValue(true); const result = await service.performRename(5, 12); @@ -877,14 +868,6 @@ describe('FileRenameService', () => { if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve(undefined); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - mockLstat.mockImplementation((path: any) => { - if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve({ dev: 1, ino: 42 } as never); - return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); - }); - mockRealpath.mockImplementation((path: any) => { - if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve('/library/Bad_ Love.epub'); - return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); - }); mockRename.mockImplementation((source: any) => { if (source.toString() === '/library/Bad: Love.epub') return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); return Promise.resolve(); diff --git a/server/src/modules/file-write/file-rename.service.ts b/server/src/modules/file-write/file-rename.service.ts index 57f5b82c7..33d1fdae5 100644 --- a/server/src/modules/file-write/file-rename.service.ts +++ b/server/src/modules/file-write/file-rename.service.ts @@ -1,12 +1,13 @@ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { access, lstat, mkdir, readdir, realpath, rename as fsRename, rmdir } from 'fs/promises'; +import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises'; import { basename, dirname, extname, isAbsolute, join, normalize, relative, sep } from 'path'; import type { FileRenameResult } from '@bookorbit/types'; import { isAudioFormat, NotificationType, resolveUploadPath, sanitizePathSegment } from '@bookorbit/types'; import { SelfWriteRegistry } from '../../common/services/self-write-registry.service'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import { AppSettingsService } from '../app-settings/app-settings.service'; import { NotificationService } from '../notification/notification.service'; import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository'; @@ -709,31 +710,17 @@ export class FileRenameService implements OnModuleDestroy { return null; } - private async pathsReferToSameEntry(firstPath: string, secondPath: string): Promise { - try { - const [first, second, resolvedFirst, resolvedSecond] = await Promise.all([ - lstat(firstPath), - lstat(secondPath), - realpath(firstPath), - realpath(secondPath), - ]); - return first.dev === second.dev && first.ino === second.ino && resolvedFirst === resolvedSecond; - } catch { - return false; - } - } - private async pathsReferToSameSource( sourcePath: string, targetPath: string, libraryFolderPath: string, sanitizeForCrossPlatform: boolean, ): Promise { - if (await this.pathsReferToSameEntry(sourcePath, targetPath)) return true; + if (await pathsReferToSameEntry(sourcePath, targetPath)) return true; if (!sanitizeForCrossPlatform) return false; const sanitizedSourcePath = this.buildSanitizedSourcePath(sourcePath, libraryFolderPath); - return sanitizedSourcePath !== null && (await this.pathsReferToSameEntry(sanitizedSourcePath, targetPath)); + return sanitizedSourcePath !== null && (await pathsReferToSameEntry(sanitizedSourcePath, targetPath)); } private async renamePath(sourcePath: string, targetPath: string, libraryFolderPath: string, sanitizeForCrossPlatform: boolean): Promise { diff --git a/server/src/modules/scanner/file-event-processor.service.test.ts b/server/src/modules/scanner/file-event-processor.service.test.ts index b69fbec6c..91502cfe4 100644 --- a/server/src/modules/scanner/file-event-processor.service.test.ts +++ b/server/src/modules/scanner/file-event-processor.service.test.ts @@ -6,14 +6,15 @@ import { Logger } from '@nestjs/common'; vi.mock('fs/promises', () => ({ stat: vi.fn(), readdir: vi.fn(), - realpath: vi.fn(), })); +vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() })); -import { stat, readdir, realpath } from 'fs/promises'; +import { stat, readdir } from 'fs/promises'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; const mockStat = stat as MockedFunction; const mockReaddir = readdir as MockedFunction; -const mockRealpath = realpath as MockedFunction; +const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction; const mockRepo: Mocked< Pick< @@ -99,6 +100,7 @@ beforeEach(() => { mockRepo.findLibraryFolderPath.mockResolvedValue('/books'); mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([]); mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue(null); + mockPathsReferToSameEntry.mockResolvedValue(false); mockReaddir.mockResolvedValue([]); mockRepo.updateBookPrimaryFile.mockResolvedValue(undefined); }); @@ -681,7 +683,7 @@ describe('handleCreate — move detection', () => { const newPath = '/books/Author/book.epub'; const fileStat = makeFileStat({ ino: 4001n, size: 70000 }); mockStat.mockResolvedValue(fileStat); - mockRealpath.mockResolvedValue(newPath); + mockPathsReferToSameEntry.mockResolvedValue(true); mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null); mockRepo.findMissingBookByFolderPath.mockResolvedValue(null); mockRepo.findBookFileWithContextByIno.mockResolvedValue({ diff --git a/server/src/modules/scanner/file-event-processor.service.ts b/server/src/modules/scanner/file-event-processor.service.ts index a8ad5e300..8e11db19b 100644 --- a/server/src/modules/scanner/file-event-processor.service.ts +++ b/server/src/modules/scanner/file-event-processor.service.ts @@ -1,8 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; -import { readdir, realpath, stat } from 'fs/promises'; +import { readdir, stat } from 'fs/promises'; import type { BigIntStats } from 'fs'; import { dirname, join, relative } from 'path'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import { classifyFile, DEFAULT_FORMAT_PRIORITY } from './lib/classify'; import { ScannerRepository } from './scanner.repository'; @@ -333,9 +334,8 @@ export class FileEventProcessorService { (s) => s.isFile(), () => false, ); - if (oldFileStillExists && file.absolutePath.toLowerCase() === newAbsolutePath.toLowerCase()) { - const canonicalPath = await realpath(file.absolutePath).catch(() => null); - oldFileStillExists = canonicalPath !== newAbsolutePath; + if (oldFileStillExists && (await pathsReferToSameEntry(file.absolutePath, newAbsolutePath))) { + oldFileStillExists = false; } if (oldFileStillExists) { this.logger.log( diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index 01e42d152..05341544d 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -1,6 +1,7 @@ vi.mock('./lib/walk'); vi.mock('./lib/hash'); vi.mock('./lib/stability', () => ({ waitForStability: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() })); vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -23,12 +24,14 @@ import { DEFAULT_FORMAT_PRIORITY } from './lib/classify'; import type { BookCandidate, FileStat } from './lib/walk'; import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk'; import { computeFileHash } from './lib/hash'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import * as assembleBookCardsModule from '../book/utils/assemble-book-cards'; const mockFindCandidates = findBookCandidates as MockedFunction; const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction; const mockBuildSingleCandidate = buildSingleBookCandidate as MockedFunction; const mockFingerprint = computeFileHash as MockedFunction; +const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction; const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; @@ -190,6 +193,7 @@ beforeEach(() => { mockFindLooseCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); mockBuildSingleCandidate.mockResolvedValue(null); mockFingerprint.mockResolvedValue('hash-abc'); + mockPathsReferToSameEntry.mockResolvedValue(false); mockReaddir.mockResolvedValue([]); mockStat.mockResolvedValue({ isFile: () => true, ino: 2001n, size: 1024, mtime: new Date('2024-01-01') } as any); delete (mockMetadata as Record).extractAndSaveIfAvailable; @@ -973,6 +977,51 @@ describe('file identity resolution', () => { expect(repo.createBookFile).not.toHaveBeenCalled(); }); + it('reuses the existing book and file for a case-only path change on a case-insensitive filesystem', async () => { + const candidateFile = makeFileStat({ + absolutePath: '/library/bell hooks/Book/book.epub', + relPath: 'bell hooks/Book/book.epub', + ino: 9999n, + }); + const existingFile = makeBookFile({ + id: 21, + bookId: 10, + absolutePath: '/library/Bell Hooks/Book/book.epub', + relPath: 'Bell Hooks/Book/book.epub', + ino: 9999n, + }); + const repo = makeRepo({ + findBooksByLibraryFolder: vi + .fn() + .mockResolvedValue([{ id: 10, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Bell Hooks/Book', status: 'present' }]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([existingFile]), + }); + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/library/bell hooks/Book', [candidateFile])], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + mockPathsReferToSameEntry.mockResolvedValue(true); + + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(repo.updateBookFolderPath).toHaveBeenCalledWith(10, '/library/bell hooks/Book'); + expect(repo.updateBookFile).toHaveBeenCalledWith( + 21, + expect.objectContaining({ + bookId: 10, + absolutePath: '/library/bell hooks/Book/book.epub', + relPath: 'bell hooks/Book/book.epub', + }), + ); + expect(repo.createBook).not.toHaveBeenCalled(); + expect(repo.createBookFile).not.toHaveBeenCalled(); + }); + it('reuses an existing book id when a MergerFS folder is renamed via an exact oversized inode match', async () => { const renamedFile = makeFileStat({ absolutePath: '/library/Author/NewName/book.epub', @@ -1809,6 +1858,57 @@ describe('cross-library transfer', () => { expect(mockGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [55] }); }); + it('transfers a source book when the previous file path is now a directory', async () => { + const destinationFile = makeFileStat({ + absolutePath: '/dest/Inbox/book.epub', + relPath: 'Inbox/book.epub', + ino: 4343n, + }); + const sourceFile = makeBookFile({ + id: 510, + bookId: 55, + libraryFolderId: 10, + absolutePath: '/source/Book/book.epub', + relPath: 'Book/book.epub', + ino: 4343n, + fileHash: 'transfer-hash', + }); + + const repo = makeRepo({ + findLibraryFolders: vi.fn().mockResolvedValue([{ id: 22, path: '/dest', libraryId: 2 }]), + findBooksByLibraryFolder: vi.fn().mockResolvedValue([]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([]), + findMissingBookFileWithContextByIno: vi.fn().mockResolvedValue(null), + findBookFileWithContextByIno: vi.fn().mockResolvedValue({ + file: sourceFile, + libraryId: 1, + bookStatus: 'present', + folderPath: '/source/Book', + libraryFolderPath: '/source', + }), + findMissingBookFileWithContextByHash: vi.fn().mockResolvedValue(null), + findBookFileWithContextByHash: vi.fn().mockResolvedValue(null), + }); + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/dest/Inbox', [destinationFile])], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + mockStat.mockImplementation((path) => { + if (String(path) === '/source/Book/book.epub') return Promise.resolve({ isFile: () => false, isDirectory: () => true } as any); + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(2, 'manual'); + await done; + + expect(repo.moveBookToLibrary).toHaveBeenCalledWith(55, 2, 22, '/dest/Inbox'); + expect(repo.createBook).not.toHaveBeenCalled(); + }); + it('transfers a missing source book via hash fallback when inode differs', async () => { const destinationFile = makeFileStat({ absolutePath: '/dest/Inbox/book.epub', diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index 3d88dad04..aaf5b3997 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common'; import { createHash } from 'crypto'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import type { BookMissingEvent, @@ -1821,7 +1822,8 @@ export class ScannerService implements OnApplicationBootstrap { const sourceBook = this.findBookEntryById(bookByFolderPath, sourceBookId); if (!sourceBook) return null; if (sourceBook.folderPath === candidate.folderPath) return sourceBook; - if (candidateFolderPaths.has(sourceBook.folderPath) || (await this.pathExists(sourceBook.folderPath))) return null; + if (candidateFolderPaths.has(sourceBook.folderPath) || (await this.pathExistsAsDistinctEntry(sourceBook.folderPath, candidate.folderPath))) + return null; await this.scannerRepo.updateBookFolderPath(sourceBook.id, candidate.folderPath); @@ -1877,6 +1879,17 @@ export class ScannerService implements OnApplicationBootstrap { } } + private async pathExistsAsDistinctEntry(existingPath: string, candidatePath: string): Promise { + if (!(await this.pathExists(existingPath))) return false; + return !(await pathsReferToSameEntry(existingPath, candidatePath)); + } + + private async fileExistsAsDistinctEntry(existingPath: string, candidatePath: string): Promise { + const existingStat = await stat(existingPath).catch(() => null); + if (!existingStat?.isFile()) return false; + return !(await pathsReferToSameEntry(existingPath, candidatePath)); + } + private async filterBookIdsMissingOnDisk(bookIds: number[]): Promise { if (bookIds.length === 0) return []; @@ -1915,7 +1928,7 @@ export class ScannerService implements OnApplicationBootstrap { for (const file of contentFiles) { if (file.ino === 0n) continue; const byIno = await this.scannerRepo.findMissingBookFileWithContextByIno(file.ino); - if (!byIno || (await this.pathExists(byIno.file.absolutePath))) continue; + if (!byIno || (await this.pathExistsAsDistinctEntry(byIno.file.absolutePath, file.absolutePath))) continue; sourceBookId = byIno.file.bookId; sourceLibraryId = byIno.libraryId; break; @@ -1926,8 +1939,7 @@ export class ScannerService implements OnApplicationBootstrap { if (file.ino === 0n) continue; const byIno = await this.scannerRepo.findBookFileWithContextByIno(file.ino); if (!byIno || byIno.file.absolutePath === file.absolutePath) continue; - const previousPathStat = await stat(byIno.file.absolutePath).catch(() => null); - if (previousPathStat?.isFile()) continue; + if (await this.fileExistsAsDistinctEntry(byIno.file.absolutePath, file.absolutePath)) continue; sourceBookId = byIno.file.bookId; sourceLibraryId = byIno.libraryId; break; @@ -1946,7 +1958,11 @@ export class ScannerService implements OnApplicationBootstrap { } const byHash = await this.scannerRepo.findMissingBookFileWithContextByHash(fileHash); - if (byHash && byHash.file.sizeBytes === file.sizeBytes && !(await this.pathExists(byHash.file.absolutePath))) { + if ( + byHash && + byHash.file.sizeBytes === file.sizeBytes && + !(await this.pathExistsAsDistinctEntry(byHash.file.absolutePath, file.absolutePath)) + ) { sourceBookId = byHash.file.bookId; sourceLibraryId = byHash.libraryId; break; @@ -1954,8 +1970,7 @@ export class ScannerService implements OnApplicationBootstrap { const byHashAny = await this.scannerRepo.findBookFileWithContextByHash(fileHash); if (!byHashAny || byHashAny.file.absolutePath === file.absolutePath || byHashAny.file.sizeBytes !== file.sizeBytes) continue; - const previousPathStat = await stat(byHashAny.file.absolutePath).catch(() => null); - if (previousPathStat?.isFile()) continue; + if (await this.fileExistsAsDistinctEntry(byHashAny.file.absolutePath, file.absolutePath)) continue; sourceBookId = byHashAny.file.bookId; sourceLibraryId = byHashAny.libraryId; break; @@ -2109,7 +2124,7 @@ export class ScannerService implements OnApplicationBootstrap { if (!byIno) return null; const oldAbsolutePath = byIno.absolutePath; - if (await this.pathExists(oldAbsolutePath)) return null; + if (await this.pathExistsAsDistinctEntry(oldAbsolutePath, fileStat.absolutePath)) return null; const sizeUnchanged = fileStat.sizeBytes === byIno.sizeBytes; const mtimeUnchanged = fileStat.mtime.getTime() === byIno.mtime?.getTime(); await this.scannerRepo.updateBookFile(byIno.id, { @@ -2168,7 +2183,7 @@ export class ScannerService implements OnApplicationBootstrap { ) { return null; } - if (await this.pathExists(globalByIno.file.absolutePath)) return null; + if (await this.pathExistsAsDistinctEntry(globalByIno.file.absolutePath, fileStat.absolutePath)) return null; const oldAbsolutePath = globalByIno.file.absolutePath; const sizeUnchanged = fileStat.sizeBytes === globalByIno.file.sizeBytes; @@ -2246,7 +2261,7 @@ export class ScannerService implements OnApplicationBootstrap { if (!isFirstScan) { const byHash = await this.scannerRepo.findBookFileByHash(fileHash, libraryFolderId); - if (byHash && byHash.sizeBytes === fileStat.sizeBytes && !(await this.pathExists(byHash.absolutePath))) { + if (byHash && byHash.sizeBytes === fileStat.sizeBytes && !(await this.pathExistsAsDistinctEntry(byHash.absolutePath, fileStat.absolutePath))) { const oldAbsolutePath = byHash.absolutePath; await this.scannerRepo.updateBookFile(byHash.id, { bookId, @@ -2304,7 +2319,7 @@ export class ScannerService implements OnApplicationBootstrap { globalByHash.file.absolutePath !== fileStat.absolutePath && globalByHash.file.sizeBytes === fileStat.sizeBytes && (globalByHash.file.bookId === bookId || globalByHash.bookStatus === 'missing') && - !(await this.pathExists(globalByHash.file.absolutePath)) + !(await this.pathExistsAsDistinctEntry(globalByHash.file.absolutePath, fileStat.absolutePath)) ) { const oldAbsolutePath = globalByHash.file.absolutePath; await this.scannerRepo.updateBookFile(globalByHash.file.id, { diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 7978fc020..f5601cf6e 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -1,6 +1,6 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises'); - return { ...actual, access: vi.fn(), stat: vi.fn() }; + return { ...actual, access: vi.fn(), lstat: vi.fn(), readdir: vi.fn(), stat: vi.fn() }; }); vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() })); @@ -10,7 +10,7 @@ vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), ex vi.mock('../metadata/lib/mobi-parser', () => ({ parseMobiFile: vi.fn() })); vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() })); -import { access as fsAccess, stat } from 'fs/promises'; +import { access as fsAccess, lstat, readdir, stat } from 'fs/promises'; import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractCbzMetadata } from '../metadata/lib/cbz-metadata'; @@ -22,6 +22,8 @@ import { books } from '../../db/schema'; import { UploadService } from './upload.service'; const mockFsAccess = fsAccess as MockedFunction; +const mockLstat = lstat as MockedFunction; +const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; @@ -133,6 +135,8 @@ describe('UploadService', () => { appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null); appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false); mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockLstat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockReaddir.mockResolvedValue([]); mockExtractEpubMetadata.mockResolvedValue(null); // addFileToBook defaults @@ -201,6 +205,50 @@ describe('UploadService', () => { expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/Frank Herbert/Dune.epub', 'epub'); }); + it('stores the directory-entry spelling when an upload targets a case-insensitive parent path', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + isbn13: null, + authors: [{ name: 'Bell Hooks' }], + tags: [], + description: null, + isbn10: null, + }); + mockLstat.mockResolvedValue({} as never); + mockReaddir.mockImplementation((path) => { + const entriesByPath: Record = { + '/': ['library'], + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }; + return Promise.resolve(entriesByPath[String(path)] ?? []) as never; + }); + + await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/library', '/library/bell hooks']); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Bell Hooks/Dune.epub'); + expect(processor.createBookRecord).toHaveBeenCalledWith( + 1, + 2, + '/library/bell hooks', + '/library/bell hooks/Dune.epub', + 'bell hooks/Dune.epub', + 'epub', + 456, + ); + expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/bell hooks/Dune.epub', 'epub'); + }); + it('only extracts metadata when an upload adds a file to an existing book', async () => { processor.createBookRecord.mockResolvedValue({ bookId: 99, created: false }); db.select diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index 2bfa8bf7c..845922338 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -25,6 +25,7 @@ import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../m import { parseMobiFile } from '../metadata/lib/mobi-parser'; import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser'; import { computeFileHash } from '../scanner/lib/hash'; +import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils'; type Db = NodePgDatabase; @@ -73,7 +74,7 @@ export class UploadService { let shouldCleanupDestination = false; try { - const { absolutePath, bookFolderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format); + const { absolutePath, bookFolderPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format); destinationPath = absolutePath; if (await this.destinationExists(absolutePath)) { @@ -83,26 +84,31 @@ export class UploadService { shouldCleanupDestination = true; await this.storage.moveToPath(tempPath, absolutePath); + const persistedAbsolutePath = (await resolveExistingPathSpelling(absolutePath, folder.path)) ?? absolutePath; + const persistedBookFolderPath = bookFolderPath === absolutePath ? persistedAbsolutePath : dirname(persistedAbsolutePath); + const persistedRelPath = relative(folder.path, persistedAbsolutePath); + destinationPath = persistedAbsolutePath; + const { bookId, created } = await this.processor.createBookRecord( libraryId, folder.id, - bookFolderPath, - absolutePath, - relPath, + persistedBookFolderPath, + persistedAbsolutePath, + persistedRelPath, format, sizeBytes, ); if (created) { - this.processor.processNewBookImportAsync(bookId, libraryId, absolutePath, format); + this.processor.processNewBookImportAsync(bookId, libraryId, persistedAbsolutePath, format); } else { - this.processor.extractMetadataAsync(bookId, absolutePath, format); + this.processor.extractMetadataAsync(bookId, persistedAbsolutePath, format); } this.logger.log( `[${event}] [end] libraryId=${libraryId} userId=${user.id} folderId=${folder.id} bookId=${bookId} format=${format} sizeBytes=${sizeBytes} durationMs=${Date.now() - startedAt} - upload completed`, ); - return { bookId, filename: basename(absolutePath), format, sizeBytes }; + return { bookId, filename: basename(persistedAbsolutePath), format, sizeBytes }; } catch (err) { const { errorClass, errorMessage } = this.parseError(err); this.logger.error( diff --git a/server/test/scanner-file-operations.e2e-spec.ts b/server/test/scanner-file-operations.e2e-spec.ts index 3e5f71679..b733961a2 100644 --- a/server/test/scanner-file-operations.e2e-spec.ts +++ b/server/test/scanner-file-operations.e2e-spec.ts @@ -151,6 +151,23 @@ const structuralScenarios: StructuralScenario[] = [ }, }, }, + { + id: 'manual-case-only-author-folder-rename', + trigger: 'manual', + libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], + entries: [file('lib-a/Bell Hooks/Book/book.epub')], + operations: [{ type: 'move', from: 'lib-a/Bell Hooks', to: 'lib-a/bell hooks' }], + expected: { + a: { + statusByFolder: { 'bell hooks/Book': 'present' }, + absentFolders: ['Bell Hooks/Book'], + fileOwners: { 'bell hooks/Book/book.epub': 'bell hooks/Book' }, + presentCount: 1, + missingCount: 0, + }, + }, + requiresCaseRename: true, + }, { id: 'manual-move-root-file-into-subfolder', trigger: 'manual',