From dcf7da10bfcef1d8b18ff1236de983a7513da1ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 29 Sep 2026 19:50:53 +0700 Subject: [PATCH] fix(editor): tier etcd statements by the operation the driver parses so lease revoke is destructive --- CHANGELOG.md | 1 + .../EtcdDriverPlugin/EtcdCommandParser.swift | 2 +- .../Utilities/SQL/QueryClassifier+Etcd.swift | 41 +++++++++++ .../Core/Utilities/SQL/QueryClassifier.swift | 16 ----- .../SQL/QueryClassifierHardeningTests.swift | 72 +++++++++++++++++++ project.yml | 3 + 6 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 TablePro/Core/Utilities/SQL/QueryClassifier+Etcd.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 40eb3555b3..ded404c41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS row editor saving the placeholder of a long text or binary value over the full value. - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - **Local only** connections taking edits and deletions made on another device. +- etcd `lease revoke`, `auth disable` and user or role deletion skipping confirmation, and list commands gated as writes. ## [0.76.1] - 2026-09-29 diff --git a/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift b/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift index 78e114e0cc..4e2b32e0fa 100644 --- a/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift +++ b/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift @@ -426,7 +426,7 @@ struct EtcdCommandParser { // MARK: - Tokenizer - private static func tokenize(_ input: String) -> [String] { + static func tokenize(_ input: String) -> [String] { var tokens: [String] = [] var current = "" var inQuote = false diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier+Etcd.swift b/TablePro/Core/Utilities/SQL/QueryClassifier+Etcd.swift new file mode 100644 index 0000000000..142087712b --- /dev/null +++ b/TablePro/Core/Utilities/SQL/QueryClassifier+Etcd.swift @@ -0,0 +1,41 @@ +// +// QueryClassifier+Etcd.swift +// TablePro +// + +import Foundation + +extension QueryClassifier { + static func etcdClassification(_ trimmed: String) -> QueryClassification { + guard let operation = try? EtcdCommandParser.parse(trimmed) else { + return etcdUnparsedClassification(command: EtcdCommandParser.tokenize(trimmed).first?.lowercased() ?? "") + } + return etcdClassification(of: operation) + } + + private static let etcdCommandsThatCanDestroy: Set = [ + "del", "delete", "compaction", "compact", "lease", "member", "auth", "user", "role" + ] + + private static let etcdCommandsReachingFilesystem: Set = ["snapshot", "defrag"] + + private static func etcdClassification(of operation: EtcdOperation) -> QueryClassification { + switch operation { + case .get, .watch, .leaseTimetolive, .leaseList, .memberList, .endpointStatus, .endpointHealth, + .userList, .roleList: + return .safe + case .put, .leaseGrant, .leaseKeepAlive, .authEnable, .userAdd, .roleAdd, .userGrantRole, .userRevokeRole: + return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + case .del, .compaction, .leaseRevoke, .authDisable, .userDelete, .roleDelete: + return QueryClassification(tier: .destructive, reachesFilesystemOrExecutesCode: false) + case .unknown(let command, _): + return etcdUnparsedClassification(command: command) + } + } + + private static func etcdUnparsedClassification(command: String) -> QueryClassification { + let reachesFilesystem = etcdCommandsReachingFilesystem.contains(command) + let tier: QueryTier = etcdCommandsThatCanDestroy.contains(command) ? .destructive : .write + return QueryClassification(tier: tier, reachesFilesystemOrExecutesCode: reachesFilesystem) + } +} diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 590274711f..c3a167f066 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -911,22 +911,6 @@ private extension QueryClassifier { return names } - static let etcdReadCommands: Set = ["GET", "RANGE", "WATCH", "LIST", "STATUS", "VERSION", "ENDPOINT"] - - static func etcdClassification(_ trimmed: String) -> QueryClassification { - let command = trimmed.prefix { !$0.isWhitespace }.uppercased() - if command == "SNAPSHOT" || command == "DEFRAG" { - return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: true) - } - if command == "DEL" || command == "DELETE" || command == "COMPACT" || command == "COMPACTION" { - return QueryClassification(tier: .destructive, reachesFilesystemOrExecutesCode: false) - } - if etcdReadCommands.contains(command) { - return .safe - } - return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) - } - static let elasticsearchReadPaths: [String] = [ "_SEARCH", "_COUNT", "_MSEARCH", "_EXPLAIN", "_ANALYZE", "_FIELD_CAPS", "_VALIDATE", "_RENDER", "_MAPPING", "_SETTINGS", "_STATS", "_CAT", "_SQL" diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index ef2cc6d94d..8b60f6a95e 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -451,6 +451,78 @@ struct QueryClassifierNonSqlTests { #expect(QueryClassifier.reachesFilesystemOrExecutesCode("snapshot save backup.db", databaseType: .etcd)) } + @Test("etcd subcommands that delete keys, users or roles, or turn authentication off, are destructive") + func etcdDestructiveSubcommands() { + let destructive = [ + "lease revoke 7b", + "LEASE REVOKE 0x7b", + "\"lease\" 'revoke' 7b", + "user delete alice", + "role delete readers", + "auth disable", + "\"del\" /keys --prefix", + "delete /keys", + "compaction 5 --physical" + ] + for statement in destructive { + #expect( + QueryClassifier.classifyTier(statement, databaseType: .etcd) == .destructive, + "\(statement) must be destructive" + ) + } + } + + @Test("etcd list, status and lookup subcommands are reads") + func etcdReadSubcommands() { + let reads = [ + "member list", + "lease list", + "lease timetolive 7b --keys", + "endpoint status", + "endpoint health", + "user list", + "role list", + "'get' /keys --prefix" + ] + for statement in reads { + #expect( + QueryClassifier.classifyTier(statement, databaseType: .etcd) == .safe, + "\(statement) must be safe" + ) + } + } + + @Test("etcd grants, keep-alives and account additions are plain writes") + func etcdWriteSubcommands() { + let writes = [ + "lease grant 60", + "lease keep-alive 7b", + "auth enable", + "user add alice secret", + "role add readers", + "user grant-role alice readers", + "user revoke-role alice readers", + "put /keys value --lease 7b" + ] + for statement in writes { + #expect( + QueryClassifier.classifyTier(statement, databaseType: .etcd) == .write, + "\(statement) must be a write" + ) + } + } + + @Test("etcd text the parser rejects is tiered by the worst its command can do") + func etcdUnparsedFailsClosed() { + #expect(QueryClassifier.classifyTier("del --from-key /a", databaseType: .etcd) == .destructive) + #expect(QueryClassifier.classifyTier("lease revoke", databaseType: .etcd) == .destructive) + #expect(QueryClassifier.classifyTier("member remove 8e9e05c52164694d", databaseType: .etcd) == .destructive) + #expect(QueryClassifier.classifyTier("put /keys", databaseType: .etcd) == .write) + #expect(QueryClassifier.classifyTier("get", databaseType: .etcd) == .write) + #expect(QueryClassifier.classifyTier("move-leader 8e9e05c52164694d", databaseType: .etcd) == .write) + #expect(QueryClassifier.reachesFilesystemOrExecutesCode("defrag", databaseType: .etcd)) + } + @Test("Elasticsearch separates read verbs from writes and deletes") func elasticsearchTiers() { #expect(!QueryClassifier.isWriteQuery("GET /index/_search", databaseType: .elasticsearch)) diff --git a/project.yml b/project.yml index 50aa44a369..f2b89e05fa 100644 --- a/project.yml +++ b/project.yml @@ -161,6 +161,9 @@ targets: # app classifies, quotes and resolves a Redis statement exactly as the driver will. - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift - Plugins/RedisDriverPlugin/RedisArgumentCodec.swift + # The etcd plugin's own command parser, so the app tiers an etcd statement by the operation + # the driver parses it into. + - Plugins/EtcdDriverPlugin/EtcdCommandParser.swift configFiles: Debug: Configs/Version.xcconfig Release: Configs/Version.xcconfig