Line data Source code
1 : import { isAttachmentFileName, type SessionAttachment, type SessionConnection, type SessionData, type SessionScript } from './storage_backend.js';
2 : import { isValidUuid } from './session_validation.js';
3 :
4 : /// The draft script of the editor, never a script of a session
5 1 : const DRAFT_SCRIPT_FILE = 'dashql-draft.sql';
6 : /// The maximum number of scripts and the maximum number of attachments of a session
7 : export const MAX_SESSION_FILES = 2048;
8 :
9 1 : const NATURAL_SORT = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
10 :
11 : /// Compare file names in natural order ("2_a.sql" before "10_a.sql")
12 1 : export function compareFileNamesNaturally(a: string, b: string): number {
13 1 : return NATURAL_SORT.compare(a, b);
14 1 : }
15 :
16 : /// Is the value a plain file name that can't escape its folder?
17 1 : export function isSafeFileName(value: unknown): value is string {
18 1 : return typeof value === 'string'
19 1 : && value.length > 0
20 1 : && value.length <= 255
21 1 : && value !== '.'
22 1 : && value !== '..'
23 1 : && !value.includes('/')
24 1 : && !value.includes('\\')
25 1 : && !value.includes('\0');
26 1 : }
27 :
28 : /// The key under which two file names denote the same file on a case-insensitive file system
29 1 : function fileNameKey(name: string): string {
30 1 : return name.normalize('NFC').toLowerCase();
31 1 : }
32 :
33 : /// Is the value the name of a script file?
34 1 : function isScriptFileName(value: unknown): value is string {
35 1 : return isSafeFileName(value)
36 1 : && value.toLowerCase().endsWith('.sql')
37 1 : && fileNameKey(value) !== DRAFT_SCRIPT_FILE;
38 1 : }
39 :
40 : /// The file lists of a session after the unusable entries were removed
41 : export interface SanitizedSessionFiles {
42 : /// The scripts in feed order
43 : scripts: SessionScript[];
44 : /// The attachments in natural order
45 : attachments: SessionAttachment[];
46 : /// False when entries were removed
47 : valid: boolean;
48 : }
49 :
50 : /// Remove the entries of the file lists that can't be used: names that are not plain file names,
51 : /// scripts without a valid connection id, duplicates and entries beyond the limit.
52 : ///
53 : /// The lists of a session file are edited by hand, a bad entry therefore does not reject the session.
54 1 : export function sanitizeSessionFiles(session: SessionData): SanitizedSessionFiles {
55 1 : let valid = true;
56 1 : const scripts: SessionScript[] = [];
57 1 : const scriptKeys = new Set<string>();
58 1 : const rawScripts: unknown = (session as { scripts?: unknown }).scripts;
59 1 : if (!Array.isArray(rawScripts)) valid = false;
60 1 : for (const script of Array.isArray(rawScripts) ? rawScripts : []) {
61 1 : if (scripts.length >= MAX_SESSION_FILES) {
62 1 : valid = false;
63 1 : break;
64 1 : }
65 1 : if (!isPlainObject(script) || !isScriptFileName(script.name)
66 1 : || typeof script.connectionId !== 'string' || !isValidUuid(script.connectionId)
67 1 : || scriptKeys.has(fileNameKey(script.name))) {
68 1 : valid = false;
69 1 : continue;
70 1 : }
71 1 : scriptKeys.add(fileNameKey(script.name));
72 1 : scripts.push({ name: script.name.normalize('NFC'), connectionId: script.connectionId });
73 1 : }
74 1 : const attachmentNames: string[] = [];
75 1 : const attachmentKeys = new Set<string>();
76 1 : const rawAttachments: unknown = (session as { attachments?: unknown }).attachments;
77 1 : if (!Array.isArray(rawAttachments)) valid = false;
78 1 : for (const attachment of Array.isArray(rawAttachments) ? rawAttachments : []) {
79 1 : if (attachmentNames.length >= MAX_SESSION_FILES) {
80 1 : valid = false;
81 1 : break;
82 1 : }
83 : // A session attaches Parquet files only
84 1 : if (!isPlainObject(attachment) || !isSafeFileName(attachment.name) || !isAttachmentFileName(attachment.name)
85 1 : || attachmentKeys.has(fileNameKey(attachment.name))) {
86 1 : valid = false;
87 1 : continue;
88 1 : }
89 1 : attachmentKeys.add(fileNameKey(attachment.name));
90 1 : attachmentNames.push(attachment.name.normalize('NFC'));
91 1 : }
92 1 : return { scripts, attachments: sessionAttachments(attachmentNames), valid };
93 1 : }
94 :
95 : /// The scripts of a session reconciled with the script files
96 : export interface ReconciledSessionScripts {
97 : /// The scripts in feed order, one entry per file
98 : scripts: SessionScript[];
99 : /// Did the reconciliation change the scripts?
100 : changed: boolean;
101 : }
102 :
103 : /// Reconcile the scripts of a session with the script files that exist.
104 : ///
105 : /// Entries keep their order. An entry without a file is dropped, the first of several entries for
106 : /// a file wins and an entry that names a file with a different case or normalization takes the name
107 : /// of the file. Files without an entry are appended in natural order, they run on the connection of
108 : /// the script before them or, without scripts, on the given connection.
109 1 : export function reconcileSessionScripts(
110 1 : scripts: readonly SessionScript[],
111 1 : fileNames: readonly string[],
112 1 : fallbackConnectionId: string,
113 1 : ): ReconciledSessionScripts {
114 1 : const filesByName = new Set(fileNames);
115 1 : const filesByKey = new Map<string, string>();
116 1 : for (const name of fileNames) {
117 1 : if (!filesByKey.has(fileNameKey(name))) filesByKey.set(fileNameKey(name), name);
118 : }
119 1 : const listed = new Set<string>();
120 1 : const result: SessionScript[] = [];
121 1 : let changed = false;
122 1 : for (const script of scripts) {
123 1 : const name = filesByName.has(script.name) ? script.name : filesByKey.get(fileNameKey(script.name));
124 1 : if (name == null || listed.has(name)) {
125 1 : changed = true;
126 1 : continue;
127 1 : }
128 1 : if (name !== script.name) changed = true;
129 1 : listed.add(name);
130 1 : result.push({ name, connectionId: script.connectionId });
131 1 : }
132 1 : const unlisted = fileNames.filter(name => !listed.has(name)).sort(compareFileNamesNaturally);
133 1 : for (const name of unlisted) {
134 1 : listed.add(name);
135 1 : result.push({ name, connectionId: result[result.length - 1]?.connectionId ?? fallbackConnectionId });
136 1 : changed = true;
137 1 : }
138 1 : return { scripts: result, changed };
139 1 : }
140 :
141 : /// The attachment list of a session for the given file names, in natural order
142 1 : export function sessionAttachments(fileNames: readonly string[]): SessionAttachment[] {
143 1 : return [...fileNames].sort(compareFileNamesNaturally).map(name => ({ name }));
144 1 : }
145 :
146 : /// The ids of the connections that a session uses: the connections of its scripts in the order the
147 : /// scripts first use them. A session without scripts uses the connection its first script will run on.
148 1 : export function sessionConnectionIdsOf(scripts: readonly SessionScript[], emptyConnectionId: string | null = null): string[] {
149 1 : if (scripts.length === 0) return emptyConnectionId == null ? [] : [emptyConnectionId];
150 1 : return [...new Set(scripts.map(script => script.connectionId))];
151 1 : }
152 :
153 : /// The connection that the first script of a session without scripts runs on
154 : export function sessionEmptyConnectionId(session: SessionData): string | null {
155 : return session.scripts.length === 0 ? session.connections[0]?.connectionId ?? null : null;
156 : }
157 :
158 : /// The session with a new script list.
159 : ///
160 : /// The connections of a session are derived from its scripts: the session holds a copy of every
161 : /// connection that a script uses, nothing else. A copy that the session holds wins over a given one,
162 : /// saving a connection patches the copies of the sessions that use it. A connection without any copy
163 : /// is left out, the loader then runs its scripts on the embedded database.
164 : ///
165 : /// A session without scripts keeps one connection, the one its first script will run on: the first
166 : /// given copy, or the first connection the session held.
167 1 : export function withSessionScripts(session: SessionData, scripts: SessionScript[], copies: readonly SessionConnection[] = []): SessionData {
168 1 : const known = new Map<string, SessionConnection>();
169 1 : for (const copy of [...session.connections, ...copies]) {
170 1 : if (!known.has(copy.connectionId)) known.set(copy.connectionId, copy);
171 : }
172 1 : const emptyConnectionId = copies[0]?.connectionId ?? session.connections[0]?.connectionId ?? null;
173 1 : const connections = sessionConnectionIdsOf(scripts, emptyConnectionId)
174 1 : .flatMap(connectionId => known.get(connectionId) ?? []);
175 1 : return { ...session, connections, scripts };
176 1 : }
177 :
178 : /// Replace the connection ids of the scripts, ids without a replacement are kept
179 1 : export function remapSessionScripts(scripts: readonly SessionScript[], connectionIds: ReadonlyMap<string, string>): SessionScript[] {
180 1 : return scripts.map(script => ({ ...script, connectionId: connectionIds.get(script.connectionId) ?? script.connectionId }));
181 1 : }
182 :
183 : /// Do two script lists hold the same scripts in the same order?
184 : export function sessionScriptsEqual(a: readonly SessionScript[], b: readonly SessionScript[]): boolean {
185 : return a.length === b.length && a.every((script, i) => script.name === b[i].name && script.connectionId === b[i].connectionId);
186 : }
187 :
188 1 : function isPlainObject(value: unknown): value is Record<string, unknown> {
189 1 : return value != null && typeof value === 'object' && !Array.isArray(value);
190 1 : }
|