LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - session_import.ts Coverage Total Hit
Test: DashQL 915828b Lines: 94.7 % 94 89
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import JSZip from 'jszip';
       2              : 
       3              : import type { ScriptData, StorageBackend } from '../../storage/storage_backend.js';
       4              : import {
       5              :     STORAGE_ATTACHMENTS_FOLDER,
       6              :     validateDataFileName,
       7              :     STORAGE_SCRIPTS_FOLDER,
       8              : } from '../../storage/storage_backend.js';
       9              : import type { SessionBundle, SessionBundleWriteOptions } from '../../storage/session_bundle.js';
      10              : import {
      11              :     assembleSessionBundle,
      12              :     isBundleCatalogPath,
      13              :     readSessionBundleHead,
      14              :     regenerateBundleConnectionIds,
      15              :     seedConnectionCatalogs,
      16              :     writeSessionBundle,
      17              : } from '../../storage/session_bundle.js';
      18              : import { MemoryFolder } from '../../storage/migration/migration_folder.js';
      19              : 
      20              : export interface SessionZipImportOptions extends SessionBundleWriteOptions {
      21              :     /// Generate fresh connection identities for a clone/create-new import
      22              :     regenerateConnectionIds?: boolean;
      23              : }
      24              : 
      25              : type SessionZipImportOptionsInput = SessionZipImportOptions | (() => string);
      26              : 
      27              : type ZipObjectWithOriginalName = JSZip.JSZipObject & { unsafeOriginalName?: string };
      28              : 
      29              : interface ParsedZipEntry {
      30              :     path: string;
      31              :     file: JSZip.JSZipObject;
      32              : }
      33              : 
      34            1 : const NATURAL_SORT = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
      35              : 
      36              : /// Parse and validate a portable session ZIP without modifying storage.
      37            1 : export async function readSessionBundleFromZip(zipBlob: Blob): Promise<SessionBundle> {
      38            1 :     const zip = await JSZip.loadAsync(zipBlob);
      39            1 :     const destinations = new Map<string, ParsedZipEntry>();
      40              : 
      41            1 :     for (const file of Object.values(zip.files)) {
      42            1 :         const originalPath = (file as ZipObjectWithOriginalName).unsafeOriginalName ?? file.name;
      43            1 :         validateDataPath(originalPath);
      44            1 :         const path = normalizeBundlePath(originalPath);
      45            1 :         validateDataPath(path);
      46            1 :         if (file.dir) continue;
      47            1 :         if (!isBundleFile(path)) {
      48            1 :             continue;
      49              :         }
      50            1 :         const key = destinationKey(path);
      51            1 :         if (destinations.has(key)) {
      52            0 :             throw new Error(`Invalid ZIP: duplicate destination ${path}`);
      53              :         }
      54            1 :         destinations.set(key, { path, file });
      55            1 :     }
      56              : 
      57              :     // The top-level files are migrated in memory, the scripts and attachments are read as they are
      58            1 :     const head = await readSessionBundleHead(new MemoryFolder(await Promise.all([...destinations]
      59            1 :         .filter(([key, entry]) => isTopLevelFile(key) || isBundleCatalogPath(entry.path))
      60            1 :         .map(async ([key, entry]) => [isBundleCatalogPath(entry.path) ? entry.path : key, await entry.file.async('text')] as [string, string]))), 'Invalid ZIP');
      61              : 
      62            1 :     const scripts: Array<{ name: string; file: JSZip.JSZipObject }> = [];
      63              : 
      64            1 :     for (const entry of destinations.values()) {
      65            1 :         const entryKey = destinationKey(entry.path);
      66            1 :         if (!entryKey.startsWith(`${destinationKey(STORAGE_SCRIPTS_FOLDER)}/`)) {
      67            1 :             continue;
      68              :         }
      69            1 :         const parts = entry.path.split('/');
      70            1 :         if (!entryKey.endsWith('.sql')) {
      71            0 :             continue;
      72              :         }
      73            1 :         if (parts.length !== 2) {
      74            1 :             throw new Error(`Invalid ZIP: SQL scripts must be directly inside scripts/: ${entry.path}`);
      75              :         }
      76            1 :         const [, scriptName] = parts;
      77            1 :         if (destinationKey(scriptName) === destinationKey('dashql-draft.sql')) {
      78            1 :             throw new Error(`Invalid ZIP: draft scripts are not supported: ${entry.path}`);
      79              :         }
      80            1 :         scripts.push({ name: scriptName, file: entry.file });
      81            1 :     }
      82              : 
      83            1 :     const parsedScripts: ScriptData[] = await Promise.all(scripts
      84            1 :         .sort((a, b) => NATURAL_SORT.compare(a.name, b.name))
      85            1 :         .map(async script => ({ name: script.name, sql: await script.file.async('text') })));
      86              : 
      87            1 :     const data = await Promise.all([...destinations.values()]
      88            1 :         .filter(entry => destinationKey(entry.path).startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`))
      89            1 :         .sort((a, b) => NATURAL_SORT.compare(a.path, b.path))
      90            1 :         .map(async entry => ({ name: entry.path.split('/')[1], bytes: await entry.file.async('uint8array') })));
      91            1 :     return assembleSessionBundle(head, parsedScripts, data);
      92            1 : }
      93              : 
      94            1 : function validateDataPath(path: string): void {
      95            1 :     if (!path.replace(/\\/g, '/').toLowerCase().startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`)) return;
      96            1 :     if (path.toLowerCase() === `${STORAGE_ATTACHMENTS_FOLDER}/`) return;
      97            1 :     const parts = path.split('/');
      98            1 :     if (parts.length !== 2 || parts[0].toLowerCase() !== STORAGE_ATTACHMENTS_FOLDER) {
      99            1 :         throw new Error(`Invalid ZIP: attachments must be directly inside attachments/: ${path}`);
     100              :     }
     101            1 :     validateDataFileName(parts[1]);
     102            1 : }
     103              : 
     104              : /// Import a portable session ZIP. By default the source UUID is preserved.
     105            1 : export async function importSessionFromZip(
     106            1 :     zipBlob: Blob,
     107            1 :     backend: StorageBackend,
     108            1 :     optionsInput: SessionZipImportOptionsInput = {},
     109            1 : ): Promise<string> {
     110            1 :     let bundle = await readSessionBundleFromZip(zipBlob);
     111            1 :     const { regenerateConnectionIds, ...options }: SessionZipImportOptions = typeof optionsInput === 'function'
     112            0 :         ? { targetSessionId: optionsInput(), targetIsFresh: true, regenerateConnectionIds: true }
     113            1 :         : optionsInput;
     114            1 :     if (regenerateConnectionIds) bundle = regenerateBundleConnectionIds(bundle);
     115            1 :     const sessionId = await writeSessionBundle(bundle, backend, options);
     116            1 :     await seedConnectionCatalogs(backend, bundle, sessionId);
     117            1 :     return sessionId;
     118            1 : }
     119              : 
     120            1 : function normalizeBundlePath(input: string): string {
     121            1 :     if (input.includes('\0')) throw new Error(`Invalid ZIP: NUL in path ${input}`);
     122            1 :     const path = input.replace(/\\/g, '/');
     123            1 :     if (path.startsWith('/')) {
     124            0 :         throw new Error(`Invalid ZIP: absolute path ${input}`);
     125              :     }
     126            1 :     const parts: string[] = [];
     127            1 :     for (const part of path.split('/')) {
     128            1 :         if (!part || part === '.') {
     129            1 :             continue;
     130              :         }
     131            1 :         if (part === '..') {
     132            0 :             throw new Error(`Invalid ZIP: path traversal ${input}`);
     133              :         }
     134            1 :         parts.push(part.normalize('NFC'));
     135            1 :     }
     136            1 :     return parts.join('/');
     137            1 : }
     138              : 
     139            1 : function destinationKey(path: string): string {
     140            1 :     return path.normalize('NFC').toLowerCase();
     141            1 : }
     142              : 
     143              : /// Is the path a top-level file of the session folder? Older versions name the files differently,
     144              : /// the session migration needs any of them.
     145            1 : function isTopLevelFile(key: string): boolean {
     146            1 :     return !key.includes('/') && key.startsWith('dashql-');
     147            1 : }
     148              : 
     149            1 : function isBundleFile(path: string): boolean {
     150            1 :     const key = destinationKey(path);
     151            1 :     return isTopLevelFile(key)
     152            1 :         || isBundleCatalogPath(path)
     153            1 :         || key.startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`)
     154            1 :         || (key.startsWith(`${destinationKey(STORAGE_SCRIPTS_FOLDER)}/`) && key.endsWith('.sql'));
     155            1 : }
        

Generated by: LCOV version 2.0-1