diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 381fb9db2..c5da42d6c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -476,7 +476,7 @@ class AppDelegate: NSObject, // profile/rc files for the shell, which is super important on macOS // due to things like Homebrew. Instead, we set the command to // `; exit` which is what Terminal and iTerm2 do. - config.initialInput = "\(filename); exit\n" + config.initialInput = "\(filename.shellQuoted()); exit\n" // For commands executed directly, we want to ensure we wait after exit // because in most cases scripts don't block on exit and we don't want diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index be5c65bfa..142ce2951 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -68,7 +68,7 @@ struct NewTerminalIntent: AppIntent { // We don't run command as "command" and instead use "initialInput" so // that we can get all the login scripts to setup things like PATH. if let command { - config.initialInput = "\(command); exit\n" + config.initialInput = "\(command.shellQuoted()); exit\n" } // If we were given a working directory then open that directory diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 139a7892c..2a15cf283 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -26,4 +26,12 @@ extension String { return self } #endif + + private static let shellUnsafe = /[^\w@%+=:,.\/-]/ + + /// Returns a shell-escaped version of the string, like Python's shlex.quote. + func shellQuoted() -> String { + guard self.isEmpty || self.contains(Self.shellUnsafe) else { return self }; + return "'" + self.replacingOccurrences(of: "'", with: #"'"'"'"#) + "'" + } } diff --git a/macos/Tests/Helpers/Extensions/StringExtensionTests.swift b/macos/Tests/Helpers/Extensions/StringExtensionTests.swift new file mode 100644 index 000000000..55bb73b38 --- /dev/null +++ b/macos/Tests/Helpers/Extensions/StringExtensionTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import Ghostty + +struct StringExtensionTests { + @Test(arguments: [ + ("", "''"), + ("filename", "filename"), + ("abcABC123@%_-+=:,./", "abcABC123@%_-+=:,./"), + ("file name", "'file name'"), + ("file$name", "'file$name'"), + ("file!name", "'file!name'"), + ("file\\name", "'file\\name'"), + ("it's", "'it'\"'\"'s'"), + ("file$'name'", "'file$'\"'\"'name'\"'\"''"), + ]) + func shellQuoted(input: String, expected: String) { + #expect(input.shellQuoted() == expected) + } +}