diff --git a/include/ghostty.h b/include/ghostty.h index e5afb6d1e..e3fb9958b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -818,6 +818,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index f3842fc56..f1df63133 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -46,6 +46,7 @@ extension Ghostty.Action { case unknown case text case html + case osc8 init(_ c: ghostty_action_open_url_kind_e) { switch c { @@ -53,6 +54,8 @@ extension Ghostty.Action { self = .text case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: self = .html + case GHOSTTY_ACTION_OPEN_URL_KIND_OSC8: + self = .osc8 default: self = .unknown } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index a791db66c..7a0c738e9 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -3,6 +3,10 @@ import UniformTypeIdentifiers import UserNotifications import GhosttyKit +#if os(macOS) +import AppKit +#endif + protocol GhosttyAppDelegate: AnyObject { #if os(macOS) /// Called when a callback needs access to a specific surface. This should return nil @@ -716,6 +720,13 @@ extension Ghostty { ) -> Bool { let action = Ghostty.Action.OpenURL(c: v) + // OSC 8 targets are producer-controlled terminal output. Keep them + // out of the unrestricted generic opener so unsafe local files and + // deceptive targets cannot reach Launch Services directly. + if action.kind == .osc8 { + return openUntrustedURL(action.url) + } + // If the URL doesn't have a valid scheme we assume its a file path. The URL // initializer will gladly take invalid URLs (e.g. plain file paths) and turn // them into schema-less URLs, but these won't open properly in text editors. @@ -745,6 +756,10 @@ extension Ghostty { case .unknown: break + + case .osc8: + assertionFailure("OSC 8 URLs must use the safe-opening policy") + return true } // Open with the default application for the URL @@ -752,6 +767,30 @@ extension Ghostty { return true } + private static func openUntrustedURL(_ value: String) -> Bool { + let target = UntrustedURL(value) + switch target.decision { + case .allow(let url): + _ = NSWorkspace.shared.open(url) + + case .confirm(let url): + UntrustedURLAlert.presentConfirmation( + for: url, + displayString: target.displayString + ) + + case .deny(let reason): + UntrustedURLAlert.presentBlock( + reason: reason, + displayString: target.displayString + ) + } + + // Always report OSC 8 actions as handled. Returning false would + // cause the core to retry with the unrestricted fallback opener. + return true + } + private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { let undoManager: UndoManager? switch target.tag { diff --git a/macos/Sources/Helpers/URLHoverBanner.swift b/macos/Sources/Helpers/URLHoverBanner.swift index 860a746d6..44d99f109 100644 --- a/macos/Sources/Helpers/URLHoverBanner.swift +++ b/macos/Sources/Helpers/URLHoverBanner.swift @@ -6,6 +6,11 @@ struct URLHoverBanner: View { let padding: CGFloat = 5 let cornerRadius: CGFloat = 9 let url: String + + private var displayedURL: String { + UntrustedURL(url).displayString + } + var body: some View { ZStack { HStack { @@ -13,7 +18,7 @@ struct URLHoverBanner: View { VStack(alignment: .leading) { Spacer() - Text(verbatim: url) + Text(verbatim: displayedURL) .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background( UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius)) @@ -29,7 +34,7 @@ struct URLHoverBanner: View { VStack(alignment: .leading) { Spacer() - Text(verbatim: url) + Text(verbatim: displayedURL) .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background( UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius)) diff --git a/macos/Sources/Helpers/UntrustedURL.swift b/macos/Sources/Helpers/UntrustedURL.swift new file mode 100644 index 000000000..587897858 --- /dev/null +++ b/macos/Sources/Helpers/UntrustedURL.swift @@ -0,0 +1,262 @@ +import Foundation +import UniformTypeIdentifiers + +/// A URL supplied by a source that should not be allowed to dispatch directly +/// to the operating system, such as terminal output. +struct UntrustedURL: Equatable { + enum DenialReason: Equatable { + case malformedURL + case unsafeCharacters + case invalidWebURL + case inaccessibleFile + case unsafeFile + + var message: String { + switch self { + case .malformedURL: + "The target is not an absolute URL with a scheme." + case .unsafeCharacters: + "The target contains invisible or line-breaking characters." + case .invalidWebURL: + "The web target does not contain a valid host." + case .inaccessibleFile: + "The local target does not exist or is not a regular file or directory." + case .unsafeFile: + "Opening this local target could execute code." + } + } + } + + enum Decision: Equatable { + /// Open schemes with non-executing, well-understood behavior directly. + case allow(URL) + + /// Ask before dispatching a custom scheme to its registered handler. + case confirm(URL) + + /// Never dispatch malformed targets or executable local files. + case deny(DenialReason) + } + + let string: String + + init(_ string: String) { + self.string = string + } + + var decision: Decision { + guard !string.isEmpty else { return .deny(.malformedURL) } + + // Foundation accepts many Unicode control and formatting characters in + // a URL. UI frameworks can render those same characters as line breaks, + // zero-width text, or bidirectional overrides, so reject them before + // parsing changes their representation. + guard !string.unicodeScalars.contains(where: Self.isUnsafeCharacter) else { + return .deny(.unsafeCharacters) + } + + // URL(string:) also accepts relative references. An untrusted target + // must include an explicit scheme so it cannot be reinterpreted as a + // local path by a later layer. + guard + let url = URL(string: string), + let scheme = url.scheme?.lowercased(), + !scheme.isEmpty + else { + return .deny(.malformedURL) + } + + switch scheme { + case "http", "https": + // Reject values such as "https:relative". They have a scheme, but + // no authority, and different consumers may resolve them against a + // base URL differently. + guard let host = url.host, !host.isEmpty else { + return .deny(.invalidWebURL) + } + return .allow(url) + + case "mailto": + // URLComponents places the address portion of a mailto URL in the + // path. Require one so a bare "mailto:" cannot dispatch an empty + // request to the user's mail application. + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + !components.path.isEmpty + else { + return .deny(.malformedURL) + } + return .allow(url) + + case "file": + return fileDecision(for: url) + + default: + // A custom scheme can invoke any application registered with + // Launch Services. The caller must show the target and handler + // before allowing that dispatch. + return .confirm(url) + } + } + + /// A single-line representation of the effective target. Paths are + /// standardized before display so traversal and repeated separators cannot + /// cause the visible and opened targets to differ. + var displayString: String { + let normalized: String + if let url = URL(string: string), url.scheme != nil { + // File URLs are standardized exactly as they are before opening, + // including symlink resolution. Keep non-file URLs byte-for-byte + // equivalent because repeated separators can be meaningful to a + // web or custom-scheme handler. + normalized = url.isFileURL + ? url.standardizedFileURL.resolvingSymlinksInPath().path + : string + } else { + // Scheme-less values are never allowed to open, but they still + // appear in the blocked-target UI. Standardizing them prevents + // slash padding and dot traversal from hiding the effective path. + normalized = URL(filePath: string).standardizedFileURL.path + } + + // Escaping happens after normalization so any unsafe scalar that + // remains is visible as text and cannot create a second display line. + var result = String() + result.reserveCapacity(normalized.count) + for scalar in normalized.unicodeScalars { + if Self.isUnsafeCharacter(scalar) { + result += "\\u{\(String(scalar.value, radix: 16, uppercase: true))}" + } else { + result.unicodeScalars.append(scalar) + } + } + + return result + } +} + +private extension UntrustedURL { + func fileDecision(for url: URL) -> Decision { + // Only local file URLs are meaningful here. Queries and fragments do + // not identify part of a filesystem object and may be interpreted + // inconsistently by Launch Services handlers. + guard url.isFileURL, url.query == nil, url.fragment == nil else { + return .deny(.malformedURL) + } + + // An empty host and localhost both refer to this machine. Do not allow + // file URLs that name a remote host and could trigger network access. + if let host = url.host, + !host.isEmpty, + host.caseInsensitiveCompare("localhost") != .orderedSame { + return .deny(.malformedURL) + } + + // Classify the effective object, not the spelling supplied by terminal + // output. This collapses dot traversal and prevents a harmless-looking + // symlink name from hiding an executable target. + let canonicalURL = url.standardizedFileURL.resolvingSymlinksInPath() + let resourceValues: URLResourceValues + do { + // Reading all relevant resource keys together also proves that the + // canonical target exists and is accessible. + resourceValues = try canonicalURL.resourceValues(forKeys: [ + .contentTypeKey, + .isDirectoryKey, + .isExecutableKey, + .isRegularFileKey, + ]) + } catch { + return .deny(.inaccessibleFile) + } + + // Exclude devices, sockets, and other special filesystem objects. A + // directory is safe to reveal in Finder unless its extension or UTI + // identifies it as an application bundle. + guard resourceValues.isDirectory == true || resourceValues.isRegularFile == true else { + return .deny(.inaccessibleFile) + } + guard !Self.isUnsafeFile(canonicalURL, resourceValues: resourceValues) else { + return .deny(.unsafeFile) + } + + return .allow(canonicalURL) + } + + static func isUnsafeFile( + _ url: URL, + resourceValues: URLResourceValues + ) -> Bool { + // Launch Services uses extensions when choosing a handler. Block known + // executable containers even when their POSIX executable bit is clear. + if unsafePathExtensions.contains(url.pathExtension.lowercased()) { + return true + } + + // UTIs cover files whose extension is missing or intentionally + // misleading. Use broad system-declared types so subclasses such as + // shell scripts and application bundles are included automatically. + if let contentType = resourceValues.contentType, + unsafeContentTypes.contains(where: { contentType.conforms(to: $0) }) { + return true + } + + // Finally, reject any regular file the filesystem marks executable, + // regardless of its name or detected content type. + return resourceValues.isDirectory != true && resourceValues.isExecutable == true + } + + static func isUnsafeCharacter(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + // C0/C1 controls include CR, LF, NEL, and other non-printing bytes. + case 0x00...0x1F, 0x7F...0x9F: + return true + + // Directional marks and zero-width characters can reorder or conceal + // portions of the target without changing what the handler receives. + case 0x061C, 0x200B...0x200F, 0x202A...0x202E, 0x2066...0x2069: + return true + + // Unicode line/paragraph separators create additional visual lines in + // SwiftUI and AppKit text even though OSC accepts their UTF-8 bytes. + case 0x2028...0x2029: + return true + + // Word Joiner and BOM are invisible formatting characters that can be + // used as padding or to disguise otherwise identical-looking targets. + case 0x2060, 0xFEFF: + return true + + default: + return false + } + } + + // Keep the large policy tables after the behavior so the primary type and + // its decision flow remain easy to scan. + static let unsafePathExtensions: Set = [ + "action", + "app", + "applescript", + "class", + "command", + "desktop", + "inetloc", + "jar", + "mobileconfig", + "mpkg", + "pkg", + "scpt", + "terminal", + "tool", + "url", + "webloc", + "workflow", + ] + + static let unsafeContentTypes: [UTType] = [ + .application, + .executable, + .script, + ] +} diff --git a/macos/Sources/Helpers/UntrustedURLAlert.swift b/macos/Sources/Helpers/UntrustedURLAlert.swift new file mode 100644 index 000000000..587d47fdd --- /dev/null +++ b/macos/Sources/Helpers/UntrustedURLAlert.swift @@ -0,0 +1,106 @@ +#if os(macOS) +import AppKit + +/// Presents decisions for untrusted URLs at the AppKit boundary. +enum UntrustedURLAlert { + static func presentConfirmation(for url: URL, displayString: String) { + deferPresentation { + let workspace = NSWorkspace.shared + let handler = workspace.urlForApplication(toOpen: url) + .map { "\u{201c}\($0.deletingPathExtension().lastPathComponent)\u{201d}" } + ?? "the default application" + let alert = NSAlert() + alert.alertStyle = .warning + alert.icon = NSImage(named: NSImage.cautionName) + alert.messageText = "Open Link from Terminal Output?" + alert.informativeText = """ + This link will open in \(handler). Only continue if you recognize \ + and trust the destination. + """ + alert.accessoryView = targetView(displayString) + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Open Link") + + present(alert) { response in + // Cancel is deliberately the default action. + guard response == .alertSecondButtonReturn else { return } + _ = workspace.open(url) + } + } + } + + static func presentBlock( + reason: UntrustedURL.DenialReason, + displayString: String + ) { + deferPresentation { + let alert = NSAlert() + alert.alertStyle = .warning + alert.icon = NSImage(named: NSImage.cautionName) + alert.messageText = "Ghostty Blocked This Link" + alert.informativeText = reason.message + alert.accessoryView = targetView(displayString) + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Copy Link") + + present(alert) { response in + // Keep blocked targets out of Launch Services. Copying the + // displayed, sanitized value gives the user an explicit path + // forward without adding a one-click policy bypass. + guard response == .alertSecondButtonReturn else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(displayString, forType: .string) + } + } + } + + /// The core action callback runs with the renderer mutex held. Queue modal + /// presentation for the next main-loop turn so AppKit cannot reenter a + /// render callback before that mutex is released. + private static func deferPresentation(_ action: @escaping () -> Void) { + DispatchQueue.main.async(execute: action) + } + + private static func present( + _ alert: NSAlert, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = NSApp.keyWindow { + alert.beginSheetModal(for: window, completionHandler: completion) + } else { + completion(alert.runModal()) + } + } + + private static func targetView(_ target: String) -> NSView { + let scrollView = NSScrollView(frame: NSRect( + x: 0, + y: 0, + width: 480, + height: 96 + )) + scrollView.borderType = .bezelBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + let textView = NSTextView(frame: scrollView.contentView.bounds) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.font = .monospacedSystemFont( + ofSize: NSFont.systemFontSize, + weight: .regular + ) + textView.textContainerInset = NSSize(width: 6, height: 6) + textView.string = target + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: scrollView.contentSize.width, + height: .greatestFiniteMagnitude + ) + scrollView.documentView = textView + return scrollView + } +} +#endif diff --git a/macos/Tests/Ghostty/UntrustedURLTests.swift b/macos/Tests/Ghostty/UntrustedURLTests.swift new file mode 100644 index 000000000..6eb921070 --- /dev/null +++ b/macos/Tests/Ghostty/UntrustedURLTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import Ghostty + +@Suite +struct UntrustedURLTests { + @Test(arguments: ["http://example.com", "https://example.com/path", "mailto:user@example.com"]) + func allowsSafeSchemes(_ value: String) { + guard case .allow(let url) = UntrustedURL(value).decision else { + Issue.record("expected an allowed URL") + return + } + #expect(url.absoluteString == value) + } + + @Test(arguments: ["https:relative", "http:///missing-host"]) + func rejectsWebURLsWithoutHosts(_ value: String) { + #expect(UntrustedURL(value).decision == .deny(.invalidWebURL)) + } + + @Test(arguments: ["/tmp/file.txt", "../file.txt", "payload.command"]) + func rejectsSchemeLessTargets(_ value: String) { + #expect(UntrustedURL(value).decision == .deny(.malformedURL)) + } + + @Test(arguments: ["vscode://file/tmp/example.swift", "ssh://example.com"]) + func confirmsCustomSchemes(_ value: String) { + guard case .confirm(let url) = UntrustedURL(value).decision else { + Issue.record("expected a confirmation decision") + return + } + #expect(url.absoluteString == value) + } + + @Test(arguments: ["\u{0085}", "\u{2028}", "\u{2029}", "\u{202E}", "\u{2066}"]) + func rejectsInvisibleAndLineBreakingCharacters(_ scalar: String) { + let value = "https://example.com/before\(scalar)after" + #expect(UntrustedURL(value).decision == .deny(.unsafeCharacters)) + } + + @Test + func allowsNonExecutableLocalFiles() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "document.txt") + try "safe".write(to: file, atomically: true, encoding: .utf8) + + guard case .allow(let result) = UntrustedURL(file.absoluteString).decision else { + Issue.record("expected a safe local file") + return + } + #expect(result == file.standardizedFileURL.resolvingSymlinksInPath()) + } + + @Test(arguments: ["payload.command", "payload.tool", "payload.app", "payload.workflow"]) + func rejectsDangerousLocalFileExtensions(_ filename: String) throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: filename) + try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8) + + #expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile)) + } + + @Test + func rejectsScriptContentTypes() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "payload.sh") + try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8) + + #expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile)) + } + + @Test + func rejectsExecutableFilesRegardlessOfExtension() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "payload.txt") + try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: file.path + ) + + #expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile)) + } + + @Test + func resolvesSymlinksBeforeClassifyingFiles() throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let payload = directory.appending(path: "payload.command") + let link = directory.appending(path: "document.txt") + try "#!/bin/sh\n".write(to: payload, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: payload) + + #expect(UntrustedURL(link.absoluteString).decision == .deny(.unsafeFile)) + } + + @Test(arguments: ["\u{0085}", "\u{2028}", "\u{2029}"]) + func previewShowsTheEffectiveStandardizedPath(_ separator: String) { + let value = "/tmp/preview\(separator)////../payload.command////" + #expect(UntrustedURL(value).displayString == "/tmp/payload.command") + } + + @Test + func previewEscapesBidirectionalControls() { + let value = "https://example.com/a\u{202E}b" + #expect(UntrustedURL(value).displayString == "https://example.com/a\\u{202E}b") + } + + private func makeTemporaryDirectory() throws -> URL { + let result = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: result, + withIntermediateDirectories: false + ) + return result + } +} diff --git a/src/Surface.zig b/src/Surface.zig index 1a890d9b8..0096878d7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -334,6 +334,7 @@ const DerivedConfig = struct { title: ?[:0]const u8, title_report: bool, links: []DerivedConfig.Link, + link_osc8: bool, link_previews: configpkg.LinkPreviews, scroll_to_bottom: configpkg.Config.ScrollToBottom, notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, @@ -413,6 +414,7 @@ const DerivedConfig = struct { .title = config.title, .title_report = config.@"title-report", .links = links, + .link_osc8 = config.@"link-osc8", .link_previews = config.@"link-previews", .scroll_to_bottom = config.@"scroll-to-bottom", .notify_on_command_finish = config.@"notify-on-command-finish", @@ -4326,7 +4328,9 @@ fn linkAtPos( const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); // If we have the proper modifiers set then we can check for OSC8 links. - if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: { + if (self.config.link_osc8 and + mouse_mods.equal(input.ctrlOrSuper(.{}))) + hyperlink: { const rac = mouse_pin.rowAndCell(); const cell = rac.cell; if (!cell.hyperlink) break :hyperlink; @@ -4436,7 +4440,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try self.openUrl(.{ .kind = .unknown, .url = uri }); + try self.openUrl(.{ .kind = .osc8, .url = uri }); }, } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 6db465277..d2fdf5d83 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -941,6 +941,11 @@ pub const OpenUrl = struct { /// The URL is known to contain HTML content. html, + /// The URL came from an OSC 8 hyperlink. Application runtimes should + /// treat this as untrusted terminal output and apply a platform-specific + /// safe-opening policy. + osc8, + test "ghostty.h OpenUrl.Kind" { try lib.checkGhosttyHEnum(Kind, "GHOSTTY_ACTION_OPEN_URL_KIND_"); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 787c10aed..7fa282437 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1469,6 +1469,14 @@ link: RepeatableLink = .{}, /// `link`). If you want to customize URL matching, use `link` and disable this. @"link-url": bool = true, +/// Enable hyperlinks created with the OSC 8 escape sequence. When disabled, +/// OSC 8 hyperlinks are not highlighted, previewed, copied, or opened. +/// +/// This does not affect URL matching controlled by `link-url`. +/// +/// Available since: 1.4.0 +@"link-osc8": bool = true, + /// Show link previews for a matched URL. /// /// When true, link previews are shown for all matched URLs. When false, link diff --git a/src/os/open.zig b/src/os/open.zig index 806030fcd..bc09518e6 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -19,12 +19,21 @@ pub fn open( kind: apprt.action.OpenUrl.Kind, url: []const u8, ) !void { + // On macOS, the apprt handles OSC 8 targets before this fallback. Ghostty's + // native apprt applies its allowlist, confirmation, and file safety policy. + // If a macOS embedder declines the action, fail closed rather than bypassing + // that policy by handing producer-controlled terminal output to `open`. + if (comptime builtin.os.tag == .macos) { + if (kind == .osc8) return error.UnsafeOSC8Link; + } + var spawn_opts: std.process.SpawnOptions = switch (builtin.os.tag) { .linux, .freebsd => .{ .argv = &.{ "xdg-open", url } }, .windows => .{ .argv = &.{ "rundll32", "url.dll,FileProtocolHandler", url } }, .macos => switch (kind) { .text => .{ .argv = &.{ "open", "-t", url } }, .html, .unknown => .{ .argv = &.{ "open", url } }, + .osc8 => unreachable, }, .ios => return error.Unimplemented, else => @compileError("unsupported OS"), @@ -58,6 +67,15 @@ pub fn open( thread.detach(); } +test "macOS OSC 8 links have no generic opener fallback" { + if (builtin.os.tag != .macos) return error.SkipZigTest; + + try std.testing.expectError( + error.UnsafeOSC8Link, + open(.osc8, "file:///tmp/payload.command"), + ); +} + fn openThread(io: std.Io, exe_: std.process.Child) void { // Copy the exe so it is non-const. This is necessary because wait() // requires a mutable reference and we can't have one as a thread