termio: reimplement OSC 7 URI handling (#9193)
This reimplements the MAC address-aware URI parsing logic used by the OSC 7 handler and adds an additional .raw_path option that returns the full, unencoded path string (including query and fragment values), which is needed for compliant kitty-shell-cwd:// handling. Notably, this implementation takes an options-based approach that allows these additional behaviors to be enabled at runtime. It also leverages two std.Uri.parse guarantees: 1. Return slices point into the original text string. 2. .raw components don't require unescaping (.percent_encoded does). The implementation is in a new 'os.uri' module because its now generic enough to not be hostname-oriented. We use os.uri.parseUri and its parsing options to reimplement our OSC 7 file-style URI handling. This has two advantages: First, it fixes kitty-shell-cwd scheme handling. This scheme expects the full, unencoded path string, whereas the file scheme expects normal URI percent encoding. This was preventing paths containing "special" URI characters (like "path?") from working correctly in our bash, zsh, and elvish shell integrations, which report working directories using the kitty-shell-cwd scheme. (fish uses file URIs, which work as expected.) Second, we can greatly simplify our hostname and path string handling because we can now rely on the "raw" std.Uri component form to always provide the correct representation. Lastly, this lets us remove the previous URI-related code from the os.hostname module, restoring its focus to hostname-related functions. See: #5289pull/9218/head
parent
54b021f6d6
commit
e5247f6d10
|
|
@ -1,148 +1,11 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const builtin = @import("builtin");
|
|
||||||
const posix = std.posix;
|
const posix = std.posix;
|
||||||
|
|
||||||
pub const HostnameParsingError = error{
|
|
||||||
NoHostnameInUri,
|
|
||||||
NoSpaceLeft,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const UrlParsingError = std.Uri.ParseError || error{
|
pub const UrlParsingError = std.Uri.ParseError || error{
|
||||||
HostnameIsNotMacAddress,
|
HostnameIsNotMacAddress,
|
||||||
NoSchemeProvided,
|
NoSchemeProvided,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mac_address_length = 17;
|
|
||||||
|
|
||||||
fn isUriPathSeparator(c: u8) bool {
|
|
||||||
return switch (c) {
|
|
||||||
'?', '#' => true,
|
|
||||||
else => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn isValidMacAddress(mac_address: []const u8) bool {
|
|
||||||
// A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef.
|
|
||||||
if (mac_address.len != 17) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (mac_address, 0..) |c, i| {
|
|
||||||
if ((i + 1) % 3 == 0) {
|
|
||||||
if (c != ':') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the provided url to a `std.Uri` struct. This is very specific to getting hostname and
|
|
||||||
/// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS
|
|
||||||
/// the url passed to this function might have a mac address as its hostname and parses it
|
|
||||||
/// correctly.
|
|
||||||
pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri {
|
|
||||||
return std.Uri.parse(url) catch |e| {
|
|
||||||
// The mac-address-as-hostname issue is specific to macOS so we just return an error if we
|
|
||||||
// hit it on other platforms.
|
|
||||||
if (comptime builtin.os.tag != .macos) return e;
|
|
||||||
|
|
||||||
// It's possible this is a mac address on macOS where the last 2 characters in the
|
|
||||||
// address are non-digits, e.g. 'ff', and thus an invalid port.
|
|
||||||
//
|
|
||||||
// Example: file://12:34:56:78:90:12/path/to/file
|
|
||||||
if (e != error.InvalidPort) return e;
|
|
||||||
|
|
||||||
const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse {
|
|
||||||
return error.NoSchemeProvided;
|
|
||||||
};
|
|
||||||
const scheme = url[0..url_without_scheme_start];
|
|
||||||
const url_without_scheme = url[url_without_scheme_start + 3 ..];
|
|
||||||
|
|
||||||
// The first '/' after the scheme marks the end of the hostname. If the first '/'
|
|
||||||
// following the end of the scheme is not at the right position this is not a
|
|
||||||
// valid mac address.
|
|
||||||
if (url_without_scheme.len != mac_address_length and
|
|
||||||
std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length)
|
|
||||||
{
|
|
||||||
return error.HostnameIsNotMacAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point we may have a mac address as the hostname.
|
|
||||||
const mac_address = url_without_scheme[0..mac_address_length];
|
|
||||||
|
|
||||||
if (!isValidMacAddress(mac_address)) {
|
|
||||||
return error.HostnameIsNotMacAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
var uri_path_end_idx: usize = mac_address_length;
|
|
||||||
while (uri_path_end_idx < url_without_scheme.len and
|
|
||||||
!isUriPathSeparator(url_without_scheme[uri_path_end_idx]))
|
|
||||||
{
|
|
||||||
uri_path_end_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI
|
|
||||||
// spec.
|
|
||||||
return .{
|
|
||||||
.scheme = scheme,
|
|
||||||
.host = .{ .percent_encoded = mac_address },
|
|
||||||
.path = .{
|
|
||||||
.percent_encoded = url_without_scheme[mac_address_length..uri_path_end_idx],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the hostname from a file URI into a buffer.
|
|
||||||
pub fn bufPrintHostnameFromFileUri(
|
|
||||||
buf: []u8,
|
|
||||||
uri: std.Uri,
|
|
||||||
) HostnameParsingError![]const u8 {
|
|
||||||
// Get the raw string of the URI. Its unclear to me if the various
|
|
||||||
// tags of this enum guarantee no percent-encoding so we just
|
|
||||||
// check all of it. This isn't a performance critical path.
|
|
||||||
const host_component = uri.host orelse return error.NoHostnameInUri;
|
|
||||||
const host: []const u8 = switch (host_component) {
|
|
||||||
.raw => |v| v,
|
|
||||||
.percent_encoded => |v| v,
|
|
||||||
};
|
|
||||||
|
|
||||||
// When the "Private Wi-Fi address" setting is toggled on macOS the hostname
|
|
||||||
// is set to a random mac address, e.g. '12:34:56:78:90:ab'.
|
|
||||||
// The URI will be parsed as if the last set of digits is a port number, so
|
|
||||||
// we need to make sure that part is included when it's set.
|
|
||||||
|
|
||||||
// We're only interested in special port handling when the current hostname is a
|
|
||||||
// partial MAC address that's potentially missing the last component.
|
|
||||||
// If that's not the case we just return the plain URI hostname directly.
|
|
||||||
// NOTE: This implementation is not sufficient to verify a valid mac address, but
|
|
||||||
// it's probably sufficient for this specific purpose.
|
|
||||||
if (host.len != 14 or std.mem.count(u8, host, ":") != 4) return host;
|
|
||||||
|
|
||||||
// If we don't have a port then we can return the hostname as-is because
|
|
||||||
// it's not a partial MAC-address.
|
|
||||||
const port = uri.port orelse return host;
|
|
||||||
|
|
||||||
// If the port is not a 1 or 2-digit number we're not looking at a partial
|
|
||||||
// MAC-address, and instead just a regular port so we return the plain
|
|
||||||
// URI hostname.
|
|
||||||
if (port > 99) return host;
|
|
||||||
|
|
||||||
var fbs = std.io.fixedBufferStream(buf);
|
|
||||||
try std.fmt.format(
|
|
||||||
fbs.writer(),
|
|
||||||
// Make sure "port" is always 2 digits, prefixed with a 0 when "port" is a 1-digit number.
|
|
||||||
"{s}:{d:0>2}",
|
|
||||||
.{ host, port },
|
|
||||||
);
|
|
||||||
|
|
||||||
return fbs.getWritten();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const LocalHostnameValidationError = error{
|
pub const LocalHostnameValidationError = error{
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
Unexpected,
|
Unexpected,
|
||||||
|
|
@ -151,7 +14,7 @@ pub const LocalHostnameValidationError = error{
|
||||||
/// Checks if a hostname is local to the current machine. This matches
|
/// Checks if a hostname is local to the current machine. This matches
|
||||||
/// both "localhost" and the current hostname of the machine (as returned
|
/// both "localhost" and the current hostname of the machine (as returned
|
||||||
/// by `gethostname`).
|
/// by `gethostname`).
|
||||||
pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool {
|
pub fn isLocal(hostname: []const u8) LocalHostnameValidationError!bool {
|
||||||
// A 'localhost' hostname is always considered local.
|
// A 'localhost' hostname is always considered local.
|
||||||
if (std.mem.eql(u8, "localhost", hostname)) return true;
|
if (std.mem.eql(u8, "localhost", hostname)) return true;
|
||||||
|
|
||||||
|
|
@ -161,185 +24,19 @@ pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool {
|
||||||
return std.mem.eql(u8, hostname, ourHostname);
|
return std.mem.eql(u8, hostname, ourHostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
test parseUrl {
|
test "isLocal returns true when provided hostname is localhost" {
|
||||||
// 1. Typical hostnames.
|
try std.testing.expect(try isLocal("localhost"));
|
||||||
|
|
||||||
var uri = try parseUrl("file://personal.computer/home/test/");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
|
|
||||||
uri = try parseUrl("kitty-shell-cwd://personal.computer/home/test/");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
|
|
||||||
// 2. Hostnames that are mac addresses.
|
|
||||||
|
|
||||||
// Numerical mac addresses.
|
|
||||||
|
|
||||||
uri = try parseUrl("file://12:34:56:78:90:12/home/test/");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == 12);
|
|
||||||
|
|
||||||
uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12/home/test/");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == 12);
|
|
||||||
|
|
||||||
// Alphabetical mac addresses.
|
|
||||||
|
|
||||||
uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
|
|
||||||
uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef/home/test/");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
|
|
||||||
// 3. Hostnames that are mac addresses with no path.
|
|
||||||
|
|
||||||
// Numerical mac addresses.
|
|
||||||
|
|
||||||
uri = try parseUrl("file://12:34:56:78:90:12");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == 12);
|
|
||||||
|
|
||||||
uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == 12);
|
|
||||||
|
|
||||||
// Alphabetical mac addresses.
|
|
||||||
|
|
||||||
uri = try parseUrl("file://ab:cd:ef:ab:cd:ef");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
|
|
||||||
uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expectEqualStrings("", uri.path.percent_encoded);
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseUrl succeeds even if path component is missing" {
|
test "isLocal returns true when hostname is local" {
|
||||||
const uri = try parseUrl("file://12:34:56:78:90:ab");
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90:ab", uri.host.?.percent_encoded);
|
|
||||||
try std.testing.expect(uri.path.isEmpty());
|
|
||||||
try std.testing.expect(uri.port == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri succeeds with ascii hostname" {
|
|
||||||
const uri = try std.Uri.parse("file://localhost/");
|
|
||||||
|
|
||||||
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const actual = try bufPrintHostnameFromFileUri(&buf, uri);
|
|
||||||
try std.testing.expectEqualStrings("localhost", actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" {
|
|
||||||
const uri = try std.Uri.parse("file://12:34:56:78:90:12");
|
|
||||||
|
|
||||||
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const actual = try bufPrintHostnameFromFileUri(&buf, uri);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90:12", actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri succeeds with hostname as mac address with the last component as ascii" {
|
|
||||||
const uri = try parseUrl("file://12:34:56:78:90:ab");
|
|
||||||
|
|
||||||
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const actual = try bufPrintHostnameFromFileUri(&buf, uri);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90:ab", actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri succeeds with hostname as a mac address and the last section is < 10" {
|
|
||||||
const uri = try std.Uri.parse("file://12:34:56:78:90:05");
|
|
||||||
|
|
||||||
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const actual = try bufPrintHostnameFromFileUri(&buf, uri);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90:05", actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri returns only hostname when there is a port component in the URI" {
|
|
||||||
// First: try with a non-2-digit port, to test general port handling.
|
|
||||||
const four_port_uri = try std.Uri.parse("file://has-a-port:1234");
|
|
||||||
|
|
||||||
var four_port_buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const four_port_actual = try bufPrintHostnameFromFileUri(&four_port_buf, four_port_uri);
|
|
||||||
try std.testing.expectEqualStrings("has-a-port", four_port_actual);
|
|
||||||
|
|
||||||
// Second: try with a 2-digit port to test mac-address handling.
|
|
||||||
const two_port_uri = try std.Uri.parse("file://has-a-port:12");
|
|
||||||
|
|
||||||
var two_port_buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const two_port_actual = try bufPrintHostnameFromFileUri(&two_port_buf, two_port_uri);
|
|
||||||
try std.testing.expectEqualStrings("has-a-port", two_port_actual);
|
|
||||||
|
|
||||||
// Third: try with a mac-address that has a port-component added to it to test mac-address handling.
|
|
||||||
const mac_with_port_uri = try std.Uri.parse("file://12:34:56:78:90:12:1234");
|
|
||||||
|
|
||||||
var mac_with_port_buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const mac_with_port_actual = try bufPrintHostnameFromFileUri(&mac_with_port_buf, mac_with_port_uri);
|
|
||||||
try std.testing.expectEqualStrings("12:34:56:78:90:12", mac_with_port_actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri returns NoHostnameInUri error when hostname is missing from uri" {
|
|
||||||
const uri = try std.Uri.parse("file:///");
|
|
||||||
|
|
||||||
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
|
||||||
const actual = bufPrintHostnameFromFileUri(&buf, uri);
|
|
||||||
try std.testing.expectError(HostnameParsingError.NoHostnameInUri, actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "bufPrintHostnameFromFileUri returns NoSpaceLeft error when provided buffer has insufficient size" {
|
|
||||||
const uri = try std.Uri.parse("file://12:34:56:78:90:12/");
|
|
||||||
|
|
||||||
var buf: [5]u8 = undefined;
|
|
||||||
const actual = bufPrintHostnameFromFileUri(&buf, uri);
|
|
||||||
try std.testing.expectError(HostnameParsingError.NoSpaceLeft, actual);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "isLocalHostname returns true when provided hostname is localhost" {
|
|
||||||
try std.testing.expect(try isLocalHostname("localhost"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "isLocalHostname returns true when hostname is local" {
|
|
||||||
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
var buf: [posix.HOST_NAME_MAX]u8 = undefined;
|
||||||
const localHostname = try posix.gethostname(&buf);
|
const localHostname = try posix.gethostname(&buf);
|
||||||
try std.testing.expect(try isLocalHostname(localHostname));
|
try std.testing.expect(try isLocal(localHostname));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "isLocalHostname returns false when hostname is not local" {
|
test "isLocal returns false when hostname is not local" {
|
||||||
try std.testing.expectEqual(
|
try std.testing.expectEqual(
|
||||||
false,
|
false,
|
||||||
try isLocalHostname("not-the-local-hostname"),
|
try isLocal("not-the-local-hostname"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ pub const xdg = @import("xdg.zig");
|
||||||
pub const windows = @import("windows.zig");
|
pub const windows = @import("windows.zig");
|
||||||
pub const macos = @import("macos.zig");
|
pub const macos = @import("macos.zig");
|
||||||
pub const shell = @import("shell.zig");
|
pub const shell = @import("shell.zig");
|
||||||
|
pub const uri = @import("uri.zig");
|
||||||
|
|
||||||
// Functions and types
|
// Functions and types
|
||||||
pub const CFReleaseThread = @import("cf_release_thread.zig");
|
pub const CFReleaseThread = @import("cf_release_thread.zig");
|
||||||
|
|
@ -67,6 +68,7 @@ pub const getKernelInfo = kernel_info.getKernelInfo;
|
||||||
test {
|
test {
|
||||||
_ = i18n;
|
_ = i18n;
|
||||||
_ = path;
|
_ = path;
|
||||||
|
_ = uri;
|
||||||
|
|
||||||
if (comptime builtin.os.tag == .linux) {
|
if (comptime builtin.os.tag == .linux) {
|
||||||
_ = kernel_info;
|
_ = kernel_info;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub const ParseOptions = struct {
|
||||||
|
/// Parse MAC addresses in the host component.
|
||||||
|
///
|
||||||
|
/// This is useful when the "Private Wi-Fi address" is enabled on macOS,
|
||||||
|
/// which sets the hostname to a rotating MAC address (12:34:56:ab:cd:ef).
|
||||||
|
mac_address: bool = false,
|
||||||
|
|
||||||
|
/// Return the full, raw, unencoded path string. Any query and fragment
|
||||||
|
/// values will be return as part of the path instead of as distinct
|
||||||
|
/// fields.
|
||||||
|
raw_path: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ParseError = std.Uri.ParseError || error{InvalidMacAddress};
|
||||||
|
|
||||||
|
/// Parses a URI from the given string.
|
||||||
|
///
|
||||||
|
/// This extends std.Uri.parse with some additional ParseOptions.
|
||||||
|
pub fn parse(text: []const u8, options: ParseOptions) ParseError!std.Uri {
|
||||||
|
var uri = std.Uri.parse(text) catch |err| uri: {
|
||||||
|
// We can attempt to re-parse the text as a URI that has a MAC address
|
||||||
|
// in its host field (which tripped up std.Uri.parse's port parsing):
|
||||||
|
//
|
||||||
|
// file://12:34:56:78:90:aa/path/to/file
|
||||||
|
// ^^ InvalidPort
|
||||||
|
//
|
||||||
|
if (err != error.InvalidPort or !options.mac_address) return err;
|
||||||
|
|
||||||
|
// We can assume that the initial Uri.parse already validated the
|
||||||
|
// scheme, so we only need to find its bounds within the string.
|
||||||
|
const scheme_end = std.mem.indexOf(u8, text, "://") orelse {
|
||||||
|
return error.InvalidFormat;
|
||||||
|
};
|
||||||
|
const scheme = text[0..scheme_end];
|
||||||
|
|
||||||
|
// We similarly find the bounds of the host component by looking
|
||||||
|
// for the first slash (/) after the scheme. This is all we need
|
||||||
|
// for this case because the resulting slice can be unambiguously
|
||||||
|
// determined to be a MAC address (or not).
|
||||||
|
const host_start = scheme_end + "://".len;
|
||||||
|
const host_end = std.mem.indexOfScalarPos(u8, text, host_start, '/') orelse text.len;
|
||||||
|
const mac_address = text[host_start..host_end];
|
||||||
|
if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress;
|
||||||
|
|
||||||
|
// Parse the rest of the text (starting with the path component) as a
|
||||||
|
// partial URI and then add our MAC address as its host component.
|
||||||
|
var uri = try std.Uri.parseAfterScheme(scheme, text[host_end..]);
|
||||||
|
uri.host = .{ .percent_encoded = mac_address };
|
||||||
|
break :uri uri;
|
||||||
|
};
|
||||||
|
|
||||||
|
// When MAC address parsing is enabled, we need to handle the case where
|
||||||
|
// std.Uri.parse parsed the address's last octet as a numeric port number.
|
||||||
|
// We use a few heuristics to identify this case (14 characters, 4 colons)
|
||||||
|
// and then "repair" the result by reassign the .host component to the full
|
||||||
|
// MAC address and clearing the .port component.
|
||||||
|
//
|
||||||
|
// 12:34:56:78:90:99 -> [12:34:56:78:90, 99] -> 12:34:56:78:90:99
|
||||||
|
// (original host) (parsed host + port) (restored host)
|
||||||
|
//
|
||||||
|
if (options.mac_address and uri.host != null) mac: {
|
||||||
|
const host = uri.host.?.percent_encoded;
|
||||||
|
if (host.len != 14 or std.mem.count(u8, host, ":") != 4) break :mac;
|
||||||
|
|
||||||
|
const port = uri.port orelse break :mac;
|
||||||
|
if (port > 99) break :mac;
|
||||||
|
|
||||||
|
// std.Uri.parse returns slices pointing into the original text string.
|
||||||
|
const host_start = @intFromPtr(host.ptr) - @intFromPtr(text.ptr);
|
||||||
|
const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr);
|
||||||
|
const mac_address = text[host_start..path_start];
|
||||||
|
if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress;
|
||||||
|
|
||||||
|
uri.host = .{ .percent_encoded = mac_address };
|
||||||
|
uri.port = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the raw_path option is active, return everything after the authority
|
||||||
|
// (host) in the .path component, including any query and fragment values.
|
||||||
|
if (options.raw_path) {
|
||||||
|
// std.Uri.parse returns slices pointing into the original text string.
|
||||||
|
const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr);
|
||||||
|
uri.path = .{ .raw = text[path_start..] };
|
||||||
|
uri.query = null;
|
||||||
|
uri.fragment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse: mac_address" {
|
||||||
|
const testing = @import("std").testing;
|
||||||
|
|
||||||
|
// Numeric MAC address without a port
|
||||||
|
const uri1 = try parse("file://00:12:34:56:78:90/path", .{ .mac_address = true });
|
||||||
|
try testing.expectEqualStrings("file", uri1.scheme);
|
||||||
|
try testing.expectEqualStrings("00:12:34:56:78:90", uri1.host.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("/path", uri1.path.percent_encoded);
|
||||||
|
try testing.expectEqual(null, uri1.port);
|
||||||
|
|
||||||
|
// Numeric MAC address with a port
|
||||||
|
const uri2 = try parse("file://00:12:34:56:78:90:999/path", .{ .mac_address = true });
|
||||||
|
try testing.expectEqualStrings("file", uri2.scheme);
|
||||||
|
try testing.expectEqualStrings("00:12:34:56:78:90", uri2.host.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("/path", uri2.path.percent_encoded);
|
||||||
|
try testing.expectEqual(999, uri2.port);
|
||||||
|
|
||||||
|
// Alphabetic MAC address without a port
|
||||||
|
const uri3 = try parse("file://ab:cd:ef:ab:cd:ef/path", .{ .mac_address = true });
|
||||||
|
try testing.expectEqualStrings("file", uri3.scheme);
|
||||||
|
try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri3.host.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("/path", uri3.path.percent_encoded);
|
||||||
|
try testing.expectEqual(null, uri3.port);
|
||||||
|
|
||||||
|
// Alphabetic MAC address with a port
|
||||||
|
const uri4 = try parse("file://ab:cd:ef:ab:cd:ef:999/path", .{ .mac_address = true });
|
||||||
|
try testing.expectEqualStrings("file", uri4.scheme);
|
||||||
|
try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri4.host.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("/path", uri4.path.percent_encoded);
|
||||||
|
try testing.expectEqual(999, uri4.port);
|
||||||
|
|
||||||
|
// Numeric MAC address without a path component
|
||||||
|
const uri5 = try parse("file://00:12:34:56:78:90", .{ .mac_address = true });
|
||||||
|
try testing.expectEqualStrings("file", uri5.scheme);
|
||||||
|
try testing.expectEqualStrings("00:12:34:56:78:90", uri5.host.?.percent_encoded);
|
||||||
|
try testing.expect(uri5.path.isEmpty());
|
||||||
|
|
||||||
|
// Alphabetic MAC address without a path component
|
||||||
|
const uri6 = try parse("file://ab:cd:ef:ab:cd:ef", .{ .mac_address = true });
|
||||||
|
try testing.expectEqualStrings("file", uri6.scheme);
|
||||||
|
try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri6.host.?.percent_encoded);
|
||||||
|
try testing.expect(uri6.path.isEmpty());
|
||||||
|
|
||||||
|
// Invalid MAC addresses
|
||||||
|
try testing.expectError(error.InvalidMacAddress, parse(
|
||||||
|
"file://zz:zz:zz:zz:zz:00/path",
|
||||||
|
.{ .mac_address = true },
|
||||||
|
));
|
||||||
|
try testing.expectError(error.InvalidMacAddress, parse(
|
||||||
|
"file://zz:zz:zz:zz:zz:zz/path",
|
||||||
|
.{ .mac_address = true },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse: raw_path" {
|
||||||
|
const testing = @import("std").testing;
|
||||||
|
|
||||||
|
const text = "file://localhost/path??#fragment";
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
|
||||||
|
const uri1 = try parse(text, .{ .raw_path = false });
|
||||||
|
try testing.expectEqualStrings("file", uri1.scheme);
|
||||||
|
try testing.expectEqualStrings("localhost", uri1.host.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("/path", try uri1.path.toRaw(&buf));
|
||||||
|
try testing.expectEqualStrings("?", uri1.query.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("fragment", uri1.fragment.?.percent_encoded);
|
||||||
|
|
||||||
|
const uri2 = try parse(text, .{ .raw_path = true });
|
||||||
|
try testing.expectEqualStrings("file", uri2.scheme);
|
||||||
|
try testing.expectEqualStrings("localhost", uri2.host.?.percent_encoded);
|
||||||
|
try testing.expectEqualStrings("/path??#fragment", try uri2.path.toRaw(&buf));
|
||||||
|
try testing.expectEqual(null, uri2.query);
|
||||||
|
try testing.expectEqual(null, uri2.fragment);
|
||||||
|
|
||||||
|
const uri3 = try parse("file://localhost", .{ .raw_path = true });
|
||||||
|
try testing.expectEqualStrings("file", uri3.scheme);
|
||||||
|
try testing.expectEqualStrings("localhost", uri3.host.?.percent_encoded);
|
||||||
|
try testing.expect(uri3.path.isEmpty());
|
||||||
|
try testing.expectEqual(null, uri3.query);
|
||||||
|
try testing.expectEqual(null, uri3.fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef.
|
||||||
|
fn isValidMacAddress(s: []const u8) bool {
|
||||||
|
if (s.len != 17) return false;
|
||||||
|
|
||||||
|
for (s, 0..) |c, i| {
|
||||||
|
if (i % 3 == 2) {
|
||||||
|
if (c != ':') return false;
|
||||||
|
} else {
|
||||||
|
switch (c) {
|
||||||
|
'0'...'9', 'A'...'F', 'a'...'f' => {},
|
||||||
|
else => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
test isValidMacAddress {
|
||||||
|
const testing = @import("std").testing;
|
||||||
|
|
||||||
|
try testing.expect(isValidMacAddress("01:23:45:67:89:Aa"));
|
||||||
|
try testing.expect(isValidMacAddress("Aa:Bb:Cc:Dd:Ee:Ff"));
|
||||||
|
|
||||||
|
try testing.expect(!isValidMacAddress(""));
|
||||||
|
try testing.expect(!isValidMacAddress("00:23:45"));
|
||||||
|
try testing.expect(!isValidMacAddress("00:23:45:Xx:Yy:Zz"));
|
||||||
|
try testing.expect(!isValidMacAddress("01-23-45-67-89-Aa"));
|
||||||
|
try testing.expect(!isValidMacAddress("01:23:45:67:89:Aa:Bb"));
|
||||||
|
}
|
||||||
|
|
@ -1089,7 +1089,13 @@ pub const StreamHandler = struct {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uri: std.Uri = internal_os.hostname.parseUrl(url) catch |e| {
|
// Attempt to parse this file-style URI using options appropriate
|
||||||
|
// for this OSC 7 context (e.g. kitty-shell-cwd expects the full,
|
||||||
|
// unencoded path).
|
||||||
|
const uri: std.Uri = internal_os.uri.parse(url, .{
|
||||||
|
.mac_address = comptime builtin.os.tag != .macos,
|
||||||
|
.raw_path = std.mem.startsWith(u8, url, "kitty-shell-cwd://"),
|
||||||
|
}) catch |e| {
|
||||||
log.warn("invalid url in OSC 7: {}", .{e});
|
log.warn("invalid url in OSC 7: {}", .{e});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -1097,26 +1103,18 @@ pub const StreamHandler = struct {
|
||||||
if (!std.mem.eql(u8, "file", uri.scheme) and
|
if (!std.mem.eql(u8, "file", uri.scheme) and
|
||||||
!std.mem.eql(u8, "kitty-shell-cwd", uri.scheme))
|
!std.mem.eql(u8, "kitty-shell-cwd", uri.scheme))
|
||||||
{
|
{
|
||||||
log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme});
|
log.warn("OSC 7 scheme must be file or kitty-shell-cwd, got: {s}", .{uri.scheme});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 793 defines port numbers as 16-bit numbers. 5 digits is sufficient to represent
|
var host_buffer: [std.Uri.host_name_max]u8 = undefined;
|
||||||
// the maximum since 2^16 - 1 = 65_535.
|
const host = uri.getHost(&host_buffer) catch |err| switch (err) {
|
||||||
// See https://www.rfc-editor.org/rfc/rfc793#section-3.1.
|
error.UriMissingHost => {
|
||||||
const PORT_NUMBER_MAX_DIGITS = 5;
|
|
||||||
// Make sure there is space for a max length hostname + the max number of digits.
|
|
||||||
var host_and_port_buf: [posix.HOST_NAME_MAX + PORT_NUMBER_MAX_DIGITS]u8 = undefined;
|
|
||||||
const hostname_from_uri = internal_os.hostname.bufPrintHostnameFromFileUri(
|
|
||||||
&host_and_port_buf,
|
|
||||||
uri,
|
|
||||||
) catch |err| switch (err) {
|
|
||||||
error.NoHostnameInUri => {
|
|
||||||
log.warn("OSC 7 uri must contain a hostname: {}", .{err});
|
log.warn("OSC 7 uri must contain a hostname: {}", .{err});
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
error.NoSpaceLeft => |e| {
|
error.UriHostTooLong => {
|
||||||
log.warn("failed to get full hostname for OSC 7 validation: {}", .{e});
|
log.warn("failed to get full hostname for OSC 7 validation: {}", .{err});
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1124,9 +1122,7 @@ pub const StreamHandler = struct {
|
||||||
// OSC 7 is a little sketchy because anyone can send any value from
|
// OSC 7 is a little sketchy because anyone can send any value from
|
||||||
// any host (such an SSH session). The best practice terminals follow
|
// any host (such an SSH session). The best practice terminals follow
|
||||||
// is to valid the hostname to be local.
|
// is to valid the hostname to be local.
|
||||||
const host_valid = internal_os.hostname.isLocalHostname(
|
const host_valid = internal_os.hostname.isLocal(host) catch |err| switch (err) {
|
||||||
hostname_from_uri,
|
|
||||||
) catch |err| switch (err) {
|
|
||||||
error.PermissionDenied,
|
error.PermissionDenied,
|
||||||
error.Unexpected,
|
error.Unexpected,
|
||||||
=> {
|
=> {
|
||||||
|
|
@ -1135,43 +1131,16 @@ pub const StreamHandler = struct {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (!host_valid) {
|
if (!host_valid) {
|
||||||
log.warn("OSC 7 host must be local", .{});
|
log.warn("OSC 7 host ({s}) must be local", .{host});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to unescape the path. We first try to unescape onto
|
// We need the raw path, which might require unescaping. We try to
|
||||||
// the stack and fall back to heap allocation if we have to.
|
// avoid making any heap allocations by using the stack first.
|
||||||
var path_buf: [1024]u8 = undefined;
|
var arena_alloc: std.heap.ArenaAllocator = .init(self.alloc);
|
||||||
const path, const heap = path: {
|
var stack_alloc = std.heap.stackFallback(1024, arena_alloc.allocator());
|
||||||
// Get the raw string of the URI. Its unclear to me if the various
|
defer arena_alloc.deinit();
|
||||||
// tags of this enum guarantee no percent-encoding so we just
|
const path = try uri.path.toRawMaybeAlloc(stack_alloc.get());
|
||||||
// check all of it. This isn't a performance critical path.
|
|
||||||
const path = switch (uri.path) {
|
|
||||||
.raw => |v| v,
|
|
||||||
.percent_encoded => |v| v,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If the path doesn't have any escapes, we can use it directly.
|
|
||||||
if (std.mem.indexOfScalar(u8, path, '%') == null)
|
|
||||||
break :path .{ path, false };
|
|
||||||
|
|
||||||
// First try to stack-allocate
|
|
||||||
var stack_writer: std.Io.Writer = .fixed(&path_buf);
|
|
||||||
if (uri.path.formatRaw(&stack_writer)) |_| {
|
|
||||||
break :path .{ stack_writer.buffered(), false };
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
// Fall back to heap
|
|
||||||
var alloc_writer: std.Io.Writer.Allocating = .init(self.alloc);
|
|
||||||
if (uri.path.formatRaw(&alloc_writer.writer)) |_| {
|
|
||||||
break :path .{ alloc_writer.written(), true };
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
// Fall back to using it directly...
|
|
||||||
log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path});
|
|
||||||
break :path .{ path, false };
|
|
||||||
};
|
|
||||||
defer if (heap) self.alloc.free(path);
|
|
||||||
|
|
||||||
log.debug("terminal pwd: {s}", .{path});
|
log.debug("terminal pwd: {s}", .{path});
|
||||||
try self.terminal.setPwd(path);
|
try self.terminal.setPwd(path);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue