diff --git a/Pluk.xcodeproj/project.pbxproj b/Pluk.xcodeproj/project.pbxproj index 8e13aab9..17059ebb 100644 --- a/Pluk.xcodeproj/project.pbxproj +++ b/Pluk.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 9A7ED1502F90000100C0DE03 /* Valkey in Frameworks */ = {isa = PBXBuildFile; productRef = 9A7ED1502F90000100C0DE02 /* Valkey */; }; 9A0216502DFD9BF300F7FCF3 /* BSON in Frameworks */ = {isa = PBXBuildFile; productRef = 9A02164F2DFD9BF300F7FCF3 /* BSON */; }; 9A25D38A2F65C24A003259CB /* ConvexMobile in Frameworks */ = {isa = PBXBuildFile; productRef = 9A25D3892F65C24A003259CB /* ConvexMobile */; }; 9A2923052DBBA5830032F301 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 9A2923042DBBA5830032F301 /* PostHog */; }; @@ -86,6 +87,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9A7ED1502F90000100C0DE03 /* Valkey in Frameworks */, 9A5DDF232E53465500812D2A /* MySQLNIO in Frameworks */, 9A623D9B2E2C0A4600B9CFCB /* Sparkle in Frameworks */, 9AA8038E2E46601200B6F1B4 /* SQLiteNIO in Frameworks */, @@ -171,6 +173,7 @@ ); name = Pluk; packageProductDependencies = ( + 9A7ED1502F90000100C0DE02 /* Valkey */, 9AE4BF702D25281600EA996E /* Meow */, 9AE4BF722D25281600EA996E /* MongoClient */, 9AE4BF742D25281600EA996E /* MongoCore */, @@ -273,6 +276,7 @@ mainGroup = 9A529E722D23CFC700618009; minimizedProjectReferenceProxies = 1; packageReferences = ( + 9A7ED1502F90000100C0DE01 /* XCRemoteSwiftPackageReference "valkey-swift" */, 9AE4BF6F2D25269A00EA996E /* XCRemoteSwiftPackageReference "MongoKitten" */, 9A44A0BC2D2E91F900E04DC8 /* XCRemoteSwiftPackageReference "UInt128" */, 9AF175BB2D895D7200351A82 /* XCRemoteSwiftPackageReference "CodeEditorView" */, @@ -685,6 +689,16 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ + 9A7ED1502F90000100C0DE01 /* XCRemoteSwiftPackageReference "valkey-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/valkey-io/valkey-swift.git"; + requirement = { + kind = exactVersion; + version = 1.5.0; + }; + traits = ( + ); + }; 9A25D3882F65C24A003259CB /* XCRemoteSwiftPackageReference "convex-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pluk-inc/convex-swift"; @@ -778,6 +792,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 9A7ED1502F90000100C0DE02 /* Valkey */ = { + isa = XCSwiftPackageProductDependency; + package = 9A7ED1502F90000100C0DE01 /* XCRemoteSwiftPackageReference "valkey-swift" */; + productName = Valkey; + }; 9A02164F2DFD9BF300F7FCF3 /* BSON */ = { isa = XCSwiftPackageProductDependency; productName = BSON; diff --git a/Pluk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pluk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f4e10238..c88cd96c 100644 --- a/Pluk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pluk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "76837d4573264012cc6d419b9c0065be8d9a34ad60d289bb4665e00b416fe27e", + "originHash" : "0241bd618835449426f5e90e1331d8931b10ee07d0516a2e6c2b07a8fc6e84b1", "pins" : [ { "identity" : "codeeditorview", @@ -141,7 +141,16 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", "version" : "1.2.0" } }, @@ -186,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "34d486b01cd891297ac615e40d5999536a1e138d", - "version" : "2.83.0" + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" } }, { @@ -204,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", - "version" : "1.24.0" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -244,6 +253,15 @@ "version" : "3.2.0" } }, + { + "identity" : "valkey-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/valkey-io/valkey-swift.git", + "state" : { + "revision" : "59d04ff34b513410e0a6d48d917e95bce7032ae3", + "version" : "1.5.0" + } + }, { "identity" : "yyjson", "kind" : "remoteSourceControl", diff --git a/README.md b/README.md index 7e3d4c05..efc677ec 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -> Connect PostgreSQL, MySQL, MariaDB, MongoDB, SQLite, or Convex and work with the data in one focused Mac app — no Electron, no browser tabs, no context switching. +> Connect PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQLite, or Convex and work with the data in one focused Mac app — no Electron, no browser tabs, no context switching. ## Installation @@ -81,7 +81,7 @@ Pluk requires macOS 15 or later and runs on Apple Silicon and Intel Macs. ## Features - **Native macOS interface** — AppKit and SwiftUI throughout, with real windows, tabs, sheets, keyboard navigation, and platform-standard controls. -- **Six database families** — PostgreSQL, MySQL, MariaDB, MongoDB, SQLite, and Convex in one connection model. +- **Seven database families** — PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQLite, and Convex in one connection model. - **Editable data grid** — browse, filter, sort, copy, paste, and edit rows directly, with dedicated views for larger values. - **Query workspace** — SQL editing, autocomplete, multiple result sets, history, schema-aware execution, and saved notebooks. - **Schema tools** — inspect columns and indexes, create tables and databases, and make schema changes without leaving the app. @@ -97,9 +97,27 @@ Pluk requires macOS 15 or later and runs on Apple Silicon and Intel Macs. | PostgreSQL | Tables, schemas, SQL, JSON, SSL, SSH tunnels | | MySQL and MariaDB | Tables, SQL, SSL, SSH tunnels | | MongoDB | Collections, documents, filters, aggregation | +| Redis | Cursor-based key browsing, core data structures, TTLs, and commands | | SQLite | Local database files and SQL | | Convex | Projects, deployments, documents, and queries | +### Redis connections + +Pluk accepts `redis://` and `rediss://` URLs, including password-only or ACL +username/password authentication and a logical database index such as `/2`. +TLS connections use full certificate and hostname verification. Saved passwords +are separated from connection metadata and stored in the macOS Keychain. + +The key browser uses cursor-based `SCAN` pages and never uses `KEYS`. Strings, +hashes, lists, sets, sorted sets, streams, and RedisJSON values can be inspected; +streams are read-only and show a bounded first page in this version, while large +string and RedisJSON values currently load in full. RedisJSON appears only when +the module is installed. Pluk currently requires Redis 6 or later (or a +compatible Valkey server) because the client negotiates RESP3. The underlying +client guarantees Redis compatibility through 7.2.4; newer Redis releases may +work but should be validated for production use. Cluster, Sentinel, and Redis +over SSH tunnels are not supported yet. + ## Building from source You need macOS 15 or later and Xcode 26 or later with Swift 6 support. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1fd79d56..bb84e53b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,6 +5,51 @@ Pluk uses third-party software in addition to the source covered by the reposito - The vendored `BSON` package is provided under the MIT License in [`BSON/LICENSE`](./BSON/LICENSE). - `js-beautify`, bundled as `pluk/Resources/js-beautify.min.js`, is provided under the MIT License by the [js-beautify contributors](https://github.com/beautifier/js-beautify). - `sql-formatter`, bundled as `pluk/Resources/sql-formatter.min.js`, is provided under the MIT License by the [sql-formatter contributors](https://github.com/sql-formatter-org/sql-formatter). +- `valkey-swift`, used for Redis protocol connectivity, is provided under the Apache License 2.0 by the [Valkey contributors](https://github.com/valkey-io/valkey-swift); its [upstream notice](https://github.com/valkey-io/valkey-swift/blob/1.5.0/Notice.txt) is reproduced below. - Swift Package Manager dependencies retain their respective upstream licenses. Their exact revisions and source repositories are recorded in `Pluk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`. Database and service names and logos belong to their respective owners. Their inclusion indicates compatibility and does not imply endorsement. + +## valkey-swift notice + +Copyright 2025 The valkey-swift Project. + +`valkey-swift` includes RESP3 and hash-slot work derived from +[RediStack](https://github.com/swift-server/RediStack) under Apache License +2.0, connection-pool work derived from +[postgres-nio](https://github.com/vapor/postgres-nio) under the MIT License, +and hash-slot update logic influenced by +[valkey-glide](https://github.com/valkey-io/valkey-glide) under Apache License +2.0. + +It also includes CRC16 code derived from work by Georges Menie and adapted to +Redis style by Salvatore Sanfilippo: + +> Copyright 2001-2010 Georges Menie (www.menie.org) +> +> Copyright 2010 Salvatore Sanfilippo (adapted to Redis coding style) +> +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions are met: +> +> - Redistributions of source code must retain the above copyright notice, +> this list of conditions and the following disclaimer. +> - Redistributions in binary form must reproduce the above copyright notice, +> this list of conditions and the following disclaimer in the documentation +> and/or other materials provided with the distribution. +> - Neither the name of the University of California, Berkeley nor the names +> of its contributors may be used to endorse or promote products derived +> from this software without specific prior written permission. +> +> THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS "AS IS" AND ANY +> EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +> DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY +> DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +> ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +> THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pluk/Core/ViewControllers/Sidebar/ConnectionDetailsSidebarViewController.swift b/pluk/Core/ViewControllers/Sidebar/ConnectionDetailsSidebarViewController.swift index c91082fd..62439b6f 100644 --- a/pluk/Core/ViewControllers/Sidebar/ConnectionDetailsSidebarViewController.swift +++ b/pluk/Core/ViewControllers/Sidebar/ConnectionDetailsSidebarViewController.swift @@ -31,6 +31,7 @@ final class ConnectionDetailsSidebarViewController: NSViewController { private var promoCardHost: NSHostingView? private var currentContent: NSView? private var listViewController: DatabaseListViewController? + private var redisListViewController: RedisKeyListViewController? private var historyViewController: QueryHistoryListViewController? // MARK: - State @@ -164,6 +165,7 @@ final class ConnectionDetailsSidebarViewController: NSViewController { // can fade in / out with hover state. controlsRowHost.rootView = AnyView(makeControlsRow()) listViewController?.setSidebarHovered(hovered) + redisListViewController?.setSidebarHovered(hovered) } private func makeSearchInput() -> some View { @@ -192,19 +194,31 @@ final class ConnectionDetailsSidebarViewController: NSViewController { currentContent?.removeFromSuperview() listViewController?.removeFromParent() listViewController = nil + redisListViewController?.removeFromParent() + redisListViewController = nil historyViewController?.removeFromParent() historyViewController = nil let newContent: NSView switch viewModel.sidebarViewMode { case .tables: - let listVC = DatabaseListViewController(instance: instance, viewModel: viewModel) - listVC.onScrolledFromTopChanged = { [weak self] scrolled in - self?.setSeparatorVisible(scrolled) + if instance.connection.databaseType.supportsKeyValueBrowser { + let listVC = RedisKeyListViewController(instance: instance, viewModel: viewModel) + listVC.onScrolledFromTopChanged = { [weak self] scrolled in + self?.setSeparatorVisible(scrolled) + } + redisListViewController = listVC + addChild(listVC) + newContent = listVC.view + } else { + let listVC = DatabaseListViewController(instance: instance, viewModel: viewModel) + listVC.onScrolledFromTopChanged = { [weak self] scrolled in + self?.setSeparatorVisible(scrolled) + } + listViewController = listVC + addChild(listVC) + newContent = listVC.view } - listViewController = listVC - addChild(listVC) - newContent = listVC.view case .history: // History mode doesn't have scroll-edge tracking wired up; keep // the separator hidden to match previous behaviour. @@ -285,6 +299,7 @@ final class ConnectionDetailsSidebarViewController: NSViewController { inset = 12 } listViewController?.setBottomContentInset(inset) + redisListViewController?.setBottomContentInset(inset) historyViewController?.setBottomContentInset(inset) } @@ -377,6 +392,10 @@ final class ConnectionDetailsSidebarViewController: NSViewController { } private func handleReadinessChange() { + if case .ready = instance.readiness { + redisListViewController?.connectionDidBecomeReady() + } + if case .needsDatabaseSelection = instance.readiness { if !isPresentingDatabaseSelector, !userDismissedDatabaseSelector { presentDatabaseSelector() @@ -472,7 +491,8 @@ private struct DatabaseControlsRow: View { showAdvancedHistory: Binding( get: { viewModel.isShowingAdvancedHistory }, set: { viewModel.isShowingAdvancedHistory = $0 } - ) + ), + databaseType: instance.databaseType ) } .padding(.leading, 4) diff --git a/pluk/Core/ViewControllers/Sidebar/ConnectionNameHeaderView.swift b/pluk/Core/ViewControllers/Sidebar/ConnectionNameHeaderView.swift index e96ed5ab..d488bb80 100644 --- a/pluk/Core/ViewControllers/Sidebar/ConnectionNameHeaderView.swift +++ b/pluk/Core/ViewControllers/Sidebar/ConnectionNameHeaderView.swift @@ -219,6 +219,7 @@ final class ConnectionNameHeaderView: NSView, NSPopoverDelegate { refreshIcon() refreshName() refreshStatus() + refreshCapabilities() } private func refreshIcon() { @@ -231,6 +232,15 @@ final class ConnectionNameHeaderView: NSView, NSPopoverDelegate { nameLabel.stringValue = instance.connection.name } + private func refreshCapabilities() { + let databaseType = instance.connection.databaseType + searchButton.toolTip = databaseType.supportsKeyValueBrowser + ? "Find Keys (⇧⌘F)" + : "Find Tables (⇧⌘F)" + plusButton.isHidden = !databaseType.supportsTableBrowser + plusButton.isEnabled = databaseType.supportsTableBrowser + } + private func refreshStatus() { let status = instance.connectionStatus statusLabel.stringValue = status.rawValue diff --git a/pluk/Core/ViewControllers/Sidebar/QueryHistoryListViewController.swift b/pluk/Core/ViewControllers/Sidebar/QueryHistoryListViewController.swift index 7d700eed..25851fb8 100644 --- a/pluk/Core/ViewControllers/Sidebar/QueryHistoryListViewController.swift +++ b/pluk/Core/ViewControllers/Sidebar/QueryHistoryListViewController.swift @@ -1,6 +1,9 @@ import AppKit import Observation +private let sanitizedRedisHistoryHelp = + "Credentials were redacted. Copy the command and re-enter them before running it." + /// AppKit replacement for the SwiftUI `QueryHistorySidebarList`. Renders /// query history grouped by date (Today / Yesterday / This Week / Last Week /// / Older) in an NSTableView, scoped to the connection's currently-selected @@ -187,7 +190,7 @@ final class QueryHistoryListViewController: NSViewController { let clickedRow = tableView.clickedRow guard clickedRow >= 0, clickedRow < rows.count else { return } if case .entry(let id) = rows[clickedRow], let entry = entriesById[id] { - instance.createSQLEditorTab(withQuery: entry.query) + loadInEditor(entry) } } @@ -208,13 +211,18 @@ final class QueryHistoryListViewController: NSViewController { action: #selector(contextCopyQuery(_:)), entryId: id ) - addItem( + let loadItem = addItem( to: menu, title: "Load in Editor", symbol: "arrow.up.forward.square", action: #selector(contextLoadInEditor(_:)), entryId: id ) + if !entry.isReplayable { + loadItem.title = "Load in Editor (Credentials Redacted)" + loadItem.toolTip = sanitizedRedisHistoryHelp + loadItem.isEnabled = false + } if let tableName = entry.tableName, !tableName.isEmpty { menu.addItem(.separator()) @@ -265,7 +273,16 @@ final class QueryHistoryListViewController: NSViewController { @objc private func contextLoadInEditor(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String, let entry = entriesById[id] else { return } - instance.createSQLEditorTab(withQuery: entry.query) + loadInEditor(entry) + } + + private func loadInEditor(_ entry: QueryHistoryEntryViewModel) { + guard entry.isReplayable else { return } + if instance.connection.databaseType == .redis { + instance.createRedisCommandTab(withCommand: entry.query) + } else { + instance.createSQLEditorTab(withQuery: entry.query) + } } @objc private func contextOpenTable(_ sender: NSMenuItem) { @@ -573,9 +590,12 @@ private final class HistoryRowCell: NSView { metaLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), ]) - toolTip = entry.wasSuccessful + let queryToolTip = entry.wasSuccessful ? entry.query : (entry.errorMessage.map { "\(entry.query)\n\n\($0)" } ?? entry.query) + toolTip = entry.isReplayable + ? queryToolTip + : "\(queryToolTip)\n\n\(sanitizedRedisHistoryHelp)" } @available(*, unavailable) diff --git a/pluk/Core/ViewControllers/Sidebar/RedisKeyListViewController.swift b/pluk/Core/ViewControllers/Sidebar/RedisKeyListViewController.swift new file mode 100644 index 00000000..982eac53 --- /dev/null +++ b/pluk/Core/ViewControllers/Sidebar/RedisKeyListViewController.swift @@ -0,0 +1,452 @@ +import AppKit +import Observation + +/// Cursor-based Redis key browser. It intentionally never materializes or +/// sorts the full keyspace: pages are appended as the user scrolls and a +/// refresh invalidates any in-flight generation. +@MainActor +final class RedisKeyListViewController: NSViewController { + private static let maximumLoadedKeyCount = 25_000 + + private let instance: ConnectionInstance + private let viewModel: SidebarViewModel + + private let typePicker = NSPopUpButton() + private let scrollView = NSScrollView() + private let tableView = NSTableView() + private let statusLabel = NSTextField(labelWithString: "") + private let progressIndicator = NSProgressIndicator() + private lazy var continueScanningButton = NSButton( + title: "Continue Scanning", + target: self, + action: #selector(continueScanning) + ) + + private var keys: [RedisKey] = [] + private var seenKeys: Set = [] + private var cursor: UInt64 = 0 + private var isComplete = false + private var isLoading = false + private var reachedDisplayLimit = false + private var generation = UUID() + private var loadTask: Task? + private var searchTask: Task? + private var selectedType: RedisKeyType? + private var emptyPageBudget = 3 + private weak var observedClipView: NSClipView? + + var onScrolledFromTopChanged: ((Bool) -> Void)? + private var isScrolledFromTop = false + + init(instance: ConnectionInstance, viewModel: SidebarViewModel) { + self.instance = instance + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func loadView() { + let root = NSView() + root.translatesAutoresizingMaskIntoConstraints = false + + configureTypePicker() + configureTable() + + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.alignment = .center + statusLabel.textColor = .tertiaryLabelColor + statusLabel.font = .preferredFont(forTextStyle: .body) + statusLabel.lineBreakMode = .byWordWrapping + statusLabel.maximumNumberOfLines = 3 + + progressIndicator.style = .spinning + progressIndicator.controlSize = .small + progressIndicator.translatesAutoresizingMaskIntoConstraints = false + progressIndicator.isHidden = true + + continueScanningButton.translatesAutoresizingMaskIntoConstraints = false + continueScanningButton.bezelStyle = .rounded + continueScanningButton.controlSize = .small + continueScanningButton.isHidden = true + + let filterLabel = NSTextField(labelWithString: "Type") + filterLabel.font = .systemFont(ofSize: 11, weight: .medium) + filterLabel.textColor = .secondaryLabelColor + filterLabel.translatesAutoresizingMaskIntoConstraints = false + + root.addSubview(filterLabel) + root.addSubview(typePicker) + root.addSubview(scrollView) + root.addSubview(statusLabel) + root.addSubview(progressIndicator) + root.addSubview(continueScanningButton) + + NSLayoutConstraint.activate([ + filterLabel.topAnchor.constraint(equalTo: root.topAnchor, constant: 3), + filterLabel.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 6), + filterLabel.centerYAnchor.constraint(equalTo: typePicker.centerYAnchor), + + typePicker.topAnchor.constraint(equalTo: root.topAnchor), + typePicker.leadingAnchor.constraint(equalTo: filterLabel.trailingAnchor, constant: 6), + typePicker.trailingAnchor.constraint(lessThanOrEqualTo: root.trailingAnchor, constant: -8), + typePicker.heightAnchor.constraint(equalToConstant: 26), + + scrollView.topAnchor.constraint(equalTo: typePicker.bottomAnchor, constant: 4), + scrollView.leadingAnchor.constraint(equalTo: root.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: root.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: root.bottomAnchor), + + statusLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), + statusLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor), + statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: scrollView.leadingAnchor, constant: 12), + statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: scrollView.trailingAnchor, constant: -12), + + progressIndicator.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), + progressIndicator.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 8), + + continueScanningButton.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), + continueScanningButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10), + ]) + + view = root + observeSearchText() + observeRefreshRequests() + resetAndLoad() + } + + override func viewWillDisappear() { + super.viewWillDisappear() + loadTask?.cancel() + searchTask?.cancel() + } + + func setBottomContentInset(_ inset: CGFloat) { + var contentInsets = scrollView.contentInsets + contentInsets.bottom = inset + scrollView.contentInsets = contentInsets + } + + func setSidebarHovered(_ hovered: Bool) { + _ = hovered + } + + func connectionDidBecomeReady() { + resetAndLoad() + } + + private func configureTypePicker() { + typePicker.translatesAutoresizingMaskIntoConstraints = false + typePicker.controlSize = .small + typePicker.addItems(withTitles: [ + "All", "String", "Hash", "List", "Set", "Sorted Set", "Stream", "JSON" + ]) + typePicker.target = self + typePicker.action = #selector(typeSelectionChanged) + } + + private func configureTable() { + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 12, right: 4) + scrollView.scrollerInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: -6) + + tableView.headerView = nil + tableView.backgroundColor = .clear + tableView.rowHeight = 34 + tableView.intercellSpacing = .zero + tableView.selectionHighlightStyle = .regular + tableView.allowsMultipleSelection = false + tableView.allowsEmptySelection = true + tableView.focusRingType = .none + tableView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle + tableView.dataSource = self + tableView.delegate = self + tableView.target = self + tableView.action = #selector(openSelectedKey) + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("redisKey")) + column.resizingMask = .autoresizingMask + column.minWidth = 80 + tableView.addTableColumn(column) + scrollView.documentView = tableView + + let clipView = scrollView.contentView + clipView.postsBoundsChangedNotifications = true + observedClipView = clipView + NotificationCenter.default.addObserver( + self, + selector: #selector(scrollBoundsChanged), + name: NSView.boundsDidChangeNotification, + object: clipView + ) + } + + private func observeRefreshRequests() { + NotificationCenter.default.addObserver( + self, + selector: #selector(redisKeysRefreshRequested), + name: .redisKeysRefreshRequested, + object: instance + ) + } + + @objc private func redisKeysRefreshRequested() { + resetAndLoad() + } + + private func observeSearchText() { + withObservationTracking { + _ = viewModel.searchText + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.searchTask?.cancel() + self.searchTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + self?.resetAndLoad() + } + self.observeSearchText() + } + } + } + + @objc private func typeSelectionChanged() { + selectedType = switch typePicker.indexOfSelectedItem { + case 1: .string + case 2: .hash + case 3: .list + case 4: .set + case 5: .sortedSet + case 6: .stream + case 7: .json + default: nil + } + resetAndLoad() + } + + @objc private func openSelectedKey() { + let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow + guard row >= 0, row < keys.count else { return } + let key = keys[row] + instance.createRedisKeyTab(keyData: key.bytes, displayName: key.displayString) + } + + @objc private func scrollBoundsChanged() { + let visibleRect = scrollView.documentVisibleRect + let scrolled = visibleRect.minY > 2 + if scrolled != isScrolledFromTop { + isScrolledFromTop = scrolled + onScrolledFromTopChanged?(scrolled) + } + + let remaining = tableView.bounds.height - visibleRect.maxY + if remaining < 300 { + loadNextPage() + } + } + + @objc private func continueScanning() { + emptyPageBudget = 3 + continueScanningButton.isHidden = true + updateStatus(message: "Scanning the next keyspace segment…", isLoading: true) + loadNextPage() + } + + private func resetAndLoad() { + generation = UUID() + loadTask?.cancel() + loadTask = nil + keys = [] + seenKeys = [] + cursor = 0 + isComplete = false + isLoading = false + reachedDisplayLimit = false + emptyPageBudget = 3 + continueScanningButton.isHidden = true + tableView.reloadData() + statusLabel.stringValue = "Loading keys…" + statusLabel.isHidden = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(nil) + loadNextPage() + } + + private func loadNextPage() { + guard !isLoading, !isComplete, instance.connectionStatus == .connected else { + if instance.connectionStatus != .connected { + updateStatus(message: "Connect to Redis to browse keys.", isLoading: false) + } + return + } + + isLoading = true + let requestGeneration = generation + let requestCursor = cursor + let pattern = redisMatchPattern(from: viewModel.searchText) + let type = selectedType + + loadTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + let page = try await self.instance.databaseService.scanRedisKeys( + cursor: requestCursor, + pattern: pattern, + type: type, + count: 200 + ) + try Task.checkCancellation() + guard self.generation == requestGeneration else { return } + + let additions = page.keys.filter { self.seenKeys.insert($0.bytes).inserted } + let remainingCapacity = max(Self.maximumLoadedKeyCount - self.keys.count, 0) + self.keys.append(contentsOf: additions.prefix(remainingCapacity)) + self.cursor = page.nextCursor + self.isComplete = page.isComplete + if !page.isComplete, self.keys.count >= Self.maximumLoadedKeyCount { + self.reachedDisplayLimit = true + self.isComplete = true + } + self.isLoading = false + self.tableView.reloadData() + + if self.keys.isEmpty, self.isComplete { + self.updateStatus(message: "No matching keys", isLoading: false) + } else { + self.updateStatus(message: "", isLoading: false) + // SCAN may return an empty or very sparse page with a + // nonzero cursor. Advance a small bounded burst while the + // result cannot fill the viewport, then require an explicit + // continuation so selective filters cannot hammer through a + // huge keyspace unattended or leave the user unable to + // trigger the scroll-based pagination path. + let visibleHeight = self.scrollView.documentVisibleRect.height + let contentHeight = CGFloat(self.keys.count) * self.tableView.rowHeight + let cannotScrollYet = visibleHeight <= 0 || contentHeight <= visibleHeight + if !self.isComplete, additions.isEmpty || cannotScrollYet { + if self.emptyPageBudget > 0 { + self.emptyPageBudget -= 1 + self.loadNextPage() + } else { + self.updateStatus( + message: additions.isEmpty + ? "No matches in this keyspace segment." + : "\(self.keys.count) matching \(self.keys.count == 1 ? "key" : "keys") loaded. More keyspace remains.", + isLoading: false + ) + self.continueScanningButton.isHidden = false + } + } else if !additions.isEmpty { + self.emptyPageBudget = 3 + self.continueScanningButton.isHidden = true + } + } + } catch is CancellationError { + return + } catch { + guard self.generation == requestGeneration else { return } + self.isLoading = false + self.updateStatus(message: error.localizedDescription, isLoading: false) + } + } + } + + private func redisMatchPattern(from searchText: String) -> String? { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.contains("*") || trimmed.contains("?") || trimmed.contains("[") { + return trimmed + } + return "*\(trimmed)*" + } + + private func updateStatus(message: String, isLoading: Bool) { + statusLabel.stringValue = message + statusLabel.isHidden = message.isEmpty + if isLoading { + continueScanningButton.isHidden = true + } + progressIndicator.isHidden = !isLoading + if isLoading { + progressIndicator.startAnimation(nil) + } else { + progressIndicator.stopAnimation(nil) + } + } +} + +extension RedisKeyListViewController: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { + keys.count + (reachedDisplayLimit ? 1 : 0) + } + + func tableView( + _ tableView: NSTableView, + viewFor tableColumn: NSTableColumn?, + row: Int + ) -> NSView? { + if reachedDisplayLimit, row == keys.count { + let identifier = NSUserInterfaceItemIdentifier("RedisKeyCell") + let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView) + ?? makeKeyCell(identifier: identifier) + cell.textField?.stringValue = "25,000-key display limit — narrow the search to continue" + cell.textField?.toolTip = "Use a key pattern or type filter to keep browsing without loading the full keyspace." + cell.imageView?.image = NSImage( + systemSymbolName: "line.3.horizontal.decrease.circle", + accessibilityDescription: nil + ) + return cell + } + guard row >= 0, row < keys.count else { return nil } + let identifier = NSUserInterfaceItemIdentifier("RedisKeyCell") + let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView) + ?? makeKeyCell(identifier: identifier) + let key = keys[row] + cell.imageView?.image = NSImage(systemSymbolName: "key.horizontal", accessibilityDescription: nil) + cell.textField?.stringValue = key.displayString + cell.textField?.toolTip = key.displayString + return cell + } + + private func makeKeyCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + + let icon = NSImageView(image: NSImage(systemSymbolName: "key.horizontal", accessibilityDescription: nil) ?? NSImage()) + icon.contentTintColor = .secondaryLabelColor + icon.translatesAutoresizingMaskIntoConstraints = false + + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 12) + label.lineBreakMode = .byTruncatingMiddle + label.translatesAutoresizingMaskIntoConstraints = false + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + cell.imageView = icon + cell.textField = label + cell.addSubview(icon) + cell.addSubview(label) + + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8), + icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 14), + icon.heightAnchor.constraint(equalToConstant: 14), + label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 7), + label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8), + label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} diff --git a/pluk/Core/Windows/MenuBarController.swift b/pluk/Core/Windows/MenuBarController.swift index 8f73ea49..58c563cb 100644 --- a/pluk/Core/Windows/MenuBarController.swift +++ b/pluk/Core/Windows/MenuBarController.swift @@ -55,9 +55,11 @@ final class MenuBarController: NSObject, NSMenuDelegate { ) } - @objc + @objc nonisolated private func userDefaultsDidChange(_ notification: Notification) { - applyMenuBarVisibility() + Task { @MainActor [weak self] in + self?.applyMenuBarVisibility() + } } private func applyMenuBarVisibility() { diff --git a/pluk/Drivers/Redis/RedisDriver.swift b/pluk/Drivers/Redis/RedisDriver.swift new file mode 100644 index 00000000..c1c1fcdb --- /dev/null +++ b/pluk/Drivers/Redis/RedisDriver.swift @@ -0,0 +1,1129 @@ +import Foundation +import Logging +import NIOCore +import NIOSSL +import Valkey + +struct RedisDatabaseWrapper: DatabaseWrapper { + let name: String + let size: String? + let tableCount: Int? +} + +struct RedisCollectionWrapper: CollectionWrapper { + let id: String + let name: String + let type: String + let schema: String? +} + +private struct RedisConnectionSettings: Sendable { + let host: String + let port: Int + let username: String? + let password: String? + let database: Int + let usesTLS: Bool + let tlsServerName: String + + func selecting(database: Int) -> Self { + .init( + host: host, + port: port, + username: username, + password: password, + database: database, + usesTLS: usesTLS, + tlsServerName: tlsServerName + ) + } +} + +private struct RedisRawArgument: RESPRenderable, Hashable, Sendable { + let bytes: Data + var respEntries: Int { 1 } + + func encode(into commandEncoder: inout ValkeyCommandEncoder) { + bytes.encode(into: &commandEncoder) + } +} + +private struct RedisRawCommand: ValkeyCommand { + static let name = "RAW" + + let arguments: [RedisRawArgument] + let affectedKeys: [ValkeyKey] + let readOnly: Bool + + init(_ arguments: [Data], affectedKeys: [RedisKey] = [], readOnly: Bool = false) { + self.arguments = arguments.map(RedisRawArgument.init(bytes:)) + self.affectedKeys = affectedKeys.map { ValkeyKey(ByteBuffer(bytes: $0.bytes)) } + self.readOnly = readOnly + } + + var keysAffected: [ValkeyKey] { affectedKeys } + var isReadOnly: Bool { readOnly } + + func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray(arguments) + } +} + +struct RedisLifecycleGeneration: Equatable, Sendable { + private(set) var current: UInt64 = 0 + + mutating func begin() -> UInt64 { + current &+= 1 + return current + } + + func isCurrent(_ generation: UInt64) -> Bool { + current == generation + } +} + +actor RedisDriver: DatabaseDriver { + typealias Database = RedisDatabaseWrapper + typealias Collection = RedisCollectionWrapper + + private let logger = Logger(label: "redis-driver") + private var client: ValkeyClient? + private var clientGeneration: UInt64? + private var lifecycleTask: Task? + private var settings: RedisConnectionSettings? + private var lifecycleGeneration = RedisLifecycleGeneration() + + deinit { + lifecycleTask?.cancel() + } + + // MARK: - Connection + + func connect(to connectionUri: String) async throws -> RedisDatabaseWrapper { + let parsed = try Self.parseConnectionURI(connectionUri) + let generation = lifecycleGeneration.begin() + try await replaceClient(using: parsed, generation: generation) + let wrapper = try await databaseWrapper(for: parsed.database) + try ensureCurrentLifecycle(generation) + return wrapper + } + + func disconnect() async { + _ = lifecycleGeneration.begin() + settings = nil + await stopClient() + } + + func reconnect() async throws { + guard let settings else { + throw DatabaseError.notConnected("No Redis connection settings are available") + } + let generation = lifecycleGeneration.begin() + try await replaceClient(using: settings, generation: generation) + } + + func ping(to connectionUri: String) async throws { + let parsed = try Self.parseConnectionURI(connectionUri) + let temporaryClient = try Self.makeClient(settings: parsed, logger: logger) + let task = Task { await temporaryClient.run() } + + do { + try await temporaryClient.ping() + // valkey-swift currently ignores errors from the SELECT command it + // sends while establishing a pooled connection. Validate the + // configured logical database explicitly so an invalid index (or a + // cluster endpoint, where SELECT is unsupported) cannot appear to + // connect successfully on database 0. + try await temporaryClient.select(index: parsed.database) + task.cancel() + await task.value + } catch { + task.cancel() + await task.value + throw Self.connectionError(error, host: parsed.host, port: parsed.port) + } + } + + func getBuildInfo() async throws -> BuildInfo { + let response = try await execute(arguments: Self.arguments("INFO", "SERVER"), readOnly: true) + let info = try Self.data(from: response) + let text = String(decoding: info, as: UTF8.self) + let version = Self.infoValue(named: "redis_version", in: text) + ?? Self.infoValue(named: "valkey_version", in: text) + ?? "Unknown" + return BuildInfo(version: version, databaseType: .redis) + } + + func switchDatabase(to databaseName: String) async throws { + guard let database = Int(databaseName), database >= 0 else { + throw DatabaseError.configurationError("Redis database must be a non-negative integer") + } + guard let settings else { + throw DatabaseError.notConnected("No active Redis connection") + } + + let updated = settings.selecting(database: database) + let generation = lifecycleGeneration.begin() + try await replaceClient(using: updated, generation: generation) + } + + private func replaceClient( + using settings: RedisConnectionSettings, + generation: UInt64 + ) async throws { + try ensureCurrentLifecycle(generation) + await stopClient() + try ensureCurrentLifecycle(generation) + + let newClient = try Self.makeClient(settings: settings, logger: logger) + let task = Task { await newClient.run() } + client = newClient + clientGeneration = generation + lifecycleTask = task + + do { + try await newClient.ping() + // Connection setup can swallow SELECT failures, then let PING + // succeed against database 0. An explicit SELECT makes database + // replacement transactional from Pluk's point of view: settings + // are published only after the server accepts the requested DB. + try await newClient.select(index: settings.database) + } catch { + await stopClient(ownedBy: generation) + guard lifecycleGeneration.isCurrent(generation) else { + throw CancellationError() + } + throw Self.connectionError(error, host: settings.host, port: settings.port) + } + + try ensureCurrentLifecycle(generation, client: newClient) + self.settings = settings + logger.info("Connected to Redis at \(settings.host):\(settings.port), database \(settings.database)") + } + + private func stopClient(ownedBy expectedGeneration: UInt64? = nil) async { + if let expectedGeneration, clientGeneration != expectedGeneration { + return + } + + client = nil + clientGeneration = nil + let task = lifecycleTask + lifecycleTask = nil + task?.cancel() + await task?.value + } + + private func ensureCurrentLifecycle( + _ generation: UInt64, + client expectedClient: ValkeyClient? = nil + ) throws { + guard lifecycleGeneration.isCurrent(generation) else { + throw CancellationError() + } + if let expectedClient { + guard clientGeneration == generation, client === expectedClient else { + throw CancellationError() + } + } + } + + private static func makeClient(settings: RedisConnectionSettings, logger: Logger) throws -> ValkeyClient { + let authentication = settings.password.map { + ValkeyClientConfiguration.Authentication( + username: settings.username ?? "default", + password: $0 + ) + } + + let tls: ValkeyClientConfiguration.TLS + if settings.usesTLS { + var tlsConfiguration = TLSConfiguration.makeClientConfiguration() + tlsConfiguration.certificateVerification = .fullVerification + tls = try .enable(tlsConfiguration, tlsServerName: settings.tlsServerName) + } else { + tls = .disable + } + + let configuration = ValkeyClientConfiguration( + authentication: authentication, + connectionPool: .init( + minimumConnectionCount: 0, + maximumConnectionSoftLimit: 4, + maximumConnectionHardLimit: 8, + // A desktop connection test must surface DNS, TCP, and TLS + // failures promptly instead of waiting for the pool's + // 60-second default circuit-breaker window. + circuitBreakerTripAfter: .seconds(8), + maximumConcurrentConnectionRequests: 4 + ), + commandTimeout: .seconds(30), + blockingCommandTimeout: .seconds(120), + tls: tls, + databaseNumber: settings.database, + enableClientCapaRedirect: false + ) + + return ValkeyClient( + .hostname(settings.host, port: settings.port), + configuration: configuration, + logger: logger + ) + } + + nonisolated private static func parseConnectionURI(_ connectionURI: String) throws -> RedisConnectionSettings { + let parsed: ParsedRedisConnectionURL + do { + parsed = try ConnectionURLParser.parseRedis(connectionURI) + } catch { + throw DatabaseError.invalidConnectionString(error.localizedDescription) + } + + let tlsServerName = URLComponents(string: connectionURI)? + .queryItems? + .last { $0.name.lowercased() == "pluk-tls-server-name" }? + .value + .flatMap { $0.isEmpty ? nil : $0 } + ?? parsed.hostname + + return RedisConnectionSettings( + host: parsed.hostname, + port: parsed.port, + username: parsed.username, + password: parsed.password, + database: parsed.databaseIndex, + usesTLS: parsed.usesTLS, + tlsServerName: tlsServerName + ) + } + + // MARK: - Redis UI API + + func scanRedisKeys( + cursor: UInt64, + pattern: String?, + type: RedisKeyType?, + count: Int + ) async throws -> RedisScanPage { + let safeCount = min(max(count, 1), 10_000) + let response = try await execute( + arguments: Self.scanCommandArguments( + cursor: cursor, + pattern: pattern, + type: type, + count: safeCount + ), + readOnly: true + ) + return try Self.decodeScanPage(from: response) + } + + func redisKeyMetadata(for key: RedisKey) async throws -> RedisKeyMetadata { + let commands: [any ValkeyCommand] = [ + RedisRawCommand(Self.arguments("TYPE") + [key.bytes], affectedKeys: [key], readOnly: true), + RedisRawCommand(Self.arguments("PTTL") + [key.bytes], affectedKeys: [key], readOnly: true), + RedisRawCommand(Self.arguments("MEMORY", "USAGE") + [key.bytes], affectedKeys: [key], readOnly: true), + RedisRawCommand(Self.arguments("OBJECT", "ENCODING") + [key.bytes], affectedKeys: [key], readOnly: true), + ] + let results = try await requireClient().execute(commands) + guard results.count == 4 else { + throw DatabaseError.operationFailed("Redis returned an incomplete metadata response") + } + + let typeToken = try Self.unwrap(results[0]) + let ttlToken = try Self.unwrap(results[1]) + + let memoryUsageBytes: Int64? + if case .success(let memoryToken) = results[2] { + memoryUsageBytes = (try? Self.optionalInt64(from: memoryToken)) ?? nil + } else { + memoryUsageBytes = nil + } + + let encoding: String? + if case .success(let encodingToken) = results[3], + let bytes = (try? Self.optionalData(from: encodingToken)) ?? nil { + encoding = String(decoding: bytes, as: UTF8.self) + } else { + encoding = nil + } + + let serverType = String(decoding: try Self.data(from: typeToken), as: UTF8.self) + let rawTTL = try Self.int64(from: ttlToken) + return RedisKeyMetadata( + key: key, + type: RedisKeyType(serverName: serverType), + ttlMilliseconds: rawTTL >= 0 ? rawTTL : nil, + memoryUsageBytes: memoryUsageBytes, + encoding: encoding + ) + } + + func redisValue(for key: RedisKey, page: RedisValuePage) async throws -> RedisValue { + let keyType = try await redisKeyType(for: key) + let pageCount = Self.boundedPageCount(page.count) + let pageOffset = max(0, page.offset) + let pageCursor = page.cursor + + switch keyType { + case .none: + return .none + + case .string: + let response = try await execute( + arguments: Self.arguments("GET") + [key.bytes], + affectedKeys: [key], + readOnly: true + ) + return .string(try Self.data(from: response)) + + case .hash: + let response = try await execute( + arguments: Self.arguments("HSCAN") + [ + key.bytes, + Self.data(pageCursor), + Self.data("COUNT"), + Self.data(pageCount), + ], + affectedKeys: [key], + readOnly: true + ) + let (nextCursor, values) = try Self.scanResponse(from: response) + let totalCount = try await integerCommand("HLEN", key: key) + return .hash( + entries: try Self.hashEntries(from: values), + totalCount: totalCount, + nextCursor: nextCursor + ) + + case .list: + let stop = Self.redisRangeEnd(offset: pageOffset, count: pageCount) + let response = try await execute( + arguments: Self.arguments("LRANGE") + [key.bytes, Self.data(pageOffset), Self.data(stop)], + affectedKeys: [key], + readOnly: true + ) + return .list( + elements: try Self.dataArray(from: response), + totalCount: try await integerCommand("LLEN", key: key), + offset: pageOffset + ) + + case .set: + let response = try await execute( + arguments: Self.arguments("SSCAN") + [ + key.bytes, + Self.data(pageCursor), + Self.data("COUNT"), + Self.data(pageCount), + ], + affectedKeys: [key], + readOnly: true + ) + let (nextCursor, values) = try Self.scanResponse(from: response) + return .set( + members: try values.map(Self.data(from:)), + totalCount: try await integerCommand("SCARD", key: key), + nextCursor: nextCursor + ) + + case .sortedSet: + let stop = Self.redisRangeEnd(offset: pageOffset, count: pageCount) + let response = try await execute( + arguments: Self.arguments("ZRANGE") + [ + key.bytes, + Self.data(pageOffset), + Self.data(stop), + Self.data("WITHSCORES"), + ], + affectedKeys: [key], + readOnly: true + ) + return .sortedSet( + entries: try Self.sortedSetEntries(from: response), + totalCount: try await integerCommand("ZCARD", key: key), + offset: pageOffset + ) + + case .stream: + // XRANGE has an ID cursor rather than a numeric offset. Fetching a bounded + // prefix keeps this API simple while the dedicated editor owns navigation. + let fetchCount = min(Self.saturatingAdd(pageOffset, pageCount), 10_000) + let response = try await execute( + arguments: Self.arguments("XRANGE") + [ + key.bytes, + Self.data("-"), + Self.data("+"), + Self.data("COUNT"), + Self.data(fetchCount), + ], + affectedKeys: [key], + readOnly: true + ) + let entries = try Self.streamEntries(from: response) + return .stream( + entries: Array(entries.dropFirst(min(pageOffset, entries.count)).prefix(pageCount)), + totalCount: try await integerCommand("XLEN", key: key) + ) + + case .json: + let response = try await execute( + arguments: Self.arguments("JSON.GET") + [key.bytes], + affectedKeys: [key], + readOnly: true + ) + return .json(try Self.data(from: response)) + + case .unknown: + let response = try await execute( + arguments: Self.arguments("DUMP") + [key.bytes], + affectedKeys: [key], + readOnly: true + ) + return .unsupported(type: keyType, raw: Self.commandValue(from: response)) + } + } + + func updateRedisValue(_ update: RedisValueUpdate, for key: RedisKey, preserveTTL: Bool) async throws { + guard let mutationArguments = try Self.mutationCommandArguments( + for: update, + key: key, + preserveTTL: preserveTTL + ) else { + return + } + _ = try await execute(arguments: mutationArguments, affectedKeys: [key]) + } + + func renameRedisKey(_ key: RedisKey, to newKey: RedisKey, overwrite: Bool) async throws { + let command = overwrite ? "RENAME" : "RENAMENX" + let response = try await execute( + arguments: Self.arguments(command) + [key.bytes, newKey.bytes], + affectedKeys: [key, newKey] + ) + if !overwrite, try Self.int64(from: response) == 0 { + throw DatabaseError.operationFailed("A Redis key with the destination name already exists") + } + } + + func deleteRedisKeys(_ keys: [RedisKey], asynchronously: Bool) async throws -> Int { + guard !keys.isEmpty else { return 0 } + let response = try await execute( + arguments: Self.arguments(asynchronously ? "UNLINK" : "DEL") + keys.map(\.bytes), + affectedKeys: keys + ) + return try Self.int(from: response) + } + + func setRedisExpiration(for key: RedisKey, milliseconds: Int64?) async throws -> Bool { + let arguments: [Data] + if let milliseconds { + guard milliseconds >= 0 else { + throw DatabaseError.operationFailed("Redis expiration cannot be negative") + } + arguments = Self.arguments("PEXPIRE") + [key.bytes, Self.data(milliseconds)] + } else { + arguments = Self.arguments("PERSIST") + [key.bytes] + } + + let response = try await execute(arguments: arguments, affectedKeys: [key]) + return try Self.int64(from: response) == 1 + } + + func executeRedisCommand(_ command: RedisCommand) async throws -> RedisCommandResult { + guard !command.arguments.isEmpty, !command.arguments[0].isEmpty else { + throw DatabaseError.operationFailed("Redis command cannot be empty") + } + + let startedAt = ContinuousClock.now + let response = try await execute(arguments: command.arguments) + let duration = startedAt.duration(to: .now) + let milliseconds = Double(duration.components.seconds) * 1_000 + + Double(duration.components.attoseconds) / 1_000_000_000_000_000 + return RedisCommandResult( + value: Self.commandValue(from: response), + durationMilliseconds: milliseconds + ) + } + + func parseRedisCommand(_ commandText: String) async throws -> RedisCommand { + try RedisCommandTokenizer.tokenize(commandText).transportCommand + } + + // MARK: - DatabaseDriver compatibility + + func listDatabases() async throws -> [RedisDatabaseWrapper] { + let current = settings?.database ?? 0 + do { + let response = try await execute(arguments: Self.arguments("INFO", "KEYSPACE"), readOnly: true) + let text = String(decoding: try Self.data(from: response), as: UTF8.self) + let databases = text.split(whereSeparator: \.isNewline).compactMap { line -> RedisDatabaseWrapper? in + guard line.hasPrefix("db"), let colon = line.firstIndex(of: ":") else { return nil } + let name = String(line[line.index(line.startIndex, offsetBy: 2).. [QueryResult] { + [try await findDocuments(in: collectionName, filter: filter, skip: 0, limit: 300)] + } + + func findDocuments( + in collectionName: String, + filter: DatabaseDocument, + skip: Int, + limit: Int + ) async throws -> QueryResult { + try await findDocuments( + in: collectionName, + databaseSchema: nil, + filter: filter, + skip: skip, + limit: limit, + sortBy: nil, + ascending: nil + ) + } + + func findDocuments( + in collectionName: String, + databaseSchema: String?, + filter: DatabaseDocument, + skip: Int, + limit: Int, + sortBy: String?, + ascending: Bool? + ) async throws -> QueryResult { + let pattern = filter["rawQuery"]?.stringValue + var cursor: UInt64 = 0 + var keys: [RedisKey] = [] + repeat { + let page = try await scanRedisKeys(cursor: cursor, pattern: pattern, type: nil, count: min(max(limit, 1), 1_000)) + cursor = page.nextCursor + keys.append(contentsOf: page.keys) + } while cursor != 0 && keys.count < skip + limit + + let selected = Array(keys.dropFirst(max(skip, 0)).prefix(max(limit, 0))) + return Self.keyQueryResult(selected) + } + + func createDocument(in collectionName: String, databaseSchema: String?, document: DatabaseDocument) async throws { + throw DatabaseError.notImplemented("Use Redis type-specific editing to create keys") + } + + func updateDocument( + in collectionName: String, + databaseSchema: String?, + id: DatabaseRecordID, + data: DatabaseDocument + ) async throws { + throw DatabaseError.notImplemented("Use Redis type-specific editing to update keys") + } + + func deleteDocument( + in collectionName: String, + databaseSchema: String?, + id: DatabaseRecordID + ) async throws { + let key = RedisKey(id.value.description) + _ = try await deleteRedisKeys([key], asynchronously: true) + } + + func executeRawQuery(_ query: String, databaseSchema: String?) async throws -> [QueryResult] { + let analysis = try RedisCommandSafety.analyze(query) + guard analysis.category == .readOnly, + analysis.executionPolicy == .allow else { + throw DatabaseError.operationFailed( + "Use the Redis command workspace to confirm write or administrative commands" + ) + } + return [Self.queryResult(try await executeRedisCommand(analysis.transportCommand))] + } + + func getSchema(for collectionName: String, schema: String?) async throws -> DatabaseSchemaResult? { nil } + func getInformationSchema() async throws -> [InformationSchema] { [] } + func getIndexes(for collectionName: String, schema: String?) async throws -> [DatabaseIndexInfo] { [] } + + func createCollection(named collectionName: String) async throws { + throw DatabaseError.notImplemented("Redis does not have collections") + } + + func renameCollection(databaseSchema: String?, from oldName: String, to newName: String) async throws { + try await renameRedisKey(RedisKey(oldName), to: RedisKey(newName), overwrite: false) + } + + func deleteCollection(named collectionName: String, databaseSchema: String?) async throws { + _ = try await deleteRedisKeys([RedisKey(collectionName)], asynchronously: true) + } + + func buildSystemPrompt(for collectionName: String, databaseSchema: String?) async throws -> String { + """ + You are working with Redis database \(settings?.database ?? 0). Use Redis commands, prefer SCAN over KEYS, + preserve TTLs when editing values, and never run destructive commands unless the user explicitly asks. + """ + } + + func buildAICommandPromptSystemPrompt(_ message: String) async throws -> String { + "Return one valid Redis command for the request. Do not wrap the command in Markdown." + } + + // MARK: - Command helpers + + nonisolated static func scanCommandArguments( + cursor: UInt64, + pattern: String?, + type: RedisKeyType?, + count: Int + ) -> [Data] { + var arguments = Self.arguments("SCAN") + [Self.data(cursor)] + if let pattern, !pattern.isEmpty { + arguments += Self.arguments("MATCH") + [Self.data(pattern)] + } + arguments += Self.arguments("COUNT") + [Self.data(count)] + if let type = type?.scanFilter { + arguments += Self.arguments("TYPE") + [Self.data(type)] + } + return arguments + } + + nonisolated static func boundedPageCount(_ count: Int) -> Int { + min(max(count, 1), 10_000) + } + + nonisolated static func redisRangeEnd(offset: Int, count: Int) -> Int { + saturatingAdd(max(offset, 0), boundedPageCount(count) - 1) + } + + nonisolated private static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { + let (result, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? Int.max : result + } + + nonisolated static func decodeScanPage(from token: RESPToken) throws -> RedisScanPage { + let (nextCursor, values) = try Self.scanResponse(from: token) + return RedisScanPage( + nextCursor: nextCursor, + keys: try values.map { RedisKey(bytes: try Self.data(from: $0)) } + ) + } + + nonisolated static func mutationCommandArguments( + for update: RedisValueUpdate, + key: RedisKey, + preserveTTL: Bool + ) throws -> [Data]? { + switch update { + case .string(let value): + return Self.arguments("SET") + [key.bytes, value] + + (preserveTTL ? Self.arguments("KEEPTTL") : []) + + case .json(let value): + do { + _ = try JSONSerialization.jsonObject(with: value, options: [.fragmentsAllowed]) + } catch { + throw DatabaseError.operationFailed("RedisJSON value is not valid JSON: \(error.localizedDescription)") + } + // JSON.SET updates an existing module value in place, as do the + // collection mutations below, so Redis retains the key's expiry. + return Self.arguments("JSON.SET") + [key.bytes, Self.data("$"), value] + + case .hashField(let field, let value): + return Self.arguments("HSET") + [key.bytes, field, value] + + case .deleteHashField(let field): + return Self.arguments("HDEL") + [key.bytes, field] + + case .listElement(let index, let value): + return Self.arguments("LSET") + [key.bytes, Self.data(index), value] + + case .appendList(let values, let toHead): + guard !values.isEmpty else { return nil } + return Self.arguments(toHead ? "LPUSH" : "RPUSH") + [key.bytes] + values + + case .setMember(let member, let isPresent): + return Self.arguments(isPresent ? "SADD" : "SREM") + [key.bytes, member] + + case .sortedSetMember(let member, let score): + if let score { + return Self.arguments("ZADD") + [key.bytes, Self.data(score), member] + } + return Self.arguments("ZREM") + [key.bytes, member] + + case .appendStream(let fields, let id): + guard !fields.isEmpty else { + throw DatabaseError.operationFailed("A Redis stream entry must contain at least one field") + } + let fieldArguments = fields.flatMap { [$0.field, $0.value] } + return Self.arguments("XADD") + [key.bytes, id ?? Self.data("*")] + fieldArguments + + case .deleteStreamEntry(let id): + return Self.arguments("XDEL") + [key.bytes, id] + } + } + + private func redisKeyType(for key: RedisKey) async throws -> RedisKeyType { + let response = try await execute( + arguments: Self.arguments("TYPE") + [key.bytes], + affectedKeys: [key], + readOnly: true + ) + return RedisKeyType( + serverName: String(decoding: try Self.data(from: response), as: UTF8.self) + ) + } + + private func requireClient() throws -> ValkeyClient { + guard let client else { + throw DatabaseError.notConnected("No active Redis connection") + } + return client + } + + private func execute( + arguments: [Data], + affectedKeys: [RedisKey] = [], + readOnly: Bool = false + ) async throws -> RESPToken { + guard !arguments.isEmpty else { + throw DatabaseError.operationFailed("Redis command cannot be empty") + } + do { + return try await requireClient().execute( + RedisRawCommand(arguments, affectedKeys: affectedKeys, readOnly: readOnly) + ) + } catch let error as DatabaseError { + throw error + } catch { + throw Self.operationError(error) + } + } + + private func integerCommand(_ command: String, key: RedisKey? = nil) async throws -> Int { + var arguments = Self.arguments(command) + if let key { arguments.append(key.bytes) } + let response = try await execute( + arguments: arguments, + affectedKeys: key.map { [$0] } ?? [], + readOnly: true + ) + return try Self.int(from: response) + } + + private func databaseWrapper(for database: Int) async throws -> RedisDatabaseWrapper { + let count = try? await integerCommand("DBSIZE") + return RedisDatabaseWrapper(name: String(database), size: nil, tableCount: count) + } + + nonisolated private static func arguments(_ values: String...) -> [Data] { + values.map(data(_:)) + } + + nonisolated private static func data(_ value: T) -> Data { + Data(String(value).utf8) + } + + nonisolated private static func data(_ value: String) -> Data { + Data(value.utf8) + } + + nonisolated private static func unwrap(_ result: Result) throws -> RESPToken { + do { + return try result.get() + } catch { + throw operationError(error) + } + } + + nonisolated private static func operationError(_ error: Error) -> DatabaseError { + let message: String + if let valkeyError = error as? ValkeyClientError { + message = valkeyError.message ?? valkeyError.errorCode.description + } else { + message = error.localizedDescription + } + let uppercase = message.uppercased() + if uppercase.contains("NOAUTH") || uppercase.contains("WRONGPASS") || uppercase.contains("AUTHENTICATION") { + return DatabaseError(code: .authenticationFailed, message: message, underlyingError: error) + } + return DatabaseError(code: .operationFailed, message: message, underlyingError: error) + } + + nonisolated private static func connectionError(_ error: Error, host: String, port: Int) -> DatabaseError { + let mapped = operationError(error) + if mapped.code == .authenticationFailed { return mapped } + return DatabaseError( + code: .connectionFailed, + message: "Could not connect to Redis at \(host):\(port): \(mapped.message)", + underlyingError: error + ) + } + + nonisolated private static func data(from token: RESPToken) throws -> Data { + switch token.value { + case .simpleString(let buffer), .bulkString(let buffer), .bigNumber(let buffer): + return Data(buffer.readableBytesView) + case .verbatimString(let buffer): + let bytes = Data(buffer.readableBytesView) + return bytes.count >= 4 ? Data(bytes.dropFirst(4)) : bytes + default: + throw DatabaseError.operationFailed("Redis response was not a string") + } + } + + nonisolated private static func optionalData(from token: RESPToken) throws -> Data? { + if case .null = token.value { return nil } + return try data(from: token) + } + + nonisolated private static func int64(from token: RESPToken) throws -> Int64 { + switch token.value { + case .number(let value): return value + case .simpleString, .bulkString, .bigNumber: + guard let value = Int64(String(decoding: try data(from: token), as: UTF8.self)) else { + throw DatabaseError.operationFailed("Redis response was not an integer") + } + return value + default: + throw DatabaseError.operationFailed("Redis response was not an integer") + } + } + + nonisolated private static func optionalInt64(from token: RESPToken) throws -> Int64? { + if case .null = token.value { return nil } + return try int64(from: token) + } + + nonisolated private static func int(from token: RESPToken) throws -> Int { + let value = try int64(from: token) + guard let converted = Int(exactly: value) else { + throw DatabaseError.operationFailed("Redis integer response is out of range") + } + return converted + } + + nonisolated private static func tokenArray(from token: RESPToken) throws -> [RESPToken] { + switch token.value { + case .array(let values), .set(let values), .push(let values): Array(values) + default: throw DatabaseError.operationFailed("Redis response was not an array") + } + } + + nonisolated private static func dataArray(from token: RESPToken) throws -> [Data] { + try tokenArray(from: token).map(data(from:)) + } + + nonisolated private static func scanResponse(from token: RESPToken) throws -> (UInt64, [RESPToken]) { + let values = try tokenArray(from: token) + guard values.count == 2 else { + throw DatabaseError.operationFailed("Redis returned an invalid SCAN response") + } + return (try uint64(from: values[0]), try tokenArray(from: values[1])) + } + + nonisolated private static func uint64(from token: RESPToken) throws -> UInt64 { + switch token.value { + case .number(let value) where value >= 0: + return UInt64(value) + case .simpleString, .bulkString, .bigNumber: + guard let value = UInt64(String(decoding: try data(from: token), as: UTF8.self)) else { + throw DatabaseError.operationFailed("Redis response was not an unsigned integer") + } + return value + default: + throw DatabaseError.operationFailed("Redis response was not an unsigned integer") + } + } + + nonisolated private static func hashEntries(from tokens: [RESPToken]) throws -> [RedisHashEntry] { + guard tokens.count.isMultiple(of: 2) else { + throw DatabaseError.operationFailed("Redis returned an invalid hash response") + } + return try stride(from: 0, to: tokens.count, by: 2).map { + RedisHashEntry(field: try data(from: tokens[$0]), value: try data(from: tokens[$0 + 1])) + } + } + + nonisolated private static func sortedSetEntries(from token: RESPToken) throws -> [RedisSortedSetEntry] { + let values = try tokenArray(from: token) + if values.allSatisfy({ if case .array = $0.value { true } else { false } }) { + return try values.map { pair in + let elements = try tokenArray(from: pair) + guard elements.count == 2 else { + throw DatabaseError.operationFailed("Redis returned an invalid sorted-set response") + } + return RedisSortedSetEntry( + member: try data(from: elements[0]), + score: try double(from: elements[1]) + ) + } + } + guard values.count.isMultiple(of: 2) else { + throw DatabaseError.operationFailed("Redis returned an invalid sorted-set response") + } + return try stride(from: 0, to: values.count, by: 2).map { + RedisSortedSetEntry(member: try data(from: values[$0]), score: try double(from: values[$0 + 1])) + } + } + + nonisolated private static func double(from token: RESPToken) throws -> Double { + if case .double(let value) = token.value { return value } + guard let value = Double(String(decoding: try data(from: token), as: UTF8.self)) else { + throw DatabaseError.operationFailed("Redis response was not a floating-point number") + } + return value + } + + nonisolated private static func streamEntries(from token: RESPToken) throws -> [RedisStreamEntry] { + try tokenArray(from: token).map { entryToken in + let entry = try tokenArray(from: entryToken) + guard entry.count == 2 else { + throw DatabaseError.operationFailed("Redis returned an invalid stream entry") + } + return RedisStreamEntry( + id: try data(from: entry[0]), + fields: try hashEntries(from: tokenArray(from: entry[1])) + ) + } + } + + nonisolated static func commandValue(from token: RESPToken) -> RedisCommandValue { + switch token.value { + case .null: + .null + case .simpleString(let buffer): + .simpleString(Data(buffer.readableBytesView)) + case .bulkString(let buffer): + .bulkString(Data(buffer.readableBytesView)) + case .simpleError(let buffer): + .simpleError(Data(buffer.readableBytesView)) + case .bulkError(let buffer): + .bulkError(Data(buffer.readableBytesView)) + case .verbatimString(let buffer): + verbatimValue(buffer) + case .number(let value): + .integer(value) + case .double(let value): + .double(value) + case .boolean(let value): + .boolean(value) + case .bigNumber(let buffer): + .bigNumber(Data(buffer.readableBytesView)) + case .array(let values): + .array(values.map(commandValue(from:))) + case .map(let values): + .map(values.map { RedisCommandMapEntry(key: commandValue(from: $0.key), value: commandValue(from: $0.value)) }) + case .set(let values): + .set(values.map(commandValue(from:))) + case .push(let values): + .push(values.map(commandValue(from:))) + case .attribute(let values): + .attribute(values.map { RedisCommandMapEntry(key: commandValue(from: $0.key), value: commandValue(from: $0.value)) }) + } + } + + nonisolated private static func verbatimValue(_ buffer: ByteBuffer) -> RedisCommandValue { + let bytes = Data(buffer.readableBytesView) + guard bytes.count >= 4, bytes[bytes.index(bytes.startIndex, offsetBy: 3)] == 58 else { + return .verbatimString(format: nil, data: bytes) + } + let formatData = bytes.prefix(3) + return .verbatimString( + format: String(data: formatData, encoding: .utf8), + data: Data(bytes.dropFirst(4)) + ) + } + + nonisolated private static func infoValue(named key: String, in text: String) -> String? { + let prefix = key + ":" + return text.split(whereSeparator: \.isNewline) + .first(where: { $0.hasPrefix(prefix) }) + .map { String($0.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) } + } + + nonisolated private static func keyQueryResult(_ keys: [RedisKey]) -> QueryResult { + let column = QueryColumnInfo(name: "key", dataType: "redis-key", format: nil, index: 0) + let rows = keys.map { key in + ["key": QueryRowInfo(value: .data(key.bytes), dataType: "redis-key", format: nil)] + } + let rawRows: [DatabaseRawRow] = keys.map { ["key": .data($0.bytes)] } + return QueryResult(columns: [column], rows: rows, totalCount: rows.count, rawRows: rawRows) + } + + nonisolated private static func queryResult(_ result: RedisCommandResult) -> QueryResult { + switch result.value { + case .map(let entries), .attribute(let entries): + let columns = [ + QueryColumnInfo(name: "key", dataType: "RESP3", format: nil, index: 0), + QueryColumnInfo(name: "value", dataType: "RESP3", format: nil, index: 1), + ] + let rows = entries.map { + [ + "key": QueryRowInfo(value: databaseValue(from: $0.key), dataType: "RESP3", format: nil), + "value": QueryRowInfo(value: databaseValue(from: $0.value), dataType: "RESP3", format: nil), + ] + } + let rawRows: [DatabaseRawRow] = entries.map { + ["key": databaseValue(from: $0.key), "value": databaseValue(from: $0.value)] + } + return QueryResult(columns: columns, rows: rows, totalCount: rows.count, rawRows: rawRows) + + case .array(let values), .set(let values), .push(let values): + return valueRows(values) + + default: + return valueRows([result.value]) + } + } + + nonisolated private static func valueRows(_ values: [RedisCommandValue]) -> QueryResult { + let column = QueryColumnInfo(name: "result", dataType: "RESP3", format: nil, index: 0) + let rows = values.map { + ["result": QueryRowInfo(value: databaseValue(from: $0), dataType: "RESP3", format: nil)] + } + let rawRows: [DatabaseRawRow] = values.map { ["result": databaseValue(from: $0)] } + return QueryResult(columns: [column], rows: rows, totalCount: rows.count, rawRows: rawRows) + } + + nonisolated private static func databaseValue(from value: RedisCommandValue) -> DatabaseValue { + switch value { + case .null: .null + case .simpleString(let data), .bulkString(let data), .simpleError(let data), .bulkError(let data), .bigNumber(let data): .data(data) + case .verbatimString(_, let data): .data(data) + case .integer(let value): .int64(value) + case .double(let value): .double(value) + case .boolean(let value): .bool(value) + case .array(let values), .set(let values), .push(let values): .array(values.map(databaseValue(from:))) + case .map(let entries), .attribute(let entries): + .array(entries.map { + .object([ + "key": databaseValue(from: $0.key), + "value": databaseValue(from: $0.value), + ]) + }) + } + } +} diff --git a/pluk/Extensions/NotificationName+Extensions.swift b/pluk/Extensions/NotificationName+Extensions.swift index dd78915e..f161e77c 100644 --- a/pluk/Extensions/NotificationName+Extensions.swift +++ b/pluk/Extensions/NotificationName+Extensions.swift @@ -1,6 +1,7 @@ import Foundation extension Notification.Name { + static let redisKeysRefreshRequested = Notification.Name("redisKeysRefreshRequested") static let didRequestDelete = Notification.Name("didRequestDelete") static let didRequestCopy = Notification.Name("didRequestCopy") static let didRequestPaste = Notification.Name("didRequestPaste") diff --git a/pluk/Models/Connection.swift b/pluk/Models/Connection.swift index 13cd9f2d..f8e8cfe3 100644 --- a/pluk/Models/Connection.swift +++ b/pluk/Models/Connection.swift @@ -18,6 +18,7 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { case mongodb = "MongoDB" case sqlite = "sqlite" case mysql = "mysql" + case redis = "redis" var displayName: String { switch self { @@ -28,6 +29,7 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { case .mongodb: return "MongoDB" case .mysql: return "MySQL" case .sqlite: return "SQLite" + case .redis: return "Redis" } } @@ -40,6 +42,7 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { case .mysql: return Color(hex: "#00546B") case .mongodb: return Color(hex: "#00ED64") case .sqlite: return Color(hex: "#003B57") + case .redis: return Color(hex: "#DC382D") } } @@ -52,6 +55,7 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { case .mysql: return Color(hex: "#00546B") case .mongodb: return Color(hex: "#021E2C") case .sqlite: return Color(hex: "#E6F0FA") + case .redis: return Color(hex: "#A41E11") } } @@ -64,6 +68,7 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { case .mongodb: return "mongodb" case .mysql: return "mysql" case .sqlite: return "sqlite" + case .redis: return "redis" } } @@ -76,6 +81,7 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { case .mongodb: return "mongodb" case .mysql: return "mysql.white" case .sqlite: return "sqlite" + case .redis: return "redis" } } @@ -105,6 +111,8 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { return "mongodb+srv://user:password@cluster.mongodb.net" case .sqlite: return "sqlite:///path/to/database.db" + case .redis: + return "redis://localhost:6379/0" } } @@ -112,14 +120,14 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { switch self { case .convex, .supabase: return .platforms - case .postgres, .mysql, .mongodb, .sqlite: + case .postgres, .mysql, .mongodb, .sqlite, .redis: return .database } } var dataModelType: DataModelType { switch self { - case .mongodb: + case .mongodb, .redis: return .noSQL case .convex, .supabase, .postgres, .mysql, .sqlite: return .sql @@ -130,7 +138,41 @@ enum DatabaseType: String, Codable, CaseIterable, Sendable { switch self { case .convex: return true - case .supabase, .postgres, .mysql, .sqlite, .mongodb: + case .supabase, .postgres, .mysql, .sqlite, .mongodb, .redis: + return false + } + } + + var supportsTableBrowser: Bool { + self != .redis + } + + var supportsSchemaBrowser: Bool { + self != .redis + } + + var supportsCanvas: Bool { + self != .redis + } + + var supportsNotebookAnalytics: Bool { + self != .redis + } + + var supportsKeyValueBrowser: Bool { + self == .redis + } + + var supportsCommandWorkspace: Bool { + self == .redis || self == .postgres || self == .supabase || self == .mysql + || self == .sqlite || self == .mongodb + } + + var supportsDatabaseCreation: Bool { + switch self { + case .postgres, .mysql, .mongodb, .supabase: + return true + case .convex, .sqlite, .redis: return false } } @@ -306,14 +348,14 @@ final class Connection { return password ?? "" } // If we have individual fields, construct URI from them (new approach) - if let hostname = hostname, !hostname.isEmpty, - let port = port, !port.isEmpty, - let username = username, !username.isEmpty { + if canConstructURIFromFields { return constructURIFromFields() } // Fallback to legacy URI construction (backward compatibility) - if let database = defaultDatabase, !database.isEmpty { + if databaseType == .redis { + return url ?? "" + } else if let database = defaultDatabase, !database.isEmpty { return "\(url ?? "")/\(database)" } else { return url ?? "" @@ -321,7 +363,23 @@ final class Connection { } private func constructURIFromFields(encodeCredentials: Bool = true) -> String { - guard let hostname = hostname, let port = port, let username = username else { + guard let hostname, let port else { + return url ?? "" + } + + if databaseType == .redis { + let databaseIndex = Int(defaultDatabase ?? "0") ?? 0 + return (try? ConnectionURLParser.makeRedisURL( + hostname: hostname, + port: Int(port) ?? 6379, + username: username, + password: password, + databaseIndex: databaseIndex, + usesTLS: redisUsesTLS + )) ?? (url ?? "") + } + + guard let username else { return url ?? "" } @@ -333,6 +391,8 @@ final class Connection { scheme = "mysql" case .mongodb: scheme = "mongodb" + case .redis: + scheme = redisUsesTLS ? "rediss" : "redis" } let resolvedHost = hostname.isEmpty ? "localhost" : hostname @@ -398,9 +458,7 @@ final class Connection { if databaseType == .convex { return password ?? "" } - if let hostname = hostname, !hostname.isEmpty, - let port = port, !port.isEmpty, - let username = username, !username.isEmpty { + if canConstructURIFromFields { return constructURIFromFields(encodeCredentials: false) } return url ?? "" @@ -408,7 +466,8 @@ final class Connection { // Helper method to check if connection uses new field-based approach var usesFieldBasedConnection: Bool { - return hostname != nil && port != nil && username != nil + guard hostname != nil, port != nil else { return false } + return databaseType == .redis || username != nil } // Helper method to check if password exists in keychain @@ -440,9 +499,7 @@ final class Connection { } // If we have individual fields, construct display URL from them - if let hostname = hostname, !hostname.isEmpty, - let port = port, !port.isEmpty, - let username = username, !username.isEmpty { + if canConstructURIFromFields { return constructDisplayURLFromFields() } @@ -460,9 +517,23 @@ final class Connection { } private func constructDisplayURLFromFields() -> String { - guard let hostname = hostname, let port = port, let username = username else { + guard let hostname, let port else { return "Invalid connection" } + + let resolvedUsername = username ?? "" + + if databaseType == .redis { + let databaseIndex = Int(defaultDatabase ?? "0") ?? 0 + return (try? ConnectionURLParser.makeRedisURL( + hostname: hostname, + port: Int(port) ?? 6379, + username: resolvedUsername, + password: hasPassword ? "****" : nil, + databaseIndex: databaseIndex, + usesTLS: redisUsesTLS + )) ?? "\(hostname):\(port)" + } var components = URLComponents() @@ -475,11 +546,13 @@ final class Connection { components.scheme = "sqlLite" case .mongodb: components.scheme = "mongodb" + case .redis: + components.scheme = redisUsesTLS ? "rediss" : "redis" } components.host = hostname components.port = Int(port) - components.user = username + components.user = resolvedUsername // Show asterisks if password exists in keychain if hasPassword { @@ -562,6 +635,20 @@ final class Connection { // Helper method to populate fields from existing URL (for migration) func populateFieldsFromURL() { + if databaseType == .redis, + let storedURL = url, + let parsed = try? ConnectionURLParser.parseRedis(storedURL) { + hostname = parsed.hostname + port = String(parsed.port) + username = parsed.username ?? "" + password = parsed.password + defaultDatabase = String(parsed.databaseIndex) + sslMode = parsed.usesTLS ? "require" : "disable" + // Redis credentials must not remain embedded in SwiftData. + url = nil + return + } + guard let urlComponents = URLComponents(string: url ?? "") else { return } self.hostname = urlComponents.host @@ -598,4 +685,21 @@ final class Connection { private var sshKeyPassphraseKeychainId: String { "\(keychainId).ssh.keyPassphrase" } + + private var canConstructURIFromFields: Bool { + guard let hostname, !hostname.isEmpty, + let port, !port.isEmpty else { + return false + } + return databaseType == .redis || !(username ?? "").isEmpty + } + + private var redisUsesTLS: Bool { + switch sslMode?.lowercased() { + case "require", "verify-ca", "verify-full": + return true + default: + return false + } + } } diff --git a/pluk/Models/ConnectionInstance.swift b/pluk/Models/ConnectionInstance.swift index 493e6713..b78c31de 100644 --- a/pluk/Models/ConnectionInstance.swift +++ b/pluk/Models/ConnectionInstance.swift @@ -500,6 +500,51 @@ struct CachedCollectionWrapper: CollectionWrapper, Codable, Sendable { selectedTab = newTab } + /// Opens the command surface appropriate for this connection type. + /// Redis does not use SQL-editor tabs, so generic new-tab entry points must + /// route to its command workspace instead of creating a dead document tab. + func createEditorTab() { + if connection.databaseType == .redis { + createRedisCommandTab() + } else { + createSQLEditorTab() + } + } + + func createRedisKeyTab(keyData: Data, displayName: String) { + if let existingTab = tabs.first(where: { + $0.type == .redisKey && $0.redisKeyData == keyData + }) { + selectedTab = existingTab + return + } + + let newTab = DatabaseTab( + name: displayName, + type: .redisKey, + queryState: .idle + ) + newTab.redisKeyData = keyData + tabs.append(newTab) + selectedTab = newTab + } + + func createRedisCommandTab(withCommand command: String? = nil) { + if command == nil, let existingTab = tabs.first(where: { $0.type == .redisCommand }) { + selectedTab = existingTab + return + } + + let newTab = DatabaseTab( + name: "Command Editor", + type: .redisCommand, + queryState: .idle + ) + newTab.initialQuery = command + tabs.append(newTab) + selectedTab = newTab + } + private func recordRecentTable(name: String, schema: String?) { guard let dbName = connectedDatabase?.name, !dbName.isEmpty else { return } let tableType = collections[dbName]?.first { diff --git a/pluk/Models/DatabaseTab.swift b/pluk/Models/DatabaseTab.swift index 70f90650..de38eb2a 100644 --- a/pluk/Models/DatabaseTab.swift +++ b/pluk/Models/DatabaseTab.swift @@ -40,6 +40,10 @@ final class DatabaseTab: Identifiable, Equatable, Transferable { var functionSchema: String? var originalFunctionDefinition: String? + // Redis key metadata (transient, not persisted). Redis keys are binary, + // so the display name is never used as the command argument. + var redisKeyData: Data? + // CodingKeys to exclude transient properties from Codable enum CodingKeys: String, CodingKey { case id, name, type, queryState, documents, hasSchemaDeviation @@ -105,6 +109,8 @@ final class DatabaseTab: Identifiable, Equatable, Transferable { case sqlEditor case canvas case functionEditor + case redisKey + case redisCommand } enum ViewMode: Int, Equatable, Codable { diff --git a/pluk/Models/QueryHistoryEntry.swift b/pluk/Models/QueryHistoryEntry.swift index 1c2bab3f..94281ca5 100644 --- a/pluk/Models/QueryHistoryEntry.swift +++ b/pluk/Models/QueryHistoryEntry.swift @@ -34,6 +34,7 @@ enum QueryType: String, Codable, CaseIterable { enum QuerySource: String, Codable, CaseIterable { case sqlEditor = "sql_editor" + case redisCommandEditor = "redis_command_editor" case documentUpdate = "document_update" case documentCreate = "document_create" case documentDelete = "document_delete" @@ -43,6 +44,7 @@ enum QuerySource: String, Codable, CaseIterable { var displayName: String { switch self { case .sqlEditor: return "SQL Editor" + case .redisCommandEditor: return "Redis Command Editor" case .documentUpdate: return "Document Update" case .documentCreate: return "Document Create" case .documentDelete: return "Document Delete" @@ -69,6 +71,7 @@ final class QueryHistoryEntry { var wasSuccessful: Bool = true var errorMessage: String? var wasSanitized: Bool = false + var redisCommandCategory: String? init( connectionKeychainId: String, @@ -83,7 +86,8 @@ final class QueryHistoryEntry { rowsAffected: Int? = nil, wasSuccessful: Bool = true, errorMessage: String? = nil, - wasSanitized: Bool = false + wasSanitized: Bool = false, + redisCommandCategory: RedisCommandCategory? = nil ) { self.connectionKeychainId = connectionKeychainId self.encryptedQuery = encryptedQuery @@ -98,6 +102,7 @@ final class QueryHistoryEntry { self.wasSuccessful = wasSuccessful self.errorMessage = errorMessage self.wasSanitized = wasSanitized + self.redisCommandCategory = redisCommandCategory?.rawValue } var queryTypeEnum: QueryType { @@ -108,6 +113,10 @@ final class QueryHistoryEntry { QuerySource(rawValue: querySource) ?? .sqlEditor } + var redisCommandCategoryEnum: RedisCommandCategory? { + redisCommandCategory.flatMap(RedisCommandCategory.init(rawValue:)) + } + var databaseTypeEnum: DatabaseType? { DatabaseType(rawValue: databaseType) } diff --git a/pluk/Protocols/DatabaseDriver.swift b/pluk/Protocols/DatabaseDriver.swift index 84d90cda..2b2cf6c5 100644 --- a/pluk/Protocols/DatabaseDriver.swift +++ b/pluk/Protocols/DatabaseDriver.swift @@ -341,6 +341,241 @@ struct QueryResult: Sendable { } } +// MARK: - Redis Models + +/// A Redis key whose original bytes are retained even when they are not valid UTF-8. +public struct RedisKey: Identifiable, Hashable, Sendable, Codable { + public let bytes: Data + + public init(bytes: Data) { + self.bytes = bytes + } + + public init(_ string: String) { + self.bytes = Data(string.utf8) + } + + public var id: Data { bytes } + public var utf8String: String? { String(data: bytes, encoding: .utf8) } + + public var displayString: String { + utf8String ?? "0x" + bytes.map { String(format: "%02x", $0) }.joined() + } +} + +public enum RedisKeyType: Hashable, Sendable, Codable { + case none + case string + case hash + case list + case set + case sortedSet + case stream + case json + case unknown(String) + + public init(serverName: String) { + switch serverName.lowercased() { + case "none": self = .none + case "string": self = .string + case "hash": self = .hash + case "list": self = .list + case "set": self = .set + case "zset": self = .sortedSet + case "stream": self = .stream + case "json", "rejson-rl": self = .json + default: self = .unknown(serverName) + } + } + + /// The value accepted by SCAN TYPE for built-in Redis data types. + public var scanFilter: String? { + switch self { + case .none: nil + case .string: "string" + case .hash: "hash" + case .list: "list" + case .set: "set" + case .sortedSet: "zset" + case .stream: "stream" + case .json: "ReJSON-RL" + case .unknown(let value): value + } + } + + public var displayName: String { + switch self { + case .none: "None" + case .string: "String" + case .hash: "Hash" + case .list: "List" + case .set: "Set" + case .sortedSet: "Sorted Set" + case .stream: "Stream" + case .json: "JSON" + case .unknown(let value): value + } + } +} + +public struct RedisScanPage: Equatable, Sendable { + /// Redis SCAN cursors are opaque unsigned 64-bit values. + public let nextCursor: UInt64 + public let keys: [RedisKey] + + public init(nextCursor: UInt64, keys: [RedisKey]) { + self.nextCursor = nextCursor + self.keys = keys + } + + public var isComplete: Bool { nextCursor == 0 } +} + +public struct RedisKeyMetadata: Equatable, Sendable { + public let key: RedisKey + public let type: RedisKeyType + /// Remaining lifetime in milliseconds. `nil` means the key is persistent. + public let ttlMilliseconds: Int64? + public let memoryUsageBytes: Int64? + public let encoding: String? + + public init( + key: RedisKey, + type: RedisKeyType, + ttlMilliseconds: Int64?, + memoryUsageBytes: Int64?, + encoding: String? + ) { + self.key = key + self.type = type + self.ttlMilliseconds = ttlMilliseconds + self.memoryUsageBytes = memoryUsageBytes + self.encoding = encoding + } + + public var exists: Bool { type != .none } +} + +public struct RedisValuePage: Equatable, Sendable { + public let offset: Int + public let count: Int + public let cursor: UInt64 + + public init(offset: Int = 0, count: Int = 200, cursor: UInt64 = 0) { + self.offset = max(0, offset) + self.count = min(max(1, count), 10_000) + self.cursor = cursor + } +} + +public struct RedisHashEntry: Equatable, Sendable { + public let field: Data + public let value: Data + + public init(field: Data, value: Data) { + self.field = field + self.value = value + } +} + +public struct RedisSortedSetEntry: Equatable, Sendable { + public let member: Data + public let score: Double + + public init(member: Data, score: Double) { + self.member = member + self.score = score + } +} + +public struct RedisStreamEntry: Equatable, Sendable { + public let id: Data + public let fields: [RedisHashEntry] + + public init(id: Data, fields: [RedisHashEntry]) { + self.id = id + self.fields = fields + } +} + +public enum RedisValue: Equatable, Sendable { + case none + case string(Data) + case hash(entries: [RedisHashEntry], totalCount: Int, nextCursor: UInt64) + case list(elements: [Data], totalCount: Int, offset: Int) + case set(members: [Data], totalCount: Int, nextCursor: UInt64) + case sortedSet(entries: [RedisSortedSetEntry], totalCount: Int, offset: Int) + case stream(entries: [RedisStreamEntry], totalCount: Int) + case json(Data) + case unsupported(type: RedisKeyType, raw: RedisCommandValue) +} + +public enum RedisValueUpdate: Equatable, Sendable { + case string(Data) + case json(Data) + case hashField(field: Data, value: Data) + case deleteHashField(field: Data) + case listElement(index: Int, value: Data) + case appendList(values: [Data], toHead: Bool) + case setMember(Data, isPresent: Bool) + /// A nil score removes the member. + case sortedSetMember(member: Data, score: Double?) + case appendStream(fields: [RedisHashEntry], id: Data?) + case deleteStreamEntry(id: Data) +} + +/// A binary-safe Redis command. The first argument is the command name. +public struct RedisCommand: Equatable, Sendable { + public let arguments: [Data] + + public init(arguments: [Data]) { + self.arguments = arguments + } + + public init(arguments: [String]) { + self.arguments = arguments.map { Data($0.utf8) } + } +} + +public struct RedisCommandMapEntry: Equatable, Sendable { + public let key: RedisCommandValue + public let value: RedisCommandValue + + public init(key: RedisCommandValue, value: RedisCommandValue) { + self.key = key + self.value = value + } +} + +/// A lossless, binary-safe representation of a RESP3 response. +public indirect enum RedisCommandValue: Equatable, Sendable { + case null + case simpleString(Data) + case bulkString(Data) + case simpleError(Data) + case bulkError(Data) + case verbatimString(format: String?, data: Data) + case integer(Int64) + case double(Double) + case boolean(Bool) + case bigNumber(Data) + case array([RedisCommandValue]) + case map([RedisCommandMapEntry]) + case set([RedisCommandValue]) + case push([RedisCommandValue]) + case attribute([RedisCommandMapEntry]) +} + +public struct RedisCommandResult: Equatable, Sendable { + public let value: RedisCommandValue + public let durationMilliseconds: Double + + public init(value: RedisCommandValue, durationMilliseconds: Double) { + self.value = value + self.durationMilliseconds = durationMilliseconds + } +} + // MARK: - Database Driver Protocol protocol DatabaseDriver: Actor { associatedtype Database: DatabaseWrapper @@ -379,6 +614,17 @@ protocol DatabaseDriver: Actor { // Raw Query Execution @discardableResult func executeRawQuery(_ query: String, databaseSchema: String?) async throws -> [QueryResult] + + // Redis operations. Non-Redis drivers use the default unsupported implementations below. + func scanRedisKeys(cursor: UInt64, pattern: String?, type: RedisKeyType?, count: Int) async throws -> RedisScanPage + func redisKeyMetadata(for key: RedisKey) async throws -> RedisKeyMetadata + func redisValue(for key: RedisKey, page: RedisValuePage) async throws -> RedisValue + func updateRedisValue(_ update: RedisValueUpdate, for key: RedisKey, preserveTTL: Bool) async throws + func renameRedisKey(_ key: RedisKey, to newKey: RedisKey, overwrite: Bool) async throws + func deleteRedisKeys(_ keys: [RedisKey], asynchronously: Bool) async throws -> Int + func setRedisExpiration(for key: RedisKey, milliseconds: Int64?) async throws -> Bool + func executeRedisCommand(_ command: RedisCommand) async throws -> RedisCommandResult + func parseRedisCommand(_ commandText: String) async throws -> RedisCommand func getSchema(for collectionName: String, schema: String?) async throws -> DatabaseSchemaResult? func getInformationSchema() async throws -> [InformationSchema] @@ -489,6 +735,42 @@ extension DatabaseDriver { // Default implementation does nothing } + func scanRedisKeys(cursor: UInt64, pattern: String?, type: RedisKeyType?, count: Int) async throws -> RedisScanPage { + throw DatabaseError.notImplemented("Redis key scanning is only available for Redis connections") + } + + func redisKeyMetadata(for key: RedisKey) async throws -> RedisKeyMetadata { + throw DatabaseError.notImplemented("Redis metadata is only available for Redis connections") + } + + func redisValue(for key: RedisKey, page: RedisValuePage) async throws -> RedisValue { + throw DatabaseError.notImplemented("Redis values are only available for Redis connections") + } + + func updateRedisValue(_ update: RedisValueUpdate, for key: RedisKey, preserveTTL: Bool) async throws { + throw DatabaseError.notImplemented("Redis value editing is only available for Redis connections") + } + + func renameRedisKey(_ key: RedisKey, to newKey: RedisKey, overwrite: Bool) async throws { + throw DatabaseError.notImplemented("Redis key renaming is only available for Redis connections") + } + + func deleteRedisKeys(_ keys: [RedisKey], asynchronously: Bool) async throws -> Int { + throw DatabaseError.notImplemented("Redis key deletion is only available for Redis connections") + } + + func setRedisExpiration(for key: RedisKey, milliseconds: Int64?) async throws -> Bool { + throw DatabaseError.notImplemented("Redis expiration is only available for Redis connections") + } + + func executeRedisCommand(_ command: RedisCommand) async throws -> RedisCommandResult { + throw DatabaseError.notImplemented("Redis commands are only available for Redis connections") + } + + func parseRedisCommand(_ commandText: String) async throws -> RedisCommand { + throw DatabaseError.notImplemented("Redis commands are only available for Redis connections") + } + // Default implementation for createDatabase - throws not implemented func createDatabase(named databaseName: String, options: CreateDatabaseOptions) async throws { @@ -870,6 +1152,8 @@ class DatabaseDriverFactory { return MySQLDriver() case .sqlite: return SQLiteDriver() + case .redis: + return RedisDriver() } } } diff --git a/pluk/Resources/Assets.xcassets/Public/database/redis.imageset/Contents.json b/pluk/Resources/Assets.xcassets/Public/database/redis.imageset/Contents.json new file mode 100644 index 00000000..3d603809 --- /dev/null +++ b/pluk/Resources/Assets.xcassets/Public/database/redis.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "redis.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/pluk/Resources/Assets.xcassets/Public/database/redis.imageset/redis.svg b/pluk/Resources/Assets.xcassets/Public/database/redis.imageset/redis.svg new file mode 100644 index 00000000..9cf0a285 --- /dev/null +++ b/pluk/Resources/Assets.xcassets/Public/database/redis.imageset/redis.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/pluk/Services/DatabaseDriverBox.swift b/pluk/Services/DatabaseDriverBox.swift index 816cc643..bc8e050b 100644 --- a/pluk/Services/DatabaseDriverBox.swift +++ b/pluk/Services/DatabaseDriverBox.swift @@ -111,6 +111,51 @@ actor DatabaseDriverBox { try await driver.executeRawQuery(query, databaseSchema: databaseSchema) } + func scanRedisKeys( + cursor: UInt64, + pattern: String?, + type: RedisKeyType?, + count: Int + ) async throws -> RedisScanPage { + try await driver.scanRedisKeys(cursor: cursor, pattern: pattern, type: type, count: count) + } + + func redisKeyMetadata(for key: RedisKey) async throws -> RedisKeyMetadata { + try await driver.redisKeyMetadata(for: key) + } + + func redisValue(for key: RedisKey, page: RedisValuePage) async throws -> RedisValue { + try await driver.redisValue(for: key, page: page) + } + + func updateRedisValue( + _ update: RedisValueUpdate, + for key: RedisKey, + preserveTTL: Bool + ) async throws { + try await driver.updateRedisValue(update, for: key, preserveTTL: preserveTTL) + } + + func renameRedisKey(_ key: RedisKey, to newKey: RedisKey, overwrite: Bool) async throws { + try await driver.renameRedisKey(key, to: newKey, overwrite: overwrite) + } + + func deleteRedisKeys(_ keys: [RedisKey], asynchronously: Bool) async throws -> Int { + try await driver.deleteRedisKeys(keys, asynchronously: asynchronously) + } + + func setRedisExpiration(for key: RedisKey, milliseconds: Int64?) async throws -> Bool { + try await driver.setRedisExpiration(for: key, milliseconds: milliseconds) + } + + func executeRedisCommand(_ command: RedisCommand) async throws -> RedisCommandResult { + try await driver.executeRedisCommand(command) + } + + func parseRedisCommand(_ commandText: String) async throws -> RedisCommand { + try await driver.parseRedisCommand(commandText) + } + func getSchema(for collectionName: String, schema: String?) async throws -> DatabaseSchemaResult? { try await driver.getSchema(for: collectionName, schema: schema) } diff --git a/pluk/Services/DatabaseService.swift b/pluk/Services/DatabaseService.swift index 13327c55..7d43d9d8 100644 --- a/pluk/Services/DatabaseService.swift +++ b/pluk/Services/DatabaseService.swift @@ -137,6 +137,8 @@ import SwiftUI self.connectedDatabase = MySQLDatabaseWrapper(name: targetName, size: nil, tableCount: nil) case .sqlite: self.connectedDatabase = SQLiteDatabaseWrapper(name: targetName, size: nil, tableCount: nil) + case .redis: + self.connectedDatabase = RedisDatabaseWrapper(name: targetName, size: nil, tableCount: nil) case .mongodb: if let wrapper = await driverBox.getCurrentDatabaseWrapper() { self.connectedDatabase = wrapper @@ -252,7 +254,7 @@ import SwiftUI switch databaseType { case .postgres, .supabase, .mysql, .mongodb: return true - case .convex, .sqlite: + case .convex, .sqlite, .redis: return false } } @@ -335,6 +337,8 @@ import SwiftUI return 3306 case .mongodb: return 27017 + case .redis: + return 6379 default: return 5432 } @@ -351,8 +355,8 @@ import SwiftUI throw DatabaseError.operationFailed("No active database driver") } - self.connectedDatabase = database try await driverBox.switchDatabase(to: database.name) + self.connectedDatabase = database currentDeploymentURL = await driverBox.getCurrentDeploymentUrl() // Post notification about database switch @@ -581,7 +585,137 @@ import SwiftUI } return try await activeDriverBox.listCollections(schema: schema) } - + + // MARK: - Redis Operations + + func scanRedisKeys( + cursor: UInt64 = 0, + pattern: String? = nil, + type: RedisKeyType? = nil, + count: Int = 200 + ) async throws -> RedisScanPage { + try await requireRedisDriverBox().scanRedisKeys( + cursor: cursor, + pattern: pattern, + type: type, + count: count + ) + } + + func redisKeyMetadata(for key: RedisKey) async throws -> RedisKeyMetadata { + try await requireRedisDriverBox().redisKeyMetadata(for: key) + } + + func redisValue( + for key: RedisKey, + page: RedisValuePage = RedisValuePage() + ) async throws -> RedisValue { + try await requireRedisDriverBox().redisValue(for: key, page: page) + } + + func updateRedisValue( + _ update: RedisValueUpdate, + for key: RedisKey, + preserveTTL: Bool = true + ) async throws { + try await requireRedisDriverBox().updateRedisValue(update, for: key, preserveTTL: preserveTTL) + } + + func renameRedisKey(_ key: RedisKey, to newKey: RedisKey, overwrite: Bool = false) async throws { + try await requireRedisDriverBox().renameRedisKey(key, to: newKey, overwrite: overwrite) + } + + @discardableResult + func deleteRedisKeys(_ keys: [RedisKey], asynchronously: Bool = true) async throws -> Int { + try await requireRedisDriverBox().deleteRedisKeys(keys, asynchronously: asynchronously) + } + + @discardableResult + func setRedisExpiration(for key: RedisKey, milliseconds: Int64?) async throws -> Bool { + try await requireRedisDriverBox().setRedisExpiration(for: key, milliseconds: milliseconds) + } + + func executeRedisCommand( + _ command: RedisCommand, + analysis suppliedAnalysis: RedisCommandAnalysis? = nil, + confirmationGranted: Bool = false + ) async throws -> RedisCommandResult { + let analysis = try validatedRedisCommandAnalysis(for: command, supplied: suppliedAnalysis) + guard analysis.allowsExecution else { + throw DatabaseError.operationFailed( + analysis.executionPolicy.message ?? "This Redis command is unavailable" + ) + } + guard !analysis.requiresConfirmation || confirmationGranted else { + throw DatabaseError.operationFailed( + analysis.executionPolicy.message ?? "This Redis command requires confirmation" + ) + } + + let driverBox = try requireRedisDriverBox() + let startedAt = ContinuousClock.now + + do { + let result = try await driverBox.executeRedisCommand(command) + queryHistoryService?.recordRedisCommand( + analysis: analysis, + databaseType: .redis, + databaseName: connectedDatabase?.name, + executionDurationMs: Int(result.durationMilliseconds.rounded()), + wasSuccessful: true + ) + return result + } catch { + let duration = startedAt.duration(to: .now) + let durationMilliseconds = Int(duration.components.seconds * 1_000) + + Int(duration.components.attoseconds / 1_000_000_000_000_000) + queryHistoryService?.recordRedisCommand( + analysis: analysis, + databaseType: .redis, + databaseName: connectedDatabase?.name, + executionDurationMs: durationMilliseconds, + wasSuccessful: false, + errorMessage: error.localizedDescription + ) + throw error + } + } + + func executeRedisCommand( + _ commandText: String, + confirmationGranted: Bool = false + ) async throws -> RedisCommandResult { + let analysis = try RedisCommandSafety.analyze(commandText) + return try await executeRedisCommand( + analysis.transportCommand, + analysis: analysis, + confirmationGranted: confirmationGranted + ) + } + + private func requireRedisDriverBox() throws -> DatabaseDriverBox { + guard activeConnection?.databaseType == .redis, let activeDriverBox else { + throw DatabaseError.operationFailed("No active Redis connection") + } + return activeDriverBox + } + + private func validatedRedisCommandAnalysis( + for command: RedisCommand, + supplied: RedisCommandAnalysis? + ) throws -> RedisCommandAnalysis { + let parsed = try ParsedRedisCommand( + arguments: command.arguments.map { RedisCommandToken(bytes: Array($0)) } + ) + if let supplied { + guard supplied.command == parsed else { + throw DatabaseError.operationFailed("Redis command analysis does not match the command arguments") + } + return supplied + } + return RedisCommandSafety.analyze(parsed) + } + // MARK: - Document Operations /// Exposes the underlying driver actor so prewarm paths can fire DB queries /// without going through this @MainActor entry point. Calls into the @@ -608,7 +742,7 @@ import SwiftUI let result: QueryResult switch connection.databaseType { - case .postgres, .supabase, .convex, .mysql, .sqlite: + case .postgres, .supabase, .convex, .mysql, .sqlite, .redis: result = try await activeDriverBox.findDocuments( in: collectionName, databaseSchema: databaseSchema, @@ -652,6 +786,8 @@ import SwiftUI case .mongodb: // TODO: Implement MongoDB filter generation return "" + case .redis: + return "" } } diff --git a/pluk/Services/DockerContainerDiscoveryService.swift b/pluk/Services/DockerContainerDiscoveryService.swift index 26b0e5b5..78053a07 100644 --- a/pluk/Services/DockerContainerDiscoveryService.swift +++ b/pluk/Services/DockerContainerDiscoveryService.swift @@ -27,6 +27,8 @@ struct DockerDatabaseCandidate: Identifiable, Hashable, Sendable { return !(username ?? "").isEmpty && !(password ?? "").isEmpty case .mongodb: return true + case .redis: + return (username ?? "").isEmpty || !(password ?? "").isEmpty case .convex, .supabase, .sqlite: return false } @@ -48,6 +50,7 @@ struct DockerDatabaseCandidate: Identifiable, Hashable, Sendable { case .postgres: services = ["postgresql", "postgres", "pg"] case .mysql: services = ["mariadb", "mysql"] case .mongodb: services = ["mongodb", "mongo"] + case .redis: services = ["redis", "valkey"] case .convex, .supabase, .sqlite: services = [] } @@ -77,11 +80,28 @@ struct DockerDatabaseCandidate: Identifiable, Hashable, Sendable { return buildSQLURI(scheme: "postgresql", defaultDatabase: "postgres") case .mysql: return buildSQLURI(scheme: "mysql", defaultDatabase: "") + case .redis: + return buildRedisURI() case .convex, .supabase, .sqlite: return "" } } + private func buildRedisURI() -> String { + var uri = "redis://" + if let username, !username.isEmpty { + uri += username.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? username + if let password, !password.isEmpty { + uri += ":\(password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password)" + } + uri += "@" + } else if let password, !password.isEmpty { + uri += ":\(password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password)@" + } + uri += "\(host):\(port)/\(databaseName ?? "0")" + return uri + } + private func buildSQLURI(scheme: String, defaultDatabase: String) -> String { var uri = "\(scheme)://" if let username, !username.isEmpty { @@ -402,6 +422,18 @@ struct DockerContainerDiscoveryService: Sendable { name: String, image: String, ports: [String: [DockerInspectPortBinding]?] + ) -> DatabaseType? { + Self.detectedDatabaseType( + name: name, + image: image, + exposedPorts: Set(ports.keys) + ) + } + + nonisolated static func detectedDatabaseType( + name: String, + image: String, + exposedPorts: Set ) -> DatabaseType? { let imageTokens = image .lowercased() @@ -417,21 +449,26 @@ struct DockerContainerDiscoveryService: Sendable { return nil } - if ports.keys.contains(where: { $0.hasPrefix("5432/") }) + if exposedPorts.contains(where: { $0.hasPrefix("5432/") }) || tokens.contains("postgres") || tokens.contains("postgresql") { return .postgres } - if ports.keys.contains(where: { $0.hasPrefix("3306/") }) + if exposedPorts.contains(where: { $0.hasPrefix("3306/") }) || tokens.contains("mysql") || tokens.contains("mariadb") { return .mysql } - if ports.keys.contains(where: { $0.hasPrefix("27017/") }) + if exposedPorts.contains(where: { $0.hasPrefix("27017/") }) || tokens.contains("mongo") || tokens.contains("mongodb") { return .mongodb } + if exposedPorts.contains(where: { $0.hasPrefix("6379/") }) + || tokens.contains("redis") + || tokens.contains("valkey") { + return .redis + } return nil } @@ -447,6 +484,8 @@ struct DockerContainerDiscoveryService: Sendable { containerPort = "3306/tcp" case .mongodb: containerPort = "27017/tcp" + case .redis: + containerPort = "6379/tcp" case .convex, .supabase, .sqlite: return nil } @@ -468,6 +507,8 @@ struct DockerContainerDiscoveryService: Sendable { return env["MYSQL_USER"] ?? "root" case .mongodb: return env["MONGO_INITDB_ROOT_USERNAME"] + case .redis: + return env["REDIS_USER"] case .convex, .supabase, .sqlite: return nil } @@ -481,6 +522,8 @@ struct DockerContainerDiscoveryService: Sendable { return env["MYSQL_PASSWORD"] ?? env["MYSQL_ROOT_PASSWORD"] case .mongodb: return env["MONGO_INITDB_ROOT_PASSWORD"] + case .redis: + return env["REDIS_PASSWORD"] ?? env["REDISCLI_AUTH"] case .convex, .supabase, .sqlite: return nil } @@ -494,6 +537,8 @@ struct DockerContainerDiscoveryService: Sendable { return env["MYSQL_DATABASE"] case .mongodb: return nil + case .redis: + return env["REDIS_DATABASE"] ?? "0" case .convex, .supabase, .sqlite: return nil } diff --git a/pluk/Services/QueryHistoryService.swift b/pluk/Services/QueryHistoryService.swift index a1fba469..fcbf2cdb 100644 --- a/pluk/Services/QueryHistoryService.swift +++ b/pluk/Services/QueryHistoryService.swift @@ -34,7 +34,9 @@ final class QueryHistoryService { executionDurationMs: Int? = nil, rowsAffected: Int? = nil, wasSuccessful: Bool = true, - errorMessage: String? = nil + errorMessage: String? = nil, + redisCommandCategory: RedisCommandCategory? = nil, + wasAlreadySanitized: Bool = false ) { let sanitizationResult = QuerySanitizer.sanitize(query) let detectedType = queryType ?? QuerySanitizer.detectQueryType(from: query) @@ -58,7 +60,8 @@ final class QueryHistoryService { rowsAffected: rowsAffected, wasSuccessful: wasSuccessful, errorMessage: errorMessage, - wasSanitized: sanitizationResult.wasSanitized + wasSanitized: sanitizationResult.wasSanitized || wasAlreadySanitized, + redisCommandCategory: redisCommandCategory ) modelContext.insert(entry) @@ -69,8 +72,48 @@ final class QueryHistoryService { debugLog("Failed to save query history entry: \(error)") } - Task { - await enforceRetentionLimits() + // Retention uses this main-actor ModelContext and performs no async + // work. Running it inline avoids leaving an unstructured task that can + // outlive a short-lived context (for example an app-hosted test) and + // race SwiftData store teardown. + enforceRetentionLimits() + } + + /// Records an analyzed Redis command without ever persisting authentication + /// credentials. The command editor should analyze before execution and pass + /// that same analysis here so the confirmation and history decisions cannot + /// drift apart. + @discardableResult + func recordRedisCommand( + analysis: RedisCommandAnalysis, + databaseType: DatabaseType, + databaseName: String? = nil, + executionDurationMs: Int? = nil, + rowsAffected: Int? = nil, + wasSuccessful: Bool = true, + errorMessage: String? = nil + ) -> Bool { + switch analysis.historyDisposition { + case .exclude: + return false + case .record(let command, let wasRedacted): + recordQuery( + query: command, + queryType: .raw, + source: .redisCommandEditor, + databaseType: databaseType, + databaseName: databaseName, + executionDurationMs: executionDurationMs, + rowsAffected: rowsAffected, + wasSuccessful: wasSuccessful, + // Some Redis servers or proxies echo command arguments in + // errors. Once a command needed credential redaction, omit + // its raw error text rather than risk persisting the secret. + errorMessage: wasRedacted ? nil : errorMessage, + redisCommandCategory: analysis.category, + wasAlreadySanitized: wasRedacted + ) + return true } } @@ -201,7 +244,7 @@ final class QueryHistoryService { } } - private func enforceRetentionLimits() async { + private func enforceRetentionLimits() { let cutoffDate = Calendar.current.date(byAdding: .day, value: -retentionDays, to: Date()) ?? Date() let agePredicate = #Predicate { entry in @@ -271,7 +314,8 @@ final class QueryHistoryService { rowsAffected: entry.rowsAffected, wasSuccessful: entry.wasSuccessful, errorMessage: entry.errorMessage, - wasSanitized: entry.wasSanitized + wasSanitized: entry.wasSanitized, + redisCommandCategory: entry.redisCommandCategoryEnum ) } } @@ -290,6 +334,14 @@ struct QueryHistoryEntryViewModel: Identifiable { let wasSuccessful: Bool let errorMessage: String? let wasSanitized: Bool + let redisCommandCategory: RedisCommandCategory? + + /// Credential-bearing Redis commands are stored only in redacted form. + /// Treat that placeholder text as display-only so it cannot be loaded back + /// into an executable command editor as if it were the original command. + var isReplayable: Bool { + querySource != .redisCommandEditor || !wasSanitized + } init( entryId: String, @@ -304,7 +356,8 @@ struct QueryHistoryEntryViewModel: Identifiable { rowsAffected: Int?, wasSuccessful: Bool, errorMessage: String?, - wasSanitized: Bool + wasSanitized: Bool, + redisCommandCategory: RedisCommandCategory? = nil ) { self.id = entryId self.query = query @@ -319,6 +372,7 @@ struct QueryHistoryEntryViewModel: Identifiable { self.wasSuccessful = wasSuccessful self.errorMessage = errorMessage self.wasSanitized = wasSanitized + self.redisCommandCategory = redisCommandCategory } var formattedDate: String { diff --git a/pluk/Utilities/ConnectionURLParser.swift b/pluk/Utilities/ConnectionURLParser.swift index 21bba6a2..fc8e4e87 100644 --- a/pluk/Utilities/ConnectionURLParser.swift +++ b/pluk/Utilities/ConnectionURLParser.swift @@ -8,12 +8,15 @@ import Foundation -enum ConnectionURLParserError: Error, LocalizedError { +enum ConnectionURLParserError: Error, LocalizedError, Equatable, Sendable { case invalidURL case missingScheme case unsupportedScheme(String) case missingHost case missingUsername + case missingPassword + case invalidPort(Int) + case invalidDatabaseIndex(String) case invalidSSLMode(String) var errorDescription: String? { @@ -28,6 +31,12 @@ enum ConnectionURLParserError: Error, LocalizedError { return "Connection URL must include a hostname" case .missingUsername: return "Connection URL must include a username" + case .missingPassword: + return "Redis ACL authentication requires a password" + case .invalidPort(let port): + return "Connection URL contains an invalid port: \(port)" + case .invalidDatabaseIndex(let index): + return "Redis database index must be a non-negative integer: \(index)" case .invalidSSLMode(let mode): return "Invalid SSL mode: \(mode)" } @@ -67,8 +76,108 @@ struct ParsedConnectionURL { } } +struct ParsedRedisConnectionURL: Equatable, Sendable { + let hostname: String + let port: Int + let username: String? + let password: String? + let databaseIndex: Int + let usesTLS: Bool + + var scheme: String { + usesTLS ? "rediss" : "redis" + } +} + struct ConnectionURLParser { + // MARK: - Redis Parsing + + static func parseRedis(_ urlString: String) throws -> ParsedRedisConnectionURL { + let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty, + let components = URLComponents(string: trimmedURL) else { + throw ConnectionURLParserError.invalidURL + } + + guard let scheme = components.scheme?.lowercased(), !scheme.isEmpty else { + throw ConnectionURLParserError.missingScheme + } + guard scheme == "redis" || scheme == "rediss" else { + throw ConnectionURLParserError.unsupportedScheme(scheme) + } + guard let parsedHostname = components.host, !parsedHostname.isEmpty else { + throw ConnectionURLParserError.missingHost + } + let hostname = normalizedParsedRedisHost(parsedHostname) + + let port = components.port ?? 6379 + guard (1...65_535).contains(port) else { + throw ConnectionURLParserError.invalidPort(port) + } + + let databaseIndex = try parseRedisDatabaseIndex(from: components.path) + let username = components.user.flatMap { $0.isEmpty ? nil : $0 } + let password = components.password.flatMap { $0.isEmpty ? nil : $0 } + guard username == nil || password != nil else { + throw ConnectionURLParserError.missingPassword + } + + return ParsedRedisConnectionURL( + hostname: hostname, + port: port, + username: username, + password: password, + databaseIndex: databaseIndex, + usesTLS: scheme == "rediss" + ) + } + + static func makeRedisURL( + hostname: String, + port: Int = 6379, + username: String? = nil, + password: String? = nil, + databaseIndex: Int = 0, + usesTLS: Bool = false + ) throws -> String { + let trimmedHost = hostname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { + throw ConnectionURLParserError.missingHost + } + guard (1...65_535).contains(port) else { + throw ConnectionURLParserError.invalidPort(port) + } + guard databaseIndex >= 0 else { + throw ConnectionURLParserError.invalidDatabaseIndex(String(databaseIndex)) + } + + var components = URLComponents() + components.scheme = usesTLS ? "rediss" : "redis" + components.host = normalizedHostForURLComponents(trimmedHost) + components.port = port + + let normalizedUsername = username?.isEmpty == false ? username : nil + let normalizedPassword = password?.isEmpty == false ? password : nil + guard normalizedUsername == nil || normalizedPassword != nil else { + throw ConnectionURLParserError.missingPassword + } + if let normalizedUsername { + components.user = normalizedUsername + } else if normalizedPassword != nil { + // Redis URI password-only authentication is represented as + // redis://:password@host, with an intentionally empty username. + components.user = "" + } + components.password = normalizedPassword + components.path = "/\(databaseIndex)" + + guard let result = components.string else { + throw ConnectionURLParserError.invalidURL + } + return result + } + // MARK: - MySQL Parsing (based on vapor/mysql-kit) static func parseMySQL(_ urlString: String) throws -> ParsedConnectionURL { @@ -148,6 +257,35 @@ struct ConnectionURLParser { // MARK: - Helpers + private static func parseRedisDatabaseIndex(from path: String) throws -> Int { + guard !path.isEmpty, path != "/" else { + return 0 + } + + let indexString = String(path.dropFirst()) + guard !indexString.isEmpty, + indexString.utf8.allSatisfy({ (48...57).contains($0) }), + let databaseIndex = Int(indexString) else { + throw ConnectionURLParserError.invalidDatabaseIndex(indexString) + } + return databaseIndex + } + + private static func normalizedHostForURLComponents(_ hostname: String) -> String { + guard hostname.contains(":"), + !(hostname.hasPrefix("[") && hostname.hasSuffix("]")) else { + return hostname + } + return "[\(hostname)]" + } + + private static func normalizedParsedRedisHost(_ hostname: String) -> String { + guard hostname.hasPrefix("["), hostname.hasSuffix("]") else { + return hostname + } + return String(hostname.dropFirst().dropLast()) + } + private static func parseSSLMode( from queryItems: [URLQueryItem], defaultMode: String diff --git a/pluk/Utilities/QuerySanitizer.swift b/pluk/Utilities/QuerySanitizer.swift index c237fbad..59e0f009 100644 --- a/pluk/Utilities/QuerySanitizer.swift +++ b/pluk/Utilities/QuerySanitizer.swift @@ -8,7 +8,7 @@ import Foundation struct QuerySanitizer { - struct SanitizationResult { + struct SanitizationResult: Equatable, Sendable { let sanitizedQuery: String let wasSanitized: Bool } @@ -54,8 +54,9 @@ struct QuerySanitizer { ] static func sanitize(_ query: String) -> SanitizationResult { - var sanitizedQuery = query - var wasSanitized = false + let redisRedaction = RedisCommandHistoryRedactor.redactIfNeeded(in: query) + var sanitizedQuery = redisRedaction?.sanitizedCommand ?? query + var wasSanitized = redisRedaction?.wasRedacted ?? false for (pattern, replacement) in patterns { if let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) { diff --git a/pluk/Utilities/RedisCommandSafety.swift b/pluk/Utilities/RedisCommandSafety.swift new file mode 100644 index 00000000..7aa8a93c --- /dev/null +++ b/pluk/Utilities/RedisCommandSafety.swift @@ -0,0 +1,952 @@ +// +// RedisCommandSafety.swift +// Pluk +// +// Redis command parsing, classification, and history redaction. +// + +import Foundation + +struct RedisCommandToken: Equatable, Hashable, Sendable { + let bytes: [UInt8] + + init(_ value: String) { + self.bytes = Array(value.utf8) + } + + init(bytes: [UInt8]) { + self.bytes = bytes + } + + var stringValue: String? { + String(bytes: bytes, encoding: .utf8) + } + + fileprivate var uppercasedASCIIValue: String? { + guard !bytes.isEmpty, + bytes.allSatisfy({ $0 >= 0x21 && $0 <= 0x7E }) else { + return nil + } + + return String( + bytes: bytes.map { byte in + guard byte >= CharacterByte.lowercaseA && byte <= CharacterByte.lowercaseZ else { + return byte + } + return byte - CharacterByte.asciiCaseOffset + }, + encoding: .ascii + ) + } + + fileprivate func equalsIgnoringASCIICase(_ value: String) -> Bool { + uppercasedASCIIValue == value.uppercased() + } + + fileprivate var historyRepresentation: String { + guard !bytes.isEmpty else { return "\"\"" } + + if bytes.allSatisfy(Self.isSafeUnquotedByte) { + return String(decoding: bytes, as: UTF8.self) + } + + if let stringValue { + var result = "\"" + for scalar in stringValue.unicodeScalars { + switch scalar.value { + case 0x07: result += "\\a" + case 0x08: result += "\\b" + case 0x09: result += "\\t" + case 0x0A: result += "\\n" + case 0x0D: result += "\\r" + case 0x22: result += "\\\"" + case 0x5C: result += "\\\\" + case 0x00...0x1F, 0x7F: + for byte in String(scalar).utf8 { + result += String(format: "\\x%02X", byte) + } + default: + result.unicodeScalars.append(scalar) + } + } + result += "\"" + return result + } + + let escaped = bytes.map { String(format: "\\x%02X", $0) }.joined() + return "\"\(escaped)\"" + } + + private static func isSafeUnquotedByte(_ byte: UInt8) -> Bool { + byte >= 0x21 && byte <= 0x7E && byte != CharacterByte.doubleQuote && + byte != CharacterByte.singleQuote && byte != CharacterByte.backslash + } +} + +struct ParsedRedisCommand: Equatable, Sendable { + let arguments: [RedisCommandToken] + + init(arguments: [RedisCommandToken]) throws { + guard let first = arguments.first else { + throw RedisCommandTokenizerError.emptyCommand + } + guard first.uppercasedASCIIValue != nil else { + throw RedisCommandTokenizerError.invalidCommandName + } + self.arguments = arguments + } + + var name: String { + // The initializer guarantees an ASCII command name. + arguments[0].uppercasedASCIIValue! + } + + var renderedForHistory: String { + arguments.map(\.historyRepresentation).joined(separator: " ") + } + + /// Lossless bridge to the driver's binary-safe transport model. + var transportCommand: RedisCommand { + RedisCommand(arguments: arguments.map { Data($0.bytes) }) + } + + func containsArgument(_ value: String, after index: Int = 0) -> Bool { + arguments.dropFirst(index).contains { $0.equalsIgnoringASCIICase(value) } + } +} + +enum RedisCommandTokenizerError: Error, Equatable, LocalizedError { + case emptyCommand + case invalidCommandName + case unterminatedQuote(Character) + case danglingEscape + case invalidHexEscape + + var errorDescription: String? { + switch self { + case .emptyCommand: + return "Enter a Redis command." + case .invalidCommandName: + return "Redis command names must be printable ASCII." + case .unterminatedQuote(let quote): + return "The \(quote) quote is not terminated." + case .danglingEscape: + return "The command ends with an incomplete escape sequence." + case .invalidHexEscape: + return "Hex escapes must contain exactly two hexadecimal digits." + } + } +} + +enum RedisCommandTokenizer { + static func tokenize(_ source: String) throws -> ParsedRedisCommand { + let input = Array(source.utf8) + var index = 0 + var arguments: [RedisCommandToken] = [] + + while true { + skipWhitespace(in: input, index: &index) + guard index < input.count else { break } + + var token: [UInt8] = [] + var tokenStarted = false + + while index < input.count, !isWhitespace(input[index]) { + tokenStarted = true + switch input[index] { + case CharacterByte.singleQuote, CharacterByte.doubleQuote: + let quote = input[index] + index += 1 + try appendQuotedBytes( + from: input, + index: &index, + quote: quote, + to: &token + ) + case CharacterByte.backslash: + index += 1 + try appendEscapedByte(from: input, index: &index, to: &token) + default: + token.append(input[index]) + index += 1 + } + } + + if tokenStarted { + arguments.append(RedisCommandToken(bytes: token)) + } + } + + return try ParsedRedisCommand(arguments: arguments) + } + + private static func appendQuotedBytes( + from input: [UInt8], + index: inout Int, + quote: UInt8, + to output: inout [UInt8] + ) throws { + while index < input.count { + if input[index] == quote { + index += 1 + return + } + if input[index] == CharacterByte.backslash { + index += 1 + try appendEscapedByte(from: input, index: &index, to: &output) + } else { + output.append(input[index]) + index += 1 + } + } + + let quoteCharacter = quote == CharacterByte.singleQuote ? Character("'") : Character("\"") + throw RedisCommandTokenizerError.unterminatedQuote(quoteCharacter) + } + + private static func appendEscapedByte( + from input: [UInt8], + index: inout Int, + to output: inout [UInt8] + ) throws { + guard index < input.count else { + throw RedisCommandTokenizerError.danglingEscape + } + + switch input[index] { + case CharacterByte.lowercaseA: + output.append(0x07) + index += 1 + case CharacterByte.lowercaseB: + output.append(0x08) + index += 1 + case CharacterByte.lowercaseN: + output.append(0x0A) + index += 1 + case CharacterByte.lowercaseR: + output.append(0x0D) + index += 1 + case CharacterByte.lowercaseT: + output.append(0x09) + index += 1 + case CharacterByte.lowercaseX: + guard index + 2 < input.count, + let high = hexValue(input[index + 1]), + let low = hexValue(input[index + 2]) else { + throw RedisCommandTokenizerError.invalidHexEscape + } + output.append((high << 4) | low) + index += 3 + default: + // redis-cli treats a backslash as escaping the following byte even + // when that byte has no named escape. + output.append(input[index]) + index += 1 + } + } + + private static func skipWhitespace(in input: [UInt8], index: inout Int) { + while index < input.count, isWhitespace(input[index]) { + index += 1 + } + } + + private static func isWhitespace(_ byte: UInt8) -> Bool { + switch byte { + case 0x09...0x0D, 0x20: return true + default: return false + } + } + + private static func hexValue(_ byte: UInt8) -> UInt8? { + switch byte { + case 0x30...0x39: return byte - 0x30 + case 0x41...0x46: return byte - 0x41 + 10 + case 0x61...0x66: return byte - 0x61 + 10 + default: return nil + } + } +} + +enum RedisCommandCategory: String, Codable, CaseIterable, Sendable { + case readOnly = "read_only" + case write + case destructive + case administrative + case connectionStateful = "connection_stateful" + case unknown +} + +enum RedisCommandExecutionDecision: String, Codable, Sendable { + case allow + case requireConfirmation = "require_confirmation" + case deny +} + +enum RedisCommandConfirmationKind: String, Codable, Sendable { + case write + case destructive + case administrative + case unknown +} + +struct RedisCommandExecutionPolicy: Equatable, Sendable { + let decision: RedisCommandExecutionDecision + let confirmationKind: RedisCommandConfirmationKind? + let message: String? + + static let allow = RedisCommandExecutionPolicy( + decision: .allow, + confirmationKind: nil, + message: nil + ) + + static func confirm(_ kind: RedisCommandConfirmationKind, message: String) -> Self { + RedisCommandExecutionPolicy( + decision: .requireConfirmation, + confirmationKind: kind, + message: message + ) + } + + static func deny(message: String) -> Self { + RedisCommandExecutionPolicy( + decision: .deny, + confirmationKind: nil, + message: message + ) + } + + var requiresConfirmation: Bool { + decision == .requireConfirmation + } + + var allowsExecution: Bool { + decision != .deny + } +} + +struct RedisServerCommandMetadata: Equatable, Sendable { + let flags: Set + let aclCategories: Set + + init(flags: Set = [], aclCategories: Set = []) { + self.flags = Set(flags.map { $0.lowercased() }) + self.aclCategories = Set(aclCategories.map { category in + let lowercased = category.lowercased() + return lowercased.hasPrefix("@") ? lowercased : "@\(lowercased)" + }) + } +} + +enum RedisCommandHistoryDisposition: Equatable, Sendable { + case record(command: String, wasRedacted: Bool) + case exclude(reason: String) +} + +struct RedisCommandAnalysis: Equatable, Sendable { + let command: ParsedRedisCommand + let category: RedisCommandCategory + let executionPolicy: RedisCommandExecutionPolicy + let historyDisposition: RedisCommandHistoryDisposition + + var requiresConfirmation: Bool { + executionPolicy.requiresConfirmation + } + + var allowsExecution: Bool { + executionPolicy.allowsExecution + } + + /// The command to execute after `executionPolicy` has been enforced. + var transportCommand: RedisCommand { + command.transportCommand + } +} + +enum RedisCommandSafety { + static func analyze( + _ source: String, + serverMetadata: RedisServerCommandMetadata? = nil + ) throws -> RedisCommandAnalysis { + let command = try RedisCommandTokenizer.tokenize(source) + return analyze(command, serverMetadata: serverMetadata) + } + + static func analyze( + _ command: ParsedRedisCommand, + serverMetadata: RedisServerCommandMetadata? = nil + ) -> RedisCommandAnalysis { + let classification = classify(command, serverMetadata: serverMetadata) + return RedisCommandAnalysis( + command: command, + category: classification.category, + executionPolicy: classification.policy, + historyDisposition: RedisCommandHistoryRedactor.historyDisposition(for: command) + ) + } + + private static func classify( + _ command: ParsedRedisCommand, + serverMetadata: RedisServerCommandMetadata? + ) -> (category: RedisCommandCategory, policy: RedisCommandExecutionPolicy) { + let name = command.name + + if deniedConnectionCommands.contains(name) || isBlocking(command) || metadataIsBlocking(serverMetadata) { + return ( + .connectionStateful, + .deny(message: "\(name) is unavailable because it can alter or monopolize a pooled Redis connection.") + ) + } + + switch name { + case "FLUSHALL": + return ( + .destructive, + .confirm(.destructive, message: "FLUSHALL deletes every key in every Redis database on this server.") + ) + case "FLUSHDB": + return ( + .destructive, + .confirm(.destructive, message: "FLUSHDB deletes every key in the selected Redis database.") + ) + case "DEL", "UNLINK", "GETDEL": + let keyCount = max(command.arguments.count - 1, 0) + let noun = keyCount == 1 ? "key" : "keys" + return ( + .destructive, + .confirm(.destructive, message: "\(name) deletes \(keyCount) Redis \(noun).") + ) + case "RENAME": + return ( + .destructive, + .confirm(.destructive, message: "RENAME overwrites the destination key when it already exists.") + ) + case "COPY" where command.containsArgument("REPLACE", after: 1), + "RESTORE" where command.containsArgument("REPLACE", after: 1): + return ( + .destructive, + .confirm(.destructive, message: "\(name) with REPLACE can overwrite an existing destination key.") + ) + default: + break + } + + if name == "MEMORY", !command.containsArgument("PURGE", after: 1) { + return (.readOnly, .allow) + } + + if administrativeCommands.contains(name) || metadataIsAdministrative(serverMetadata) { + return ( + .administrative, + .confirm(.administrative, message: "\(name) is an administrative command that can change server-wide state.") + ) + } + + if readOnlyCommands.contains(name) { + return (.readOnly, .allow) + } + + if writeCommands.contains(name) || serverMetadata?.flags.contains("write") == true { + return ( + .write, + .confirm(.write, message: "\(name) can modify Redis data.") + ) + } + + if serverMetadata?.flags.contains("readonly") == true { + return (.readOnly, .allow) + } + + return ( + .unknown, + .confirm(.unknown, message: "Pluk could not verify that \(name) is read-only.") + ) + } + + private static func isBlocking(_ command: ParsedRedisCommand) -> Bool { + if blockingCommands.contains(command.name) { + return true + } + if command.name == "SCRIPT", + command.arguments.count > 1, + command.arguments[1].equalsIgnoringASCIICase("DEBUG") { + return true + } + if command.name == "XREAD" || command.name == "XREADGROUP" { + return command.containsArgument("BLOCK", after: 1) + } + return false + } + + private static func metadataIsAdministrative(_ metadata: RedisServerCommandMetadata?) -> Bool { + guard let metadata else { return false } + return metadata.flags.contains("admin") || metadata.flags.contains("dangerous") || + metadata.aclCategories.contains("@admin") || metadata.aclCategories.contains("@dangerous") + } + + private static func metadataIsBlocking(_ metadata: RedisServerCommandMetadata?) -> Bool { + guard let metadata else { return false } + return metadata.flags.contains("blocking") || metadata.flags.contains("pubsub") + } + + private static let deniedConnectionCommands: Set = [ + "ASKING", "AUTH", "CLIENT", "DISCARD", "EXEC", "HELLO", "MONITOR", "MULTI", + "PSUBSCRIBE", "PSYNC", "PUNSUBSCRIBE", "QUIT", "READONLY", "READWRITE", "REPLCONF", + "RESET", "SELECT", "SSUBSCRIBE", "SUBSCRIBE", "SUNSUBSCRIBE", "SYNC", "UNSUBSCRIBE", + "UNWATCH", "WATCH", + ] + + private static let blockingCommands: Set = [ + "BLMOVE", "BLMPOP", "BLPOP", "BRPOPLPUSH", "BRPOP", "BZMPOP", "BZPOPMAX", "BZPOPMIN", + "WAIT", "WAITAOF", + ] + + private static let administrativeCommands: Set = [ + "ACL", "BGREWRITEAOF", "BGSAVE", "CLUSTER", "CONFIG", "DEBUG", "FAILOVER", "FUNCTION", + "LATENCY", "MEMORY", "MIGRATE", "MODULE", "REPLICAOF", "SAVE", "SCRIPT", "SENTINEL", + "SHUTDOWN", "SLAVEOF", "SLOWLOG", "SWAPDB", + ] + + private static let readOnlyCommands: Set = [ + "BITCOUNT", "BITFIELD_RO", "BITPOS", "COMMAND", "DBSIZE", "DUMP", "ECHO", "EVAL_RO", + "EXISTS", "EXPIRETIME", "FCALL_RO", "GEODIST", "GEOHASH", "GEOPOS", "GEORADIUSBYMEMBER_RO", + "GEORADIUS_RO", "GEOSEARCH", "GET", + "GETBIT", "GETRANGE", "HEXISTS", "HGET", "HGETALL", "HKEYS", "HLEN", "HMGET", "HRANDFIELD", + "HSCAN", "HSTRLEN", "HVALS", "INFO", "JSON.ARRLEN", "JSON.GET", "JSON.OBJKEYS", "JSON.OBJLEN", + "JSON.RESP", "JSON.STRLEN", "JSON.TYPE", "LASTSAVE", "LINDEX", "LLEN", "LPOS", "LRANGE", "MGET", + "OBJECT", "PEXPIRETIME", "PFCOUNT", "PING", "PTTL", "RANDOMKEY", "SCARD", "SCAN", + "SDIFF", "SINTER", "SINTERCARD", "SISMEMBER", "SMEMBERS", "SMISMEMBER", "SRANDMEMBER", + "ROLE", "SORT_RO", "SSCAN", "STRLEN", "SUNION", "TIME", "TOUCH", "TTL", "TYPE", "XINFO", "XLEN", "XPENDING", "XRANGE", + "XREAD", "XREVRANGE", "ZCARD", "ZCOUNT", "ZDIFF", "ZINTER", "ZINTERCARD", "ZLEXCOUNT", + "ZMSCORE", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX", "ZRANGEBYSCORE", "ZRANK", "ZREVRANGE", + "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK", "ZSCAN", "ZSCORE", "ZUNION", + ] + + private static let writeCommands: Set = [ + "APPEND", "BITFIELD", "BITOP", "COPY", "DECR", "DECRBY", "EXPIRE", "EXPIREAT", "GEOADD", + "GEOSEARCHSTORE", "GETEX", "GETSET", "HDEL", "HGETEX", "HINCRBY", "HINCRBYFLOAT", "HMSET", "HSET", "HSETEX", "HSETNX", "INCR", + "INCRBY", "INCRBYFLOAT", "JSON.ARRAPPEND", "JSON.ARRINSERT", "JSON.ARRPOP", "JSON.ARRTRIM", + "JSON.CLEAR", "JSON.DEBUG", "JSON.DEL", "JSON.FORGET", "JSON.MERGE", "JSON.MSET", "JSON.NUMINCRBY", + "JSON.NUMMULTBY", "JSON.SET", "JSON.STRAPPEND", "LINSERT", "LMOVE", "LMPOP", "LPOP", "LPUSH", + "LPUSHX", "LREM", "LSET", "LTRIM", "MSET", "MSETNX", "PERSIST", "PEXPIRE", "PEXPIREAT", + "PFADD", "PFMERGE", "PSETEX", "PUBLISH", "RENAMENX", "RESTORE", "RPOP", "RPOPLPUSH", "RPUSH", + "RPUSHX", "SADD", "SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SINTERSTORE", "SMOVE", "SPOP", + "SDIFFSTORE", "SREM", "SUNIONSTORE", "XACK", "XADD", "XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XREADGROUP", "XSETID", + "XTRIM", "ZADD", "ZDIFFSTORE", "ZINCRBY", "ZINTERSTORE", "ZMPOP", "ZPOPMAX", "ZPOPMIN", + "ZRANGESTORE", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK", "ZREMRANGEBYSCORE", "ZUNIONSTORE", + ] +} + +enum RedisCommandHistoryRedactor { + private static let nestedCredentialExclusionReason = + "Commands containing authentication credentials are never stored in query history." + private static let maximumNestedCommandDepth = 8 + + struct Redaction: Equatable, Sendable { + let sanitizedCommand: String + let wasRedacted: Bool + let shouldExclude: Bool + } + + static func historyDisposition(for command: ParsedRedisCommand) -> RedisCommandHistoryDisposition { + if command.name == "AUTH" { + return .exclude(reason: "Authentication commands are never stored in query history.") + } + + if isACLSetUser(command) { + return .exclude(reason: "ACL SETUSER commands are never stored in query history.") + } + + if let redaction = parsedRedaction(for: command) { + if redaction.shouldExclude { + return .exclude(reason: nestedCredentialExclusionReason) + } + return .record(command: redaction.sanitizedCommand, wasRedacted: true) + } + + return .record(command: command.renderedForHistory, wasRedacted: false) + } + + static func redactIfNeeded(in source: String) -> Redaction? { + if let command = try? RedisCommandTokenizer.tokenize(source) { + return parsedRedaction(for: command) + } + + return fallbackRedaction(in: source) + } + + private static func parsedRedaction( + for command: ParsedRedisCommand, + depth: Int = 0 + ) -> Redaction? { + if command.name == "AUTH" { + return Redaction( + sanitizedCommand: "AUTH [REDACTED]", + wasRedacted: true, + shouldExclude: true + ) + } + + if isACLSetUser(command) { + return Redaction( + sanitizedCommand: sanitizedACLSetUser(command), + wasRedacted: true, + shouldExclude: true + ) + } + + if let nestedStartIndex = nestedCommandStartIndex(in: command) { + let prefix = command.arguments.prefix(nestedStartIndex) + .map(\.historyRepresentation) + .joined(separator: " ") + + // Introspection wrappers can themselves be nested. Bound recursion + // and conservatively hide an invalid or excessively deep tail. + guard depth < maximumNestedCommandDepth, + command.arguments.count > nestedStartIndex, + let nestedCommand = try? ParsedRedisCommand( + arguments: Array(command.arguments.dropFirst(nestedStartIndex)) + ) else { + return Redaction( + sanitizedCommand: prefix + " [REDACTED]", + wasRedacted: true, + shouldExclude: true + ) + } + + if let nestedRedaction = parsedRedaction(for: nestedCommand, depth: depth + 1) { + return Redaction( + sanitizedCommand: prefix + " " + nestedRedaction.sanitizedCommand, + wasRedacted: true, + shouldExclude: nestedRedaction.shouldExclude + ) + } + } + + guard let redacted = redactSensitiveArguments(in: command) else { + return nil + } + return Redaction( + sanitizedCommand: redacted, + wasRedacted: true, + shouldExclude: false + ) + } + + private static func nestedCommandStartIndex(in command: ParsedRedisCommand) -> Int? { + if command.name == "ACL", + command.arguments.count > 1, + command.arguments[1].equalsIgnoringASCIICase("DRYRUN") { + // ACL DRYRUN [ ...] + return 3 + } + + if command.name == "COMMAND", + command.arguments.count > 1, + command.arguments[1].equalsIgnoringASCIICase("GETKEYS") || + command.arguments[1].equalsIgnoringASCIICase("GETKEYSANDFLAGS") { + // COMMAND GETKEYS[ANDFLAGS] [ ...] + return 2 + } + + return nil + } + + private static func redactSensitiveArguments(in command: ParsedRedisCommand) -> String? { + switch command.name { + case "HELLO": + return redactHello(command) + case "CONFIG": + return redactConfigSet(command) + case "MIGRATE": + return redactMigrate(command) + case "SENTINEL": + return redactSentinelSet(command) + default: + return nil + } + } + + private static func isACLSetUser(_ command: ParsedRedisCommand) -> Bool { + command.name == "ACL" && + command.arguments.count > 1 && + command.arguments[1].equalsIgnoringASCIICase("SETUSER") + } + + private static func sanitizedACLSetUser(_ command: ParsedRedisCommand) -> String { + guard command.arguments.count > 2 else { + return "ACL SETUSER [REDACTED]" + } + + return [ + command.arguments[0].historyRepresentation, + command.arguments[1].historyRepresentation, + command.arguments[2].historyRepresentation, + "[REDACTED]", + ].joined(separator: " ") + } + + private static func redactHello(_ command: ParsedRedisCommand) -> String? { + var arguments = command.arguments + var redacted = false + var index = 1 + + while index < arguments.count { + guard arguments[index].equalsIgnoringASCIICase("AUTH") else { + index += 1 + continue + } + + // HELLO AUTH requires a username and password. If the command is + // malformed, redact everything after AUTH rather than risk storing + // a partial credential. + guard index + 2 < arguments.count else { + let prefix = arguments[...index].map(\.historyRepresentation).joined(separator: " ") + return prefix + " [REDACTED]" + } + + arguments[index + 2] = RedisCommandToken("[REDACTED]") + redacted = true + index += 3 + } + + guard redacted else { return nil } + return arguments.map(\.historyRepresentation).joined(separator: " ") + } + + private static func redactConfigSet(_ command: ParsedRedisCommand) -> String? { + guard command.arguments.count > 1, + command.arguments[1].equalsIgnoringASCIICase("SET") else { + return nil + } + + var arguments = command.arguments + var redacted = false + var parameterIndex = 2 + + // CONFIG SET arguments are parameter/value pairs. Walking pair + // boundaries avoids treating an ordinary value that happens to be + // named "requirepass" as the next parameter. + while parameterIndex < arguments.count { + let valueIndex = parameterIndex + 1 + if isSensitiveConfigParameter(arguments[parameterIndex]) { + if valueIndex < arguments.count { + arguments[valueIndex] = RedisCommandToken("[REDACTED]") + } else { + arguments.append(RedisCommandToken("[REDACTED]")) + } + redacted = true + } + parameterIndex += 2 + } + + guard redacted else { return nil } + return arguments.map(\.historyRepresentation).joined(separator: " ") + } + + private static func isSensitiveConfigParameter(_ token: RedisCommandToken) -> Bool { + guard let parameter = token.uppercasedASCIIValue else { return false } + return parameter == "REQUIREPASS" || parameter == "MASTERAUTH" || + parameter == "PRIMARYAUTH" || parameter.hasSuffix("KEY-FILE-PASS") + } + + private static func redactMigrate(_ command: ParsedRedisCommand) -> String? { + // MIGRATE has five required positional arguments after the command; + // authentication options begin at argument index 6. Everything after + // KEYS is a key name and must not be mistaken for an AUTH option. + guard command.arguments.count > 6 else { return nil } + + var arguments = command.arguments + var redacted = false + var index = 6 + + while index < arguments.count { + if arguments[index].equalsIgnoringASCIICase("KEYS") { + break + } + + if arguments[index].equalsIgnoringASCIICase("AUTH") { + let passwordIndex = index + 1 + if passwordIndex < arguments.count { + arguments[passwordIndex] = RedisCommandToken("[REDACTED]") + } else { + arguments.append(RedisCommandToken("[REDACTED]")) + } + redacted = true + index += 2 + continue + } + + if arguments[index].equalsIgnoringASCIICase("AUTH2") { + let passwordIndex = index + 2 + if passwordIndex < arguments.count { + arguments[passwordIndex] = RedisCommandToken("[REDACTED]") + } else { + arguments.append(RedisCommandToken("[REDACTED]")) + } + redacted = true + index += 3 + continue + } + + index += 1 + } + + guard redacted else { return nil } + return arguments.map(\.historyRepresentation).joined(separator: " ") + } + + private static func redactSentinelSet(_ command: ParsedRedisCommand) -> String? { + guard command.arguments.count > 1, + command.arguments[1].equalsIgnoringASCIICase("SET") else { + return nil + } + + var arguments = command.arguments + var redacted = false + var parameterIndex = 3 + + // SENTINEL SET arguments after the master name are option/value + // pairs. Only inspect option positions so non-secret values are kept. + while parameterIndex < arguments.count { + let valueIndex = parameterIndex + 1 + if arguments[parameterIndex].equalsIgnoringASCIICase("AUTH-PASS") || + arguments[parameterIndex].equalsIgnoringASCIICase("AUTH-USER") { + if valueIndex < arguments.count { + arguments[valueIndex] = RedisCommandToken("[REDACTED]") + } else { + arguments.append(RedisCommandToken("[REDACTED]")) + } + redacted = true + } + parameterIndex += 2 + } + + guard redacted else { return nil } + return arguments.map(\.historyRepresentation).joined(separator: " ") + } + + private static func fallbackRedaction(in source: String) -> Redaction? { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + + if startsWithCommand("AUTH", in: trimmed) { + return Redaction( + sanitizedCommand: "AUTH [REDACTED]", + wasRedacted: true, + shouldExclude: true + ) + } + + if startsWithCommand("ACL", in: trimmed), + let setUserMatch = firstMatch(of: #"^ACL\s+SETUSER\b"#, in: trimmed) { + let prefix = prefix(through: setUserMatch, in: trimmed) + return Redaction( + sanitizedCommand: prefix + " [REDACTED]", + wasRedacted: true, + shouldExclude: true + ) + } + + if startsWithCommand("ACL", in: trimmed), + let dryRunMatch = firstMatch(of: #"^ACL\s+DRYRUN\b"#, in: trimmed) { + // Tokenization has already failed, so argument boundaries in the + // nested command are not trustworthy. Drop the complete tail. + return fallbackRedaction( + through: dryRunMatch, + in: trimmed, + shouldExclude: true + ) + } + + if startsWithCommand("COMMAND", in: trimmed), + let getKeysMatch = firstMatch( + of: #"^COMMAND\s+GETKEYS(?:ANDFLAGS)?\b"#, + in: trimmed + ) { + return fallbackRedaction( + through: getKeysMatch, + in: trimmed, + shouldExclude: true + ) + } + + if startsWithCommand("HELLO", in: trimmed), + let authMatch = firstMatch(of: #"\bAUTH\b"#, in: trimmed) { + return fallbackRedaction(through: authMatch, in: trimmed) + } + + if startsWithCommand("CONFIG", in: trimmed), + firstMatch(of: #"^CONFIG\s+SET\b"#, in: trimmed) != nil, + let parameterMatch = firstMatch( + of: #"\b(?:REQUIREPASS|MASTERAUTH|PRIMARYAUTH|[A-Z0-9._-]*KEY-FILE-PASS)\b"#, + in: trimmed + ) { + return fallbackRedaction(through: parameterMatch, in: trimmed) + } + + if startsWithCommand("MIGRATE", in: trimmed), + let authMatch = firstMatch(of: #"\bAUTH2?\b"#, in: trimmed) { + return fallbackRedaction(through: authMatch, in: trimmed) + } + + if startsWithCommand("SENTINEL", in: trimmed), + firstMatch(of: #"^SENTINEL\s+SET\b"#, in: trimmed) != nil, + let parameterMatch = firstMatch(of: #"\bAUTH-(?:PASS|USER)\b"#, in: trimmed) { + return fallbackRedaction(through: parameterMatch, in: trimmed) + } + + return nil + } + + private static func startsWithCommand(_ command: String, in source: String) -> Bool { + guard source.count >= command.count else { return false } + let commandEnd = source.index(source.startIndex, offsetBy: command.count) + guard String(source[.. NSTextCheckingResult? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + return regex.firstMatch( + in: source, + range: NSRange(source.startIndex..., in: source) + ) + } + + private static func prefix(through match: NSTextCheckingResult, in source: String) -> String { + let prefixRange = NSRange(location: 0, length: NSMaxRange(match.range)) + return (source as NSString).substring(with: prefixRange) + } + + private static func fallbackRedaction( + through match: NSTextCheckingResult, + in source: String, + shouldExclude: Bool = false + ) -> Redaction { + Redaction( + sanitizedCommand: prefix(through: match, in: source) + " [REDACTED]", + wasRedacted: true, + shouldExclude: shouldExclude + ) + } +} + +private enum CharacterByte { + static let asciiCaseOffset: UInt8 = 0x20 + static let backslash: UInt8 = 0x5C + static let doubleQuote: UInt8 = 0x22 + static let singleQuote: UInt8 = 0x27 + static let lowercaseA: UInt8 = 0x61 + static let lowercaseB: UInt8 = 0x62 + static let lowercaseN: UInt8 = 0x6E + static let lowercaseR: UInt8 = 0x72 + static let lowercaseT: UInt8 = 0x74 + static let lowercaseX: UInt8 = 0x78 + static let lowercaseZ: UInt8 = 0x7A +} diff --git a/pluk/Views/CreateConnection/CreateConnection.swift b/pluk/Views/CreateConnection/CreateConnection.swift index 658dcf17..905d10f0 100644 --- a/pluk/Views/CreateConnection/CreateConnection.swift +++ b/pluk/Views/CreateConnection/CreateConnection.swift @@ -244,6 +244,12 @@ struct CreateConnectionForm: View { _uri = State(initialValue: connection == nil ? initialSQLiteFileURL?.path ?? "" : "") _name = State(initialValue: connection == nil ? initialSQLiteFileURL?.deletingPathExtension().lastPathComponent ?? "" : "") _color = State(initialValue: .blue) + if initialType == .redis { + _hostname = State(initialValue: "localhost") + _port = State(initialValue: "6379") + _defaultDatabase = State(initialValue: "0") + _sslMode = State(initialValue: "disable") + } } private var supportsConnectionTest: Bool { @@ -252,6 +258,7 @@ struct CreateConnectionForm: View { || databaseType == .supabase || databaseType == .mysql || databaseType == .mongodb + || databaseType == .redis } private var supportsSSHTunnel: Bool { @@ -259,7 +266,7 @@ struct CreateConnectionForm: View { switch databaseType { case .postgres, .supabase, .mysql, .mongodb: return true - case .convex, .sqlite: + case .convex, .sqlite, .redis: return false } } @@ -328,8 +335,16 @@ struct CreateConnectionForm: View { // For PostgreSQL databases using field-based input if (databaseType == .postgres || databaseType == .mysql - || databaseType == .supabase) && useFieldBasedInput + || databaseType == .supabase || databaseType == .redis) && useFieldBasedInput { + if databaseType == .redis { + let portIsValid = port.isEmpty || (Int(port).map { (1...65_535).contains($0) } ?? false) + let databaseIsValid = defaultDatabase.isEmpty + || (Int(defaultDatabase).map { $0 >= 0 } ?? false) + let authenticationIsValid = username.isEmpty || !password.isEmpty + return !name.isEmpty && portIsValid && databaseIsValid + && authenticationIsValid + } return !name.isEmpty } @@ -358,6 +373,8 @@ struct CreateConnectionForm: View { validatePostgresUri(uri) case .mysql: validateMySQLUri(uri) + case .redis: + validateRedisURI(uri) case .sqlite: validateSQLiteUri(uri) default: @@ -395,6 +412,15 @@ struct CreateConnectionForm: View { } } + private func validateRedisURI(_ uri: String) { + do { + _ = try ConnectionURLParser.parseRedis(uri) + uriError = nil + } catch { + uriError = error.localizedDescription + } + } + private func validateSQLiteUri(_ uri: String) { if uri.hasPrefix("sqlite://") || uri.hasPrefix("file:") || uri.hasPrefix("/") || uri == ":memory:" @@ -491,8 +517,11 @@ struct CreateConnectionForm: View { } .onChange(of: selectedDatabaseType) { oldValue, newValue in // Reset form when switching database types (but not on initial load or when editing) - if connection == nil && oldValue != nil && newValue != oldValue { - resetForm() + if connection == nil && newValue != oldValue { + if oldValue != nil { + resetForm() + } + applyDefaults(for: newValue) } } .postHogScreenView("CreateConnection") @@ -637,6 +666,17 @@ struct CreateConnectionForm: View { sslMode: $sslMode, onImportURI: { parseMySQLURI($0) } ) + } else if selectedDatabaseType == .redis { + RedisFieldsView( + hostname: $hostname, + port: $port, + username: $username, + password: $password, + databaseIndex: $defaultDatabase, + sslMode: $sslMode, + importError: uriError, + onImportURI: { parseRedisURI($0) } + ) } else if selectedDatabaseType == .mongodb { Section { TextField( @@ -707,6 +747,8 @@ struct CreateConnectionForm: View { return constructPostgresURI() case .mysql: return constructMySQLURI() + case .redis: + return constructRedisURI() case .sqlite: return uri case .mongodb: @@ -806,6 +848,14 @@ struct CreateConnectionForm: View { sshKeyPassphrase = "" } + private func applyDefaults(for databaseType: DatabaseType?) { + guard databaseType == .redis else { return } + hostname = "localhost" + port = "6379" + defaultDatabase = "0" + sslMode = "disable" + } + private func mapExistingConnectionData() { if let connection = connection { uri = connection.url ?? "" @@ -823,7 +873,8 @@ struct CreateConnectionForm: View { hostname = connection.hostname ?? "" port = connection.port ?? "" username = connection.username ?? "" - sslMode = connection.sslMode ?? "prefer" + sslMode = connection.sslMode + ?? (selectedDatabaseType == .redis ? "disable" : "prefer") sslKeyPath = connection.sslKeyPath ?? "" sslCertPath = connection.sslCertPath ?? "" sslRootCertPath = connection.sslRootCertPath ?? "" @@ -831,13 +882,16 @@ struct CreateConnectionForm: View { // Get password from keychain password = connection.password ?? "" } else if let databaseType = selectedDatabaseType, - (databaseType == .postgres || databaseType == .mysql), + (databaseType == .postgres || databaseType == .mysql + || databaseType == .redis), let connectionUrl = connection.url, !connectionUrl.isEmpty { // For legacy URI-based connections, parse the URI to populate fields if databaseType == .postgres { parsePostgresURI(connectionUrl) } else if databaseType == .mysql { parseMySQLURI(connectionUrl) + } else if databaseType == .redis { + parseRedisURI(connectionUrl) } } @@ -881,6 +935,25 @@ struct CreateConnectionForm: View { if sslMode.isEmpty { sslMode = "prefer" } } + private func parseRedisURI(_ uriString: String) { + do { + let parsed = try ConnectionURLParser.parseRedis(uriString) + hostname = parsed.hostname + port = String(parsed.port) + username = parsed.username ?? "" + password = parsed.password ?? "" + defaultDatabase = String(parsed.databaseIndex) + sslMode = parsed.usesTLS ? "require" : "disable" + useFieldBasedInput = true + // Imported Redis URIs are decomposed immediately. The password is + // persisted only through Connection.password (Keychain) on save. + uri = "" + uriError = nil + } catch { + uriError = error.localizedDescription + } + } + private func parseURIComponents(_ components: URLComponents, url: URL) { hostname = components.host ?? "" port = components.port?.description ?? "" @@ -961,6 +1034,21 @@ struct CreateConnectionForm: View { return components.url?.absoluteString ?? "" } + private func constructRedisURI() -> String? { + let resolvedPort = port.isEmpty ? 6379 : Int(port) + let resolvedDatabase = defaultDatabase.isEmpty ? 0 : Int(defaultDatabase) + guard let resolvedPort, let resolvedDatabase else { return nil } + + return try? ConnectionURLParser.makeRedisURL( + hostname: hostname.isEmpty ? "localhost" : hostname, + port: resolvedPort, + username: username.isEmpty ? nil : username, + password: password.isEmpty ? nil : password, + databaseIndex: resolvedDatabase, + usesTLS: sslMode == "require" + ) + } + private func currentSSHConfiguration() -> SSHConfiguration? { guard supportsSSHTunnel, sshEnabled else { return nil } let trimmedPort = sshPort.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1011,9 +1099,9 @@ struct CreateConnectionForm: View { guard let databaseType = selectedDatabaseType else { return } guard databaseType != .convex else { return } - // Fill defaults for PostgreSQL databases using field-based input + // Fill defaults for databases using field-based input. if (databaseType == .postgres || databaseType == .supabase - || databaseType == .mysql) && useFieldBasedInput + || databaseType == .mysql || databaseType == .redis) && useFieldBasedInput { if hostname.isEmpty { if databaseType == .mysql { @@ -1026,12 +1114,14 @@ struct CreateConnectionForm: View { if port.isEmpty { if databaseType == .mysql { port = "3306" + } else if databaseType == .redis { + port = "6379" } else { port = "5432" } } - if username.isEmpty { + if username.isEmpty && databaseType != .redis { if databaseType == .mysql { username = "root" } else { @@ -1039,7 +1129,14 @@ struct CreateConnectionForm: View { } } - if sslMode.isEmpty { + if databaseType == .redis { + if defaultDatabase.isEmpty { + defaultDatabase = "0" + } + if sslMode != "require" { + sslMode = "disable" + } + } else if sslMode.isEmpty { sslMode = "prefer" } } @@ -1071,7 +1168,8 @@ struct CreateConnectionForm: View { applySSHSettings(to: existing) // For PostgreSQL databases using field-based input, update individual fields - if (databaseType == .postgres || databaseType == .supabase || databaseType == .mysql) + if (databaseType == .postgres || databaseType == .supabase + || databaseType == .mysql || databaseType == .redis) && useFieldBasedInput { existing.hostname = hostname @@ -1113,7 +1211,8 @@ struct CreateConnectionForm: View { let newConnection: Connection // For PostgreSQL databases using field-based input, use the new initializer - if (databaseType == .postgres || databaseType == .supabase || databaseType == .mysql) + if (databaseType == .postgres || databaseType == .supabase + || databaseType == .mysql || databaseType == .redis) && useFieldBasedInput { newConnection = Connection( @@ -1155,7 +1254,8 @@ struct CreateConnectionForm: View { try? modelContext.save() // For field-based connections, store password in keychain after getting persistentModelID - if (databaseType == .postgres || databaseType == .supabase || databaseType == .mysql) + if (databaseType == .postgres || databaseType == .supabase + || databaseType == .mysql || databaseType == .redis) && useFieldBasedInput { if !password.isEmpty { diff --git a/pluk/Views/CreateConnection/Drivers/RedisFieldsView.swift b/pluk/Views/CreateConnection/Drivers/RedisFieldsView.swift new file mode 100644 index 00000000..4a0d887a --- /dev/null +++ b/pluk/Views/CreateConnection/Drivers/RedisFieldsView.swift @@ -0,0 +1,91 @@ +// +// RedisFieldsView.swift +// Pluk +// + +import SwiftUI + +struct RedisFieldsView: View { + @Binding var hostname: String + @Binding var port: String + @Binding var username: String + @Binding var password: String + @Binding var databaseIndex: String + @Binding var sslMode: String + let importError: String? + let onImportURI: (String) -> Void + + @State private var showURIImportPopover = false + + private var usesTLS: Binding { + Binding( + get: { sslMode == "require" }, + set: { sslMode = $0 ? "require" : "disable" } + ) + } + + var body: some View { + Group { + Section { + LabeledContent("Host") { + HStack(spacing: 4) { + TextField("", text: $hostname, prompt: Text("localhost")) + .multilineTextAlignment(.trailing) + .labelsHidden() + .frame(width: 180) + + Text(":") + .foregroundStyle(.tertiary) + + TextField("", text: $port, prompt: Text("6379")) + .multilineTextAlignment(.trailing) + .labelsHidden() + .frame(width: 50) + } + } + + TextField( + "Database Index", + text: $databaseIndex, + prompt: Text("0") + ) + } header: { + HStack { + Text("Connection") + Spacer() + Button("Import from URI") { + showURIImportPopover.toggle() + } + .popover(isPresented: $showURIImportPopover, arrowEdge: .top) { + URIImportPopover( + placeholder: "redis://username:password@host:6379/0" + ) { uri in + onImportURI(uri) + showURIImportPopover = false + } + } + } + .padding(.trailing, -8) + } footer: { + if let importError { + Text(importError) + .foregroundStyle(.red) + } + } + + Section("Authentication (Optional)") { + TextField("Username", text: $username, prompt: Text("default")) + SecureField("Password", text: $password, prompt: Text("password")) + } + + Section("Security") { + Toggle("Use TLS (rediss://)", isOn: usesTLS) + } + } + .onAppear { + if sslMode != "disable" && sslMode != "require" { + sslMode = "disable" + } + } + } +} diff --git a/pluk/Views/CreateDatabase/CreateDatabase.swift b/pluk/Views/CreateDatabase/CreateDatabase.swift index 88081ff4..a659d197 100644 --- a/pluk/Views/CreateDatabase/CreateDatabase.swift +++ b/pluk/Views/CreateDatabase/CreateDatabase.swift @@ -182,12 +182,7 @@ struct CreateDatabaseForm: View { private var supportsOperation: Bool { guard let databaseType = instance.databaseType else { return false } - switch databaseType { - case .postgres, .mysql, .mongodb, .supabase: - return true - case .sqlite, .convex: - return false - } + return databaseType.supportsDatabaseCreation } private var unsupportedMessage: String { diff --git a/pluk/Views/Documents/DatabaseSelectorModal.swift b/pluk/Views/Documents/DatabaseSelectorModal.swift index 30e2ff52..b7d86070 100644 --- a/pluk/Views/Documents/DatabaseSelectorModal.swift +++ b/pluk/Views/Documents/DatabaseSelectorModal.swift @@ -23,12 +23,7 @@ struct DatabaseSelectorModal: View { private var supportsCreateDatabase: Bool { guard let databaseType else { return false } - switch databaseType { - case .postgres, .mysql, .mongodb, .supabase: - return true - case .sqlite, .convex: - return false - } + return databaseType.supportsDatabaseCreation } var filteredDatabases: [DatabaseWrapper] { diff --git a/pluk/Views/Documents/DocumentView.swift b/pluk/Views/Documents/DocumentView.swift index 3b4efdb8..178adbc7 100644 --- a/pluk/Views/Documents/DocumentView.swift +++ b/pluk/Views/Documents/DocumentView.swift @@ -44,6 +44,9 @@ class TabContentView: NSView { case .mongodb: setupMongoDBView() + case .redis: + setupRedisView() + default: setupDefaultView() } @@ -71,7 +74,7 @@ class TabContentView: NSView { rootView = applyEnvironments(FunctionEditorView()) case .canvas: return - case .browse, .aggregate, .schema, .indexes: + case .browse, .aggregate, .schema, .indexes, .redisKey, .redisCommand: // SQL .browse/.aggregate/.schema/.indexes tabs are routed to // TableContentViewController (AppKit) in // DocumentViewController.makeTabContentView and never reach here. @@ -87,9 +90,22 @@ class TabContentView: NSView { setContentView(NSHostingView(rootView: rootView)) } + private func setupRedisView() { + switch tab.type { + case .redisKey: + setContentView(NSHostingView(rootView: applyEnvironments(RedisKeyDetailView(tab: tab)))) + case .redisCommand: + setContentView(NSHostingView(rootView: applyEnvironments(RedisCommandEditorView(tab: tab)))) + default: + setupDefaultView() + } + } + private func setupDefaultView() { let noSelectionView = NSView() - let label = NSTextField(labelWithString: "No collection selected") + let label = NSTextField( + labelWithString: databaseType == .redis ? "No key selected" : "No collection selected" + ) label.font = NSFont.systemFont(ofSize: 18, weight: .medium) label.textColor = NSColor.secondaryLabelColor label.alignment = .center diff --git a/pluk/Views/Documents/EmptyStateView.swift b/pluk/Views/Documents/EmptyStateView.swift index 00c071fb..6174fb8d 100644 --- a/pluk/Views/Documents/EmptyStateView.swift +++ b/pluk/Views/Documents/EmptyStateView.swift @@ -524,7 +524,7 @@ final class EmptyStateViewController: NSViewController, NSTextFieldDelegate { switch event.keyCode { case 17 where event.modifierFlags.contains(.command): - self.instance.createSQLEditorTab() + self.instance.createEditorTab() return nil default: break diff --git a/pluk/Views/Documents/TabBar.swift b/pluk/Views/Documents/TabBar.swift index 65dcf85b..b7150177 100644 --- a/pluk/Views/Documents/TabBar.swift +++ b/pluk/Views/Documents/TabBar.swift @@ -5,6 +5,8 @@ func getTabIconName(for tab: DatabaseTab, databaseType: DatabaseType) -> String guard tab.type != .functionEditor else { return "f.cursive" } guard tab.type != .sqlEditor else { return "terminal" } guard tab.type != .canvas else { return "rectangle.connected.to.line.below" } + guard tab.type != .redisCommand else { return "terminal" } + guard tab.type != .redisKey else { return "key.horizontal" } switch tab.viewMode { case .content: diff --git a/pluk/Views/Documents/TabBarView.swift b/pluk/Views/Documents/TabBarView.swift index 4f1c0a14..3a923f5e 100644 --- a/pluk/Views/Documents/TabBarView.swift +++ b/pluk/Views/Documents/TabBarView.swift @@ -562,7 +562,7 @@ final class TabBarView: NSView { } @objc private func newTabAction() { - instance.createSQLEditorTab() + instance.createEditorTab() } @objc private func toggleSidebarAction() { @@ -685,7 +685,7 @@ final class TabBarView: NSView { self.syncTabs() return nil case "t": - instance.createSQLEditorTab() + instance.createEditorTab() return nil default: if let char = key, let digit = Int(char), (1...9).contains(digit) { diff --git a/pluk/Views/Home/HomeView.swift b/pluk/Views/Home/HomeView.swift index 502cdeee..7a30ebb0 100644 --- a/pluk/Views/Home/HomeView.swift +++ b/pluk/Views/Home/HomeView.swift @@ -396,7 +396,7 @@ struct HomeView: View { connection.updatedAt = Date() switch candidate.databaseType { - case .postgres, .mysql: + case .postgres, .mysql, .redis: connection.url = nil connection.sslMode = "disable" case .mongodb: @@ -413,7 +413,7 @@ struct HomeView: View { private func makeConnection(from candidate: DockerDatabaseCandidate) -> Connection { switch candidate.databaseType { - case .postgres, .mysql: + case .postgres, .mysql, .redis: return Connection( databaseType: candidate.databaseType, name: candidate.connectionName, diff --git a/pluk/Views/Notebook/NotebookDataController.swift b/pluk/Views/Notebook/NotebookDataController.swift index 67657ae5..52c9ed35 100644 --- a/pluk/Views/Notebook/NotebookDataController.swift +++ b/pluk/Views/Notebook/NotebookDataController.swift @@ -454,7 +454,8 @@ final class NotebookDataController { let connectionDescriptor = FetchDescriptor( sortBy: [SortDescriptor(\.lastOpenedAt, order: .reverse)] ) - connections = (try? modelContainer.mainContext.fetch(connectionDescriptor)) ?? [] + let fetchedConnections = (try? modelContainer.mainContext.fetch(connectionDescriptor)) ?? [] + connections = fetchedConnections.filter { $0.databaseType.supportsNotebookAnalytics } } private func save() { diff --git a/pluk/Views/QueryHistory/QueryHistoryView.swift b/pluk/Views/QueryHistory/QueryHistoryView.swift index 80516fc4..2c8b20a7 100644 --- a/pluk/Views/QueryHistory/QueryHistoryView.swift +++ b/pluk/Views/QueryHistory/QueryHistoryView.swift @@ -103,8 +103,13 @@ struct QueryHistoryView: View { historyEntries = [] } - private func reExecuteQuery(_ query: String) { - instance.createSQLEditorTab(withQuery: query) + private func reExecuteQuery(_ entry: QueryHistoryEntryViewModel) { + guard entry.isReplayable else { return } + if instance.connection.databaseType == .redis { + instance.createRedisCommandTab(withCommand: entry.query) + } else { + instance.createSQLEditorTab(withQuery: entry.query) + } } @ViewBuilder @@ -117,10 +122,19 @@ struct QueryHistoryView: View { } Button { - reExecuteQuery(entry.query) + reExecuteQuery(entry) } label: { - Label("Load in Editor", systemImage: "arrow.up.forward.square") + Label( + entry.isReplayable ? "Load in Editor" : "Load in Editor (Credentials Redacted)", + systemImage: "arrow.up.forward.square" + ) } + .disabled(!entry.isReplayable) + .help( + entry.isReplayable + ? "Load this query in an editor" + : "Credentials were redacted. Copy the command and re-enter them before running it." + ) Divider() @@ -297,7 +311,7 @@ struct QueryHistoryFilterBar: View { struct QueryHistoryRow: View { let entry: QueryHistoryEntryViewModel - var onReExecute: (String) -> Void + var onReExecute: (QueryHistoryEntryViewModel) -> Void var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -354,7 +368,8 @@ struct QueryHistoryRow: View { .background(Color.clear) .contentShape(Rectangle()) .onTapGesture(count: 2) { - onReExecute(entry.query) + guard entry.isReplayable else { return } + onReExecute(entry) } } @@ -382,6 +397,7 @@ struct QueryHistoryRow: View { .background(Color.orange.opacity(0.15)) .foregroundStyle(.orange) .clipShape(.capsule) + .help("Credentials were redacted. Copy the command and re-enter them before running it.") } private var failedBadge: some View { diff --git a/pluk/Views/Redis/RedisCommandEditorView.swift b/pluk/Views/Redis/RedisCommandEditorView.swift new file mode 100644 index 00000000..479fe7f6 --- /dev/null +++ b/pluk/Views/Redis/RedisCommandEditorView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct RedisCommandEditorView: View { + @Environment(ConnectionInstance.self) private var instance + let tab: DatabaseTab + + @State private var commandText = "PING" + @State private var resultText = "" + @State private var executionSummary = "" + @State private var isExecuting = false + @State private var errorMessage: String? + @State private var pendingAnalysis: RedisCommandAnalysis? + @State private var showConfirmation = false + + init(tab: DatabaseTab) { + self.tab = tab + _commandText = State(initialValue: tab.initialQuery ?? "PING") + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Label("Redis Command", systemImage: "terminal") + .font(.headline) + + Text("DB \(instance.connectedDatabase?.name ?? instance.connection.defaultDatabase ?? "0")") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Menu("Examples") { + example("PING") + example("DBSIZE") + example("SCAN 0 COUNT 100") + example("GET my:key") + example("HGETALL my:hash") + example("XRANGE my:stream - + COUNT 100") + } + + Button("Run", systemImage: "play.fill") { + prepareExecution() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: [.command]) + .disabled(isExecuting || commandText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + VSplitView { + VStack(alignment: .leading, spacing: 6) { + Text("Command") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextEditor(text: $commandText) + .font(.system(.body, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(8) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.separator, lineWidth: 1) + } + } + .padding(16) + .frame(minHeight: 120) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Result") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if !executionSummary.isEmpty { + Text(executionSummary) + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + if isExecuting { + ProgressView().controlSize(.small) + } + Button("Clear") { + resultText = "" + executionSummary = "" + errorMessage = nil + } + .disabled(resultText.isEmpty && errorMessage == nil) + } + + ScrollView([.horizontal, .vertical]) { + Text(displayedResult) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(errorMessage == nil ? Color.primary : Color.red) + .textSelection(.enabled) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(10) + } + .background(.background) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.separator, lineWidth: 1) + } + } + .padding(16) + .frame(minHeight: 180) + } + } + .confirmationDialog( + pendingAnalysis?.executionPolicy.confirmationKind == .destructive + ? "Confirm destructive Redis command" + : "Confirm Redis command", + isPresented: $showConfirmation, + titleVisibility: .visible + ) { + Button( + pendingAnalysis?.executionPolicy.confirmationKind == .destructive ? "Run Destructive Command" : "Run Command", + role: pendingAnalysis?.executionPolicy.confirmationKind == .destructive ? .destructive : nil + ) { + guard let analysis = pendingAnalysis else { return } + Task { await execute(analysis, confirmationGranted: true) } + } + Button("Cancel", role: .cancel) { + pendingAnalysis = nil + } + } message: { + Text(pendingAnalysis?.executionPolicy.message ?? "This command can modify Redis state.") + } + } + + private func example(_ command: String) -> some View { + Button(command) { commandText = command } + } + + private var displayedResult: String { + if let errorMessage { + return "(error) \(errorMessage)" + } + return resultText + } + + private func prepareExecution() { + errorMessage = nil + do { + let analysis = try RedisCommandSafety.analyze(commandText) + guard analysis.allowsExecution else { + errorMessage = analysis.executionPolicy.message ?? "This command is unavailable." + return + } + if analysis.requiresConfirmation { + pendingAnalysis = analysis + showConfirmation = true + } else { + Task { await execute(analysis) } + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func execute( + _ analysis: RedisCommandAnalysis, + confirmationGranted: Bool = false + ) async { + isExecuting = true + errorMessage = nil + pendingAnalysis = nil + defer { isExecuting = false } + + do { + let result = try await instance.databaseService.executeRedisCommand( + analysis.transportCommand, + analysis: analysis, + confirmationGranted: confirmationGranted + ) + resultText = redisCommandDisplay(result.value) + executionSummary = "\(analysis.categoryLabel) · \(result.durationMilliseconds.formatted(.number.precision(.fractionLength(1)))) ms" + } catch { + errorMessage = error.localizedDescription + executionSummary = analysis.categoryLabel + } + } +} + +private extension RedisCommandAnalysis { + var categoryLabel: String { + switch category { + case .readOnly: "read only" + case .write: "write" + case .destructive: "destructive" + case .administrative: "administrative" + case .connectionStateful: "connection state" + case .unknown: "unknown" + } + } +} diff --git a/pluk/Views/Redis/RedisKeyDetailView.swift b/pluk/Views/Redis/RedisKeyDetailView.swift new file mode 100644 index 00000000..0ba2caa3 --- /dev/null +++ b/pluk/Views/Redis/RedisKeyDetailView.swift @@ -0,0 +1,920 @@ +import SwiftUI + +struct RedisKeyDetailView: View { + @Environment(ConnectionInstance.self) private var instance + let tab: DatabaseTab + + @State private var metadata: RedisKeyMetadata? + @State private var value: RedisValue? + @State private var isLoading = false + @State private var isMutating = false + @State private var errorMessage: String? + @State private var loadGeneration = UUID() + + @State private var valueText = "" + @State private var valueIsHex = false + @State private var editRequest: RedisEditRequest? + @State private var isRenaming = false + @State private var renameText = "" + @State private var isEditingTTL = false + @State private var ttlText = "" + @State private var isConfirmingDelete = false + + private var redisKey: RedisKey? { + tab.redisKeyData.map(RedisKey.init(bytes:)) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + + if isLoading, value == nil { + ProgressView("Loading key…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage, value == nil { + ContentUnavailableView( + "Unable to Load Key", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if metadata?.exists == false { + ContentUnavailableView( + "Key No Longer Exists", + systemImage: "key.slash", + description: Text("Refresh the key browser to update the keyspace.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + valueContent + } + } + .task(id: tab.redisKeyData) { + await reload() + } + .sheet(item: $editRequest) { request in + RedisValueEditSheet(request: request) { first, firstIsHex, second, secondIsHex in + await applyEdit( + request, + first: redisData(first, isHex: firstIsHex), + second: redisData(second, isHex: secondIsHex) + ) + } + } + .sheet(isPresented: $isRenaming) { + RedisRenameKeySheet(initialName: renameText) { name, overwrite in + await renameKey(to: name, overwrite: overwrite) + } + } + .sheet(isPresented: $isEditingTTL) { + RedisTTLView(initialMilliseconds: metadata?.ttlMilliseconds) { milliseconds in + await updateTTL(milliseconds) + } + } + .confirmationDialog( + "Delete this Redis key?", + isPresented: $isConfirmingDelete, + titleVisibility: .visible + ) { + Button("Delete with UNLINK", role: .destructive) { + Task { await deleteKey() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Deletion cannot be undone. Pluk uses UNLINK so large values do not block the Redis server.") + } + .alert("Redis Error", isPresented: Binding( + get: { errorMessage != nil && value != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "Unknown error") + } + } + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "key.horizontal") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(redisKey?.displayString ?? tab.name) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + + HStack(spacing: 10) { + if let metadata { + Label(metadata.type.displayName, systemImage: typeIcon(metadata.type)) + Text(ttlDescription(metadata.ttlMilliseconds)) + if let bytes = metadata.memoryUsageBytes { + Text(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .memory)) + } + if let encoding = metadata.encoding, !encoding.isEmpty { + Text(encoding) + } + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if isMutating { + ProgressView().controlSize(.small) + } + + Button { + Task { await reload() } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh key") + .disabled(isLoading || isMutating) + + Menu { + Button("Rename…") { + renameText = redisKey?.utf8String ?? "" + isRenaming = true + } + Button("Change TTL…") { + ttlText = metadata?.ttlMilliseconds.map(String.init) ?? "" + isEditingTTL = true + } + Divider() + Button("Delete…", role: .destructive) { + isConfirmingDelete = true + } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(redisKey == nil || isMutating) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + @ViewBuilder + private var valueContent: some View { + switch value { + case nil, .some(.none): + ContentUnavailableView("No Value", systemImage: "key.slash") + + case .string: + stringEditor(title: "String value", saveTitle: "Save String", isJSON: false) + + case .json: + stringEditor(title: "JSON document", saveTitle: "Save JSON", isJSON: true) + + case .hash(let entries, let totalCount, let nextCursor): + collectionContainer( + title: "\(totalCount) hash fields", + canLoadMore: nextCursor != 0, + addAction: { + editRequest = RedisEditRequest(kind: .hashField, title: "Add Hash Field") + } + ) { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + RedisTwoColumnRow( + first: redisDisplay(entry.field), + second: redisDisplay(entry.value), + editAction: { + editRequest = RedisEditRequest( + kind: .hashField, + title: "Edit Hash Field", + first: redisDraft(entry.field).text, + firstIsHex: redisDraft(entry.field).isHex, + firstIsReadOnly: true, + second: redisDraft(entry.value).text, + secondIsHex: redisDraft(entry.value).isHex + ) + }, + deleteAction: { + Task { await mutate(.deleteHashField(field: entry.field)) } + } + ) + } + } + + case .list(let elements, let totalCount, let offset): + collectionContainer( + title: "\(totalCount) list elements", + canLoadMore: offset + elements.count < totalCount, + addAction: { + editRequest = RedisEditRequest(kind: .appendList, title: "Append List Element") + } + ) { + ForEach(Array(elements.enumerated()), id: \.offset) { index, element in + RedisTwoColumnRow( + first: "\(offset + index)", + second: redisDisplay(element), + editAction: { + let draft = redisDraft(element) + editRequest = RedisEditRequest( + kind: .listElement(index: offset + index), + title: "Edit List Element", + second: draft.text, + secondIsHex: draft.isHex + ) + } + ) + } + } + + case .set(let members, let totalCount, let nextCursor): + collectionContainer( + title: "\(totalCount) set members", + canLoadMore: nextCursor != 0, + addAction: { + editRequest = RedisEditRequest(kind: .setMember, title: "Add Set Member") + } + ) { + ForEach(Array(members.enumerated()), id: \.offset) { _, member in + RedisTwoColumnRow( + first: redisDisplay(member), + second: "", + deleteAction: { + Task { await mutate(.setMember(member, isPresent: false)) } + } + ) + } + } + + case .sortedSet(let entries, let totalCount, let offset): + collectionContainer( + title: "\(totalCount) sorted-set members", + canLoadMore: offset + entries.count < totalCount, + addAction: { + editRequest = RedisEditRequest(kind: .sortedSetMember, title: "Add Sorted-Set Member") + } + ) { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + RedisTwoColumnRow( + first: redisDisplay(entry.member), + second: entry.score.formatted(), + editAction: { + let draft = redisDraft(entry.member) + editRequest = RedisEditRequest( + kind: .sortedSetMember, + title: "Edit Sorted-Set Member", + first: draft.text, + firstIsHex: draft.isHex, + firstIsReadOnly: true, + // Redis command arguments always use a dot as + // the decimal separator. Keep the editable + // seed locale-independent even though the + // display-only score above is localized. + second: String(entry.score) + ) + }, + deleteAction: { + Task { await mutate(.sortedSetMember(member: entry.member, score: nil)) } + } + ) + } + } + + case .stream(let entries, let totalCount): + collectionContainer(title: "\(totalCount) stream entries", canLoadMore: false) { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + VStack(alignment: .leading, spacing: 5) { + Text(redisDisplay(entry.id)) + .font(.system(.body, design: .monospaced).weight(.medium)) + ForEach(Array(entry.fields.enumerated()), id: \.offset) { _, field in + HStack(alignment: .firstTextBaseline) { + Text(redisDisplay(field.field)).foregroundStyle(.secondary) + Text(redisDisplay(field.value)).textSelection(.enabled) + Spacer() + } + .font(.system(.caption, design: .monospaced)) + } + } + .padding(.vertical, 7) + Divider() + } + } + + case .unsupported(let type, let raw): + ScrollView { + Text("Unsupported \(type.displayName) value\n\n\(redisCommandDisplay(raw))") + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + } + } + + private func stringEditor(title: String, saveTitle: String, isJSON: Bool) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(title).font(.headline) + Spacer() + Toggle("Hex bytes", isOn: $valueIsHex) + .toggleStyle(.switch) + .controlSize(.small) + Button(saveTitle) { + Task { + let data = redisData(valueText, isHex: valueIsHex) + await mutate(isJSON ? .json(data) : .string(data)) + } + } + .buttonStyle(.borderedProminent) + .disabled(isMutating || (valueIsHex && redisHexData(valueText) == nil)) + } + + TextEditor(text: $valueText) + .font(.system(.body, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(8) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.separator, lineWidth: 1) + } + + if valueIsHex { + Text("Hex input accepts hexadecimal digits with optional spaces.") + .font(.caption) + .foregroundStyle(redisHexData(valueText) == nil ? .red : .secondary) + } + } + .padding(16) + } + + private func collectionContainer( + title: String, + canLoadMore: Bool, + addAction: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(spacing: 0) { + HStack { + Text(title).font(.headline) + Spacer() + if let addAction { + Button(action: addAction) { + Label("Add", systemImage: "plus") + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + ScrollView { + LazyVStack(spacing: 0) { + content() + if canLoadMore { + Button("Load More") { + Task { await loadMore() } + } + .padding(16) + .disabled(isLoading) + } + } + .padding(.horizontal, 16) + } + } + } + + private func reload() async { + guard let redisKey else { return } + let requestGeneration = UUID() + loadGeneration = requestGeneration + isLoading = true + defer { + if loadGeneration == requestGeneration { + isLoading = false + } + } + do { + async let metadataRequest = instance.databaseService.redisKeyMetadata(for: redisKey) + async let valueRequest = instance.databaseService.redisValue(for: redisKey, page: RedisValuePage()) + let (newMetadata, newValue) = try await (metadataRequest, valueRequest) + try Task.checkCancellation() + guard loadGeneration == requestGeneration, self.redisKey == redisKey else { return } + metadata = newMetadata + value = newValue + updateStringDraft(from: newValue) + errorMessage = nil + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, + loadGeneration == requestGeneration, + self.redisKey == redisKey else { return } + errorMessage = error.localizedDescription + } + } + + private func loadMore() async { + guard let redisKey, let value, !isLoading else { return } + let requestGeneration = loadGeneration + isLoading = true + defer { + if loadGeneration == requestGeneration, self.redisKey == redisKey { + isLoading = false + } + } + + let page: RedisValuePage + switch value { + case .hash(_, _, let nextCursor), .set(_, _, let nextCursor): + page = RedisValuePage(cursor: nextCursor) + case .list(let elements, _, let offset): + page = RedisValuePage(offset: offset + elements.count) + case .sortedSet(let entries, _, let offset): + page = RedisValuePage(offset: offset + entries.count) + default: + return + } + + do { + let next = try await instance.databaseService.redisValue(for: redisKey, page: page) + try Task.checkCancellation() + guard loadGeneration == requestGeneration, self.redisKey == redisKey else { return } + self.value = merge(value, with: next) + } catch is CancellationError { + return + } catch { + guard loadGeneration == requestGeneration, self.redisKey == redisKey else { return } + errorMessage = error.localizedDescription + } + } + + private func mutate(_ update: RedisValueUpdate) async { + guard let redisKey else { return } + errorMessage = nil + isMutating = true + defer { isMutating = false } + do { + try await instance.databaseService.updateRedisValue(update, for: redisKey, preserveTTL: true) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } + + private func applyEdit( + _ request: RedisEditRequest, + first: Data, + second: Data + ) async -> Bool { + switch request.kind { + case .hashField: + await mutate(.hashField(field: first, value: second)) + case .listElement(let index): + await mutate(.listElement(index: index, value: second)) + case .appendList: + await mutate(.appendList(values: [second], toHead: false)) + case .setMember: + await mutate(.setMember(first, isPresent: true)) + case .sortedSetMember: + guard let score = Double(String(data: second, encoding: .utf8) ?? "") else { + errorMessage = "Score must be a number." + return false + } + await mutate(.sortedSetMember(member: first, score: score)) + } + return errorMessage == nil + } + + private func renameKey(to name: String, overwrite: Bool) async -> Bool { + guard let redisKey else { return false } + guard !name.isEmpty else { + errorMessage = "Key name cannot be empty." + return false + } + isMutating = true + defer { isMutating = false } + do { + let newKey = RedisKey(name) + try await instance.databaseService.renameRedisKey(redisKey, to: newKey, overwrite: overwrite) + tab.redisKeyData = newKey.bytes + tab.name = newKey.displayString + NotificationCenter.default.post(name: .redisKeysRefreshRequested, object: instance) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private func updateTTL(_ milliseconds: Int64?) async -> Bool { + guard let redisKey else { return false } + isMutating = true + defer { isMutating = false } + do { + _ = try await instance.databaseService.setRedisExpiration(for: redisKey, milliseconds: milliseconds) + metadata = try await instance.databaseService.redisKeyMetadata(for: redisKey) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private func deleteKey() async { + guard let redisKey else { return } + isMutating = true + defer { isMutating = false } + do { + _ = try await instance.databaseService.deleteRedisKeys([redisKey], asynchronously: true) + metadata = RedisKeyMetadata(key: redisKey, type: .none, ttlMilliseconds: nil, memoryUsageBytes: nil, encoding: nil) + value = RedisValue.none + NotificationCenter.default.post(name: .redisKeysRefreshRequested, object: instance) + } catch { + errorMessage = error.localizedDescription + } + } + + private func updateStringDraft(from value: RedisValue) { + let data: Data? + switch value { + case .string(let bytes), .json(let bytes): data = bytes + default: data = nil + } + guard let data else { return } + let draft = redisDraft(data) + valueText = draft.text + valueIsHex = draft.isHex + } + + private func merge(_ current: RedisValue, with next: RedisValue) -> RedisValue { + switch (current, next) { + case let (.hash(entries, _, _), .hash(nextEntries, total, cursor)): + var seenFields = Set(entries.map(\.field)) + let additions = nextEntries.filter { seenFields.insert($0.field).inserted } + return .hash(entries: entries + additions, totalCount: total, nextCursor: cursor) + case let (.list(elements, _, offset), .list(nextElements, total, _)): + return .list(elements: elements + nextElements, totalCount: total, offset: offset) + case let (.set(members, _, _), .set(nextMembers, total, cursor)): + var seenMembers = Set(members) + let additions = nextMembers.filter { seenMembers.insert($0).inserted } + return .set(members: members + additions, totalCount: total, nextCursor: cursor) + case let (.sortedSet(entries, _, offset), .sortedSet(nextEntries, total, _)): + return .sortedSet(entries: entries + nextEntries, totalCount: total, offset: offset) + default: + return next + } + } + + private func ttlDescription(_ milliseconds: Int64?) -> String { + guard let milliseconds else { return "Persistent" } + if milliseconds < 1_000 { return "TTL \(milliseconds) ms" } + let seconds = Double(milliseconds) / 1_000 + if seconds < 60 { return "TTL \(seconds.formatted(.number.precision(.fractionLength(0...1)))) s" } + if seconds < 3_600 { return "TTL \((seconds / 60).formatted(.number.precision(.fractionLength(0...1)))) min" } + return "TTL \((seconds / 3_600).formatted(.number.precision(.fractionLength(0...1)))) hr" + } + + private func typeIcon(_ type: RedisKeyType) -> String { + switch type { + case .string: "textformat" + case .hash: "number" + case .list: "list.bullet" + case .set: "circle.grid.2x2" + case .sortedSet: "list.number" + case .stream: "waveform.path.ecg" + case .json: "curlybraces" + case .none, .unknown: "questionmark" + } + } +} + +private struct RedisDataDraft { + let text: String + let isHex: Bool +} + +private func redisDraft(_ data: Data) -> RedisDataDraft { + if let string = String(data: data, encoding: .utf8) { + return RedisDataDraft(text: string, isHex: false) + } + return RedisDataDraft(text: data.map { String(format: "%02x", $0) }.joined(), isHex: true) +} + +private func redisDisplay(_ data: Data) -> String { + let draft = redisDraft(data) + return draft.isHex ? "0x\(draft.text)" : draft.text +} + +private func redisData(_ text: String, isHex: Bool) -> Data { + if isHex, let data = redisHexData(text) { + return data + } + return Data(text.utf8) +} + +private func redisHexData(_ text: String) -> Data? { + let compact = text + .replacingOccurrences(of: "0x", with: "", options: [.caseInsensitive, .anchored]) + .filter { !$0.isWhitespace } + guard compact.count.isMultiple(of: 2), compact.allSatisfy(\.isHexDigit) else { return nil } + var result = Data(capacity: compact.count / 2) + var index = compact.startIndex + while index < compact.endIndex { + let next = compact.index(index, offsetBy: 2) + guard let byte = UInt8(compact[index.. Void)? + var deleteAction: (() -> Void)? + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(first) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(second.isEmpty ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: second.isEmpty ? .infinity : 260, alignment: .leading) + .textSelection(.enabled) + + if !second.isEmpty { + Text(second) + .font(.system(.body, design: .monospaced)) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + if let editAction { + Button(action: editAction) { Image(systemName: "pencil") } + .buttonStyle(.borderless) + } + if let deleteAction { + Button(role: .destructive, action: deleteAction) { Image(systemName: "trash") } + .buttonStyle(.borderless) + } + } + .padding(.vertical, 9) + Divider() + } +} + +private struct RedisEditRequest: Identifiable { + enum Kind { + case hashField + case listElement(index: Int) + case appendList + case setMember + case sortedSetMember + } + + let id = UUID() + let kind: Kind + let title: String + var first = "" + var firstIsHex = false + var firstIsReadOnly = false + var second = "" + var secondIsHex = false +} + +private struct RedisValueEditSheet: View { + @Environment(\.dismiss) private var dismiss + let request: RedisEditRequest + let onSave: (String, Bool, String, Bool) async -> Bool + + @State private var first: String + @State private var firstIsHex: Bool + @State private var second: String + @State private var secondIsHex: Bool + @State private var isSaving = false + + init( + request: RedisEditRequest, + onSave: @escaping (String, Bool, String, Bool) async -> Bool + ) { + self.request = request + self.onSave = onSave + _first = State(initialValue: request.first) + _firstIsHex = State(initialValue: request.firstIsHex) + _second = State(initialValue: request.second) + _secondIsHex = State(initialValue: request.secondIsHex) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(request.title).font(.title3.weight(.semibold)) + + if needsFirstValue { + RedisDataField( + title: firstTitle, + text: $first, + isHex: $firstIsHex, + isReadOnly: request.firstIsReadOnly + ) + } + if !request.kind.isSetMember { + RedisDataField(title: secondTitle, text: $second, isHex: $secondIsHex, isNumeric: needsNumericSecond) + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Save") { + Task { + isSaving = true + if await onSave(first, firstIsHex, second, secondIsHex) { + dismiss() + } + isSaving = false + } + } + .buttonStyle(.borderedProminent) + .disabled(isSaving || !isValid) + } + } + .padding(20) + .frame(width: 460) + } + + private var needsFirstValue: Bool { + switch request.kind { + case .hashField, .setMember, .sortedSetMember: true + case .listElement, .appendList: false + } + } + + private var needsNumericSecond: Bool { + if case .sortedSetMember = request.kind { return true } + return false + } + + private var firstTitle: String { + switch request.kind { + case .hashField: "Field" + case .setMember, .sortedSetMember: "Member" + case .listElement, .appendList: "" + } + } + + private var secondTitle: String { + switch request.kind { + case .sortedSetMember: "Score" + case .hashField, .listElement, .appendList: "Value" + case .setMember: "Value (unused)" + } + } + + private var isValid: Bool { + if firstIsHex, redisHexData(first) == nil { return false } + if secondIsHex, redisHexData(second) == nil { return false } + if needsNumericSecond, Double(second) == nil { return false } + return true + } +} + +private extension RedisEditRequest.Kind { + var isSetMember: Bool { + if case .setMember = self { return true } + return false + } +} + +private struct RedisDataField: View { + let title: String + @Binding var text: String + @Binding var isHex: Bool + var isNumeric = false + var isReadOnly = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title).font(.caption).foregroundStyle(.secondary) + Spacer() + if !isNumeric { + Toggle("Hex", isOn: $isHex) + .toggleStyle(.switch) + .controlSize(.small) + .disabled(isReadOnly) + } + } + TextField(title, text: $text) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .disabled(isReadOnly) + } + } +} + +private struct RedisRenameKeySheet: View { + @Environment(\.dismiss) private var dismiss + let onRename: (String, Bool) async -> Bool + @State private var name: String + @State private var overwrite = false + @State private var isSaving = false + + init(initialName: String, onRename: @escaping (String, Bool) async -> Bool) { + self.onRename = onRename + _name = State(initialValue: initialName) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Rename Redis Key").font(.title3.weight(.semibold)) + TextField("Key name", text: $name).textFieldStyle(.roundedBorder) + Toggle("Overwrite an existing key", isOn: $overwrite) + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Rename") { + Task { + isSaving = true + if await onRename(name, overwrite) { dismiss() } + isSaving = false + } + } + .buttonStyle(.borderedProminent) + .disabled(isSaving || name.isEmpty) + } + } + .padding(20) + .frame(width: 420) + } +} + +private struct RedisTTLView: View { + @Environment(\.dismiss) private var dismiss + let onSave: (Int64?) async -> Bool + @State private var milliseconds: String + @State private var persistent: Bool + @State private var isSaving = false + + init(initialMilliseconds: Int64?, onSave: @escaping (Int64?) async -> Bool) { + self.onSave = onSave + _milliseconds = State(initialValue: initialMilliseconds.map(String.init) ?? "") + _persistent = State(initialValue: initialMilliseconds == nil) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Key Expiration").font(.title3.weight(.semibold)) + Toggle("Persist without expiration", isOn: $persistent) + if !persistent { + TextField("Milliseconds", text: $milliseconds) + .textFieldStyle(.roundedBorder) + Text("Enter a positive TTL in milliseconds.") + .font(.caption).foregroundStyle(.secondary) + } + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Save") { + Task { + isSaving = true + let value = persistent ? nil : Int64(milliseconds) + if await onSave(value) { dismiss() } + isSaving = false + } + } + .buttonStyle(.borderedProminent) + .disabled(isSaving || (!persistent && (Int64(milliseconds) ?? 0) <= 0)) + } + } + .padding(20) + .frame(width: 400) + } +} + +func redisCommandDisplay(_ value: RedisCommandValue, indent: Int = 0) -> String { + let padding = String(repeating: " ", count: indent) + switch value { + case .null: return "null" + case .simpleString(let data), .bulkString(let data), .bigNumber(let data): + return redisDisplay(data) + case .simpleError(let data), .bulkError(let data): + return "(error) \(redisDisplay(data))" + case .verbatimString(let format, let data): + return format.map { "\($0):\(redisDisplay(data))" } ?? redisDisplay(data) + case .integer(let value): return String(value) + case .double(let value): return String(value) + case .boolean(let value): return String(value) + case .array(let values), .set(let values), .push(let values): + return values.enumerated().map { + "\(padding)\($0.offset + 1)) \(redisCommandDisplay($0.element, indent: indent + 1))" + }.joined(separator: "\n") + case .map(let entries), .attribute(let entries): + return entries.map { + "\(padding)\(redisCommandDisplay($0.key, indent: indent + 1)): \(redisCommandDisplay($0.value, indent: indent + 1))" + }.joined(separator: "\n") + } +} diff --git a/pluk/Views/Sidebar/ConnectionDetails/CollectionDetails.swift b/pluk/Views/Sidebar/ConnectionDetails/CollectionDetails.swift index 90802121..204bb6a7 100644 --- a/pluk/Views/Sidebar/ConnectionDetails/CollectionDetails.swift +++ b/pluk/Views/Sidebar/ConnectionDetails/CollectionDetails.swift @@ -59,6 +59,7 @@ final class SidebarCollectionLoadCoordinator { struct SidebarViewModeToggle: View { @Binding var viewMode: SidebarViewMode @Binding var showAdvancedHistory: Bool + var databaseType: DatabaseType? var body: some View { HStack(spacing: 2) { @@ -68,7 +69,7 @@ struct SidebarViewModeToggle: View { ) { viewMode = .tables } - .customHelp("Tables") + .customHelp(databaseType == .redis ? "Keys" : "Tables") SegmentIconButton( icon: "clock.arrow.circlepath", @@ -329,6 +330,10 @@ struct QueryHistorySidebarRow: View { } private func loadQueryInEditor() { - instance.createSQLEditorTab(withQuery: entry.query) + if instance.connection.databaseType == .redis { + instance.createRedisCommandTab(withCommand: entry.query) + } else { + instance.createSQLEditorTab(withQuery: entry.query) + } } } diff --git a/pluk/Views/Sidebar/ConnectionDetails/ConnectionDetailsPopover.swift b/pluk/Views/Sidebar/ConnectionDetails/ConnectionDetailsPopover.swift index a923e05f..b715f551 100644 --- a/pluk/Views/Sidebar/ConnectionDetails/ConnectionDetailsPopover.swift +++ b/pluk/Views/Sidebar/ConnectionDetails/ConnectionDetailsPopover.swift @@ -48,6 +48,8 @@ struct ConnectionDetailsPopover: View { return "3306" case .mongodb: return "27017" + case .redis: + return "6379" } } @@ -127,10 +129,17 @@ struct ConnectionDetailsPopover: View { VStack(spacing: 8) { CompactDetailRow(label: "Driver", value: driverWithVersion) - CompactDetailRow(label: databaseType == .convex ? "Environment" : "Environment", value: databaseName) + CompactDetailRow( + label: databaseType == .convex + ? "Environment" + : (databaseType == .redis ? "DB Index" : "Database"), + value: databaseName + ) // Only show username for network-based databases - if let username = connection?.username, databaseType != .convex { + if let username = connection?.username, + !username.isEmpty, + databaseType != .convex { CompactDetailRow(label: "Username", value: username) } diff --git a/pluk/Views/Sidebar/ConnectionDetails/DatabaseHeader.swift b/pluk/Views/Sidebar/ConnectionDetails/DatabaseHeader.swift index de4e7f95..0393e1b5 100644 --- a/pluk/Views/Sidebar/ConnectionDetails/DatabaseHeader.swift +++ b/pluk/Views/Sidebar/ConnectionDetails/DatabaseHeader.swift @@ -31,7 +31,7 @@ struct DatabaseHeader: View { switch databaseType { case .postgres, .mysql, .mongodb, .supabase: return true - case .sqlite, .convex: + case .sqlite, .convex, .redis: return false } } @@ -40,7 +40,13 @@ struct DatabaseHeader: View { VStack { HStack { HStack(spacing: 0) { - if instance.databaseType == .convex { + if instance.databaseType == .redis { + RedisDatabaseHeaderView( + databaseIndex: instance.connectedDatabase?.name + ?? instance.connection.defaultDatabase + ?? "0" + ) + } else if instance.databaseType == .convex { ConvexHeaderView( availableSchemas: availableSchemas, selectedSchema: $selectedSchema, @@ -69,7 +75,9 @@ struct DatabaseHeader: View { let shouldShowRefreshButton = isSidebarHovered || isLoadingCollections Button { - if isLoadingCollections { + if instance.databaseType == .redis { + NotificationCenter.default.post(name: .redisKeysRefreshRequested, object: instance) + } else if isLoadingCollections { collectionLoader.cancel() } else { refreshSidebarItems() @@ -79,23 +87,37 @@ struct DatabaseHeader: View { .contentShape(.rect) } .buttonStyle(SidebarHeaderIconButtonStyle(isActive: isLoadingCollections)) - .customHelp(isLoadingCollections ? "Stop Refresh" : "Refresh Tables") + .customHelp(isLoadingCollections ? "Stop Refresh" : (instance.databaseType == .redis ? "Refresh Keys" : "Refresh Tables")) .disabled(!isLoadingCollections && instance.connectionStatus != .connected) .opacity(shouldShowRefreshButton ? 1 : 0) .allowsHitTesting(shouldShowRefreshButton) .animation(.easeOut(duration: 0.12), value: shouldShowRefreshButton) - Button { - instance.createCanvasTab() - } label: { - Image(systemName: "rectangle.connected.to.line.below") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .frame(width: 24, height: 20) - .contentShape(.rect) + if instance.connection.databaseType.supportsCanvas { + Button { + instance.createCanvasTab() + } label: { + Image(systemName: "rectangle.connected.to.line.below") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 20) + .contentShape(.rect) + } + .buttonStyle(SidebarHeaderIconButtonStyle()) + .customHelp("Schema Visualizer") + } else if instance.connection.databaseType.supportsCommandWorkspace { + Button { + instance.createRedisCommandTab() + } label: { + Image(systemName: "terminal") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 20) + .contentShape(.rect) + } + .buttonStyle(SidebarHeaderIconButtonStyle()) + .customHelp("Open Command Editor") } - .buttonStyle(SidebarHeaderIconButtonStyle()) - .customHelp("Schema Visualizer") } } } @@ -106,11 +128,15 @@ struct DatabaseHeader: View { }) .onAppear { selectedDatabase = instance.connectedDatabase?.name ?? "" - loadAvailableSchemas() + if instance.databaseType != .redis { + loadAvailableSchemas() + } } .onChange(of: instance.readiness) { _, _ in selectedDatabase = instance.connectedDatabase?.name ?? "" - loadAvailableSchemas() + if instance.databaseType != .redis { + loadAvailableSchemas() + } } .onChange(of: instance.databaseService.currentSchema) { oldSchema, newSchema in // The first `nil → default-schema` transition fires right after @@ -120,6 +146,7 @@ struct DatabaseHeader: View { // internally), so reloading here would just re-fetch the same data // — wasting an API round-trip and flashing the empty state. // User-initiated schema switches always go default → other. + guard instance.databaseType != .redis else { return } guard oldSchema != nil else { return } collectionLoader.start { await loadCollectionsForSchemaChange(newSchema) @@ -352,6 +379,24 @@ struct DatabaseHeader: View { } } +private struct RedisDatabaseHeaderView: View { + let databaseIndex: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "externaldrive.connected.to.line.below") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text("DB \(databaseIndex)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } +} + @MainActor private final class DatabaseShortcutMenuActionHandler: NSObject { private let onSelect: (String) -> Void @@ -714,7 +759,7 @@ struct TraditionalDatabaseHeaderView: View { switch databaseType { case .postgres, .mysql, .mongodb, .supabase: return true - case .sqlite, .convex: + case .sqlite, .convex, .redis: return false } } diff --git a/plukTests/RedisCommandSafetyTests.swift b/plukTests/RedisCommandSafetyTests.swift new file mode 100644 index 00000000..e821cc61 --- /dev/null +++ b/plukTests/RedisCommandSafetyTests.swift @@ -0,0 +1,425 @@ +import Testing +import SwiftData +import Foundation +@testable import Pluk + +struct RedisCommandTokenizerTests { + @Test + func tokenizesQuotesEscapesEmptyArgumentsAndBinaryBytes() throws { + let command = try RedisCommandTokenizer.tokenize( + #"SET "spaced key" 'single quoted' plain\ value "" "\x00\xFF\n""# + ) + + #expect(command.name == "SET") + #expect(command.arguments.map(\.bytes) == [ + Array("SET".utf8), + Array("spaced key".utf8), + Array("single quoted".utf8), + Array("plain value".utf8), + [], + [0x00, 0xFF, 0x0A], + ]) + #expect(command.transportCommand.arguments == command.arguments.map { Data($0.bytes) }) + } + + @Test + func concatenatesQuotedAndUnquotedTokenSegments() throws { + let command = try RedisCommandTokenizer.tokenize(#"SET pre"mid"'post' value"#) + + #expect(command.arguments[1].stringValue == "premidpost") + } + + @Test + func acceptsWhitespaceAcrossLinesAsArgumentsOfOneCommand() throws { + let command = try RedisCommandTokenizer.tokenize("MGET first\nsecond\tthird") + + #expect(command.name == "MGET") + #expect(command.arguments.compactMap(\.stringValue) == ["MGET", "first", "second", "third"]) + } + + @Test + func rejectsMalformedInput() { + #expect(throws: RedisCommandTokenizerError.emptyCommand) { + try RedisCommandTokenizer.tokenize(" \n\t") + } + #expect(throws: RedisCommandTokenizerError.unterminatedQuote(Character("\""))) { + try RedisCommandTokenizer.tokenize(#"GET "unfinished"#) + } + #expect(throws: RedisCommandTokenizerError.danglingEscape) { + try RedisCommandTokenizer.tokenize(#"GET trailing\"#) + } + #expect(throws: RedisCommandTokenizerError.invalidHexEscape) { + try RedisCommandTokenizer.tokenize(#"GET \xG0"#) + } + } +} + +struct RedisCommandRedactionTests { + @Test + func excludesAuthFromRedisHistoryAndSanitizesGenericHistory() throws { + let analysis = try RedisCommandSafety.analyze(#"AUTH "very secret""#) + + #expect( + analysis.historyDisposition == .exclude( + reason: "Authentication commands are never stored in query history." + ) + ) + + let sanitized = QuerySanitizer.sanitize(#"AUTH "very secret""#) + #expect(sanitized.sanitizedQuery == "AUTH [REDACTED]") + #expect(sanitized.wasSanitized) + #expect(!sanitized.sanitizedQuery.contains("very secret")) + } + + @Test + func redactsHelloAuthPasswordButKeepsNonSecretArguments() throws { + let analysis = try RedisCommandSafety.analyze( + #"HELLO 3 AUTH "user name" "very secret" SETNAME Pluk"# + ) + + guard case .record(let command, let wasRedacted) = analysis.historyDisposition else { + Issue.record("HELLO should remain recordable after credential redaction") + return + } + + #expect(wasRedacted) + #expect(command.contains("user name")) + #expect(command.contains("[REDACTED]")) + #expect(command.contains("SETNAME Pluk")) + #expect(!command.contains("very secret")) + + let reparsed = try RedisCommandTokenizer.tokenize(command) + #expect(reparsed.arguments[4].stringValue == "[REDACTED]") + } + + @Test + func malformedHelloCannotLeakTrailingCredential() { + let sanitized = QuerySanitizer.sanitize(#"HELLO 3 AUTH user "unterminated secret"#) + + #expect(sanitized.wasSanitized) + #expect(sanitized.sanitizedQuery == "HELLO 3 AUTH [REDACTED]") + #expect(!sanitized.sanitizedQuery.contains("secret")) + } + + @Test + func excludesACLSetUserAndSanitizesGenericHistory() throws { + let source = #"ACL SETUSER alice on >"very secret" ~cached:* +get"# + let analysis = try RedisCommandSafety.analyze(source) + + #expect( + analysis.historyDisposition == .exclude( + reason: "ACL SETUSER commands are never stored in query history." + ) + ) + + let sanitized = QuerySanitizer.sanitize(source) + #expect(sanitized.sanitizedQuery == "ACL SETUSER alice [REDACTED]") + #expect(sanitized.wasSanitized) + #expect(!sanitized.sanitizedQuery.contains("very secret")) + } + + @Test + func redactsSensitiveConfigSetValuesWithoutDroppingOtherArguments() throws { + let source = #"CONFIG SET maxmemory 1gb requirepass "server secret" tls-key-file-pass "key secret" appendonly yes"# + let analysis = try RedisCommandSafety.analyze(source) + + guard case .record(let command, let wasRedacted) = analysis.historyDisposition else { + Issue.record("CONFIG SET should remain recordable after credential redaction") + return + } + + #expect(wasRedacted) + #expect( + command == "CONFIG SET maxmemory 1gb requirepass [REDACTED] " + + "tls-key-file-pass [REDACTED] appendonly yes" + ) + #expect(!command.contains("server secret")) + #expect(!command.contains("key secret")) + + for parameter in ["masterauth", "primaryauth", "tls-client-key-file-pass"] { + let sanitized = QuerySanitizer.sanitize("CONFIG SET \(parameter) hidden-\(parameter)") + #expect(sanitized.sanitizedQuery == "CONFIG SET \(parameter) [REDACTED]") + #expect(!sanitized.sanitizedQuery.contains("hidden-")) + } + } + + @Test + func configRedactionOnlyTreatsParameterPositionsAsSensitive() throws { + let analysis = try RedisCommandSafety.analyze( + "CONFIG SET logfile requirepass masterauth actual-secret" + ) + + guard case .record(let command, true) = analysis.historyDisposition else { + Issue.record("CONFIG SET masterauth should be redacted") + return + } + + #expect(command == "CONFIG SET logfile requirepass masterauth [REDACTED]") + #expect(!command.contains("actual-secret")) + } + + @Test + func redactsMigrateAuthAndAuth2WhilePreservingOptionsAndKeys() throws { + let auth = try RedisCommandSafety.analyze( + #"MIGRATE cache.example 6379 item 0 5000 COPY AUTH "migration secret" REPLACE"# + ) + let auth2 = try RedisCommandSafety.analyze( + #"MIGRATE cache.example 6379 "" 0 5000 AUTH2 "user name" "second secret" KEYS key1 AUTH"# + ) + + guard case .record(let authCommand, true) = auth.historyDisposition, + case .record(let auth2Command, true) = auth2.historyDisposition else { + Issue.record("MIGRATE authentication should be redacted and remain recordable") + return + } + + #expect( + authCommand == "MIGRATE cache.example 6379 item 0 5000 COPY AUTH [REDACTED] REPLACE" + ) + #expect( + auth2Command == "MIGRATE cache.example 6379 \"\" 0 5000 AUTH2 \"user name\" " + + "[REDACTED] KEYS key1 AUTH" + ) + #expect(!authCommand.contains("migration secret")) + #expect(!auth2Command.contains("second secret")) + } + + @Test + func redactsSentinelAuthenticationSettings() throws { + let analysis = try RedisCommandSafety.analyze( + #"SENTINEL SET primary auth-user "sentinel user" auth-pass "sentinel secret" down-after-milliseconds 5000"# + ) + + guard case .record(let command, true) = analysis.historyDisposition else { + Issue.record("SENTINEL SET should remain recordable after credential redaction") + return + } + + #expect( + command == "SENTINEL SET primary auth-user [REDACTED] auth-pass [REDACTED] " + + "down-after-milliseconds 5000" + ) + #expect(!command.contains("sentinel user")) + #expect(!command.contains("sentinel secret")) + } + + @Test + func sentinelRedactionOnlyTreatsOptionPositionsAsSensitive() throws { + let analysis = try RedisCommandSafety.analyze( + "SENTINEL SET primary notification-script auth-pass auth-user actual-user" + ) + + guard case .record(let command, true) = analysis.historyDisposition else { + Issue.record("SENTINEL auth-user should be redacted") + return + } + + #expect( + command == "SENTINEL SET primary notification-script auth-pass auth-user [REDACTED]" + ) + #expect(!command.contains("actual-user")) + } + + @Test + func malformedSecretBearingCommandsUseConservativeFallbacks() { + let cases = [ + (#"ACL SETUSER alice on >"unterminated acl-secret"#, "ACL SETUSER [REDACTED]"), + (#"CONFIG SET maxmemory 1gb requirepass "unterminated config-secret"#, + "CONFIG SET maxmemory 1gb requirepass [REDACTED]"), + (#"MIGRATE host 6379 key 0 5000 AUTH2 user "unterminated migrate-secret"#, + "MIGRATE host 6379 key 0 5000 AUTH2 [REDACTED]"), + (#"SENTINEL SET primary auth-pass "unterminated sentinel-secret"#, + "SENTINEL SET primary auth-pass [REDACTED]"), + ] + + for (source, expected) in cases { + let sanitized = QuerySanitizer.sanitize(source) + #expect(sanitized.wasSanitized) + #expect(sanitized.sanitizedQuery == expected) + #expect(!sanitized.sanitizedQuery.localizedCaseInsensitiveContains("secret")) + } + } + + @Test + func excludesAuthNestedInCommandIntrospectionWrappers() throws { + let sources = [ + "ACL DRYRUN alice AUTH default acl-dryrun-secret", + "COMMAND GETKEYS AUTH command-secret", + "COMMAND GETKEYSANDFLAGS AUTH default command-flags-secret", + "COMMAND GETKEYS COMMAND GETKEYS AUTH recursive-secret", + ] + + for source in sources { + let analysis = try RedisCommandSafety.analyze(source) + guard case .exclude = analysis.historyDisposition else { + Issue.record("Nested AUTH credentials must be excluded from history") + continue + } + + let sanitized = QuerySanitizer.sanitize(source) + #expect(sanitized.wasSanitized) + #expect(sanitized.sanitizedQuery.hasSuffix("AUTH [REDACTED]")) + #expect(!sanitized.sanitizedQuery.localizedCaseInsensitiveContains("secret")) + } + } + + @Test + func redactsRecordableNestedCredentialsWithoutDroppingWrapperArguments() throws { + let analysis = try RedisCommandSafety.analyze( + "COMMAND GETKEYS HELLO 3 AUTH default nested-secret SETNAME Pluk" + ) + + guard case .record(let command, true) = analysis.historyDisposition else { + Issue.record("Recordable nested credentials should be redacted") + return + } + + #expect( + command == "COMMAND GETKEYS HELLO 3 AUTH default [REDACTED] SETNAME Pluk" + ) + #expect(!command.contains("nested-secret")) + } + + @Test + func malformedNestedAuthUsesConservativeFallback() { + let cases = [ + #"ACL DRYRUN alice AUTH default "unterminated acl-secret"#, + #"COMMAND GETKEYS AUTH "unterminated command-secret"#, + #"COMMAND GETKEYSANDFLAGS HELLO 3 AUTH default "unterminated hello-secret"#, + ] + + for source in cases { + let sanitized = QuerySanitizer.sanitize(source) + #expect(sanitized.wasSanitized) + #expect(sanitized.sanitizedQuery.hasSuffix("[REDACTED]")) + #expect(!sanitized.sanitizedQuery.localizedCaseInsensitiveContains("secret")) + } + } +} + +struct RedisCommandClassifierTests { + @Test + func permitsKnownReadOnlyCommands() throws { + for source in ["GET key", "SCAN 0 COUNT 100", "MEMORY USAGE key", "JSON.GET key"] { + let analysis = try RedisCommandSafety.analyze(source) + #expect(analysis.category == .readOnly) + #expect(analysis.executionPolicy == .allow) + } + } + + @Test + func requiresWriteConfirmation() throws { + for source in ["SET key value", "HSET key field value", "RENAMENX source destination"] { + let analysis = try RedisCommandSafety.analyze(source) + #expect(analysis.category == .write) + #expect(analysis.requiresConfirmation) + #expect(analysis.executionPolicy.confirmationKind == .write) + } + } + + @Test + func requiresDestructiveConfirmationForExplicitDataLoss() throws { + let commands = [ + "FLUSHALL", + "FLUSHDB ASYNC", + "DEL one two", + "UNLINK one", + "RENAME source destination", + "COPY source destination REPLACE", + ] + + for source in commands { + let analysis = try RedisCommandSafety.analyze(source) + #expect(analysis.category == .destructive) + #expect(analysis.executionPolicy.confirmationKind == .destructive) + #expect(analysis.requiresConfirmation) + } + } + + @Test + func classifiesAdminAndUnknownCommandsConservatively() throws { + let admin = try RedisCommandSafety.analyze("CONFIG SET maxmemory 1gb") + #expect(admin.category == .administrative) + #expect(admin.executionPolicy.confirmationKind == .administrative) + + let unknown = try RedisCommandSafety.analyze("MYMODULE.DO key") + #expect(unknown.category == .unknown) + #expect(unknown.executionPolicy.confirmationKind == .unknown) + } + + @Test + func usesServerMetadataWithoutOverridingLocalDestructiveRules() throws { + let readMetadata = RedisServerCommandMetadata(flags: ["readonly"]) + let customRead = try RedisCommandSafety.analyze("MYMODULE.GET key", serverMetadata: readMetadata) + #expect(customRead.category == .readOnly) + #expect(customRead.executionPolicy == .allow) + + let incorrectlyReadDelete = try RedisCommandSafety.analyze("DEL key", serverMetadata: readMetadata) + #expect(incorrectlyReadDelete.category == .destructive) + #expect(incorrectlyReadDelete.requiresConfirmation) + } + + @Test + func deniesCommandsThatPoisonOrMonopolizePooledConnections() throws { + let commands = [ + "AUTH secret", + "SELECT 2", + "MULTI", + "SUBSCRIBE channel", + "XREAD BLOCK 0 STREAMS s $", + "SYNC", + "PSYNC ? -1", + "REPLCONF listening-port 0", + "WAIT 1 1000", + "WAITAOF 1 1 1000", + "SCRIPT DEBUG yes", + ] + + for source in commands { + let analysis = try RedisCommandSafety.analyze(source) + #expect(analysis.category == .connectionStateful) + #expect(!analysis.allowsExecution) + #expect(!analysis.requiresConfirmation) + } + } +} + +struct RedisCommandHistoryTests { + @MainActor + @Test + func excludesAuthAndPersistsOnlyRedactedHelloCredentials() throws { + let schema = Schema([QueryHistoryEntry.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let service = QueryHistoryService( + modelContext: container.mainContext, + connectionKeychainId: "redis-history-test" + ) + + let auth = try RedisCommandSafety.analyze("AUTH default top-secret") + #expect(!service.recordRedisCommand(analysis: auth, databaseType: .redis)) + #expect(service.getHistoryCount() == 0) + + let acl = try RedisCommandSafety.analyze("ACL SETUSER alice >acl-secret") + #expect(!service.recordRedisCommand(analysis: acl, databaseType: .redis)) + #expect(service.getHistoryCount() == 0) + + let hello = try RedisCommandSafety.analyze("HELLO 3 AUTH default top-secret SETNAME Pluk") + #expect(service.recordRedisCommand(analysis: hello, databaseType: .redis, databaseName: "0")) + + let config = try RedisCommandSafety.analyze("CONFIG SET requirepass config-secret") + #expect(service.recordRedisCommand(analysis: config, databaseType: .redis, databaseName: "0")) + + let history = service.fetchHistory() + #expect(history.count == 2) + let allEntriesWereSanitized = history.allSatisfy { $0.wasSanitized } + #expect(allEntriesWereSanitized) + #expect(history.allSatisfy { !$0.isReplayable }) + #expect(history.allSatisfy { $0.querySource == .redisCommandEditor }) + #expect(history.contains { $0.query == "HELLO 3 AUTH default [REDACTED] SETNAME Pluk" }) + #expect(history.contains { $0.query == "CONFIG SET requirepass [REDACTED]" }) + #expect(history.allSatisfy { !$0.query.contains("top-secret") }) + #expect(history.allSatisfy { !$0.query.contains("acl-secret") }) + #expect(history.allSatisfy { !$0.query.contains("config-secret") }) + } +} diff --git a/plukTests/RedisConnectionURLParserTests.swift b/plukTests/RedisConnectionURLParserTests.swift new file mode 100644 index 00000000..790a7b98 --- /dev/null +++ b/plukTests/RedisConnectionURLParserTests.swift @@ -0,0 +1,148 @@ +// +// RedisConnectionURLParserTests.swift +// collectionTests +// + +import Testing +@testable import Pluk + +struct RedisConnectionURLParserTests { + @Test func parsesPlainRedisURLWithDefaults() throws { + let parsed = try ConnectionURLParser.parseRedis("redis://localhost") + + #expect(parsed.hostname == "localhost") + #expect(parsed.port == 6379) + #expect(parsed.username == nil) + #expect(parsed.password == nil) + #expect(parsed.databaseIndex == 0) + #expect(!parsed.usesTLS) + } + + @Test func parsesTLSACLAndPercentEncodedCredentials() throws { + let parsed = try ConnectionURLParser.parseRedis( + "rediss://app%20user:p%40ss%2Fword@redis.example.com:6380/12" + ) + + #expect(parsed.scheme == "rediss") + #expect(parsed.hostname == "redis.example.com") + #expect(parsed.port == 6380) + #expect(parsed.username == "app user") + #expect(parsed.password == "p@ss/word") + #expect(parsed.databaseIndex == 12) + #expect(parsed.usesTLS) + } + + @Test func parsesPasswordOnlyAuthentication() throws { + let parsed = try ConnectionURLParser.parseRedis( + "redis://:s%3Aecret@localhost:6379/2" + ) + + #expect(parsed.username == nil) + #expect(parsed.password == "s:ecret") + #expect(parsed.databaseIndex == 2) + } + + @Test func buildsAndRoundTripsTLSURL() throws { + let url = try ConnectionURLParser.makeRedisURL( + hostname: "::1", + port: 6380, + username: "cache user", + password: "p@ss/word", + databaseIndex: 3, + usesTLS: true + ) + let parsed = try ConnectionURLParser.parseRedis(url) + + #expect(parsed.hostname == "::1") + #expect(parsed.port == 6380) + #expect(parsed.username == "cache user") + #expect(parsed.password == "p@ss/word") + #expect(parsed.databaseIndex == 3) + #expect(parsed.usesTLS) + } + + @Test func buildsPasswordOnlyURL() throws { + let url = try ConnectionURLParser.makeRedisURL( + hostname: "localhost", + password: "secret", + databaseIndex: 1 + ) + + #expect(url == "redis://:secret@localhost:6379/1") + let parsed = try ConnectionURLParser.parseRedis(url) + #expect(parsed.username == nil) + #expect(parsed.password == "secret") + } + + @Test func rejectsUnsupportedScheme() { + #expect(throws: ConnectionURLParserError.unsupportedScheme("http")) { + try ConnectionURLParser.parseRedis("http://localhost:6379/0") + } + } + + @Test func rejectsInvalidPort() { + #expect(throws: ConnectionURLParserError.invalidPort(65_536)) { + try ConnectionURLParser.parseRedis("redis://localhost:65536/0") + } + } + + @Test func rejectsACLUsernameWithoutPassword() { + #expect(throws: ConnectionURLParserError.missingPassword) { + try ConnectionURLParser.parseRedis("redis://cache-user@localhost/0") + } + } + + @Test(arguments: ["-1", "abc", "1/extra"]) + func rejectsInvalidDatabaseIndex(_ index: String) { + #expect(throws: ConnectionURLParserError.invalidDatabaseIndex(index)) { + try ConnectionURLParser.parseRedis("redis://localhost/\(index)") + } + } + + @Test func redisCapabilitiesExcludeRelationalSurfaces() { + #expect(!DatabaseType.redis.supportsTableBrowser) + #expect(!DatabaseType.redis.supportsSchemaBrowser) + #expect(!DatabaseType.redis.supportsCanvas) + #expect(!DatabaseType.redis.supportsNotebookAnalytics) + #expect(DatabaseType.redis.supportsKeyValueBrowser) + #expect(DatabaseType.redis.supportsCommandWorkspace) + #expect(!DatabaseType.redis.supportsDatabaseCreation) + } + + @Test func fieldBasedRedisConnectionDoesNotPersistAURL() { + let connection = Connection( + databaseType: .redis, + name: "Local Redis", + color: .red, + environment: .local, + hostname: "localhost", + port: "6379", + username: "", + database: "4", + sslMode: "disable" + ) + + #expect(connection.url == nil) + #expect(connection.connectionUri == "redis://localhost:6379/4") + #expect(connection.usesFieldBasedConnection) + } + + @Test func fieldBasedRedisConnectionMapsRequiredTLS() { + let connection = Connection( + databaseType: .redis, + name: "Secure Redis", + color: .red, + environment: .production, + hostname: "redis.example.com", + port: "6380", + username: "", + database: "7", + sslMode: "require" + ) + + #expect( + connection.connectionUri + == "rediss://redis.example.com:6380/7" + ) + } +} diff --git a/plukTests/RedisDockerDiscoveryTests.swift b/plukTests/RedisDockerDiscoveryTests.swift new file mode 100644 index 00000000..3fe9fa4f --- /dev/null +++ b/plukTests/RedisDockerDiscoveryTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import Pluk + +struct RedisDockerDiscoveryTests { + @Test + func detectsRedisAndValkeyButNotExporterSidecars() { + #expect( + DockerContainerDiscoveryService.detectedDatabaseType( + name: "cache", + image: "redis:7-alpine", + exposedPorts: ["6379/tcp"] + ) == .redis + ) + #expect( + DockerContainerDiscoveryService.detectedDatabaseType( + name: "session-valkey-1", + image: "valkey/valkey:8", + exposedPorts: [] + ) == .redis + ) + #expect( + DockerContainerDiscoveryService.detectedDatabaseType( + name: "redis-exporter", + image: "oliver006/redis_exporter:latest", + exposedPorts: ["9121/tcp"] + ) == nil + ) + } + + @Test + func buildsPasswordOnlyRedisCandidateURIWithoutRequiringCredentials() { + let candidate = DockerDatabaseCandidate( + id: "container-id", + containerName: "project-redis-1", + imageName: "redis:7", + databaseType: .redis, + host: "localhost", + port: "6380", + username: nil, + password: "p@ss/word", + databaseName: "2", + isRunning: true, + createdAt: nil, + startedAt: nil + ) + + #expect(candidate.isReadyToConnect) + #expect(candidate.connectionName == "project") + #expect(candidate.connectionURI == "redis://:p%40ss%2Fword@localhost:6380/2") + } + + @Test + func requiresAPasswordWhenDockerDiscoveryFindsAnACLUsername() { + let candidate = DockerDatabaseCandidate( + id: "container-id", + containerName: "project-redis-1", + imageName: "redis:7", + databaseType: .redis, + host: "localhost", + port: "6379", + username: "cache-user", + password: nil, + databaseName: "0", + isRunning: true, + createdAt: nil, + startedAt: nil + ) + + #expect(!candidate.isReadyToConnect) + } + + @Test + func acceptsPasswordlessRedisCandidates() { + let candidate = DockerDatabaseCandidate( + id: "container-id", + containerName: "project-redis-1", + imageName: "redis:7", + databaseType: .redis, + host: "localhost", + port: "6379", + username: nil, + password: nil, + databaseName: "0", + isRunning: true, + createdAt: nil, + startedAt: nil + ) + + #expect(candidate.isReadyToConnect) + } +} diff --git a/plukTests/RedisDriverCoreTests.swift b/plukTests/RedisDriverCoreTests.swift new file mode 100644 index 00000000..4d1aee3e --- /dev/null +++ b/plukTests/RedisDriverCoreTests.swift @@ -0,0 +1,143 @@ +import Foundation +import NIOCore +import Testing +import Valkey +@testable import Pluk + +struct RedisDriverCoreTests { + @Test + func lifecycleGenerationInvalidatesOlderOperations() { + var lifecycle = RedisLifecycleGeneration() + let first = lifecycle.begin() + let second = lifecycle.begin() + + #expect(second > first) + #expect(!lifecycle.isCurrent(first)) + #expect(lifecycle.isCurrent(second)) + } + + @Test + func browserScanBuildsScanCommandAndNeverKeys() throws { + let arguments = RedisDriver.scanCommandArguments( + cursor: 42, + pattern: "user:*", + type: .hash, + count: 250 + ) + + let rendered = arguments.map { String(decoding: $0, as: UTF8.self) } + #expect(rendered == ["SCAN", "42", "MATCH", "user:*", "COUNT", "250", "TYPE", "hash"]) + #expect(rendered.first == "SCAN") + #expect(!rendered.contains("KEYS")) + } + + @Test + func decodesBinarySafeScanPage() throws { + var bytes = Array("*2\r\n$1\r\n0\r\n*2\r\n$3\r\nfoo\r\n$2\r\n".utf8) + bytes += [0xFF, 0x00] + bytes += Array("\r\n".utf8) + var buffer = ByteBuffer(bytes: bytes) + let decodedToken = try RESPToken(consuming: &buffer) + let token = try #require(decodedToken) + + let page = try RedisDriver.decodeScanPage(from: token) + + #expect(page.nextCursor == 0) + #expect(page.isComplete) + #expect(page.keys == [RedisKey("foo"), RedisKey(bytes: Data([0xFF, 0x00]))]) + } + + @Test + func roundTripsMaximumUnsignedScanCursor() throws { + let cursorText = String(UInt64.max) + var buffer = ByteBuffer( + bytes: Array("*2\r\n$\(cursorText.utf8.count)\r\n\(cursorText)\r\n*0\r\n".utf8) + ) + let decodedToken = try RESPToken(consuming: &buffer) + let token = try #require(decodedToken) + + let page = try RedisDriver.decodeScanPage(from: token) + let arguments = RedisDriver.scanCommandArguments( + cursor: page.nextCursor, + pattern: nil, + type: nil, + count: 200 + ) + + #expect(page.nextCursor == UInt64.max) + #expect(!page.isComplete) + #expect(String(decoding: arguments[1], as: UTF8.self) == cursorText) + #expect(RedisValuePage(cursor: .max).cursor == UInt64.max) + } + + @Test + func decodesRESP3MapWithoutLosingBinaryValues() throws { + var bytes = Array("%2\r\n+ok\r\n$3\r\n".utf8) + bytes += [0x00, 0xFF, 0x41] + bytes += Array("\r\n+count\r\n:2\r\n".utf8) + var buffer = ByteBuffer(bytes: bytes) + let decodedToken = try RESPToken(consuming: &buffer) + let token = try #require(decodedToken) + + let value = RedisDriver.commandValue(from: token) + + #expect( + value == .map([ + RedisCommandMapEntry( + key: .simpleString(Data("ok".utf8)), + value: .bulkString(Data([0x00, 0xFF, 0x41])) + ), + RedisCommandMapEntry( + key: .simpleString(Data("count".utf8)), + value: .integer(2) + ), + ]) + ) + } + + @Test + func encodesTypeSpecificMutationsWithoutChangingBytes() throws { + let key = RedisKey(bytes: Data([0x6B, 0x00, 0xFF])) + let value = Data([0x00, 0xFE, 0x41]) + + let encodedStringCommand = try RedisDriver.mutationCommandArguments( + for: .string(value), key: key, preserveTTL: true + ) + let stringCommand = try #require(encodedStringCommand) + #expect(stringCommand == [Data("SET".utf8), key.bytes, value, Data("KEEPTTL".utf8)]) + #expect(!stringCommand.contains(Data("PTTL".utf8))) + #expect(!stringCommand.contains(Data("PEXPIRE".utf8))) + + let encodedExpiringStringCommand = try RedisDriver.mutationCommandArguments( + for: .string(value), key: key, preserveTTL: false + ) + let expiringStringCommand = try #require(encodedExpiringStringCommand) + #expect(expiringStringCommand == [Data("SET".utf8), key.bytes, value]) + + let encodedHashCommand = try RedisDriver.mutationCommandArguments( + for: .hashField(field: Data([0xFF]), value: value), + key: key, + preserveTTL: true + ) + let hashCommand = try #require(encodedHashCommand) + #expect(hashCommand == [Data("HSET".utf8), key.bytes, Data([0xFF]), value]) + + let json = Data(#"{"enabled":true}"#.utf8) + let encodedJSONCommand = try RedisDriver.mutationCommandArguments( + for: .json(json), key: key, preserveTTL: true + ) + let jsonCommand = try #require(encodedJSONCommand) + #expect(jsonCommand == [Data("JSON.SET".utf8), key.bytes, Data("$".utf8), json]) + #expect(!jsonCommand.contains(Data("PTTL".utf8))) + #expect(!jsonCommand.contains(Data("PEXPIRE".utf8))) + } + + @Test + func boundsValuePagesAndAvoidsRangeOverflow() { + let page = RedisValuePage(offset: .max, count: .max, cursor: .max) + + #expect(page.count == 10_000) + #expect(RedisDriver.boundedPageCount(.max) == 10_000) + #expect(RedisDriver.redisRangeEnd(offset: .max, count: page.count) == .max) + } +} diff --git a/plukTests/RedisDriverLiveTests.swift b/plukTests/RedisDriverLiveTests.swift new file mode 100644 index 00000000..1cd780e5 --- /dev/null +++ b/plukTests/RedisDriverLiveTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import Pluk + +/// Opt-in integration coverage for a disposable Redis/Valkey server. +/// Provide `PLUK_REDIS_TEST_URL` to the hosted test process (for example through +/// the generated `.xctestrun` environment) to run it. Setting the variable only +/// on `xcodebuild` does not propagate it to this app-hosted test bundle. +struct RedisDriverLiveTests { + @Test + func failedDatabaseSwitchRetainsSettingsForReconnect() async throws { + guard let connectionURL = ProcessInfo.processInfo.environment["PLUK_REDIS_TEST_URL"], + !connectionURL.isEmpty else { + return + } + + let driver = RedisDriver() + _ = try await driver.connect(to: connectionURL) + + do { + do { + try await driver.switchDatabase(to: String(Int.max)) + Issue.record("Expected Redis to reject an out-of-range logical database") + } catch { + // A failed replacement stops the pool, but reconnect must still be + // able to use the last successfully connected settings. + try await driver.reconnect() + let ping = try await driver.executeRedisCommand(RedisCommand(arguments: ["PING"])) + #expect(ping.value == .simpleString(Data("PONG".utf8))) + } + + await driver.disconnect() + } catch { + await driver.disconnect() + throw error + } + } + + @Test + func liveServerRoundTripCoversScanningTypesTTLAndDatabaseIsolation() async throws { + guard let connectionURL = ProcessInfo.processInfo.environment["PLUK_REDIS_TEST_URL"], + !connectionURL.isEmpty else { + return + } + + let driver = RedisDriver() + let originalDatabase = try ConnectionURLParser.parseRedis(connectionURL).databaseIndex + let isolationDatabase = originalDatabase == 0 ? 1 : 0 + _ = try await driver.connect(to: connectionURL) + + let prefix = "pluk:integration:\(UUID().uuidString)" + var binaryKeyBytes = Data("\(prefix):binary:".utf8) + binaryKeyBytes.append(contentsOf: [0x00, 0xFF]) + let binaryKey = RedisKey(bytes: binaryKeyBytes) + let hashKey = RedisKey("\(prefix):hash") + let listKey = RedisKey("\(prefix):list") + let setKey = RedisKey("\(prefix):set") + let sortedSetKey = RedisKey("\(prefix):zset") + let streamKey = RedisKey("\(prefix):stream") + let keys = [binaryKey, hashKey, listKey, setKey, sortedSetKey, streamKey] + + do { + let originalValue = Data([0x00, 0x41, 0xFF]) + _ = try await driver.executeRedisCommand( + RedisCommand(arguments: [bytes("SET"), binaryKey.bytes, originalValue, bytes("PX"), bytes("60000")]) + ) + + let initialMetadata = try await driver.redisKeyMetadata(for: binaryKey) + #expect(initialMetadata.type == .string) + #expect((initialMetadata.ttlMilliseconds ?? 0) > 0) + + let initialValue = try await driver.redisValue(for: binaryKey, page: RedisValuePage()) + #expect(initialValue == .string(originalValue)) + + let replacement = Data([0xFE, 0x00, 0x42]) + try await driver.updateRedisValue(.string(replacement), for: binaryKey, preserveTTL: true) + let updatedMetadata = try await driver.redisKeyMetadata(for: binaryKey) + #expect((updatedMetadata.ttlMilliseconds ?? 0) > 0) + let updatedValue = try await driver.redisValue(for: binaryKey, page: RedisValuePage()) + #expect(updatedValue == .string(replacement)) + + _ = try await driver.executeRedisCommand( + RedisCommand(arguments: [bytes("HSET"), hashKey.bytes, Data([0xFF]), Data([0x00, 0x01])]) + ) + _ = try await driver.executeRedisCommand( + RedisCommand(arguments: [bytes("RPUSH"), listKey.bytes, Data([0x80]), bytes("two")]) + ) + _ = try await driver.executeRedisCommand( + RedisCommand(arguments: [bytes("SADD"), setKey.bytes, Data([0x00]), bytes("member")]) + ) + _ = try await driver.executeRedisCommand( + RedisCommand(arguments: [bytes("ZADD"), sortedSetKey.bytes, bytes("1.5"), Data([0xFE])]) + ) + _ = try await driver.executeRedisCommand( + RedisCommand(arguments: [bytes("XADD"), streamKey.bytes, bytes("*"), bytes("field"), Data([0xFF])]) + ) + + let decodedTypes = try await [hashKey, listKey, setKey, sortedSetKey, streamKey] + .asyncMap { try await driver.redisKeyMetadata(for: $0).type } + #expect(decodedTypes == [.hash, .list, .set, .sortedSet, .stream]) + + let hashValue = try await driver.redisValue(for: hashKey, page: RedisValuePage()) + if case .hash(let entries, let totalCount, _) = hashValue { + #expect(totalCount == 1) + #expect(entries == [RedisHashEntry(field: Data([0xFF]), value: Data([0x00, 0x01]))]) + } else { + Issue.record("Expected a decoded Redis hash") + } + + let listValue = try await driver.redisValue(for: listKey, page: RedisValuePage()) + if case .list(let elements, let totalCount, _) = listValue { + #expect(totalCount == 2) + #expect(elements == [Data([0x80]), bytes("two")]) + } else { + Issue.record("Expected a decoded Redis list") + } + + let setValue = try await driver.redisValue(for: setKey, page: RedisValuePage()) + if case .set(let members, let totalCount, _) = setValue { + #expect(totalCount == 2) + #expect(Set(members) == Set([Data([0x00]), bytes("member")])) + } else { + Issue.record("Expected a decoded Redis set") + } + + let sortedSetValue = try await driver.redisValue(for: sortedSetKey, page: RedisValuePage()) + if case .sortedSet(let entries, let totalCount, _) = sortedSetValue { + #expect(totalCount == 1) + #expect(entries == [RedisSortedSetEntry(member: Data([0xFE]), score: 1.5)]) + } else { + Issue.record("Expected a decoded Redis sorted set") + } + + let streamValue = try await driver.redisValue(for: streamKey, page: RedisValuePage()) + if case .stream(let entries, let totalCount) = streamValue { + #expect(totalCount == 1) + #expect(entries.count == 1) + #expect(entries.first?.fields == [RedisHashEntry(field: bytes("field"), value: Data([0xFF]))]) + } else { + Issue.record("Expected a decoded Redis stream") + } + + var cursor: UInt64 = 0 + var scanned = Set() + repeat { + let page = try await driver.scanRedisKeys( + cursor: cursor, + pattern: "\(prefix):*", + type: nil, + count: 2 + ) + scanned.formUnion(page.keys) + cursor = page.nextCursor + } while cursor != 0 + #expect(Set(keys).isSubset(of: scanned)) + + try await driver.switchDatabase(to: String(isolationDatabase)) + let isolatedPage = try await driver.scanRedisKeys( + cursor: 0, + pattern: "\(prefix):*", + type: nil, + count: 100 + ) + #expect(isolatedPage.keys.isEmpty) + try await driver.switchDatabase(to: String(originalDatabase)) + + _ = try await driver.deleteRedisKeys(keys, asynchronously: true) + await driver.disconnect() + } catch { + try? await driver.switchDatabase(to: String(originalDatabase)) + _ = try? await driver.deleteRedisKeys(keys, asynchronously: true) + await driver.disconnect() + throw error + } + } + + private func bytes(_ value: String) -> Data { + Data(value.utf8) + } +} + +private extension Array { + func asyncMap(_ transform: (Element) async throws -> Output) async rethrows -> [Output] { + var result: [Output] = [] + result.reserveCapacity(count) + for element in self { + result.append(try await transform(element)) + } + return result + } +} diff --git a/plukTests/collectionTests.swift b/plukTests/collectionTests.swift index 7a266c66..64aa63d5 100644 --- a/plukTests/collectionTests.swift +++ b/plukTests/collectionTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import collection +@testable import Pluk struct collectionTests {