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

            Line data    Source code
       1              : import type { ScriptData } from '../../storage/storage_backend.js';
       2              : import {
       3              :     STORAGE_ATTACHMENTS_FOLDER,
       4              :     validateDataFileName,
       5              :     STORAGE_SCRIPTS_FOLDER,
       6              : } from '../../storage/storage_backend.js';
       7              : import type { SessionBundle } from '../../storage/session_bundle.js';
       8              : import { assembleSessionBundle, isBundleCatalogPath, readSessionBundleHead } from '../../storage/session_bundle.js';
       9              : import { MemoryFolder } from '../../storage/migration/migration_folder.js';
      10              : 
      11              : interface BrowserFolderEntry {
      12              :     path: string;
      13              :     sourcePath: string;
      14              :     file: File;
      15              : }
      16              : 
      17            1 : const NATURAL_SORT = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
      18              : 
      19              : /// Parse a session folder selected by an input with `webkitdirectory`. Browsers do not include
      20              : /// directory entries in FileList, so explicitly empty script folders cannot be preserved.
      21            1 : export async function readSessionBundleFromBrowserFolder(
      22            1 :     selectedFiles: FileList | readonly File[],
      23            1 : ): Promise<SessionBundle> {
      24            1 :     const entries: BrowserFolderEntry[] = [];
      25            1 :     let rootName: string | null = null;
      26            1 :     let usesRelativePaths: boolean | null = null;
      27              : 
      28            1 :     for (const file of Array.from(selectedFiles)) {
      29            1 :         const hasRelativePath = (file.webkitRelativePath?.length ?? 0) > 0;
      30            1 :         if (usesRelativePaths == null) {
      31            1 :             usesRelativePaths = hasRelativePath;
      32            1 :         } else if (usesRelativePaths !== hasRelativePath) {
      33            0 :             throw new Error('Invalid session folder: selection mixes rooted and fallback file paths');
      34              :         }
      35            1 :         const selectedPath = hasRelativePath ? file.webkitRelativePath : file.name;
      36            1 :         const rawPath = hasRelativePath ? selectedPath.split('/').slice(1).join('/') : selectedPath;
      37            1 :         if (rawPath.replace(/\\/g, '/').toLowerCase().startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`)) {
      38            1 :             const parts = rawPath.split('/');
      39            1 :             if (parts.length !== 2 || parts[0].toLowerCase() !== STORAGE_ATTACHMENTS_FOLDER) {
      40            1 :                 throw new Error(`Invalid session folder: attachments must be directly inside attachments/: ${rawPath}`);
      41              :             }
      42            1 :             validateDataFileName(parts[1]);
      43            1 :         }
      44            1 :         const { path, sourcePath } = normalizeSelectedPath(selectedPath);
      45            1 :         const parts = path.split('/');
      46            1 :         const sourceParts = sourcePath.split('/');
      47            1 :         if (hasRelativePath) {
      48            1 :             if (parts.length < 2) {
      49            0 :                 throw new Error(`Invalid session folder: file is outside a selected root folder: ${selectedPath}`);
      50              :             }
      51            1 :             parts.shift();
      52            1 :             const sourceRoot = sourceParts.shift()!;
      53            1 :             if (rootName == null) {
      54            1 :                 rootName = sourceRoot;
      55            1 :             } else if (rootName !== sourceRoot) {
      56            0 :                 throw new Error('Invalid session folder: selection contains multiple root folders');
      57              :             }
      58            1 :         }
      59            1 :         entries.push({ path: parts.join('/'), sourcePath: sourceParts.join('/'), file });
      60            1 :     }
      61              : 
      62            1 :     const destinations = new Map<string, BrowserFolderEntry>();
      63            1 :     for (const entry of entries) {
      64            1 :         const classification = classifyBundlePath(entry.path);
      65            1 :         if (classification === 'ignore') {
      66            0 :             continue;
      67              :         }
      68            1 :         if (classification === 'invalid-script') {
      69            1 :             throw new Error(
      70            1 :                 `Invalid session folder: SQL scripts must be directly inside scripts/: ${entry.path}`,
      71            1 :             );
      72              :         }
      73            1 :         if (classification === 'invalid-data') {
      74            0 :             throw new Error(`Invalid session folder: attachments must be directly inside attachments/: ${entry.path}`);
      75              :         }
      76              : 
      77            1 :         const key = destinationKey(entry.path);
      78            1 :         if (destinations.has(key)) {
      79            0 :             throw new Error(`Invalid session folder: duplicate destination ${entry.path}`);
      80              :         }
      81            1 :         destinations.set(key, entry);
      82              : 
      83            1 :     }
      84              : 
      85            1 :     const contents = new Map<string, string>();
      86            1 :     await Promise.all([...destinations].map(async ([key, entry]) => {
      87            1 :         if (classifyBundlePath(entry.path) === 'data') return;
      88            1 :         contents.set(key, await entry.file.text());
      89            1 :     }));
      90              : 
      91              :     // The top-level files are migrated in memory, the scripts and attachments are read as they are
      92            1 :     const head = await readSessionBundleHead(new MemoryFolder([...destinations]
      93            1 :         .filter(([key, entry]) => isTopLevelFile(key) || isBundleCatalogPath(entry.path))
      94            1 :         .map(([key, entry]) => [isBundleCatalogPath(entry.path) ? entry.path : key, contents.get(key)!] as [string, string])),
      95            1 :         'Invalid session folder');
      96              : 
      97            1 :     const scripts: ScriptData[] = [...destinations]
      98            1 :         .filter(([, entry]) => classifyBundlePath(entry.path) === 'script')
      99            1 :         .map(([key, entry]) => ({
     100            1 :             name: entry.path.split('/')[1],
     101            1 :             sql: contents.get(key)!,
     102            1 :         }))
     103            1 :         .sort((a, b) => NATURAL_SORT.compare(a.name, b.name));
     104              : 
     105            1 :     return assembleSessionBundle(head, scripts, await Promise.all([...destinations.values()]
     106            1 :         .filter(entry => classifyBundlePath(entry.path) === 'data')
     107            1 :         .sort((a, b) => NATURAL_SORT.compare(a.path, b.path))
     108            1 :         .map(async entry => ({ name: entry.path.split('/')[1], bytes: new Uint8Array(await entry.file.arrayBuffer()) }))));
     109            1 : }
     110              : 
     111            1 : function normalizeSelectedPath(input: string): { path: string; sourcePath: string } {
     112            1 :     if (input.includes('\0')) throw new Error(`Invalid session folder: NUL in path ${input}`);
     113            1 :     const path = input.replace(/\\/g, '/');
     114            1 :     if (path.startsWith('/') || /^[a-z]:\//i.test(path)) {
     115            0 :         throw new Error(`Invalid session folder: absolute path ${input}`);
     116              :     }
     117            1 :     const sourceParts: string[] = [];
     118            1 :     for (const part of path.split('/')) {
     119            1 :         if (!part || part === '.') {
     120            0 :             continue;
     121              :         }
     122            1 :         if (part === '..') {
     123            0 :             throw new Error(`Invalid session folder: path traversal ${input}`);
     124              :         }
     125            1 :         sourceParts.push(part);
     126            1 :     }
     127            1 :     return {
     128            1 :         path: sourceParts.map(part => part.normalize('NFC')).join('/'),
     129            1 :         sourcePath: sourceParts.join('/'),
     130            1 :     };
     131            1 : }
     132              : 
     133            1 : function classifyBundlePath(path: string): 'accept' | 'script' | 'data' | 'ignore' | 'invalid-script' | 'invalid-data' {
     134            1 :     const key = destinationKey(path);
     135            1 :     if (key.startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`)) {
     136            1 :         const parts = path.split('/');
     137            1 :         if (parts.length !== 2) return 'invalid-data';
     138            1 :         validateDataFileName(parts[1]);
     139            1 :         return 'data';
     140            1 :     }
     141            1 :     if (isTopLevelFile(key) || isBundleCatalogPath(path)) {
     142            1 :         return 'accept';
     143              :     }
     144              : 
     145            1 :     const scriptsPrefix = `${destinationKey(STORAGE_SCRIPTS_FOLDER)}/`;
     146            1 :     if (!key.startsWith(scriptsPrefix) || !key.endsWith('.sql')) {
     147            0 :         return 'ignore';
     148              :     }
     149            1 :     const parts = path.split('/');
     150            1 :     if (parts.length !== 2) {
     151            1 :         return 'invalid-script';
     152              :     }
     153            1 :     if (destinationKey(parts[1]) === destinationKey('dashql-draft.sql')) {
     154            1 :         return 'invalid-script';
     155              :     }
     156            1 :     return 'script';
     157            1 : }
     158              : 
     159              : /// Is the path a top-level file of the session folder? Older versions name the files differently,
     160              : /// the session migration needs any of them.
     161            1 : function isTopLevelFile(key: string): boolean {
     162            1 :     return !key.includes('/') && key.startsWith('dashql-');
     163            1 : }
     164              : 
     165            1 : function destinationKey(path: string): string {
     166            1 :     return path.normalize('NFC').toLowerCase();
     167            1 : }
        

Generated by: LCOV version 2.0-1