Skip to content

Commit 86d7dcf

Browse files
committed
fix(connections): drain password-source command output without a reader race
1 parent a64d5dd commit 86d7dcf

3 files changed

Lines changed: 122 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- Reading a connection password from a command, 1Password, Vault, or AWS Secrets Manager no longer occasionally returns corrupted output from a race while reading the command's output.
1213
- The Structure tab filter and column sort now update the grid instead of leaving stale rows on screen.
1314
- The row details inspector now shows the selected row's values, including JSON, when a column value filter is active, and a JSON or serialized value you open now follows the selected row as you move between rows. (#1837)
1415
- Copying, duplicating, and deleting rows now act on the rows you selected when a column value filter is active, instead of the rows sitting at those positions in the unfiltered result. (#1837)

TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,26 @@ enum PasswordSourceResolver {
156156

157157
let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes)
158158
let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes)
159-
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
160-
let chunk = handle.availableData
161-
guard !chunk.isEmpty else { return }
162-
stdoutCollector.append(chunk)
163-
if stdoutCollector.overflowed, process.isRunning {
159+
try process.run()
160+
161+
let drainGroup = DispatchGroup()
162+
let drainQueue = DispatchQueue(label: "com.TablePro.PasswordSourceResolver.pipe-drain", attributes: .concurrent)
163+
drainPipe(
164+
stdoutPipe.fileHandleForReading,
165+
into: stdoutCollector,
166+
using: drainGroup,
167+
queue: drainQueue
168+
) {
169+
if process.isRunning {
164170
process.terminate()
165171
}
166172
}
167-
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
168-
let chunk = handle.availableData
169-
if !chunk.isEmpty { stderrCollector.append(chunk) }
170-
}
171-
172-
try process.run()
173+
drainPipe(
174+
stderrPipe.fileHandleForReading,
175+
into: stderrCollector,
176+
using: drainGroup,
177+
queue: drainQueue
178+
)
173179

174180
let didTimeout = AtomicFlag()
175181
let timeoutTask = Task.detached {
@@ -182,14 +188,7 @@ enum PasswordSourceResolver {
182188

183189
process.waitUntilExit()
184190
timeoutTask.cancel()
185-
186-
stdoutPipe.fileHandleForReading.readabilityHandler = nil
187-
stderrPipe.fileHandleForReading.readabilityHandler = nil
188-
189-
let remainingStdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
190-
if !remainingStdout.isEmpty { stdoutCollector.append(remainingStdout) }
191-
let remainingStderr = stderrPipe.fileHandleForReading.readDataToEndOfFile()
192-
if !remainingStderr.isEmpty { stderrCollector.append(remainingStderr) }
191+
drainGroup.wait()
193192

194193
if stdoutCollector.overflowed {
195194
throw ResolutionError.outputTooLarge
@@ -212,6 +211,27 @@ enum PasswordSourceResolver {
212211
return try nonEmpty(output.trimmingCharacters(in: .whitespacesAndNewlines))
213212
}
214213

214+
private static func drainPipe(
215+
_ handle: FileHandle,
216+
into collector: PipeDataCollector,
217+
using group: DispatchGroup,
218+
queue: DispatchQueue,
219+
onOverflow: (() -> Void)? = nil
220+
) {
221+
group.enter()
222+
queue.async {
223+
defer { group.leave() }
224+
while true {
225+
let chunk = handle.readData(ofLength: 8_192)
226+
guard !chunk.isEmpty else { return }
227+
collector.append(chunk)
228+
if collector.overflowed {
229+
onOverflow?()
230+
}
231+
}
232+
}
233+
}
234+
215235
private static func augmentedEnvironment() -> [String: String] {
216236
var environment = ProcessInfo.processInfo.environment
217237
let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// PasswordSourceResolverTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
9+
@testable import TablePro
10+
11+
@Suite("PasswordSourceResolver command output")
12+
struct PasswordSourceResolverTests {
13+
@Test("Command stdout is returned trimmed")
14+
func returnsTrimmedStdout() async throws {
15+
let output = try await PasswordSourceResolver.resolveCommand(
16+
shell: "printf ' hunter2\\n'",
17+
timeoutSeconds: 5
18+
)
19+
#expect(output == "hunter2")
20+
}
21+
22+
@Test("Large stdout drains in full and in order while stderr is also draining")
23+
func drainsLargeStdoutWithoutTruncation() async throws {
24+
let shell = """
25+
head -c 300000 /dev/zero | tr '\\0' 'a'
26+
head -c 300000 /dev/zero | tr '\\0' 'b' >&2
27+
"""
28+
let output = try await PasswordSourceResolver.resolveCommand(shell: shell, timeoutSeconds: 30)
29+
#expect(output.count == 300_000)
30+
#expect(output.allSatisfy { $0 == "a" })
31+
}
32+
33+
@Test("A failing command surfaces the exit code and stderr")
34+
func failingCommandSurfacesStderr() async throws {
35+
do {
36+
_ = try await PasswordSourceResolver.resolveCommand(
37+
shell: "printf boom >&2; exit 7",
38+
timeoutSeconds: 5
39+
)
40+
Issue.record("Expected resolveCommand to throw")
41+
} catch let PasswordSourceResolver.ResolutionError.commandFailed(exitCode, stderr) {
42+
#expect(exitCode == 7)
43+
#expect(stderr.contains("boom"))
44+
}
45+
}
46+
47+
@Test("Output over the size cap fails as too large")
48+
func overflowFailsAsTooLarge() async throws {
49+
do {
50+
_ = try await PasswordSourceResolver.resolveCommand(
51+
shell: "yes | head -c 1200000",
52+
timeoutSeconds: 30
53+
)
54+
Issue.record("Expected outputTooLarge")
55+
} catch PasswordSourceResolver.ResolutionError.outputTooLarge {
56+
}
57+
}
58+
59+
@Test("A command that exceeds the timeout is terminated")
60+
func timeoutTerminatesCommand() async throws {
61+
do {
62+
_ = try await PasswordSourceResolver.resolveCommand(
63+
shell: "sleep 30",
64+
timeoutSeconds: 1
65+
)
66+
Issue.record("Expected commandTimedOut")
67+
} catch PasswordSourceResolver.ResolutionError.commandTimedOut {
68+
}
69+
}
70+
71+
@Test("Empty output fails as an empty password")
72+
func emptyOutputFailsAsEmpty() async throws {
73+
do {
74+
_ = try await PasswordSourceResolver.resolveCommand(
75+
shell: "true",
76+
timeoutSeconds: 5
77+
)
78+
Issue.record("Expected emptyPassword")
79+
} catch PasswordSourceResolver.ResolutionError.emptyPassword {
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)