mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
合并来自 auto/fix-exdev-upload-52d3 的合并请求 #28
fix: 修复跨文件系统上传报 EXDEV 错误 PR-URL: #28
This commit is contained in:
2 files changed
+86
-4
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user