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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<unknown>;
|
||||
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`]);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>,
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user