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 : }
|