Skip to content

Commit c210d3a

Browse files
authored
Merge pull request #125 from darkroomengineering/port/ipv6-scp-normalization
fix(ssh): bracket IPv6 hosts in our scp upload call sites too
2 parents a716706 + 512052c commit c210d3a

5 files changed

Lines changed: 157 additions & 64 deletions

Sources/RemoteSSHConnectionPolicy.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,49 @@ enum RemoteSSHConnectionPolicy {
8686
static func shellSingleQuoted(_ value: String) -> String {
8787
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
8888
}
89+
90+
/// Rewrites a destination for `scp`'s combined `host:path` argument syntax by
91+
/// bracketing a bare IPv6 literal host (`user@2001:db8::1` -> `user@[2001:db8::1]`).
92+
///
93+
/// `ssh` takes the destination as its own argument, so a bare IPv6 literal like
94+
/// `2001:db8::1` is unambiguous there (see `CLI+SSH.swift`'s `normalizeSSHDestination`,
95+
/// which instead *strips* brackets for that call). `scp` glues the destination and the
96+
/// remote path together with a colon (`host:path`), so an un-bracketed IPv6 host's own
97+
/// colons collide with that separator and scp misparses the path. Only bare/unbracketed
98+
/// IPv6 hosts are rewritten — `user@host`, hostnames, IPv4 literals, and already-bracketed
99+
/// hosts pass through unchanged (#4948 follow-up: the ssh-only fix in `CLI+SSH.swift`
100+
/// didn't cover our scp call sites).
101+
static func scpRemoteDestination(_ destination: String) -> String {
102+
let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines)
103+
guard !trimmedDestination.isEmpty else { return destination }
104+
105+
let parts = trimmedDestination.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false)
106+
let userPart: String?
107+
let hostPart: String
108+
if parts.count == 2 {
109+
userPart = String(parts[0])
110+
hostPart = String(parts[1])
111+
} else {
112+
userPart = nil
113+
hostPart = trimmedDestination
114+
}
115+
116+
guard shouldBracketIPv6LiteralForSCP(hostPart) else {
117+
return trimmedDestination
118+
}
119+
120+
let bracketedHost = "[\(hostPart)]"
121+
if let userPart {
122+
return "\(userPart)@\(bracketedHost)"
123+
}
124+
return bracketedHost
125+
}
126+
127+
private static func shouldBracketIPv6LiteralForSCP(_ host: String) -> Bool {
128+
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
129+
return !trimmedHost.isEmpty &&
130+
trimmedHost.contains(":") &&
131+
!trimmedHost.hasPrefix("[") &&
132+
!trimmedHost.hasSuffix("]")
133+
}
89134
}

Sources/TerminalSSHSessionDetector.swift

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ struct DetectedSSHSession: Equatable {
154154
args += ["-o", option]
155155
}
156156

157-
args += [localPath, "\(Self.scpRemoteDestination(destination)):\(remotePath)"]
157+
args += [localPath, "\(RemoteSSHConnectionPolicy.scpRemoteDestination(destination)):\(remotePath)"]
158158
return args
159159
}
160160

@@ -311,40 +311,6 @@ struct DetectedSSHSession: Equatable {
311311
.first(where: { !$0.isEmpty })
312312
}
313313

314-
private static func scpRemoteDestination(_ destination: String) -> String {
315-
let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines)
316-
guard !trimmedDestination.isEmpty else { return destination }
317-
318-
let parts = trimmedDestination.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false)
319-
let userPart: String?
320-
let hostPart: String
321-
if parts.count == 2 {
322-
userPart = String(parts[0])
323-
hostPart = String(parts[1])
324-
} else {
325-
userPart = nil
326-
hostPart = trimmedDestination
327-
}
328-
329-
guard shouldBracketIPv6Literal(hostPart) else {
330-
return trimmedDestination
331-
}
332-
333-
let bracketedHost = "[\(hostPart)]"
334-
if let userPart {
335-
return "\(userPart)@\(bracketedHost)"
336-
}
337-
return bracketedHost
338-
}
339-
340-
private static func shouldBracketIPv6Literal(_ host: String) -> Bool {
341-
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
342-
return !trimmedHost.isEmpty &&
343-
trimmedHost.contains(":") &&
344-
!trimmedHost.hasPrefix("[") &&
345-
!trimmedHost.hasSuffix("]")
346-
}
347-
348314
#if DEBUG
349315
func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] {
350316
scpArguments(localPath: localPath, remotePath: remotePath)

Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ enum WorkspaceRemoteSSHBatchCommandBuilder {
3939
return args
4040
}
4141

42+
/// Builds the `scp` argument list for staging a local file to `configuration.destination`.
43+
/// Shared by the `programad-remote` binary upload and the dropped-file upload so the
44+
/// IPv6-bracketing fix (see `RemoteSSHConnectionPolicy.scpRemoteDestination`) only has
45+
/// to be applied once.
46+
static func scpUploadArguments(
47+
configuration: WorkspaceRemoteConfiguration,
48+
localPath: String,
49+
remotePath: String
50+
) -> [String] {
51+
let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions)
52+
var args: [String] = ["-q", "-o", "ControlMaster=no"]
53+
args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions)
54+
if let port = configuration.port {
55+
args += ["-P", String(port)]
56+
}
57+
if let identityFile = configuration.identityFile,
58+
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
59+
args += ["-i", identityFile]
60+
}
61+
for option in scpSSHOptions {
62+
args += ["-o", option]
63+
}
64+
args += [localPath, "\(RemoteSSHConnectionPolicy.scpRemoteDestination(configuration.destination)):\(remotePath)"]
65+
return args
66+
}
67+
4268
private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] {
4369
let effectiveSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions)
4470
var args = RemoteSSHConnectionPolicy.keepaliveArguments

Sources/WorkspaceRemoteSessionController+DaemonInstall.swift

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -471,21 +471,11 @@ extension WorkspaceRemoteSessionController {
471471
])
472472
}
473473

474-
let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions)
475-
var scpArgs: [String] = ["-q"]
476-
scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions)
477-
scpArgs += ["-o", "ControlMaster=no"]
478-
if let port = configuration.port {
479-
scpArgs += ["-P", String(port)]
480-
}
481-
if let identityFile = configuration.identityFile,
482-
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
483-
scpArgs += ["-i", identityFile]
484-
}
485-
for option in scpSSHOptions {
486-
scpArgs += ["-o", option]
487-
}
488-
scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"]
474+
let scpArgs = WorkspaceRemoteSSHBatchCommandBuilder.scpUploadArguments(
475+
configuration: configuration,
476+
localPath: localBinary.path,
477+
remotePath: remoteTempPath
478+
)
489479
let scpResult = try scpExec(arguments: scpArgs, timeout: 45)
490480
guard scpResult.status == 0 else {
491481
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)"
@@ -512,7 +502,6 @@ extension WorkspaceRemoteSessionController {
512502
_ fileURLs: [URL],
513503
operation: TerminalImageTransferOperation
514504
) throws -> [String] {
515-
let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions)
516505
return try performSCPUploadWithCancelCleanup(
517506
items: fileURLs,
518507
checkCancelled: { try operation.throwIfCancelled() },
@@ -524,19 +513,11 @@ extension WorkspaceRemoteSessionController {
524513

525514
let remotePath = Self.remoteDropPath(for: normalizedLocalURL)
526515
record(remotePath)
527-
var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"]
528-
scpArgs += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions)
529-
if let port = configuration.port {
530-
scpArgs += ["-P", String(port)]
531-
}
532-
if let identityFile = configuration.identityFile,
533-
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
534-
scpArgs += ["-i", identityFile]
535-
}
536-
for option in scpSSHOptions {
537-
scpArgs += ["-o", option]
538-
}
539-
scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"]
516+
let scpArgs = WorkspaceRemoteSSHBatchCommandBuilder.scpUploadArguments(
517+
configuration: configuration,
518+
localPath: normalizedLocalURL.path,
519+
remotePath: remotePath
520+
)
540521

541522
let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation)
542523
guard scpResult.status == 0 else {

programaTests/WorkspaceRemoteConnectionTests.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,81 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
12291229
XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8::1]:/tmp/programa-drop-123.png")
12301230
}
12311231

1232+
func testRemoteSSHConnectionPolicyScpRemoteDestinationBracketsBareIPv6Literal() {
1233+
XCTAssertEqual(
1234+
RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@2001:db8::1"),
1235+
"lawrence@[2001:db8::1]"
1236+
)
1237+
XCTAssertEqual(
1238+
RemoteSSHConnectionPolicy.scpRemoteDestination("2001:db8::1"),
1239+
"[2001:db8::1]"
1240+
)
1241+
XCTAssertEqual(
1242+
RemoteSSHConnectionPolicy.scpRemoteDestination("::1"),
1243+
"[::1]"
1244+
)
1245+
}
1246+
1247+
func testRemoteSSHConnectionPolicyScpRemoteDestinationPassesThroughNonBareIPv6Hosts() {
1248+
// Already-bracketed hosts are left alone.
1249+
XCTAssertEqual(
1250+
RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@[2001:db8::1]"),
1251+
"lawrence@[2001:db8::1]"
1252+
)
1253+
// IPv4 literals have no ambiguous colon, so they pass through untouched.
1254+
XCTAssertEqual(
1255+
RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@192.168.1.1"),
1256+
"lawrence@192.168.1.1"
1257+
)
1258+
// Plain hostnames and configured SSH aliases pass through untouched.
1259+
XCTAssertEqual(
1260+
RemoteSSHConnectionPolicy.scpRemoteDestination("cmux-macmini"),
1261+
"cmux-macmini"
1262+
)
1263+
XCTAssertEqual(
1264+
RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@example.com"),
1265+
"lawrence@example.com"
1266+
)
1267+
}
1268+
1269+
func testScpUploadArgumentsBracketsIPv6LiteralDestinationForDaemonBinaryUpload() {
1270+
let configuration = WorkspaceRemoteConfiguration(
1271+
destination: "2001:db8::1",
1272+
port: 2222,
1273+
identityFile: "/Users/test/.ssh/id_ed25519",
1274+
sshOptions: [
1275+
"ControlMaster=auto",
1276+
"ControlPersist=600",
1277+
"StrictHostKeyChecking=accept-new",
1278+
],
1279+
localProxyPort: nil,
1280+
relayPort: nil,
1281+
relayID: nil,
1282+
relayToken: nil,
1283+
localSocketPath: nil,
1284+
terminalStartupCommand: "ssh [2001:db8::1]"
1285+
)
1286+
1287+
// This is the exact call `WorkspaceRemoteSessionController+DaemonInstall.swift`
1288+
// makes for both the programad-remote binary upload and dropped-file uploads
1289+
// (#4948 follow-up: the ssh-only fix in CLI+SSH.swift left these scp call
1290+
// sites choking on bracketless IPv6 hosts).
1291+
let arguments = WorkspaceRemoteSSHBatchCommandBuilder.scpUploadArguments(
1292+
configuration: configuration,
1293+
localPath: "/tmp/programad-remote",
1294+
remotePath: "/home/lawrence/.programa/remote/programad-remote"
1295+
)
1296+
1297+
XCTAssertEqual(
1298+
arguments.last,
1299+
"[2001:db8::1]:/home/lawrence/.programa/remote/programad-remote"
1300+
)
1301+
XCTAssertTrue(arguments.contains("-P"))
1302+
XCTAssertTrue(arguments.contains("2222"))
1303+
XCTAssertTrue(arguments.contains("-i"))
1304+
XCTAssertTrue(arguments.contains("/Users/test/.ssh/id_ed25519"))
1305+
}
1306+
12321307
func testDetectsForegroundSSHSessionWithLowercaseAgentFlag() {
12331308
let session = TerminalSSHSessionDetector.detectForTesting(
12341309
ttyName: "/dev/ttys004",

0 commit comments

Comments
 (0)