Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation.
- Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off.
- **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.
- Mac Alert (Full), Safe Mode and Safe Mode (Full) levels dropped to Alert by any edit to the connection on iOS.
- `pg_terminate_backend`, `nextval` and other state-changing calls treated as reads by Safe Mode and external clients.
- `EXPLAIN ANALYSE` treated as a plain `EXPLAIN` that does not run its statement.
Expand Down
2 changes: 1 addition & 1 deletion Plugins/EtcdDriverPlugin/EtcdCommandParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Core/Utilities/SQL/QueryClassifier+Etcd.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"del", "delete", "compaction", "compact", "lease", "member", "auth", "user", "role"
]

private static let etcdCommandsReachingFilesystem: Set<String> = ["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)
}
}
16 changes: 0 additions & 16 deletions TablePro/Core/Utilities/SQL/QueryClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -933,22 +933,6 @@ private extension QueryClassifier {
return names
}

static let etcdReadCommands: Set<String> = ["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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading