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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Sources/GhosttyApp+TerminalCustomUpload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AppKit
import CmuxTerminalCore

extension GhosttyApp {
@MainActor
@discardableResult
static func handleCustomPasteUploadIfMatched(
plan: TerminalImageTransferPlan,
Expand Down
35 changes: 24 additions & 11 deletions Sources/TerminalCustomUploadRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,38 @@ struct TerminalCustomUploadRunner {
private let runProcess: ProcessRunner
/// `DisableFileTransfer` (MDM), injected so tests can force it.
private let isFileTransferDisabled: () -> Bool
/// The `terminal.uploadCommands` rules. The settings catalog (cmux.json) by default,
/// injected so tests can supply rules without a settings runtime.
private let uploadRules: @MainActor () -> [TerminalUploadCommandRule]

init(
runProcess: @escaping ProcessRunner = TerminalCustomUploadRunner.spawnCommand,
isFileTransferDisabled: @escaping () -> Bool = { ManagedFileTransferPolicy.isDisabled }
isFileTransferDisabled: @escaping () -> Bool = { ManagedFileTransferPolicy.isDisabled },
uploadRules: @escaping @MainActor () -> [TerminalUploadCommandRule] = {
AppDelegate.shared?.settingsRuntime.map {
$0.jsonStore.snapshotValue(for: $0.catalog.terminal.uploadCommands)
} ?? []
}
) {
self.isFileTransferDisabled = isFileTransferDisabled
self.runProcess = runProcess
self.uploadRules = uploadRules
}

/// The command matching `endpoint.destination`, or nil when the built-in
/// transport should be used. Reads the `terminal.uploadCommands` rules from the
/// settings catalog (cmux.json). Called on the main thread from the drop/paste
/// sites, so the catalog is read via `MainActor.assumeIsolated`.
/// The command matching this endpoint, or nil when the built-in transport should be
/// used. A rule matches either `endpoint.destination` or the first usable `HostName`
/// in `endpoint.sshOptions`, so a broker alias still matches the host it reaches and
/// rules written against the alias keep working.
@MainActor
private func matchedCommand(for endpoint: Endpoint) -> String? {
let rules = MainActor.assumeIsolated {
AppDelegate.shared?.settingsRuntime.map {
$0.jsonStore.snapshotValue(for: $0.catalog.terminal.uploadCommands)
} ?? []
}
return TerminalUploadCommand(rules: rules).command(forDestination: endpoint.destination)
// Swift 5 mode only warns when a closure handed to DispatchQueue, Timer or
// NotificationCenter calls a main-actor function, so the run-time check stays.
MainActor.preconditionIsolated()
let rules = uploadRules()
return TerminalUploadCommand(rules: rules).command(
forDestination: endpoint.destination,
sshOptions: endpoint.sshOptions
)
Comment thread
ejc3 marked this conversation as resolved.
}

/// Runs `command` once per file and returns the space-joined string to type
Expand Down Expand Up @@ -139,6 +151,7 @@ struct TerminalCustomUploadRunner {
/// the main queue after the transfer operation is marked finished. Returns
/// true when it took ownership — the caller must NOT run the built-in
/// `execute`; false to fall through to the built-in transport unchanged.
@MainActor
@discardableResult
func handleIfMatched(
plan: TerminalImageTransferPlan,
Expand Down
48 changes: 42 additions & 6 deletions Sources/TerminalUploadCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ import Foundation
struct TerminalUploadCommand: Sendable, Equatable {
let rules: [TerminalUploadCommandRule]

/// The first enabled rule whose `hostPattern` matches `destination`, or nil
/// when none matches (the caller then uses the built-in `scp` transport).
func command(forDestination destination: String) -> String? {
let host = Self.hostForMatching(destination)
/// The first enabled rule whose `hostPattern` matches `destination` or the
/// `HostName` in `sshOptions`, or nil when none matches (the caller then uses
/// the built-in `scp` transport).
func command(forDestination destination: String, sshOptions: [String] = []) -> String? {
let hosts = Self.hostsForMatching(destination, sshOptions: sshOptions)
for rule in rules where rule.enabled {
guard let pattern = rule.hostPattern else {
return rule.command
}
if Self.hostMatches(pattern: pattern, host: host) {
if hosts.contains(where: { Self.hostMatches(pattern: pattern, host: $0) }) {
return rule.command
}
}
Expand All @@ -31,7 +32,42 @@ struct TerminalUploadCommand: Sendable, Equatable {
/// detected-ssh port is carried separately, so destinations here are bare
/// hosts in practice.
static func hostForMatching(_ destination: String) -> String {
var value = destination.trimmingCharacters(in: .whitespacesAndNewlines)
normalized(destination)
}

/// Every host a rule may match for this connection: the destination (the ssh
/// alias, or `localhost` for a brokered session) and, when present, the
/// first usable `HostName` option. A connection through a ProxyCommand or
/// jump host is dialled as a placeholder with the host it actually reaches
/// carried in `HostName`, so matching either one lets rules written against
/// the real host fire without breaking rules written against the alias.
static func hostsForMatching(_ destination: String, sshOptions: [String] = []) -> [String] {
let destinationHost = normalized(destination)
guard let hostName = hostNameOption(in: sshOptions) else { return [destinationHost] }
let resolvedHost = normalized(hostName)
return resolvedHost == destinationHost ? [destinationHost] : [destinationHost, resolvedHost]
}

/// The value of the first `HostName` option, or nil when none carries one.
/// ssh uses the first value it obtains for a parameter, so the first wins
/// here too. Keys are case-insensitive and `-o` accepts `Key value` as well
/// as `Key=Value`.
static func hostNameOption(in sshOptions: [String]) -> String? {
let separators = CharacterSet(charactersIn: "= \t")
for option in sshOptions {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard let separator = trimmed.rangeOfCharacter(from: separators) else { continue }
let key = trimmed[trimmed.startIndex..<separator.lowerBound]
guard key.lowercased() == "hostname" else { continue }
let value = trimmed[separator.lowerBound...]
.trimmingCharacters(in: CharacterSet(charactersIn: "= \t"))
if !value.isEmpty { return value }
}
return nil
}

private static func normalized(_ host: String) -> String {
var value = host.trimmingCharacters(in: .whitespacesAndNewlines)
if let atIndex = value.lastIndex(of: "@") {
value = String(value[value.index(after: atIndex)...])
}
Expand Down
139 changes: 139 additions & 0 deletions cmuxTests/TerminalUploadCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,105 @@ import Testing
#expect(TerminalUploadCommand.hostForMatching(" host ") == "host")
}

// MARK: - Brokered connections (ProxyCommand / jump host)

/// A connection through a broker is dialled as `localhost`, with the host it
/// actually reaches carried in `HostName`. Matching the destination argument
/// alone makes every brokered host look like `localhost`, so a rule for the
/// real host never fires.
@Test func hostNameOptionMatchesABrokeredLocalhostDestination() {
let options = [
"ProxyCommand=/usr/local/bin/broker --tunnel 'host1.corp.example.com'",
"HostName=host1.corp.example.com",
]
#expect(
TerminalUploadCommand.hostsForMatching("localhost", sshOptions: options)
== ["localhost", "host1.corp.example.com"]
)

let resolver = TerminalUploadCommand(rules: [
TerminalUploadCommandRule(hostPattern: "host*", command: "A"),
])
#expect(resolver.command(forDestination: "localhost", sshOptions: options) == "A")
}

/// Rules written against the alias (or the `localhost` workaround people used
/// before `HostName` was honored) keep matching when a `HostName` is present.
@Test func aliasRulesStillMatchWhenHostNameIsPresent() {
let options = ["HostName=host1.corp.example.com"]

let aliasRule = TerminalUploadCommand(rules: [
TerminalUploadCommandRule(hostPattern: "devbox", command: "alias"),
])
#expect(aliasRule.command(forDestination: "me@devbox", sshOptions: options) == "alias")

let localhostRule = TerminalUploadCommand(rules: [
TerminalUploadCommandRule(hostPattern: "localhost", command: "workaround"),
])
#expect(localhostRule.command(forDestination: "localhost", sshOptions: options) == "workaround")

let hostNameRule = TerminalUploadCommand(rules: [
TerminalUploadCommandRule(hostPattern: "*.corp.example.com", command: "resolved"),
])
#expect(hostNameRule.command(forDestination: "me@devbox", sshOptions: options) == "resolved")

let neither = TerminalUploadCommand(rules: [
TerminalUploadCommandRule(hostPattern: "other.example.com", command: "X"),
])
#expect(neither.command(forDestination: "devbox", sshOptions: options) == nil)
}

/// Rule order still decides: the first rule matching either host wins.
@Test func firstRuleMatchingEitherHostWins() {
let options = ["HostName=host1.corp.example.com"]
let resolver = TerminalUploadCommand(rules: [
TerminalUploadCommandRule(hostPattern: "*.corp.example.com", command: "resolved"),
TerminalUploadCommandRule(hostPattern: "devbox", command: "alias"),
])
#expect(resolver.command(forDestination: "devbox", sshOptions: options) == "resolved")
}

@Test func hostNameIsReadRegardlessOfSpellingOrSeparator() {
// ssh option keys are case-insensitive, and `-o` accepts `Key value` as
// well as `Key=Value`.
#expect(
TerminalUploadCommand.hostsForMatching("localhost", sshOptions: ["hostname=Host1.Example.COM"])
== ["localhost", "host1.example.com"]
)
#expect(
TerminalUploadCommand.hostsForMatching("localhost", sshOptions: ["HostName host1.example.com"])
== ["localhost", "host1.example.com"]
)
// ssh uses the first value it obtains for a parameter.
#expect(
TerminalUploadCommand.hostsForMatching(
"localhost",
sshOptions: ["HostName=first.example.com", "HostName=second.example.com"]
) == ["localhost", "first.example.com"]
)
// A HostName equal to the destination is not listed twice.
#expect(
TerminalUploadCommand.hostsForMatching("me@Host1.example.com", sshOptions: ["HostName=host1.example.com"])
== ["host1.example.com"]
)
}

@Test func withoutAHostNameTheDestinationStillDecides() {
#expect(
TerminalUploadCommand.hostsForMatching("me@host1.example.com", sshOptions: ["Port=22"])
== ["host1.example.com"]
)
// An empty or valueless HostName is ignored rather than matching "".
#expect(
TerminalUploadCommand.hostsForMatching("host1.example.com", sshOptions: ["HostName="])
== ["host1.example.com"]
)
#expect(
TerminalUploadCommand.hostsForMatching("host1.example.com", sshOptions: [])
== ["host1.example.com"]
)
}

// MARK: - Glob matching (fnmatch / ssh_config style)

@Test func hostMatchesGlob() {
Expand Down Expand Up @@ -210,6 +309,46 @@ import Testing
TerminalCustomUploadRunner(runProcess: fake)
}

/// ssh is given the broker alias, but the rule names the host the broker reaches. The
/// runner has to pass the session's ssh options into matching, or every brokered drop
/// falls through to the built-in transport.
@MainActor
@Test func brokeredSessionMatchesRuleByHostName() async {
let session = DetectedSSHSession(
destination: "broker-alias", port: nil, identityFile: nil,
configFile: nil, jumpHost: nil, controlPath: nil,
useIPv4: false, useIPv6: false, forwardAgent: false,
compressionEnabled: false, sshOptions: ["HostName=real-host.example.com"]
)
let runner = TerminalCustomUploadRunner(
runProcess: { _, env, _, _ in (0, "matched:\(env["CMUX_UPLOAD_DESTINATION"] ?? "")", "") },
isFileTransferDisabled: { false },
uploadRules: {
[TerminalUploadCommandRule(hostPattern: "real-host.example.com", command: "upload-tool put")]
}
)

var handled = false
let result: Result<String, Error> = await withCheckedContinuation { finished in
handled = runner.handleIfMatched(
plan: .uploadFiles([URL(fileURLWithPath: "/tmp/cmux-brokered-drop.png")], .detectedSSH(session)),
operation: TerminalImageTransferOperation(),
cleanup: { _ in },
completion: { finished.resume(returning: $0) }
)
if !handled {
finished.resume(returning: .failure(CancellationError()))
}
}

#expect(handled, "a rule naming the broker's HostName must take the drop")
guard case .success(let text) = result else {
Issue.record("expected the matched command to run, got \(result)")
return
}
#expect(text == "matched:broker-alias")
}

@Test func perFileStdoutJoinedWithSpaces() {
let result = runner { _, env, _, _ in
(0, "OUT:\(env["CMUX_UPLOAD_LOCAL_PATH"] ?? "")", "")
Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,10 @@ instead and inserts what the command prints.
- `hostPattern`: an fnmatch glob matched against the ssh destination (`user@` and
IPv6 brackets stripped, then lowercased) — the same glob style as a single
`ssh_config` `Host` pattern (`*`, `?`; no pattern lists or `!` negation). Omit
it, or set it to `null`, for a catch-all.
it, or set it to `null`, for a catch-all. When the session carries a `HostName`
ssh option (for example a connection through a ProxyCommand broker dialled as
`localhost`), a rule also matches that resolved host, so a pattern written
against either the alias or the real host works.
- `command`: run through `/bin/sh -c`, **once per file**. It receives the file and
endpoint on its environment: `CMUX_UPLOAD_LOCAL_PATH`, `CMUX_UPLOAD_REMOTE_PATH`
(the `/tmp/cmux-drop-<uuid>` path cmux picked), `CMUX_UPLOAD_DESTINATION`,
Expand Down
2 changes: 1 addition & 1 deletion web/data/cmux.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,7 @@
"hostPattern": {
"type": ["string", "null"],
"minLength": 1,
"description": "fnmatch glob matched against the ssh destination (user@ and IPv6 brackets stripped, lowercased), like a single ssh_config Host pattern. Omit the key or set it to null for a catch-all (a blank string is rejected)."
"description": "fnmatch glob matched against the ssh destination (user@ and IPv6 brackets stripped, lowercased), like a single ssh_config Host pattern. When the session sets a HostName (for example through a ProxyCommand or broker), the rule also matches against that resolved host. Omit the key or set it to null for a catch-all (a blank string is rejected)."
},
"command": {
"type": "string",
Expand Down
Loading