@saptools/cf-hana 0.5.0 → 0.5.1
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.
- package/CHANGELOG.md +46 -0
- package/README.md +51 -3
- package/dist/cli.js +350 -124
- package/dist/cli.js.map +1 -1
- package/dist/index.js +348 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from "os";
|
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
9
|
-
var CLI_VERSION = "0.5.
|
|
9
|
+
var CLI_VERSION = "0.5.1";
|
|
10
10
|
var ENV_PREFIX = "CF_HANA";
|
|
11
11
|
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
12
12
|
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
@@ -438,7 +438,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
// src/statements.ts
|
|
441
|
-
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"
|
|
441
|
+
var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
|
|
442
442
|
var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
|
|
443
443
|
var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
|
|
444
444
|
function firstKeyword(sql) {
|
|
@@ -478,8 +478,165 @@ function firstKeyword(sql) {
|
|
|
478
478
|
}
|
|
479
479
|
return sql.slice(index, keywordEnd).toUpperCase();
|
|
480
480
|
}
|
|
481
|
-
function
|
|
482
|
-
const
|
|
481
|
+
function skipQuotedText2(sql, start) {
|
|
482
|
+
const quote = sql[start];
|
|
483
|
+
let index = start + 1;
|
|
484
|
+
while (index < sql.length) {
|
|
485
|
+
if (sql[index] === quote) {
|
|
486
|
+
if (sql[index + 1] === quote) {
|
|
487
|
+
index += 2;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
index += 1;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
index += 1;
|
|
494
|
+
}
|
|
495
|
+
return index;
|
|
496
|
+
}
|
|
497
|
+
function skipLineComment2(sql, start) {
|
|
498
|
+
let index = start + 2;
|
|
499
|
+
while (index < sql.length && sql[index] !== "\n") {
|
|
500
|
+
index += 1;
|
|
501
|
+
}
|
|
502
|
+
return index;
|
|
503
|
+
}
|
|
504
|
+
function skipBlockComment2(sql, start) {
|
|
505
|
+
let index = start + 2;
|
|
506
|
+
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
507
|
+
index += 1;
|
|
508
|
+
}
|
|
509
|
+
return Math.min(index + 2, sql.length);
|
|
510
|
+
}
|
|
511
|
+
function maskIgnoredSqlText(sql) {
|
|
512
|
+
let masked = "";
|
|
513
|
+
let index = 0;
|
|
514
|
+
while (index < sql.length) {
|
|
515
|
+
const char = sql[index];
|
|
516
|
+
if (char === "'" || char === '"') {
|
|
517
|
+
const end = skipQuotedText2(sql, index);
|
|
518
|
+
masked += " ".repeat(end - index);
|
|
519
|
+
index = end;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (char === "-" && sql[index + 1] === "-") {
|
|
523
|
+
const end = skipLineComment2(sql, index);
|
|
524
|
+
masked += " ".repeat(end - index);
|
|
525
|
+
index = end;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (char === "/" && sql[index + 1] === "*") {
|
|
529
|
+
const end = skipBlockComment2(sql, index);
|
|
530
|
+
masked += " ".repeat(end - index);
|
|
531
|
+
index = end;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
masked += char ?? "";
|
|
535
|
+
index += 1;
|
|
536
|
+
}
|
|
537
|
+
return masked;
|
|
538
|
+
}
|
|
539
|
+
function topLevelKeywordIndex(sql, keyword) {
|
|
540
|
+
const masked = maskIgnoredSqlText(sql);
|
|
541
|
+
let depth = 0;
|
|
542
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
543
|
+
const char = masked[index];
|
|
544
|
+
if (char === "(") {
|
|
545
|
+
depth += 1;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (char === ")") {
|
|
549
|
+
depth = Math.max(0, depth - 1);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
|
|
553
|
+
return index;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return void 0;
|
|
557
|
+
}
|
|
558
|
+
function hasTopLevelKeyword(sql, keyword) {
|
|
559
|
+
return topLevelKeywordIndex(sql, keyword) !== void 0;
|
|
560
|
+
}
|
|
561
|
+
function isIdentifierChar2(char) {
|
|
562
|
+
return /[A-Za-z0-9_$#]/.test(char);
|
|
563
|
+
}
|
|
564
|
+
function skipWhitespace(masked, start) {
|
|
565
|
+
let index = start;
|
|
566
|
+
while (index < masked.length && /\s/.test(masked.charAt(index))) {
|
|
567
|
+
index += 1;
|
|
568
|
+
}
|
|
569
|
+
return index;
|
|
570
|
+
}
|
|
571
|
+
function matchingCloseParenIndex(masked, openIndex) {
|
|
572
|
+
let depth = 0;
|
|
573
|
+
for (let index = openIndex; index < masked.length; index += 1) {
|
|
574
|
+
const char = masked.charAt(index);
|
|
575
|
+
if (char === "(") {
|
|
576
|
+
depth += 1;
|
|
577
|
+
} else if (char === ")") {
|
|
578
|
+
depth -= 1;
|
|
579
|
+
if (depth === 0) {
|
|
580
|
+
return index;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return void 0;
|
|
585
|
+
}
|
|
586
|
+
function isAsKeywordAt(masked, index) {
|
|
587
|
+
return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
|
|
588
|
+
}
|
|
589
|
+
function cteListEndIndex(masked, afterWith) {
|
|
590
|
+
let index = afterWith;
|
|
591
|
+
for (; ; ) {
|
|
592
|
+
index = skipWhitespace(masked, index);
|
|
593
|
+
while (index < masked.length && isIdentifierChar2(masked.charAt(index))) {
|
|
594
|
+
index += 1;
|
|
595
|
+
}
|
|
596
|
+
index = skipWhitespace(masked, index);
|
|
597
|
+
if (masked.charAt(index) === "(") {
|
|
598
|
+
const columnListEnd = matchingCloseParenIndex(masked, index);
|
|
599
|
+
if (columnListEnd === void 0) {
|
|
600
|
+
return void 0;
|
|
601
|
+
}
|
|
602
|
+
index = skipWhitespace(masked, columnListEnd + 1);
|
|
603
|
+
}
|
|
604
|
+
if (!isAsKeywordAt(masked, index)) {
|
|
605
|
+
return void 0;
|
|
606
|
+
}
|
|
607
|
+
index = skipWhitespace(masked, index + 2);
|
|
608
|
+
if (masked.charAt(index) !== "(") {
|
|
609
|
+
return void 0;
|
|
610
|
+
}
|
|
611
|
+
const closeIndex = matchingCloseParenIndex(masked, index);
|
|
612
|
+
if (closeIndex === void 0) {
|
|
613
|
+
return void 0;
|
|
614
|
+
}
|
|
615
|
+
index = skipWhitespace(masked, closeIndex + 1);
|
|
616
|
+
if (masked.charAt(index) === ",") {
|
|
617
|
+
index += 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
return index;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function resolveWithStatement(sql) {
|
|
624
|
+
const withIndex = topLevelKeywordIndex(sql, "WITH");
|
|
625
|
+
if (withIndex === void 0) {
|
|
626
|
+
return void 0;
|
|
627
|
+
}
|
|
628
|
+
const masked = maskIgnoredSqlText(sql);
|
|
629
|
+
const trailingIndex = cteListEndIndex(masked, withIndex + "WITH".length);
|
|
630
|
+
if (trailingIndex === void 0) {
|
|
631
|
+
return void 0;
|
|
632
|
+
}
|
|
633
|
+
let keywordEnd = trailingIndex;
|
|
634
|
+
while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
|
|
635
|
+
keywordEnd += 1;
|
|
636
|
+
}
|
|
637
|
+
return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
|
|
638
|
+
}
|
|
639
|
+
function classifyByKeyword(keyword) {
|
|
483
640
|
if (SELECT_KEYWORDS.has(keyword)) {
|
|
484
641
|
return "select";
|
|
485
642
|
}
|
|
@@ -491,6 +648,14 @@ function classifyStatement(sql) {
|
|
|
491
648
|
}
|
|
492
649
|
return "unknown";
|
|
493
650
|
}
|
|
651
|
+
function classifyStatement(sql) {
|
|
652
|
+
const keyword = firstKeyword(sql);
|
|
653
|
+
if (keyword === "WITH") {
|
|
654
|
+
const resolved = resolveWithStatement(sql);
|
|
655
|
+
return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
|
|
656
|
+
}
|
|
657
|
+
return classifyByKeyword(keyword);
|
|
658
|
+
}
|
|
494
659
|
function quoteIdentifier(identifier) {
|
|
495
660
|
if (identifier.length === 0) {
|
|
496
661
|
throw new QueryError("A SQL identifier must not be empty");
|
|
@@ -654,11 +819,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
|
|
|
654
819
|
return target.reference;
|
|
655
820
|
}
|
|
656
821
|
if (/^PARTITION\b/i.test(suffix)) {
|
|
657
|
-
const
|
|
658
|
-
if (
|
|
822
|
+
const open2 = suffix.indexOf("(");
|
|
823
|
+
if (open2 === -1) {
|
|
659
824
|
return void 0;
|
|
660
825
|
}
|
|
661
|
-
const close = findTopLevelChar(suffix, ")",
|
|
826
|
+
const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
|
|
662
827
|
if (close === void 0) {
|
|
663
828
|
return void 0;
|
|
664
829
|
}
|
|
@@ -841,13 +1006,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
|
841
1006
|
}
|
|
842
1007
|
}
|
|
843
1008
|
function buildWriteBackupPlan(sql, params = []) {
|
|
844
|
-
const
|
|
845
|
-
const
|
|
1009
|
+
const trimmed = trimStatementSql(sql);
|
|
1010
|
+
const leading = firstKeyword(trimmed);
|
|
1011
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
|
|
1012
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
846
1013
|
if (!isWriteKeyword(keyword)) {
|
|
847
1014
|
return void 0;
|
|
848
1015
|
}
|
|
849
|
-
|
|
850
|
-
|
|
1016
|
+
const startIndex = withResolved?.index ?? 0;
|
|
1017
|
+
const statementSql = trimmed.slice(startIndex);
|
|
1018
|
+
const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
|
|
1019
|
+
assertParamArity(statementSql, slicedParams);
|
|
1020
|
+
const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
|
|
1021
|
+
return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
|
|
851
1022
|
}
|
|
852
1023
|
|
|
853
1024
|
// src/backup.ts
|
|
@@ -1176,9 +1347,9 @@ function buildEnv(ctx, overrides = {}) {
|
|
|
1176
1347
|
env["CF_HOME"] = ctx.cfHome;
|
|
1177
1348
|
return env;
|
|
1178
1349
|
}
|
|
1179
|
-
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1350
|
+
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
|
|
1180
1351
|
let lastErr;
|
|
1181
|
-
for (let attempt = 0; attempt <
|
|
1352
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1182
1353
|
try {
|
|
1183
1354
|
const { stdout } = await execFileAsync(bin, args, {
|
|
1184
1355
|
env,
|
|
@@ -1197,18 +1368,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
1197
1368
|
if (isEnoent || !isTimeout && !isNetworkFlake) {
|
|
1198
1369
|
throw err;
|
|
1199
1370
|
}
|
|
1200
|
-
if (attempt <
|
|
1371
|
+
if (attempt < maxAttempts - 1) {
|
|
1201
1372
|
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
1202
1373
|
}
|
|
1203
1374
|
}
|
|
1204
1375
|
}
|
|
1205
1376
|
throw lastErr;
|
|
1206
1377
|
}
|
|
1207
|
-
async function runCf(args, ctx, overrides = {}) {
|
|
1378
|
+
async function runCf(args, ctx, overrides = {}, execOptions = {}) {
|
|
1208
1379
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1209
1380
|
const env = buildEnv(ctx, overrides);
|
|
1210
1381
|
try {
|
|
1211
|
-
return await execWithRetries(
|
|
1382
|
+
return await execWithRetries(
|
|
1383
|
+
bin,
|
|
1384
|
+
[...argsPrefix, ...args],
|
|
1385
|
+
env,
|
|
1386
|
+
execOptions.timeoutMs,
|
|
1387
|
+
execOptions.maxAttempts
|
|
1388
|
+
);
|
|
1212
1389
|
} catch (lastErr) {
|
|
1213
1390
|
const e = lastErr;
|
|
1214
1391
|
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
@@ -1238,15 +1415,26 @@ async function cfEnvDirect(appName) {
|
|
|
1238
1415
|
delete env["SAP_PASSWORD"];
|
|
1239
1416
|
return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
|
|
1240
1417
|
}
|
|
1241
|
-
async function cfApps(ctx) {
|
|
1242
|
-
return await runCf(
|
|
1418
|
+
async function cfApps(ctx, timeoutMs) {
|
|
1419
|
+
return await runCf(
|
|
1420
|
+
["apps"],
|
|
1421
|
+
ctx,
|
|
1422
|
+
{},
|
|
1423
|
+
timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
|
|
1424
|
+
);
|
|
1243
1425
|
}
|
|
1244
|
-
async function cfAppsDirect() {
|
|
1426
|
+
async function cfAppsDirect(timeoutMs) {
|
|
1245
1427
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1246
1428
|
const env = { ...process.env };
|
|
1247
1429
|
delete env["SAP_EMAIL"];
|
|
1248
1430
|
delete env["SAP_PASSWORD"];
|
|
1249
|
-
return await execWithRetries(
|
|
1431
|
+
return await execWithRetries(
|
|
1432
|
+
bin,
|
|
1433
|
+
[...argsPrefix, "apps"],
|
|
1434
|
+
env,
|
|
1435
|
+
timeoutMs,
|
|
1436
|
+
timeoutMs === void 0 ? void 0 : 1
|
|
1437
|
+
);
|
|
1250
1438
|
}
|
|
1251
1439
|
async function readCurrentCfTarget() {
|
|
1252
1440
|
const { bin, argsPrefix } = resolveCfBin();
|
|
@@ -2153,6 +2341,13 @@ async function connectHdb(params) {
|
|
|
2153
2341
|
password: params.password,
|
|
2154
2342
|
ca: params.certificate,
|
|
2155
2343
|
useTLS: true,
|
|
2344
|
+
// HANA Cloud can reactively redirect a connection mid-auth to an
|
|
2345
|
+
// internal per-node hostname (pod-locality routing). That target is
|
|
2346
|
+
// often unreachable outside SAP's own network, and for a tunneled
|
|
2347
|
+
// connection it would silently abandon the SSH forward for a fresh,
|
|
2348
|
+
// untunneled socket. The original bound host is always a real, working
|
|
2349
|
+
// endpoint, so disabling the redirect is safe on the direct path too.
|
|
2350
|
+
disableCloudRedirect: true,
|
|
2156
2351
|
...params.servername === void 0 ? {} : { servername: params.servername }
|
|
2157
2352
|
});
|
|
2158
2353
|
await openClient(client, params.connectTimeoutMs);
|
|
@@ -2262,83 +2457,6 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
2262
2457
|
// src/safety.ts
|
|
2263
2458
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
2264
2459
|
var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
|
|
2265
|
-
function skipQuotedText2(sql, start) {
|
|
2266
|
-
const quote = sql[start];
|
|
2267
|
-
let index = start + 1;
|
|
2268
|
-
while (index < sql.length) {
|
|
2269
|
-
if (sql[index] === quote) {
|
|
2270
|
-
if (sql[index + 1] === quote) {
|
|
2271
|
-
index += 2;
|
|
2272
|
-
continue;
|
|
2273
|
-
}
|
|
2274
|
-
index += 1;
|
|
2275
|
-
break;
|
|
2276
|
-
}
|
|
2277
|
-
index += 1;
|
|
2278
|
-
}
|
|
2279
|
-
return index;
|
|
2280
|
-
}
|
|
2281
|
-
function skipLineComment2(sql, start) {
|
|
2282
|
-
let index = start + 2;
|
|
2283
|
-
while (index < sql.length && sql[index] !== "\n") {
|
|
2284
|
-
index += 1;
|
|
2285
|
-
}
|
|
2286
|
-
return index;
|
|
2287
|
-
}
|
|
2288
|
-
function skipBlockComment2(sql, start) {
|
|
2289
|
-
let index = start + 2;
|
|
2290
|
-
while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
|
|
2291
|
-
index += 1;
|
|
2292
|
-
}
|
|
2293
|
-
return Math.min(index + 2, sql.length);
|
|
2294
|
-
}
|
|
2295
|
-
function maskIgnoredSqlText(sql) {
|
|
2296
|
-
let masked = "";
|
|
2297
|
-
let index = 0;
|
|
2298
|
-
while (index < sql.length) {
|
|
2299
|
-
const char = sql[index];
|
|
2300
|
-
if (char === "'" || char === '"') {
|
|
2301
|
-
const end = skipQuotedText2(sql, index);
|
|
2302
|
-
masked += " ".repeat(end - index);
|
|
2303
|
-
index = end;
|
|
2304
|
-
continue;
|
|
2305
|
-
}
|
|
2306
|
-
if (char === "-" && sql[index + 1] === "-") {
|
|
2307
|
-
const end = skipLineComment2(sql, index);
|
|
2308
|
-
masked += " ".repeat(end - index);
|
|
2309
|
-
index = end;
|
|
2310
|
-
continue;
|
|
2311
|
-
}
|
|
2312
|
-
if (char === "/" && sql[index + 1] === "*") {
|
|
2313
|
-
const end = skipBlockComment2(sql, index);
|
|
2314
|
-
masked += " ".repeat(end - index);
|
|
2315
|
-
index = end;
|
|
2316
|
-
continue;
|
|
2317
|
-
}
|
|
2318
|
-
masked += char ?? "";
|
|
2319
|
-
index += 1;
|
|
2320
|
-
}
|
|
2321
|
-
return masked;
|
|
2322
|
-
}
|
|
2323
|
-
function hasTopLevelKeyword(sql, keyword) {
|
|
2324
|
-
const masked = maskIgnoredSqlText(sql);
|
|
2325
|
-
let depth = 0;
|
|
2326
|
-
for (let index = 0; index < masked.length; index += 1) {
|
|
2327
|
-
const char = masked[index];
|
|
2328
|
-
if (char === "(") {
|
|
2329
|
-
depth += 1;
|
|
2330
|
-
continue;
|
|
2331
|
-
}
|
|
2332
|
-
if (char === ")") {
|
|
2333
|
-
depth = Math.max(0, depth - 1);
|
|
2334
|
-
continue;
|
|
2335
|
-
}
|
|
2336
|
-
if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
|
|
2337
|
-
return true;
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
return false;
|
|
2341
|
-
}
|
|
2342
2460
|
function isUnconditionalMergeDelete(sql) {
|
|
2343
2461
|
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2344
2462
|
}
|
|
@@ -2381,17 +2499,23 @@ function appendLimit(sql, limit) {
|
|
|
2381
2499
|
}
|
|
2382
2500
|
function inspectStatement(sql) {
|
|
2383
2501
|
const kind = classifyStatement(sql);
|
|
2384
|
-
const
|
|
2502
|
+
const leading = firstKeyword(sql);
|
|
2503
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
|
|
2504
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
2505
|
+
const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
|
|
2385
2506
|
if (kind === "ddl") {
|
|
2386
2507
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
2387
2508
|
}
|
|
2388
2509
|
if (kind === "dml") {
|
|
2389
|
-
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(
|
|
2510
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
|
|
2390
2511
|
return {
|
|
2391
2512
|
kind,
|
|
2392
2513
|
destructive
|
|
2393
2514
|
};
|
|
2394
2515
|
}
|
|
2516
|
+
if (kind === "unknown") {
|
|
2517
|
+
return { kind, destructive: keyword === "CALL" };
|
|
2518
|
+
}
|
|
2395
2519
|
return { kind, destructive: false };
|
|
2396
2520
|
}
|
|
2397
2521
|
function evaluateGuard(sql, config) {
|
|
@@ -2436,13 +2560,13 @@ function applyAutoLimit(sql, limit) {
|
|
|
2436
2560
|
|
|
2437
2561
|
// src/tunnel/cache.ts
|
|
2438
2562
|
import { createHash } from "crypto";
|
|
2439
|
-
import { mkdir as mkdir3, readdir as readdir2, readFile, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2563
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2440
2564
|
import { homedir as homedir3 } from "os";
|
|
2441
2565
|
import { join as join5 } from "path";
|
|
2442
2566
|
|
|
2443
2567
|
// src/tunnel/process.ts
|
|
2444
2568
|
import { spawn } from "child_process";
|
|
2445
|
-
import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
|
|
2569
|
+
import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
|
|
2446
2570
|
import { connect as netConnect, createServer } from "net";
|
|
2447
2571
|
import { tmpdir as tmpdir2 } from "os";
|
|
2448
2572
|
import { join as join4 } from "path";
|
|
@@ -2549,8 +2673,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
2549
2673
|
child.on("exit", onAbort);
|
|
2550
2674
|
child.on("error", onAbort);
|
|
2551
2675
|
timers.poll = setInterval(() => {
|
|
2552
|
-
void probePort(localPort).then((
|
|
2553
|
-
if (
|
|
2676
|
+
void probePort(localPort).then((open2) => {
|
|
2677
|
+
if (open2 && child.pid !== void 0) {
|
|
2554
2678
|
finish({ localPort, pid: child.pid });
|
|
2555
2679
|
}
|
|
2556
2680
|
});
|
|
@@ -2558,6 +2682,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
2558
2682
|
timers.deadline = setTimeout(finish, boundMs);
|
|
2559
2683
|
});
|
|
2560
2684
|
}
|
|
2685
|
+
var STDERR_TAIL_MAX_CHARS = 4096;
|
|
2686
|
+
async function openStderrCapture() {
|
|
2687
|
+
try {
|
|
2688
|
+
const dir = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
|
|
2689
|
+
const path = join4(dir, "stderr.log");
|
|
2690
|
+
const handle = await open(path, "w");
|
|
2691
|
+
return {
|
|
2692
|
+
path,
|
|
2693
|
+
fd: handle.fd,
|
|
2694
|
+
close: async () => {
|
|
2695
|
+
await handle.close().catch(() => {
|
|
2696
|
+
});
|
|
2697
|
+
await rm3(dir, { recursive: true, force: true });
|
|
2698
|
+
}
|
|
2699
|
+
};
|
|
2700
|
+
} catch {
|
|
2701
|
+
return void 0;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
async function readStderrTail(path) {
|
|
2705
|
+
try {
|
|
2706
|
+
const content = (await readFile(path, "utf8")).trim().replace(/\s+/g, " ");
|
|
2707
|
+
if (content.length === 0) {
|
|
2708
|
+
return void 0;
|
|
2709
|
+
}
|
|
2710
|
+
return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
|
|
2711
|
+
} catch {
|
|
2712
|
+
return void 0;
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2561
2715
|
async function spawnTunnel(params, deps = {}) {
|
|
2562
2716
|
assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
|
|
2563
2717
|
const remainingMs = params.deadline - Date.now();
|
|
@@ -2582,8 +2736,24 @@ async function spawnTunnel(params, deps = {}) {
|
|
|
2582
2736
|
`sleep ${String(params.keepaliveSeconds)}`
|
|
2583
2737
|
];
|
|
2584
2738
|
const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2739
|
+
const openCapture = deps.openStderrCapture ?? openStderrCapture;
|
|
2740
|
+
const capture = await openCapture();
|
|
2741
|
+
const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
|
|
2742
|
+
const child = spawnProcess(bin, args, { detached: true, stdio, env });
|
|
2743
|
+
const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
|
|
2744
|
+
if (capture !== void 0) {
|
|
2745
|
+
if (result === void 0) {
|
|
2746
|
+
try {
|
|
2747
|
+
const tail = await readStderrTail(capture.path);
|
|
2748
|
+
if (tail !== void 0) {
|
|
2749
|
+
deps.onFailureDiagnostic?.(tail);
|
|
2750
|
+
}
|
|
2751
|
+
} catch {
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
await capture.close();
|
|
2755
|
+
}
|
|
2756
|
+
return result;
|
|
2587
2757
|
}
|
|
2588
2758
|
|
|
2589
2759
|
// src/tunnel/cache.ts
|
|
@@ -2628,7 +2798,7 @@ function isTunnelCacheRecord(value) {
|
|
|
2628
2798
|
}
|
|
2629
2799
|
async function parseTunnelCacheFile(path) {
|
|
2630
2800
|
try {
|
|
2631
|
-
const raw = JSON.parse(await
|
|
2801
|
+
const raw = JSON.parse(await readFile2(path, "utf8"));
|
|
2632
2802
|
return isTunnelCacheRecord(raw) ? raw : void 0;
|
|
2633
2803
|
} catch {
|
|
2634
2804
|
return void 0;
|
|
@@ -2846,6 +3016,9 @@ function isConnectivityFailure(error) {
|
|
|
2846
3016
|
}
|
|
2847
3017
|
return hasOpenConnectionCause(error.cause, 0);
|
|
2848
3018
|
}
|
|
3019
|
+
function isUnattributedQueryFailure(error) {
|
|
3020
|
+
return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
|
|
3021
|
+
}
|
|
2849
3022
|
|
|
2850
3023
|
// src/tunnel/fallback.ts
|
|
2851
3024
|
var EXPIRY_SAFETY_MARGIN_MS = 3e4;
|
|
@@ -2875,30 +3048,40 @@ async function discardQuietly(work) {
|
|
|
2875
3048
|
await work.catch(() => {
|
|
2876
3049
|
});
|
|
2877
3050
|
}
|
|
3051
|
+
function shouldDiscardTunnel(error) {
|
|
3052
|
+
return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
|
|
3053
|
+
}
|
|
2878
3054
|
async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
|
|
2879
3055
|
try {
|
|
2880
3056
|
const connection = await driver.connect(tunneledParams(directParams, record.localPort));
|
|
2881
3057
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
|
|
2882
3058
|
return connection;
|
|
2883
3059
|
} catch (error) {
|
|
2884
|
-
if (
|
|
3060
|
+
if (shouldDiscardTunnel(error)) {
|
|
2885
3061
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
2886
3062
|
return void 0;
|
|
2887
3063
|
}
|
|
2888
3064
|
throw error;
|
|
2889
3065
|
}
|
|
2890
3066
|
}
|
|
2891
|
-
|
|
3067
|
+
function knownCandidates(targetAppName, hintApp) {
|
|
3068
|
+
return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
|
|
3069
|
+
}
|
|
3070
|
+
async function discoverAppsStdout(ctx, timeoutMs) {
|
|
2892
3071
|
try {
|
|
2893
|
-
return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
|
|
3072
|
+
return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
|
|
2894
3073
|
} catch {
|
|
2895
3074
|
return;
|
|
2896
3075
|
}
|
|
2897
3076
|
}
|
|
2898
|
-
async function
|
|
2899
|
-
const
|
|
3077
|
+
async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
|
|
3078
|
+
const remainingMs = deadline - Date.now();
|
|
3079
|
+
if (remainingMs <= 0) {
|
|
3080
|
+
return [];
|
|
3081
|
+
}
|
|
3082
|
+
const stdout = await discoverAppsStdout(ctx, remainingMs);
|
|
2900
3083
|
const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
|
|
2901
|
-
return
|
|
3084
|
+
return base.filter((app) => !alreadyTried.includes(app));
|
|
2902
3085
|
}
|
|
2903
3086
|
async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
|
|
2904
3087
|
const readyRecord = {
|
|
@@ -2914,7 +3097,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
|
|
|
2914
3097
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
|
|
2915
3098
|
return connection;
|
|
2916
3099
|
} catch (error) {
|
|
2917
|
-
if (
|
|
3100
|
+
if (shouldDiscardTunnel(error)) {
|
|
2918
3101
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
2919
3102
|
killTunnelProcess(spawned.pid);
|
|
2920
3103
|
return void 0;
|
|
@@ -2931,7 +3114,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
2931
3114
|
return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
|
|
2932
3115
|
}
|
|
2933
3116
|
if (claim.outcome === "wait") {
|
|
2934
|
-
const
|
|
3117
|
+
const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
|
|
3118
|
+
const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
|
|
2935
3119
|
return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
|
|
2936
3120
|
}
|
|
2937
3121
|
const spawned = await spawnTunnel(
|
|
@@ -2944,7 +3128,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
2944
3128
|
deadline,
|
|
2945
3129
|
candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
|
|
2946
3130
|
},
|
|
2947
|
-
|
|
3131
|
+
{
|
|
3132
|
+
...overrides.process,
|
|
3133
|
+
onFailureDiagnostic: (message) => {
|
|
3134
|
+
config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
2948
3137
|
);
|
|
2949
3138
|
if (spawned === void 0) {
|
|
2950
3139
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
@@ -2954,7 +3143,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
2954
3143
|
}
|
|
2955
3144
|
async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
|
|
2956
3145
|
if (config.refreshTunnel) {
|
|
3146
|
+
const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
2957
3147
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3148
|
+
if (superseded?.status === "ready") {
|
|
3149
|
+
(cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
|
|
3150
|
+
}
|
|
2958
3151
|
return { connection: void 0, hintApp: void 0 };
|
|
2959
3152
|
}
|
|
2960
3153
|
const entry = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
@@ -2972,8 +3165,12 @@ async function tryDirectConnect(driver, config, directParams) {
|
|
|
2972
3165
|
if (config.tunnelMode !== "auto") {
|
|
2973
3166
|
return { connection: void 0, directError: void 0 };
|
|
2974
3167
|
}
|
|
3168
|
+
const boundedDirectParams = {
|
|
3169
|
+
...directParams,
|
|
3170
|
+
connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
|
|
3171
|
+
};
|
|
2975
3172
|
try {
|
|
2976
|
-
return { connection: await driver.connect(
|
|
3173
|
+
return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
|
|
2977
3174
|
} catch (error) {
|
|
2978
3175
|
if (!isConnectivityFailure(error)) {
|
|
2979
3176
|
throw error;
|
|
@@ -2990,18 +3187,47 @@ function buildExhaustionError(host, directError, candidates) {
|
|
|
2990
3187
|
`Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
|
|
2991
3188
|
);
|
|
2992
3189
|
}
|
|
2993
|
-
async function
|
|
2994
|
-
const
|
|
2995
|
-
for (const app of candidatesTried) {
|
|
3190
|
+
async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
|
|
3191
|
+
for (const app of apps) {
|
|
2996
3192
|
if (Date.now() >= deadline) {
|
|
2997
|
-
|
|
3193
|
+
return void 0;
|
|
2998
3194
|
}
|
|
3195
|
+
tried.push(app);
|
|
2999
3196
|
const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
|
|
3000
3197
|
if (connection !== void 0) {
|
|
3001
|
-
return
|
|
3198
|
+
return connection;
|
|
3002
3199
|
}
|
|
3003
3200
|
}
|
|
3004
|
-
return
|
|
3201
|
+
return void 0;
|
|
3202
|
+
}
|
|
3203
|
+
async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
|
|
3204
|
+
const tried = [];
|
|
3205
|
+
const known = knownCandidates(config.appName, hintApp);
|
|
3206
|
+
const knownConnection = await tryCandidateList(
|
|
3207
|
+
known,
|
|
3208
|
+
ctx,
|
|
3209
|
+
driver,
|
|
3210
|
+
config,
|
|
3211
|
+
directParams,
|
|
3212
|
+
deadline,
|
|
3213
|
+
overrides,
|
|
3214
|
+
tried
|
|
3215
|
+
);
|
|
3216
|
+
if (knownConnection !== void 0) {
|
|
3217
|
+
return { connection: knownConnection, candidatesTried: tried };
|
|
3218
|
+
}
|
|
3219
|
+
const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
|
|
3220
|
+
const discoveredConnection = await tryCandidateList(
|
|
3221
|
+
discovered,
|
|
3222
|
+
ctx,
|
|
3223
|
+
driver,
|
|
3224
|
+
config,
|
|
3225
|
+
directParams,
|
|
3226
|
+
deadline,
|
|
3227
|
+
overrides,
|
|
3228
|
+
tried
|
|
3229
|
+
);
|
|
3230
|
+
return { connection: discoveredConnection, candidatesTried: tried };
|
|
3005
3231
|
}
|
|
3006
3232
|
async function connectWithTunnelFallback(driver, config, overrides = {}) {
|
|
3007
3233
|
const directParams = directParamsOf(config);
|