diff --git a/apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts b/apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts new file mode 100644 index 00000000..3482f754 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import type { ZipWriter } from '@zip.js/zip.js'; +import { addBackupEntriesToZip } from '@/services/backupService'; +import type { AppService, FileItem } from '@/types/system'; + +/** + * Regression test for issue #4703: a backup zip exported on Windows failed to + * restore on every platform. `readDirectory` returns paths using the host + * separator, so on Windows `file.path` is `hash\cover.png` (backslash). When + * that backslash leaked into the zip entry name, restore's + * `filename.startsWith(`${hash}/`)` filter (forward slash) never matched and + * every book file was silently skipped. Entry names must always use `/`. + */ + +const BOOK_HASH = '6afdd0136531fbe028e0503a14ba234c'; + +/** Build a stub AppService whose `readDirectory` returns the given file list. */ +function makeAppService(files: FileItem[]): AppService { + return { + loadLibraryBooks: async () => [], + loadSettings: async () => ({}) as never, + resolveFilePath: async () => 'C:/Users/me/AppData/Books', + readDirectory: async () => files, + readFile: async () => new ArrayBuffer(8), + } as unknown as AppService; +} + +/** A ZipWriter stub that records the entry name passed to each `add` call. */ +function makeCapturingWriter() { + const names: string[] = []; + const writer = { + add: async (name: string) => { + names.push(name); + }, + } as unknown as ZipWriter; + return { writer, names }; +} + +describe('addBackupEntriesToZip - cross-platform entry names (#4703)', () => { + it('normalizes Windows backslash paths to forward slashes', async () => { + const windowsFiles: FileItem[] = [ + { path: `${BOOK_HASH}\\book.epub`, size: 1000 }, + { path: `${BOOK_HASH}\\cover.png`, size: 200 }, + { path: `${BOOK_HASH}\\config.json`, size: 50 }, + ]; + const { writer, names } = makeCapturingWriter(); + + await addBackupEntriesToZip(writer, makeAppService(windowsFiles), {}); + + // No entry name may contain a backslash. + expect(names.some((n) => n.includes('\\'))).toBe(false); + + // Restore filters book files by `${hash}/`; the Windows export must match. + const bookEntries = names.filter((n) => n.startsWith(`${BOOK_HASH}/`)); + expect(bookEntries).toEqual([ + `${BOOK_HASH}/book.epub`, + `${BOOK_HASH}/cover.png`, + `${BOOK_HASH}/config.json`, + ]); + }); + + it('leaves POSIX forward-slash paths unchanged', async () => { + const posixFiles: FileItem[] = [ + { path: `${BOOK_HASH}/book.epub`, size: 1000 }, + { path: `${BOOK_HASH}/cover.png`, size: 200 }, + ]; + const { writer, names } = makeCapturingWriter(); + + await addBackupEntriesToZip(writer, makeAppService(posixFiles), {}); + + const bookEntries = names.filter((n) => n.startsWith(`${BOOK_HASH}/`)); + expect(bookEntries).toEqual([`${BOOK_HASH}/book.epub`, `${BOOK_HASH}/cover.png`]); + }); +}); diff --git a/apps/readest-app/src/services/backupService.ts b/apps/readest-app/src/services/backupService.ts index 36d1c105..5c220100 100644 --- a/apps/readest-app/src/services/backupService.ts +++ b/apps/readest-app/src/services/backupService.ts @@ -250,7 +250,7 @@ type ProgressCallback = (current: number, total: number, filename: string) => vo /** * Shared logic: add all library entries to a ZipWriter. */ -async function addBackupEntriesToZip( +export async function addBackupEntriesToZip( writer: ZipWriter, appService: AppService, options: BackupOptions, @@ -288,7 +288,12 @@ async function addBackupEntriesToZip( try { const content = await appService.readFile(file.path, 'Books', 'binary'); const data = new Uint8Array(content as ArrayBuffer); - await writer.add(file.path, new Uint8ArrayReader(data), { level: 0 }); + // `readDirectory` returns host-separator paths; on Windows that is a + // backslash (e.g. `hash\cover.png`). Zip entry names must use forward + // slashes so the backup restores on every platform — restore matches a + // book's files by `${hash}/` (see `restoreFromBackupZip`). Issue #4703. + const entryName = file.path.replace(/\\/g, '/'); + await writer.add(entryName, new Uint8ArrayReader(data), { level: 0 }); } catch (error) { console.warn(`Skipping file ${file.path}:`, error); }