合并来自 auto/fix-exdev-upload-52d3 的合并请求 #28

fix: 修复跨文件系统上传报 EXDEV 错误

PR-URL: #28
This commit is contained in:
cnb.dQC5J6ohAGA authored and cnb committed 2026-09-17 17:37:31 +08:00
commit efe703078f
2 files changed
+86 -4

No files matched your search

+30 -2
View File
@@ -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<void> {
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 };
}
+56 -2
View File
@@ -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')));
});
});