diff --git a/server/src/services/uploads.ts b/server/src/services/uploads.ts index 628b1ea..a599427 100644 --- a/server/src/services/uploads.ts +++ b/server/src/services/uploads.ts @@ -1,5 +1,5 @@ import { createWriteStream } from 'node:fs'; -import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { pipeline } from 'node:stream/promises'; import type { Readable } from 'node:stream'; @@ -131,6 +131,34 @@ export function parseConflictPolicy(value: unknown): ConflictPolicy { return value as ConflictPolicy; } +/** + * Hard-linked rename refuses with `EXDEV` when the source and the destination are + * different filesystems, which is the normal case here rather than an exotic one: + * the scratch directory lives in `DATA_DIR` (a container's writable layer, a + * different volume) and the library is a mount of its own. + * + * The fallback still behaves like a move: `copyFile` + `unlink` leaves no + * scratch copy behind, and it is only reached after `rename` has already said + * the two paths cannot share an inode. A `copyFile` that dies halfway leaves a + * partial destination, which `rollback` removes along with everything else the + * failed request wrote — the same guarantee a failed rename had. + */ +export async function moveIntoLibrary( + source: string, + destination: string, + move: typeof rename = rename, +): Promise { + try { + await move(source, destination); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + } + // Different filesystems: copy the bytes, then drop the scratch original. + await copyFile(source, destination); + await unlink(source).catch(() => undefined); +} + export class UploadService { constructor( private readonly db: Db, @@ -394,7 +422,7 @@ export class UploadService { const relPath = dir === '' ? finalName : `${dir}/${finalName}`; const abs = resolveInside(this.config.booksDir, relPath); await mkdir(dirname(abs), { recursive: true }); - await rename(stagedPath, abs); + await moveIntoLibrary(stagedPath, abs); const info = await stat(abs); return { path: relPath, originalName, name: finalName, size: info.size, kind }; } diff --git a/server/test/uploads.test.ts b/server/test/uploads.test.ts index ba22c21..741458a 100644 --- a/server/test/uploads.test.ts +++ b/server/test/uploads.test.ts @@ -12,7 +12,7 @@ */ import { test, before, after, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { mkdtemp, mkdir, rename, writeFile, readFile, readdir, rm, stat } from 'node:fs/promises'; import { Readable } from 'node:stream'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -29,7 +29,7 @@ import { ShelfService } from '../src/services/shelf.ts'; import { SyncService } from '../src/services/sync.ts'; import { TtsService } from '../src/services/tts.ts'; import { BrowseService } from '../src/services/browse.ts'; -import { UploadService, sanitizeUploadName } from '../src/services/uploads.ts'; +import { UploadService, moveIntoLibrary, sanitizeUploadName } from '../src/services/uploads.ts'; import { buildApp } from '../src/http/app.ts'; import type { AppContext } from '../src/http/context.ts'; @@ -775,3 +775,57 @@ describe('a read-only mount', () => { assert.deepEqual(left, [], 'a refused upload must not leave scratch behind'); }); }); + +describe('moving a staged file into the library', () => { + /** + * The scratch directory and the library are different mounts in every real + * deployment — `DATA_DIR` is the container's writable layer, `BOOKS_DIR` is a + * bind mount — so `rename` across them fails with `EXDEV`. The move has to + * fall back to copying instead of surfacing a raw errno to the user. + * + * The cross-device rename is injected rather than staged on two real mounts: + * ownership of the branch under test is the `EXDEV` handling, not the kernel's. + */ + const exdev = (): never => { + const err = new Error('EXDEV: cross-device link not permitted, rename') as NodeJS.ErrnoException; + err.code = 'EXDEV'; + throw err; + }; + + test('copies when the rename reports EXDEV, and removes the scratch file', async () => { + const dir = await mkdtemp(join(root, 'cross-')); + const source = join(dir, 'part'); + const destination = join(booksDir, '跨设备.epub'); + await writeFile(source, 'the bytes'); + await moveIntoLibrary(source, destination, exdev as unknown as typeof rename); + assert.equal(await readFile(destination, 'utf8'), 'the bytes'); + await assert.rejects(stat(source), 'the scratch copy must not be left behind'); + }); + + test('relies on rename when the filesystems match', async () => { + const dir = await mkdtemp(join(root, 'same-')); + const source = join(dir, 'part'); + const destination = join(booksDir, '同设备.epub'); + await writeFile(source, 'the bytes'); + await moveIntoLibrary(source, destination); + assert.equal(await readFile(destination, 'utf8'), 'the bytes'); + await assert.rejects(stat(source)); + }); + + test('does not swallow any other errno', async () => { + const dir = await mkdtemp(join(root, 'enosys-')); + const source = join(dir, 'part'); + await writeFile(source, 'the bytes'); + const enoent = (): never => { + const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }; + await assert.rejects( + moveIntoLibrary(source, join(booksDir, '不会存在.epub'), enoent as unknown as typeof rename), + (err: unknown) => (err as { code?: string }).code === 'ENOENT', + ); + // A failed move must not have copied anything either. + await assert.rejects(stat(join(booksDir, '不会存在.epub'))); + }); +});