diff --git a/libs/lume/src/Commands/SSH.swift b/libs/lume/src/Commands/SSH.swift index a3ec38137..6b7a684dd 100644 --- a/libs/lume/src/Commands/SSH.swift +++ b/libs/lume/src/Commands/SSH.swift @@ -101,8 +101,8 @@ struct SSH: AsyncParsableCommand { timeout: TimeInterval(timeout) ) - if !result.output.isEmpty { - print(result.output) + if !result.outputData.isEmpty { + FileHandle.standardOutput.write(result.outputData) } if result.exitCode != 0 { @@ -129,8 +129,8 @@ struct SSH: AsyncParsableCommand { timeout: TimeInterval(timeout) ) - if !result.output.isEmpty { - print(result.output) + if !result.outputData.isEmpty { + FileHandle.standardOutput.write(result.outputData) } if result.exitCode != 0 { diff --git a/libs/lume/src/SSH/SSHClient.swift b/libs/lume/src/SSH/SSHClient.swift index 46137e72b..7617b5c1b 100644 --- a/libs/lume/src/SSH/SSHClient.swift +++ b/libs/lume/src/SSH/SSHClient.swift @@ -7,12 +7,20 @@ import Foundation /// Result of an SSH command execution public struct SSHResult: Sendable { public let exitCode: Int32 + public let outputData: Data public let output: String public init(exitCode: Int32, output: String) { self.exitCode = exitCode + self.outputData = Data(output.utf8) self.output = output } + + public init(exitCode: Int32, outputData: Data) { + self.exitCode = exitCode + self.outputData = outputData + self.output = String(decoding: outputData, as: UTF8.self) + } } /// SSH client using SwiftNIO SSH for typed, versioned API @@ -310,13 +318,13 @@ private final class CommandExecHandler: ChannelDuplexHandler, @unchecked Sendabl // Complete when we have exit status (some servers close channel before sending exit status) if let status = exitStatus { resultPromise = nil - let output = outputBuffer.readString(length: outputBuffer.readableBytes) ?? "" - promise.succeed(SSHResult(exitCode: status, output: output)) + let outputData = outputBuffer.readData(length: outputBuffer.readableBytes) ?? Data() + promise.succeed(SSHResult(exitCode: status, outputData: outputData)) } else if channelClosed { // Channel closed without exit status - assume success with exit code 0 resultPromise = nil - let output = outputBuffer.readString(length: outputBuffer.readableBytes) ?? "" - promise.succeed(SSHResult(exitCode: 0, output: output)) + let outputData = outputBuffer.readData(length: outputBuffer.readableBytes) ?? Data() + promise.succeed(SSHResult(exitCode: 0, outputData: outputData)) } } diff --git a/libs/lume/src/SSH/SystemSSHClient.swift b/libs/lume/src/SSH/SystemSSHClient.swift index b033c64d6..d3948186a 100644 --- a/libs/lume/src/SSH/SystemSSHClient.swift +++ b/libs/lume/src/SSH/SystemSSHClient.swift @@ -61,7 +61,6 @@ public final class SystemSSHClient: Sendable { let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: stdoutData, encoding: .utf8) ?? "" let errorOutput = String(data: stderrData, encoding: .utf8) ?? "" // Filter out SSH warnings from stderr (known_hosts, etc.) @@ -73,11 +72,14 @@ public final class SystemSSHClient: Sendable { } .joined(separator: "\n") - let combinedOutput = filteredError.isEmpty ? output : output + filteredError + var outputData = stdoutData + if !filteredError.isEmpty, let errorData = filteredError.data(using: .utf8) { + outputData.append(errorData) + } return SSHResult( exitCode: process.terminationStatus, - output: combinedOutput + outputData: outputData ) } diff --git a/libs/lume/tests/SSHResultTests.swift b/libs/lume/tests/SSHResultTests.swift new file mode 100644 index 000000000..ab0e3d1a5 --- /dev/null +++ b/libs/lume/tests/SSHResultTests.swift @@ -0,0 +1,15 @@ +import Foundation +import Testing + +@testable import lume + +@Test("SSHResult preserves raw output bytes alongside text view") +func testSSHResultPreservesRawOutputBytes() { + let bytes = Data([0x00, 0xff, 0x41, 0x80, 0x42]) + let result = SSHResult(exitCode: 0, outputData: bytes) + + #expect(result.outputData == bytes) + #expect(result.output.contains("A")) + #expect(result.output.contains("B")) +} +