From febb0d9a6921a0c2ee5c3d291f42cdf65b7ef9d8 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 22 Jun 2026 00:48:07 +0800 Subject: [PATCH] fix(backup): normalize Windows backslash paths in backup zip entries (#4706) A backup .zip exported on Windows failed to restore on every platform (Web, Android, Windows): books restored with metadata but no files or covers. `appService.readDirectory` returns paths using the host separator, so on Windows `file.path` is `hash\cover.png` (backslash). `addBackupEntriesToZip` used that verbatim as the zip entry name, so entries were named with `\`. Restore matches a book's files with `filename.startsWith(`${hash}/`)` (forward slash), which never matched the backslash names, so every book file was silently skipped. Normalize the zip entry name to forward slashes when adding files. Entry names are now cross-platform and restorable everywhere. Already-exported broken backups need re-exporting from the fixed app. Fixes #4703 Co-authored-by: Claude Opus 4.8 (1M context) --- .../services/backup-windows-paths.test.ts | 74 +++++++++++++++++++ .../readest-app/src/services/backupService.ts | 9 ++- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts 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); }