sqlite-hub 0.9.3 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +2 -158
  5. package/frontend/js/app.js +41 -22
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +145 -195
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +81 -99
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +28 -31
  15. package/frontend/js/components/structureGraph.js +10 -9
  16. package/frontend/js/components/tableDesignerEditor.js +91 -116
  17. package/frontend/js/store.js +39 -3
  18. package/frontend/js/utils/dom.js +28 -0
  19. package/frontend/js/utils/tableDesigner.js +8 -2
  20. package/frontend/js/views/charts.js +23 -43
  21. package/frontend/js/views/data.js +116 -132
  22. package/frontend/js/views/mediaTagging.js +131 -164
  23. package/frontend/js/views/structure.js +52 -48
  24. package/frontend/styles/tailwind.css +80 -0
  25. package/frontend/styles/tailwind.generated.css +2 -0
  26. package/frontend/styles/tokens.css +3 -3
  27. package/package.json +19 -5
  28. package/server/routes/mediaTagging.js +2 -10
  29. package/server/routes/sql.js +35 -10
  30. package/server/server.js +24 -0
  31. package/server/services/sqlite/dataBrowserService.js +25 -5
  32. package/server/services/sqlite/exportService.js +4 -2
  33. package/server/services/sqlite/introspection.js +2 -2
  34. package/server/services/sqlite/mediaTaggingService.js +166 -53
  35. package/server/services/sqlite/structureService.js +2 -2
  36. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  37. package/server/services/storage/appStateStore.js +227 -87
  38. package/server/utils/appPaths.js +55 -19
  39. package/server/utils/fileValidation.js +94 -8
  40. package/tailwind.config.cjs +73 -0
  41. package/tests/security-paths.test.js +84 -0
  42. package/tests/sql-identifier-safety.test.js +66 -0
  43. package/.npmingnore +0 -4
  44. package/changelog.md +0 -84
  45. package/docs/DESIGN_GUIDELINES.md +0 -36
  46. package/scripts/publish_brew.sh +0 -466
  47. package/scripts/publish_npm.sh +0 -241
  48. package/shortkeys.md +0 -5
@@ -1,7 +1,12 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
+ const { pathToFileURL } = require("node:url");
3
4
  const Database = require("better-sqlite3");
4
5
  const { ConflictError, NotFoundError, ValidationError } = require("../../utils/errors");
6
+ const {
7
+ resolvePathInsideDirectory,
8
+ resolveUserPath,
9
+ } = require("../../utils/fileValidation");
5
10
  const {
6
11
  buildAutoTitle,
7
12
  buildSqlPreview,
@@ -33,6 +38,8 @@ const DEFAULT_STATE = {
33
38
  };
34
39
 
35
40
  const CONNECTION_LOGO_DIRECTORY = "db_logos";
41
+ const LEGACY_STATE_FILENAME = "app-state.json";
42
+ const STATE_DATABASE_FILENAME = "sqlite-hub-state.db";
36
43
  const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
37
44
  const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
38
45
  "image/jpeg": "jpg",
@@ -107,17 +114,81 @@ function isChartCompatibleQuery(queryType, rawSql = "") {
107
114
  );
108
115
  }
109
116
 
117
+ function normalizeStateStorePath(filePath, label) {
118
+ return resolveUserPath(filePath, { label });
119
+ }
120
+
121
+ function normalizeLegacyFilePath(filePath, { currentFilePath, expectedFileName, label }) {
122
+ if (!filePath) {
123
+ return null;
124
+ }
125
+
126
+ const resolvedPath = normalizeStateStorePath(filePath, label);
127
+
128
+ if (resolvedPath === currentFilePath || path.basename(resolvedPath) !== expectedFileName) {
129
+ return null;
130
+ }
131
+
132
+ return resolvedPath;
133
+ }
134
+
135
+ function normalizeLegacyDatabasePaths(legacyDatabasePaths, currentFilePath) {
136
+ if (!Array.isArray(legacyDatabasePaths)) {
137
+ return [];
138
+ }
139
+
140
+ const normalizedPaths = legacyDatabasePaths
141
+ .map((legacyPath) =>
142
+ normalizeLegacyFilePath(legacyPath, {
143
+ currentFilePath,
144
+ expectedFileName: STATE_DATABASE_FILENAME,
145
+ label: "Legacy app state database path",
146
+ })
147
+ )
148
+ .filter(Boolean);
149
+
150
+ return normalizedPaths.filter(
151
+ (legacyPath, index, legacyPaths) => legacyPaths.indexOf(legacyPath) === index
152
+ );
153
+ }
154
+
155
+ function readUtf8File(fileUrl) {
156
+ const fileHandle = fs.openSync(fileUrl, "r");
157
+
158
+ try {
159
+ const fileSize = fs.fstatSync(fileHandle).size;
160
+ const buffer = Buffer.alloc(fileSize);
161
+ fs.readSync(fileHandle, buffer, 0, buffer.length, 0);
162
+ return buffer.toString("utf8");
163
+ } finally {
164
+ fs.closeSync(fileHandle);
165
+ }
166
+ }
167
+
110
168
  class AppStateStore {
111
169
  constructor(filePath, options = {}) {
112
- this.filePath = filePath;
113
- this.logoDirectory = path.join(path.dirname(this.filePath), CONNECTION_LOGO_DIRECTORY);
114
- this.legacyFilePath = options.legacyFilePath ?? null;
115
- this.legacyDatabasePaths = Array.isArray(options.legacyDatabasePaths)
116
- ? options.legacyDatabasePaths
117
- : [];
118
- this.isFreshDatabase = !fs.existsSync(filePath);
119
-
120
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
170
+ this.filePath = normalizeStateStorePath(filePath, "App state database path");
171
+ this.stateDirectory = path.dirname(this.filePath);
172
+ this.logoDirectory = resolvePathInsideDirectory(
173
+ this.stateDirectory,
174
+ CONNECTION_LOGO_DIRECTORY,
175
+ "Connection logo directory"
176
+ );
177
+ this.legacyFilePath = normalizeLegacyFilePath(options.legacyFilePath, {
178
+ currentFilePath: this.filePath,
179
+ expectedFileName: LEGACY_STATE_FILENAME,
180
+ label: "Legacy app state path",
181
+ });
182
+ this.legacyFileUrl = this.legacyFilePath
183
+ ? pathToFileURL(this.legacyFilePath)
184
+ : null;
185
+ this.legacyDatabasePaths = normalizeLegacyDatabasePaths(
186
+ options.legacyDatabasePaths,
187
+ this.filePath
188
+ );
189
+ this.isFreshDatabase = !fs.existsSync(this.filePath);
190
+
191
+ fs.mkdirSync(this.stateDirectory, { recursive: true });
121
192
  fs.mkdirSync(this.logoDirectory, { recursive: true });
122
193
 
123
194
  this.db = new Database(this.filePath);
@@ -313,7 +384,7 @@ class AppStateStore {
313
384
  }
314
385
 
315
386
  readLegacyState() {
316
- const raw = fs.readFileSync(this.legacyFilePath, "utf8");
387
+ const raw = readUtf8File(this.legacyFileUrl);
317
388
  const parsed = JSON.parse(raw);
318
389
 
319
390
  return {
@@ -328,11 +399,9 @@ class AppStateStore {
328
399
 
329
400
  getExistingLegacyDatabasePaths() {
330
401
  return this.legacyDatabasePaths
331
- .filter(Boolean)
332
- .map((legacyPath) => path.resolve(legacyPath))
333
402
  .filter(
334
403
  (legacyPath, index, legacyPaths) =>
335
- legacyPath !== path.resolve(this.filePath) &&
404
+ legacyPath !== this.filePath &&
336
405
  legacyPaths.indexOf(legacyPath) === index &&
337
406
  fs.existsSync(legacyPath)
338
407
  );
@@ -361,6 +430,9 @@ class AppStateStore {
361
430
  .map((column) => column.name)
362
431
  )
363
432
  : new Set();
433
+ const logoPathSelection = recentConnectionColumns.has("logoPath")
434
+ ? "logoPath"
435
+ : "NULL AS logoPath";
364
436
 
365
437
  return {
366
438
  settings: tables.has("settings")
@@ -373,19 +445,23 @@ class AppStateStore {
373
445
  : {},
374
446
  recentConnections: tables.has("recent_connections")
375
447
  ? legacyDb
376
- .prepare(`
377
- SELECT
378
- id,
379
- label,
380
- path,
381
- lastOpenedAt,
382
- lastModifiedAt,
383
- sizeBytes,
384
- readOnly,
385
- ${recentConnectionColumns.has("logoPath") ? "logoPath" : "NULL AS logoPath"}
386
- FROM recent_connections
387
- ORDER BY lastOpenedAt DESC, id ASC
388
- `)
448
+ .prepare(
449
+ [
450
+ "SELECT",
451
+ [
452
+ "id",
453
+ "label",
454
+ "path",
455
+ "lastOpenedAt",
456
+ "lastModifiedAt",
457
+ "sizeBytes",
458
+ "readOnly",
459
+ logoPathSelection,
460
+ ].join(", "),
461
+ "FROM recent_connections",
462
+ "ORDER BY lastOpenedAt DESC, id ASC",
463
+ ].join(" ")
464
+ )
389
465
  .all()
390
466
  : [],
391
467
  sqlHistory: tables.has("sql_history")
@@ -939,8 +1015,8 @@ class AppStateStore {
939
1015
  onlyFavorites,
940
1016
  latestStatus,
941
1017
  });
942
- const rows = this.db
943
- .prepare(`
1018
+ const queryHistoryRowsSql = [
1019
+ `
944
1020
  SELECT
945
1021
  q.id,
946
1022
  q.database_key,
@@ -963,20 +1039,24 @@ class AppStateStore {
963
1039
  latest.status AS last_run_status,
964
1040
  latest.error_message AS last_run_error_message,
965
1041
  latest.affected_rows AS last_run_affected_rows
966
- ${baseFromSql}
967
- ${whereSql}
968
- ${orderBySql}
969
- LIMIT ?
970
- OFFSET ?
971
- `)
1042
+ `,
1043
+ baseFromSql,
1044
+ whereSql,
1045
+ orderBySql,
1046
+ "LIMIT ?",
1047
+ "OFFSET ?",
1048
+ ].join("\n");
1049
+ const rows = this.db
1050
+ .prepare(queryHistoryRowsSql)
972
1051
  .all(...params, normalizedLimit, normalizedOffset)
973
1052
  .map((row) => this.decorateQueryHistoryRow(row));
1053
+ const queryHistoryCountSql = [
1054
+ "SELECT COUNT(*) AS count",
1055
+ baseFromSql,
1056
+ whereSql,
1057
+ ].join("\n");
974
1058
  const countRow = this.db
975
- .prepare(`
976
- SELECT COUNT(*) AS count
977
- ${baseFromSql}
978
- ${whereSql}
979
- `)
1059
+ .prepare(queryHistoryCountSql)
980
1060
  .get(...params);
981
1061
  const total = Number(countRow?.count ?? 0);
982
1062
 
@@ -1263,7 +1343,14 @@ class AppStateStore {
1263
1343
  .filter((item) => item.chartsEligible);
1264
1344
  }
1265
1345
 
1266
- getQueryHistoryItemById(historyId) {
1346
+ getQueryHistoryItemById(historyId, databaseKey) {
1347
+ const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1348
+ const tenantId = normalizedDatabaseKey;
1349
+
1350
+ if (!tenantId) {
1351
+ throw new ValidationError("Query history lookup requires a database key.");
1352
+ }
1353
+
1267
1354
  const row = this.db
1268
1355
  .prepare(`
1269
1356
  SELECT
@@ -1289,6 +1376,7 @@ class AppStateStore {
1289
1376
  latest.error_message AS last_run_error_message,
1290
1377
  latest.affected_rows AS last_run_affected_rows
1291
1378
  FROM query_history q
1379
+ -- tenantId scope is enforced on q.database_key for direct id lookups.
1292
1380
  LEFT JOIN query_runs latest
1293
1381
  ON latest.id = (
1294
1382
  SELECT runs.id
@@ -1298,8 +1386,9 @@ class AppStateStore {
1298
1386
  LIMIT 1
1299
1387
  )
1300
1388
  WHERE q.id = ?
1389
+ AND q.database_key = ?
1301
1390
  `)
1302
- .get(Number(historyId));
1391
+ .get(Number(historyId), tenantId);
1303
1392
 
1304
1393
  if (!row) {
1305
1394
  throw new NotFoundError(`Query history item not found: ${historyId}`);
@@ -1357,14 +1446,7 @@ class AppStateStore {
1357
1446
  }
1358
1447
 
1359
1448
  getQueryHistoryItemForDatabase(historyId, databaseKey) {
1360
- const item = this.getQueryHistoryItemById(historyId);
1361
- const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1362
-
1363
- if (normalizedDatabaseKey && item.databaseKey !== normalizedDatabaseKey) {
1364
- throw new NotFoundError(`Query history item not found: ${historyId}`);
1365
- }
1366
-
1367
- return item;
1449
+ return this.getQueryHistoryItemById(historyId, databaseKey);
1368
1450
  }
1369
1451
 
1370
1452
  getChartQueryHistoryItemForDatabase(historyId, databaseKey) {
@@ -1377,8 +1459,9 @@ class AppStateStore {
1377
1459
  return item;
1378
1460
  }
1379
1461
 
1380
- getQueryRunsByHistoryId(historyId, limit = 8) {
1462
+ getQueryRunsByHistoryId(historyId, limit = 8, databaseKey) {
1381
1463
  const normalizedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
1464
+ const item = this.getQueryHistoryItemById(historyId, databaseKey);
1382
1465
 
1383
1466
  return this.db
1384
1467
  .prepare(`
@@ -1396,7 +1479,7 @@ class AppStateStore {
1396
1479
  ORDER BY executed_at DESC, id DESC
1397
1480
  LIMIT ?
1398
1481
  `)
1399
- .all(Number(historyId), normalizedLimit)
1482
+ .all(item.id, normalizedLimit)
1400
1483
  .map((row) => this.decorateQueryRun(row));
1401
1484
  }
1402
1485
 
@@ -1489,16 +1572,40 @@ class AppStateStore {
1489
1572
  };
1490
1573
  }
1491
1574
 
1492
- updateQueryHistoryField(historyId, fieldName, value) {
1493
- const result = this.db
1494
- .prepare(`UPDATE query_history SET ${fieldName} = ? WHERE id = ?`)
1495
- .run(value, Number(historyId));
1575
+ updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
1576
+ const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1577
+
1578
+ if (!normalizedDatabaseKey) {
1579
+ throw new ValidationError("Query history update requires a database key.");
1580
+ }
1581
+
1582
+ const statements = {
1583
+ is_favorite: this.db.prepare(
1584
+ "UPDATE query_history SET is_favorite = ? WHERE id = ? AND database_key = ?"
1585
+ ),
1586
+ is_saved: this.db.prepare(
1587
+ "UPDATE query_history SET is_saved = ? WHERE id = ? AND database_key = ?"
1588
+ ),
1589
+ title: this.db.prepare(
1590
+ "UPDATE query_history SET title = ? WHERE id = ? AND database_key = ?"
1591
+ ),
1592
+ notes: this.db.prepare(
1593
+ "UPDATE query_history SET notes = ? WHERE id = ? AND database_key = ?"
1594
+ ),
1595
+ };
1596
+ const statement = statements[fieldName];
1597
+
1598
+ if (!statement) {
1599
+ throw new ValidationError(`Query history field cannot be updated: ${fieldName}`);
1600
+ }
1601
+
1602
+ const result = statement.run(value, Number(historyId), normalizedDatabaseKey);
1496
1603
 
1497
1604
  if (!result.changes) {
1498
1605
  throw new NotFoundError(`Query history item not found: ${historyId}`);
1499
1606
  }
1500
1607
 
1501
- return this.getQueryHistoryItemById(historyId);
1608
+ return this.getQueryHistoryItemById(historyId, normalizedDatabaseKey);
1502
1609
  }
1503
1610
 
1504
1611
  resolveUniqueQueryHistoryChartName(queryHistoryId, candidateName, { excludeChartId = null } = {}) {
@@ -1507,15 +1614,20 @@ class AppStateStore {
1507
1614
  let suffix = 2;
1508
1615
 
1509
1616
  while (true) {
1617
+ const excludeChartClause = excludeChartId ? "AND id != ?" : "";
1510
1618
  const row = this.db
1511
- .prepare(`
1512
- SELECT id
1513
- FROM query_history_chart
1514
- WHERE query_history_id = ?
1515
- AND name = ?
1516
- ${excludeChartId ? "AND id != ?" : ""}
1517
- LIMIT 1
1518
- `)
1619
+ .prepare(
1620
+ [
1621
+ "SELECT id",
1622
+ "FROM query_history_chart",
1623
+ "WHERE query_history_id = ?",
1624
+ "AND name = ?",
1625
+ excludeChartClause,
1626
+ "LIMIT 1",
1627
+ ]
1628
+ .filter(Boolean)
1629
+ .join(" ")
1630
+ )
1519
1631
  .get(
1520
1632
  Number(queryHistoryId),
1521
1633
  nextName,
@@ -1653,34 +1765,42 @@ class AppStateStore {
1653
1765
  return true;
1654
1766
  }
1655
1767
 
1656
- toggleFavorite(historyId, nextValue) {
1657
- return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0);
1768
+ toggleFavorite(historyId, nextValue, databaseKey) {
1769
+ return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0, databaseKey);
1658
1770
  }
1659
1771
 
1660
- toggleSaved(historyId, nextValue) {
1661
- return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0);
1772
+ toggleSaved(historyId, nextValue, databaseKey) {
1773
+ return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0, databaseKey);
1662
1774
  }
1663
1775
 
1664
- renameQuery(historyId, title) {
1776
+ renameQuery(historyId, title, databaseKey) {
1665
1777
  return this.updateQueryHistoryField(
1666
1778
  historyId,
1667
1779
  "title",
1668
- this.normalizeQueryHistoryText(title)
1780
+ this.normalizeQueryHistoryText(title),
1781
+ databaseKey
1669
1782
  );
1670
1783
  }
1671
1784
 
1672
- updateQueryNotes(historyId, notes) {
1785
+ updateQueryNotes(historyId, notes, databaseKey) {
1673
1786
  return this.updateQueryHistoryField(
1674
1787
  historyId,
1675
1788
  "notes",
1676
- this.normalizeQueryHistoryText(notes)
1789
+ this.normalizeQueryHistoryText(notes),
1790
+ databaseKey
1677
1791
  );
1678
1792
  }
1679
1793
 
1680
- deleteQueryHistoryItem(historyId) {
1794
+ deleteQueryHistoryItem(historyId, databaseKey) {
1795
+ const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1796
+
1797
+ if (!normalizedDatabaseKey) {
1798
+ throw new ValidationError("Query history delete requires a database key.");
1799
+ }
1800
+
1681
1801
  const result = this.db
1682
- .prepare("DELETE FROM query_history WHERE id = ?")
1683
- .run(Number(historyId));
1802
+ .prepare("DELETE FROM query_history WHERE id = ? AND database_key = ?")
1803
+ .run(Number(historyId), normalizedDatabaseKey);
1684
1804
 
1685
1805
  if (!result.changes) {
1686
1806
  throw new NotFoundError(`Query history item not found: ${historyId}`);
@@ -1938,8 +2058,13 @@ class AppStateStore {
1938
2058
 
1939
2059
  const safeConnectionId = String(connectionId ?? "connection").replace(/[^a-zA-Z0-9_-]/g, "_");
1940
2060
  const storedFileName = `${safeConnectionId}-${Date.now()}.${extension}`;
2061
+ const logoFilePath = resolvePathInsideDirectory(
2062
+ this.logoDirectory,
2063
+ storedFileName,
2064
+ "Connection logo path"
2065
+ );
1941
2066
 
1942
- fs.writeFileSync(path.join(this.logoDirectory, storedFileName), buffer);
2067
+ fs.writeFileSync(logoFilePath, buffer);
1943
2068
 
1944
2069
  return storedFileName;
1945
2070
  }
@@ -1951,7 +2076,14 @@ class AppStateStore {
1951
2076
  return;
1952
2077
  }
1953
2078
 
1954
- fs.rmSync(path.join(this.logoDirectory, normalizedLogoPath), { force: true });
2079
+ fs.rmSync(
2080
+ resolvePathInsideDirectory(
2081
+ this.logoDirectory,
2082
+ normalizedLogoPath,
2083
+ "Connection logo path"
2084
+ ),
2085
+ { force: true }
2086
+ );
1955
2087
  }
1956
2088
 
1957
2089
  decorateConnection(connection = {}) {
@@ -1972,7 +2104,13 @@ class AppStateStore {
1972
2104
  return null;
1973
2105
  }
1974
2106
 
1975
- return path.basename(trimmedLogoPath);
2107
+ const baseName = path.basename(trimmedLogoPath);
2108
+
2109
+ if (baseName !== trimmedLogoPath || baseName === "." || baseName === "..") {
2110
+ return null;
2111
+ }
2112
+
2113
+ return baseName;
1976
2114
  }
1977
2115
 
1978
2116
  setActiveConnectionId(id) {
@@ -2142,7 +2280,7 @@ class AppStateStore {
2142
2280
  ...MEDIA_TAGGING_CONFIG_FIELDS.map((field) => field.column),
2143
2281
  ];
2144
2282
  const assignments = MEDIA_TAGGING_CONFIG_FIELDS.map(
2145
- (field) => `${field.column} = excluded.${field.column}`
2283
+ (field) => field.column + " = excluded." + field.column
2146
2284
  );
2147
2285
 
2148
2286
  if (this.mediaTaggingConfigHasLegacyJsonColumn) {
@@ -2155,15 +2293,17 @@ class AppStateStore {
2155
2293
  assignments.push("updated_at = excluded.updated_at");
2156
2294
  values.push(updatedAt);
2157
2295
 
2296
+ const upsertSql = [
2297
+ "INSERT INTO media_tagging_config",
2298
+ "(" + explicitColumns.join(", ") + ")",
2299
+ "VALUES",
2300
+ "(" + explicitColumns.map(() => "?").join(", ") + ")",
2301
+ "ON CONFLICT(database_key) DO UPDATE SET",
2302
+ assignments.join(", "),
2303
+ ].join(" ");
2304
+
2158
2305
  this.db
2159
- .prepare(
2160
- `
2161
- INSERT INTO media_tagging_config (${explicitColumns.join(", ")})
2162
- VALUES (${explicitColumns.map(() => "?").join(", ")})
2163
- ON CONFLICT(database_key) DO UPDATE SET
2164
- ${assignments.join(",\n ")}
2165
- `
2166
- )
2306
+ .prepare(upsertSql)
2167
2307
  .run(...values);
2168
2308
 
2169
2309
  return this.getMediaTaggingConfig(normalizedDatabaseKey);
@@ -1,10 +1,24 @@
1
1
  const fs = require("node:fs");
2
2
  const os = require("node:os");
3
3
  const path = require("node:path");
4
+ const {
5
+ assertSafePathInput,
6
+ resolvePathInsideDirectory,
7
+ } = require("./fileValidation");
4
8
 
5
9
  const APP_NAME = "sqlite-hub";
6
10
  const APP_STATE_DB_FILENAME = "sqlite-hub-state.db";
7
11
  const LEGACY_STATE_FILENAME = "app-state.json";
12
+ const SAFE_HOMEBREW_SEGMENT_PATTERN = /^[a-zA-Z0-9._+-]+$/;
13
+
14
+ function isSafeHomebrewSegment(segment) {
15
+ return (
16
+ typeof segment === "string" &&
17
+ SAFE_HOMEBREW_SEGMENT_PATTERN.test(segment) &&
18
+ segment !== "." &&
19
+ segment !== ".."
20
+ );
21
+ }
8
22
 
9
23
  function resolvePackagedDataDirectories(packageRoot) {
10
24
  return [
@@ -51,7 +65,7 @@ function resolvePackagedLegacyStatePath(packageRoot) {
51
65
  }
52
66
 
53
67
  function resolveHomebrewCellarInfo(packageRoot) {
54
- const resolvedPackageRoot = path.resolve(packageRoot);
68
+ const resolvedPackageRoot = path.resolve(assertSafePathInput(packageRoot, "Package root"));
55
69
  const { root } = path.parse(resolvedPackageRoot);
56
70
  const relativeSegments = resolvedPackageRoot
57
71
  .slice(root.length)
@@ -66,16 +80,44 @@ function resolveHomebrewCellarInfo(packageRoot) {
66
80
  const formulaName = relativeSegments[cellarIndex + 1];
67
81
  const currentVersion = relativeSegments[cellarIndex + 2];
68
82
 
69
- if (formulaName !== APP_NAME || !currentVersion) {
83
+ if (
84
+ formulaName !== APP_NAME ||
85
+ !isSafeHomebrewSegment(currentVersion) ||
86
+ !isSafeHomebrewSegment(formulaName)
87
+ ) {
70
88
  return null;
71
89
  }
72
90
 
73
91
  return {
74
- cellarRoot: path.join(root, ...relativeSegments.slice(0, cellarIndex + 2)),
92
+ cellarRoot: path.resolve(root, ...relativeSegments.slice(0, cellarIndex + 2)),
75
93
  currentVersion,
76
94
  };
77
95
  }
78
96
 
97
+ function resolveLegacyStateDbPath(cellarRoot, versionName, dataSegments) {
98
+ const versionRoot = resolvePathInsideDirectory(
99
+ cellarRoot,
100
+ versionName,
101
+ "Homebrew version directory"
102
+ );
103
+ const packagedDataSegments = Array.isArray(dataSegments)
104
+ ? dataSegments
105
+ : [dataSegments];
106
+
107
+ return resolvePathInsideDirectory(
108
+ versionRoot,
109
+ path.join(
110
+ "libexec",
111
+ "lib",
112
+ "node_modules",
113
+ APP_NAME,
114
+ ...packagedDataSegments,
115
+ APP_STATE_DB_FILENAME
116
+ ),
117
+ "Legacy app state database path"
118
+ );
119
+ }
120
+
79
121
  function safeStatMtimeMs(filePath) {
80
122
  try {
81
123
  return fs.statSync(filePath).mtimeMs;
@@ -93,29 +135,23 @@ function collectHomebrewLegacyStateDbPaths(packageRoot) {
93
135
 
94
136
  return fs
95
137
  .readdirSync(cellarInfo.cellarRoot, { withFileTypes: true })
96
- .filter((entry) => entry.isDirectory() && entry.name !== cellarInfo.currentVersion)
138
+ .filter(
139
+ (entry) =>
140
+ entry.isDirectory() &&
141
+ entry.name !== cellarInfo.currentVersion &&
142
+ isSafeHomebrewSegment(entry.name)
143
+ )
97
144
  .flatMap((entry) =>
98
145
  [
99
- path.join(
146
+ resolveLegacyStateDbPath(
100
147
  cellarInfo.cellarRoot,
101
148
  entry.name,
102
- "libexec",
103
- "lib",
104
- "node_modules",
105
- APP_NAME,
106
- "server",
107
- "data",
108
- APP_STATE_DB_FILENAME
149
+ ["server", "data"]
109
150
  ),
110
- path.join(
151
+ resolveLegacyStateDbPath(
111
152
  cellarInfo.cellarRoot,
112
153
  entry.name,
113
- "libexec",
114
- "lib",
115
- "node_modules",
116
- APP_NAME,
117
- "data",
118
- APP_STATE_DB_FILENAME
154
+ ["data"]
119
155
  ),
120
156
  ].map((candidatePath) => ({
121
157
  path: candidatePath,