@saptools/cf-hana 0.5.0 → 0.5.2
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 +64 -0
- package/README.md +51 -3
- package/dist/cli.js +1763 -1507
- package/dist/cli.js.map +1 -1
- package/dist/index.js +380 -124
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.2";
|
|
10
10
|
var ENV_PREFIX = "CF_HANA";
|
|
11
11
|
var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
|
|
12
12
|
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
@@ -244,7 +244,7 @@ function formatResult(result, format, compactColumn) {
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
// src/
|
|
247
|
+
// src/backup-sql-parser.ts
|
|
248
248
|
function isIdentifierChar(char) {
|
|
249
249
|
return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
|
|
250
250
|
}
|
|
@@ -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,189 @@ 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 skipCteName(sql, masked, start) {
|
|
590
|
+
let index = start;
|
|
591
|
+
while (index < sql.length) {
|
|
592
|
+
const char = sql.charAt(index);
|
|
593
|
+
if (char.trim().length === 0) {
|
|
594
|
+
index += 1;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (char === "-" && sql.charAt(index + 1) === "-") {
|
|
598
|
+
index = skipLineComment2(sql, index);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (char === "/" && sql.charAt(index + 1) === "*") {
|
|
602
|
+
index = skipBlockComment2(sql, index);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
if (sql.charAt(index) === "'" || sql.charAt(index) === '"') {
|
|
608
|
+
return skipQuotedText2(sql, index);
|
|
609
|
+
}
|
|
610
|
+
let end = index;
|
|
611
|
+
while (end < masked.length && isIdentifierChar2(masked.charAt(end))) {
|
|
612
|
+
end += 1;
|
|
613
|
+
}
|
|
614
|
+
return end;
|
|
615
|
+
}
|
|
616
|
+
function cteListEndIndex(sql, masked, afterWith) {
|
|
617
|
+
let index = afterWith;
|
|
618
|
+
for (; ; ) {
|
|
619
|
+
index = skipCteName(sql, masked, index);
|
|
620
|
+
index = skipWhitespace(masked, index);
|
|
621
|
+
if (masked.charAt(index) === "(") {
|
|
622
|
+
const columnListEnd = matchingCloseParenIndex(masked, index);
|
|
623
|
+
if (columnListEnd === void 0) {
|
|
624
|
+
return void 0;
|
|
625
|
+
}
|
|
626
|
+
index = skipWhitespace(masked, columnListEnd + 1);
|
|
627
|
+
}
|
|
628
|
+
if (!isAsKeywordAt(masked, index)) {
|
|
629
|
+
return void 0;
|
|
630
|
+
}
|
|
631
|
+
index = skipWhitespace(masked, index + 2);
|
|
632
|
+
if (masked.charAt(index) !== "(") {
|
|
633
|
+
return void 0;
|
|
634
|
+
}
|
|
635
|
+
const closeIndex = matchingCloseParenIndex(masked, index);
|
|
636
|
+
if (closeIndex === void 0) {
|
|
637
|
+
return void 0;
|
|
638
|
+
}
|
|
639
|
+
index = skipWhitespace(masked, closeIndex + 1);
|
|
640
|
+
if (masked.charAt(index) === ",") {
|
|
641
|
+
index += 1;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
return index;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function resolveWithStatement(sql) {
|
|
648
|
+
const withIndex = topLevelKeywordIndex(sql, "WITH");
|
|
649
|
+
if (withIndex === void 0) {
|
|
650
|
+
return void 0;
|
|
651
|
+
}
|
|
652
|
+
const masked = maskIgnoredSqlText(sql);
|
|
653
|
+
const trailingIndex = cteListEndIndex(sql, masked, withIndex + "WITH".length);
|
|
654
|
+
if (trailingIndex === void 0) {
|
|
655
|
+
return void 0;
|
|
656
|
+
}
|
|
657
|
+
let keywordEnd = trailingIndex;
|
|
658
|
+
while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
|
|
659
|
+
keywordEnd += 1;
|
|
660
|
+
}
|
|
661
|
+
return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
|
|
662
|
+
}
|
|
663
|
+
function classifyByKeyword(keyword) {
|
|
483
664
|
if (SELECT_KEYWORDS.has(keyword)) {
|
|
484
665
|
return "select";
|
|
485
666
|
}
|
|
@@ -491,6 +672,14 @@ function classifyStatement(sql) {
|
|
|
491
672
|
}
|
|
492
673
|
return "unknown";
|
|
493
674
|
}
|
|
675
|
+
function classifyStatement(sql) {
|
|
676
|
+
const keyword = firstKeyword(sql);
|
|
677
|
+
if (keyword === "WITH") {
|
|
678
|
+
const resolved = resolveWithStatement(sql);
|
|
679
|
+
return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
|
|
680
|
+
}
|
|
681
|
+
return classifyByKeyword(keyword);
|
|
682
|
+
}
|
|
494
683
|
function quoteIdentifier(identifier) {
|
|
495
684
|
if (identifier.length === 0) {
|
|
496
685
|
throw new QueryError("A SQL identifier must not be empty");
|
|
@@ -556,7 +745,7 @@ function assertParamArity(sql, params) {
|
|
|
556
745
|
}
|
|
557
746
|
}
|
|
558
747
|
|
|
559
|
-
// src/
|
|
748
|
+
// src/backup-planner.ts
|
|
560
749
|
var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
561
750
|
"UPDATE",
|
|
562
751
|
"UPSERT",
|
|
@@ -654,11 +843,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
|
|
|
654
843
|
return target.reference;
|
|
655
844
|
}
|
|
656
845
|
if (/^PARTITION\b/i.test(suffix)) {
|
|
657
|
-
const
|
|
658
|
-
if (
|
|
846
|
+
const open2 = suffix.indexOf("(");
|
|
847
|
+
if (open2 === -1) {
|
|
659
848
|
return void 0;
|
|
660
849
|
}
|
|
661
|
-
const close = findTopLevelChar(suffix, ")",
|
|
850
|
+
const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
|
|
662
851
|
if (close === void 0) {
|
|
663
852
|
return void 0;
|
|
664
853
|
}
|
|
@@ -841,13 +1030,24 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
|
|
|
841
1030
|
}
|
|
842
1031
|
}
|
|
843
1032
|
function buildWriteBackupPlan(sql, params = []) {
|
|
844
|
-
const
|
|
845
|
-
const
|
|
1033
|
+
const trimmed = trimStatementSql(sql);
|
|
1034
|
+
const leading = firstKeyword(trimmed);
|
|
1035
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
|
|
1036
|
+
if (leading === "WITH" && withResolved === void 0) {
|
|
1037
|
+
throw new BackupRequiredError(
|
|
1038
|
+
"WITH write refused: cannot determine the statement's real trailing keyword"
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
846
1042
|
if (!isWriteKeyword(keyword)) {
|
|
847
1043
|
return void 0;
|
|
848
1044
|
}
|
|
849
|
-
|
|
850
|
-
|
|
1045
|
+
const startIndex = withResolved?.index ?? 0;
|
|
1046
|
+
const statementSql = trimmed.slice(startIndex);
|
|
1047
|
+
const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
|
|
1048
|
+
assertParamArity(statementSql, slicedParams);
|
|
1049
|
+
const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
|
|
1050
|
+
return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
|
|
851
1051
|
}
|
|
852
1052
|
|
|
853
1053
|
// src/backup.ts
|
|
@@ -1176,9 +1376,9 @@ function buildEnv(ctx, overrides = {}) {
|
|
|
1176
1376
|
env["CF_HOME"] = ctx.cfHome;
|
|
1177
1377
|
return env;
|
|
1178
1378
|
}
|
|
1179
|
-
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1379
|
+
async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
|
|
1180
1380
|
let lastErr;
|
|
1181
|
-
for (let attempt = 0; attempt <
|
|
1381
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1182
1382
|
try {
|
|
1183
1383
|
const { stdout } = await execFileAsync(bin, args, {
|
|
1184
1384
|
env,
|
|
@@ -1197,18 +1397,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
1197
1397
|
if (isEnoent || !isTimeout && !isNetworkFlake) {
|
|
1198
1398
|
throw err;
|
|
1199
1399
|
}
|
|
1200
|
-
if (attempt <
|
|
1400
|
+
if (attempt < maxAttempts - 1) {
|
|
1201
1401
|
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
1202
1402
|
}
|
|
1203
1403
|
}
|
|
1204
1404
|
}
|
|
1205
1405
|
throw lastErr;
|
|
1206
1406
|
}
|
|
1207
|
-
async function runCf(args, ctx, overrides = {}) {
|
|
1407
|
+
async function runCf(args, ctx, overrides = {}, execOptions = {}) {
|
|
1208
1408
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1209
1409
|
const env = buildEnv(ctx, overrides);
|
|
1210
1410
|
try {
|
|
1211
|
-
return await execWithRetries(
|
|
1411
|
+
return await execWithRetries(
|
|
1412
|
+
bin,
|
|
1413
|
+
[...argsPrefix, ...args],
|
|
1414
|
+
env,
|
|
1415
|
+
execOptions.timeoutMs,
|
|
1416
|
+
execOptions.maxAttempts
|
|
1417
|
+
);
|
|
1212
1418
|
} catch (lastErr) {
|
|
1213
1419
|
const e = lastErr;
|
|
1214
1420
|
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
@@ -1238,15 +1444,26 @@ async function cfEnvDirect(appName) {
|
|
|
1238
1444
|
delete env["SAP_PASSWORD"];
|
|
1239
1445
|
return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
|
|
1240
1446
|
}
|
|
1241
|
-
async function cfApps(ctx) {
|
|
1242
|
-
return await runCf(
|
|
1447
|
+
async function cfApps(ctx, timeoutMs) {
|
|
1448
|
+
return await runCf(
|
|
1449
|
+
["apps"],
|
|
1450
|
+
ctx,
|
|
1451
|
+
{},
|
|
1452
|
+
timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
|
|
1453
|
+
);
|
|
1243
1454
|
}
|
|
1244
|
-
async function cfAppsDirect() {
|
|
1455
|
+
async function cfAppsDirect(timeoutMs) {
|
|
1245
1456
|
const { bin, argsPrefix } = resolveCfBin();
|
|
1246
1457
|
const env = { ...process.env };
|
|
1247
1458
|
delete env["SAP_EMAIL"];
|
|
1248
1459
|
delete env["SAP_PASSWORD"];
|
|
1249
|
-
return await execWithRetries(
|
|
1460
|
+
return await execWithRetries(
|
|
1461
|
+
bin,
|
|
1462
|
+
[...argsPrefix, "apps"],
|
|
1463
|
+
env,
|
|
1464
|
+
timeoutMs,
|
|
1465
|
+
timeoutMs === void 0 ? void 0 : 1
|
|
1466
|
+
);
|
|
1250
1467
|
}
|
|
1251
1468
|
async function readCurrentCfTarget() {
|
|
1252
1469
|
const { bin, argsPrefix } = resolveCfBin();
|
|
@@ -2153,6 +2370,13 @@ async function connectHdb(params) {
|
|
|
2153
2370
|
password: params.password,
|
|
2154
2371
|
ca: params.certificate,
|
|
2155
2372
|
useTLS: true,
|
|
2373
|
+
// HANA Cloud can reactively redirect a connection mid-auth to an
|
|
2374
|
+
// internal per-node hostname (pod-locality routing). That target is
|
|
2375
|
+
// often unreachable outside SAP's own network, and for a tunneled
|
|
2376
|
+
// connection it would silently abandon the SSH forward for a fresh,
|
|
2377
|
+
// untunneled socket. The original bound host is always a real, working
|
|
2378
|
+
// endpoint, so disabling the redirect is safe on the direct path too.
|
|
2379
|
+
disableCloudRedirect: true,
|
|
2156
2380
|
...params.servername === void 0 ? {} : { servername: params.servername }
|
|
2157
2381
|
});
|
|
2158
2382
|
await openClient(client, params.connectTimeoutMs);
|
|
@@ -2262,83 +2486,6 @@ async function appendSqlHistory(input, options = {}) {
|
|
|
2262
2486
|
// src/safety.ts
|
|
2263
2487
|
var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
|
|
2264
2488
|
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
2489
|
function isUnconditionalMergeDelete(sql) {
|
|
2343
2490
|
return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
|
|
2344
2491
|
}
|
|
@@ -2381,17 +2528,24 @@ function appendLimit(sql, limit) {
|
|
|
2381
2528
|
}
|
|
2382
2529
|
function inspectStatement(sql) {
|
|
2383
2530
|
const kind = classifyStatement(sql);
|
|
2384
|
-
const
|
|
2531
|
+
const leading = firstKeyword(sql);
|
|
2532
|
+
const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
|
|
2533
|
+
const keyword = withResolved?.keyword ?? leading;
|
|
2534
|
+
const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
|
|
2385
2535
|
if (kind === "ddl") {
|
|
2386
2536
|
return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
|
|
2387
2537
|
}
|
|
2388
2538
|
if (kind === "dml") {
|
|
2389
|
-
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(
|
|
2539
|
+
const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
|
|
2390
2540
|
return {
|
|
2391
2541
|
kind,
|
|
2392
2542
|
destructive
|
|
2393
2543
|
};
|
|
2394
2544
|
}
|
|
2545
|
+
if (kind === "unknown") {
|
|
2546
|
+
const unresolvedWith = leading === "WITH" && withResolved === void 0;
|
|
2547
|
+
return { kind, destructive: keyword === "CALL" || unresolvedWith };
|
|
2548
|
+
}
|
|
2395
2549
|
return { kind, destructive: false };
|
|
2396
2550
|
}
|
|
2397
2551
|
function evaluateGuard(sql, config) {
|
|
@@ -2436,13 +2590,13 @@ function applyAutoLimit(sql, limit) {
|
|
|
2436
2590
|
|
|
2437
2591
|
// src/tunnel/cache.ts
|
|
2438
2592
|
import { createHash } from "crypto";
|
|
2439
|
-
import { mkdir as mkdir3, readdir as readdir2, readFile, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2593
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2440
2594
|
import { homedir as homedir3 } from "os";
|
|
2441
2595
|
import { join as join5 } from "path";
|
|
2442
2596
|
|
|
2443
2597
|
// src/tunnel/process.ts
|
|
2444
2598
|
import { spawn } from "child_process";
|
|
2445
|
-
import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
|
|
2599
|
+
import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
|
|
2446
2600
|
import { connect as netConnect, createServer } from "net";
|
|
2447
2601
|
import { tmpdir as tmpdir2 } from "os";
|
|
2448
2602
|
import { join as join4 } from "path";
|
|
@@ -2549,8 +2703,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
2549
2703
|
child.on("exit", onAbort);
|
|
2550
2704
|
child.on("error", onAbort);
|
|
2551
2705
|
timers.poll = setInterval(() => {
|
|
2552
|
-
void probePort(localPort).then((
|
|
2553
|
-
if (
|
|
2706
|
+
void probePort(localPort).then((open2) => {
|
|
2707
|
+
if (open2 && child.pid !== void 0) {
|
|
2554
2708
|
finish({ localPort, pid: child.pid });
|
|
2555
2709
|
}
|
|
2556
2710
|
});
|
|
@@ -2558,6 +2712,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
|
|
|
2558
2712
|
timers.deadline = setTimeout(finish, boundMs);
|
|
2559
2713
|
});
|
|
2560
2714
|
}
|
|
2715
|
+
var STDERR_TAIL_MAX_CHARS = 4096;
|
|
2716
|
+
async function openStderrCapture() {
|
|
2717
|
+
try {
|
|
2718
|
+
const dir = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
|
|
2719
|
+
const path = join4(dir, "stderr.log");
|
|
2720
|
+
const handle = await open(path, "w");
|
|
2721
|
+
return {
|
|
2722
|
+
path,
|
|
2723
|
+
fd: handle.fd,
|
|
2724
|
+
close: async () => {
|
|
2725
|
+
await handle.close().catch(() => {
|
|
2726
|
+
});
|
|
2727
|
+
await rm3(dir, { recursive: true, force: true });
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
} catch {
|
|
2731
|
+
return void 0;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
async function readStderrTail(path) {
|
|
2735
|
+
try {
|
|
2736
|
+
const content = (await readFile(path, "utf8")).trim().replace(/\s+/g, " ");
|
|
2737
|
+
if (content.length === 0) {
|
|
2738
|
+
return void 0;
|
|
2739
|
+
}
|
|
2740
|
+
return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
|
|
2741
|
+
} catch {
|
|
2742
|
+
return void 0;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2561
2745
|
async function spawnTunnel(params, deps = {}) {
|
|
2562
2746
|
assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
|
|
2563
2747
|
const remainingMs = params.deadline - Date.now();
|
|
@@ -2582,8 +2766,24 @@ async function spawnTunnel(params, deps = {}) {
|
|
|
2582
2766
|
`sleep ${String(params.keepaliveSeconds)}`
|
|
2583
2767
|
];
|
|
2584
2768
|
const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2769
|
+
const openCapture = deps.openStderrCapture ?? openStderrCapture;
|
|
2770
|
+
const capture = await openCapture();
|
|
2771
|
+
const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
|
|
2772
|
+
const child = spawnProcess(bin, args, { detached: true, stdio, env });
|
|
2773
|
+
const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
|
|
2774
|
+
if (capture !== void 0) {
|
|
2775
|
+
if (result === void 0) {
|
|
2776
|
+
try {
|
|
2777
|
+
const tail = await readStderrTail(capture.path);
|
|
2778
|
+
if (tail !== void 0) {
|
|
2779
|
+
deps.onFailureDiagnostic?.(tail);
|
|
2780
|
+
}
|
|
2781
|
+
} catch {
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
await capture.close();
|
|
2785
|
+
}
|
|
2786
|
+
return result;
|
|
2587
2787
|
}
|
|
2588
2788
|
|
|
2589
2789
|
// src/tunnel/cache.ts
|
|
@@ -2628,7 +2828,7 @@ function isTunnelCacheRecord(value) {
|
|
|
2628
2828
|
}
|
|
2629
2829
|
async function parseTunnelCacheFile(path) {
|
|
2630
2830
|
try {
|
|
2631
|
-
const raw = JSON.parse(await
|
|
2831
|
+
const raw = JSON.parse(await readFile2(path, "utf8"));
|
|
2632
2832
|
return isTunnelCacheRecord(raw) ? raw : void 0;
|
|
2633
2833
|
} catch {
|
|
2634
2834
|
return void 0;
|
|
@@ -2846,6 +3046,9 @@ function isConnectivityFailure(error) {
|
|
|
2846
3046
|
}
|
|
2847
3047
|
return hasOpenConnectionCause(error.cause, 0);
|
|
2848
3048
|
}
|
|
3049
|
+
function isUnattributedQueryFailure(error) {
|
|
3050
|
+
return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
|
|
3051
|
+
}
|
|
2849
3052
|
|
|
2850
3053
|
// src/tunnel/fallback.ts
|
|
2851
3054
|
var EXPIRY_SAFETY_MARGIN_MS = 3e4;
|
|
@@ -2875,30 +3078,40 @@ async function discardQuietly(work) {
|
|
|
2875
3078
|
await work.catch(() => {
|
|
2876
3079
|
});
|
|
2877
3080
|
}
|
|
3081
|
+
function shouldDiscardTunnel(error) {
|
|
3082
|
+
return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
|
|
3083
|
+
}
|
|
2878
3084
|
async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
|
|
2879
3085
|
try {
|
|
2880
3086
|
const connection = await driver.connect(tunneledParams(directParams, record.localPort));
|
|
2881
3087
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
|
|
2882
3088
|
return connection;
|
|
2883
3089
|
} catch (error) {
|
|
2884
|
-
if (
|
|
3090
|
+
if (shouldDiscardTunnel(error)) {
|
|
2885
3091
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
2886
3092
|
return void 0;
|
|
2887
3093
|
}
|
|
2888
3094
|
throw error;
|
|
2889
3095
|
}
|
|
2890
3096
|
}
|
|
2891
|
-
|
|
3097
|
+
function knownCandidates(targetAppName, hintApp) {
|
|
3098
|
+
return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
|
|
3099
|
+
}
|
|
3100
|
+
async function discoverAppsStdout(ctx, timeoutMs) {
|
|
2892
3101
|
try {
|
|
2893
|
-
return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
|
|
3102
|
+
return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
|
|
2894
3103
|
} catch {
|
|
2895
3104
|
return;
|
|
2896
3105
|
}
|
|
2897
3106
|
}
|
|
2898
|
-
async function
|
|
2899
|
-
const
|
|
3107
|
+
async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
|
|
3108
|
+
const remainingMs = deadline - Date.now();
|
|
3109
|
+
if (remainingMs <= 0) {
|
|
3110
|
+
return [];
|
|
3111
|
+
}
|
|
3112
|
+
const stdout = await discoverAppsStdout(ctx, remainingMs);
|
|
2900
3113
|
const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
|
|
2901
|
-
return
|
|
3114
|
+
return base.filter((app) => !alreadyTried.includes(app));
|
|
2902
3115
|
}
|
|
2903
3116
|
async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
|
|
2904
3117
|
const readyRecord = {
|
|
@@ -2914,7 +3127,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
|
|
|
2914
3127
|
config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
|
|
2915
3128
|
return connection;
|
|
2916
3129
|
} catch (error) {
|
|
2917
|
-
if (
|
|
3130
|
+
if (shouldDiscardTunnel(error)) {
|
|
2918
3131
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
2919
3132
|
killTunnelProcess(spawned.pid);
|
|
2920
3133
|
return void 0;
|
|
@@ -2931,7 +3144,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
2931
3144
|
return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
|
|
2932
3145
|
}
|
|
2933
3146
|
if (claim.outcome === "wait") {
|
|
2934
|
-
const
|
|
3147
|
+
const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
|
|
3148
|
+
const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
|
|
2935
3149
|
return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
|
|
2936
3150
|
}
|
|
2937
3151
|
const spawned = await spawnTunnel(
|
|
@@ -2944,7 +3158,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
2944
3158
|
deadline,
|
|
2945
3159
|
candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
|
|
2946
3160
|
},
|
|
2947
|
-
|
|
3161
|
+
{
|
|
3162
|
+
...overrides.process,
|
|
3163
|
+
onFailureDiagnostic: (message) => {
|
|
3164
|
+
config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
2948
3167
|
);
|
|
2949
3168
|
if (spawned === void 0) {
|
|
2950
3169
|
await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
|
|
@@ -2954,7 +3173,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
|
|
|
2954
3173
|
}
|
|
2955
3174
|
async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
|
|
2956
3175
|
if (config.refreshTunnel) {
|
|
3176
|
+
const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
2957
3177
|
await discardQuietly(evictTunnelCache(config.host, cacheOptions));
|
|
3178
|
+
if (superseded?.status === "ready") {
|
|
3179
|
+
(cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
|
|
3180
|
+
}
|
|
2958
3181
|
return { connection: void 0, hintApp: void 0 };
|
|
2959
3182
|
}
|
|
2960
3183
|
const entry = await readTunnelCacheEntry(config.host, cacheOptions);
|
|
@@ -2972,8 +3195,12 @@ async function tryDirectConnect(driver, config, directParams) {
|
|
|
2972
3195
|
if (config.tunnelMode !== "auto") {
|
|
2973
3196
|
return { connection: void 0, directError: void 0 };
|
|
2974
3197
|
}
|
|
3198
|
+
const boundedDirectParams = {
|
|
3199
|
+
...directParams,
|
|
3200
|
+
connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
|
|
3201
|
+
};
|
|
2975
3202
|
try {
|
|
2976
|
-
return { connection: await driver.connect(
|
|
3203
|
+
return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
|
|
2977
3204
|
} catch (error) {
|
|
2978
3205
|
if (!isConnectivityFailure(error)) {
|
|
2979
3206
|
throw error;
|
|
@@ -2990,18 +3217,47 @@ function buildExhaustionError(host, directError, candidates) {
|
|
|
2990
3217
|
`Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
|
|
2991
3218
|
);
|
|
2992
3219
|
}
|
|
2993
|
-
async function
|
|
2994
|
-
const
|
|
2995
|
-
for (const app of candidatesTried) {
|
|
3220
|
+
async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
|
|
3221
|
+
for (const app of apps) {
|
|
2996
3222
|
if (Date.now() >= deadline) {
|
|
2997
|
-
|
|
3223
|
+
return void 0;
|
|
2998
3224
|
}
|
|
3225
|
+
tried.push(app);
|
|
2999
3226
|
const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
|
|
3000
3227
|
if (connection !== void 0) {
|
|
3001
|
-
return
|
|
3228
|
+
return connection;
|
|
3002
3229
|
}
|
|
3003
3230
|
}
|
|
3004
|
-
return
|
|
3231
|
+
return void 0;
|
|
3232
|
+
}
|
|
3233
|
+
async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
|
|
3234
|
+
const tried = [];
|
|
3235
|
+
const known = knownCandidates(config.appName, hintApp);
|
|
3236
|
+
const knownConnection = await tryCandidateList(
|
|
3237
|
+
known,
|
|
3238
|
+
ctx,
|
|
3239
|
+
driver,
|
|
3240
|
+
config,
|
|
3241
|
+
directParams,
|
|
3242
|
+
deadline,
|
|
3243
|
+
overrides,
|
|
3244
|
+
tried
|
|
3245
|
+
);
|
|
3246
|
+
if (knownConnection !== void 0) {
|
|
3247
|
+
return { connection: knownConnection, candidatesTried: tried };
|
|
3248
|
+
}
|
|
3249
|
+
const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
|
|
3250
|
+
const discoveredConnection = await tryCandidateList(
|
|
3251
|
+
discovered,
|
|
3252
|
+
ctx,
|
|
3253
|
+
driver,
|
|
3254
|
+
config,
|
|
3255
|
+
directParams,
|
|
3256
|
+
deadline,
|
|
3257
|
+
overrides,
|
|
3258
|
+
tried
|
|
3259
|
+
);
|
|
3260
|
+
return { connection: discoveredConnection, candidatesTried: tried };
|
|
3005
3261
|
}
|
|
3006
3262
|
async function connectWithTunnelFallback(driver, config, overrides = {}) {
|
|
3007
3263
|
const directParams = directParamsOf(config);
|