// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The checks every open request passes before it reaches the operating //! system, and the decoding of a `file:` URI into a local path. Neither needs //! a full URI parser: an open request only has to know the scheme, and a //! `file:` URI only has to yield its path. const std = @import("std"); pub const Error = error{ InvalidUri, SchemeNotAllowed, }; /// Return the scheme of `uri` -- the text before the first `:` -- after /// checking that the URI is something it is safe to hand to an opener: /// /// - The scheme follows RFC 3986: a letter, then letters, digits, `+`, `-` /// or `.`. /// - The scheme is at least two characters long. A single letter is a /// Windows drive (`C:\x\evil.exe`), and `ShellExecuteExW` would run it; /// see docs/adr/0001. /// - The text is UTF-8 with no control characters, spaces or DEL, none of /// which may appear in a URI and any of which could make the opener read /// something other than what the caller checked. /// - When `allowed` is not null the scheme is one of them, compared without /// regard to case as RFC 3986 requires. pub fn check(uri: []const u8, allowed: ?[]const []const u8) Error![]const u8 { if (!std.unicode.utf8ValidateSlice(uri)) return error.InvalidUri; for (uri) |c| if (c <= ' ' or c == 0x7f) return error.InvalidUri; const colon = std.mem.findScalar(u8, uri, ':') orelse return error.InvalidUri; const scheme = uri[0..colon]; if (scheme.len == 0 or !std.ascii.isAlphabetic(scheme[0])) return error.InvalidUri; for (scheme[1..]) |c| switch (c) { 'a'...'z', 'A'...'Z', '0'...'9', '+', '-', '.' => {}, else => return error.InvalidUri, }; if (scheme.len < 2) return error.SchemeNotAllowed; if (allowed) |schemes| { for (schemes) |name| { if (std.ascii.eqlIgnoreCase(name, scheme)) return scheme; } return error.SchemeNotAllowed; } return scheme; } /// Whether `scheme`, as returned by `check`, is `file`. pub fn isFile(scheme: []const u8) bool { return std.ascii.eqlIgnoreCase(scheme, "file"); } /// Decode the path of a `file:` URI into `buffer`, returning it NUL /// terminated so it can be handed straight to the kernel. /// /// Accepted are `file:///path`, `file://localhost/path` and RFC 8089's /// `file:/path`; a file on another host cannot be opened locally, so any /// other authority is `error.InvalidUri`, as is a query. A fragment is /// dropped, because a file is opened by descriptor and a descriptor has /// nowhere to carry one. Percent-escapes are decoded, and an escape of NUL /// is refused rather than silently cutting the path short. pub fn filePath(uri: []const u8, buffer: []u8) Error![:0]const u8 { const colon = std.mem.findScalar(u8, uri, ':') orelse return error.InvalidUri; var rest = uri[colon + 1 ..]; if (std.mem.findScalar(u8, rest, '#')) |hash| rest = rest[0..hash]; if (std.mem.findScalar(u8, rest, '?') != null) return error.InvalidUri; if (std.mem.startsWith(u8, rest, "//")) { const after = rest[2..]; const slash = std.mem.findScalar(u8, after, '/') orelse return error.InvalidUri; const host = after[0..slash]; if (host.len != 0 and !std.ascii.eqlIgnoreCase(host, "localhost")) return error.InvalidUri; rest = after[slash..]; } if (rest.len == 0 or rest[0] != '/') return error.InvalidUri; var len: usize = 0; var i: usize = 0; while (i < rest.len) : (len += 1) { if (len + 1 >= buffer.len) return error.InvalidUri; if (rest[i] == '%') { if (i + 2 >= rest.len) return error.InvalidUri; const hi = std.fmt.charToDigit(rest[i + 1], 16) catch return error.InvalidUri; const lo = std.fmt.charToDigit(rest[i + 2], 16) catch return error.InvalidUri; const byte = hi * 16 + lo; if (byte == 0) return error.InvalidUri; buffer[len] = byte; i += 3; } else { buffer[len] = rest[i]; i += 1; } } buffer[len] = 0; return buffer[0..len :0]; } const testing = std.testing; test "a scheme is returned" { try testing.expectEqualStrings("https", try check("https://example.com/", null)); try testing.expectEqualStrings("mailto", try check("mailto:someone@example.com", null)); try testing.expectEqualStrings("x-my.app+1", try check("x-my.app+1:thing", null)); } test "a drive letter is never a scheme" { try testing.expectError(error.SchemeNotAllowed, check("C:\\Windows\\System32\\calc.exe", null)); try testing.expectError(error.SchemeNotAllowed, check("c:/x", &.{"c"})); } test "malformed URIs are refused" { try testing.expectError(error.InvalidUri, check("", null)); try testing.expectError(error.InvalidUri, check("example.com", null)); try testing.expectError(error.InvalidUri, check(":nothing", null)); try testing.expectError(error.InvalidUri, check("1http://x", null)); try testing.expectError(error.InvalidUri, check("ht tp://x", null)); try testing.expectError(error.InvalidUri, check("https://x/a b", null)); try testing.expectError(error.InvalidUri, check("https://x/\n", null)); try testing.expectError(error.InvalidUri, check("https://x/\x00", null)); try testing.expectError(error.InvalidUri, check("https://x/\x7f", null)); try testing.expectError(error.InvalidUri, check("https://x/\xff", null)); } test "an IRI with UTF-8 in it is allowed" { try testing.expectEqualStrings("https", try check("https://example.com/caf\u{e9}", null)); } test "the allowlist is compared without case" { const web: []const []const u8 = &.{ "http", "https", "mailto" }; try testing.expectEqualStrings("HTTPS", try check("HTTPS://example.com", web)); try testing.expectError(error.SchemeNotAllowed, check("file:///etc/passwd", web)); try testing.expectError(error.SchemeNotAllowed, check("https://x", &.{})); } test "file paths are decoded" { var buffer: [64]u8 = undefined; try testing.expectEqualStrings("/tmp/a b", try filePath("file:///tmp/a%20b", &buffer)); try testing.expectEqualStrings("/tmp/x", try filePath("file://localhost/tmp/x", &buffer)); try testing.expectEqualStrings("/tmp/x", try filePath("FILE://LocalHost/tmp/x", &buffer)); try testing.expectEqualStrings("/tmp/x", try filePath("file:/tmp/x", &buffer)); try testing.expectEqualStrings("/tmp/x.html", try filePath("file:///tmp/x.html#part", &buffer)); try testing.expectEqualStrings("/tmp/caf\u{e9}", try filePath("file:///tmp/caf%C3%a9", &buffer)); try testing.expectEqualStrings("/", try filePath("file:///", &buffer)); } test "file URIs that cannot name a local file are refused" { var buffer: [16]u8 = undefined; try testing.expectError(error.InvalidUri, filePath("file://server/share/x", &buffer)); try testing.expectError(error.InvalidUri, filePath("file://localhost", &buffer)); try testing.expectError(error.InvalidUri, filePath("file:relative", &buffer)); try testing.expectError(error.InvalidUri, filePath("file:///x?y=1", &buffer)); try testing.expectError(error.InvalidUri, filePath("file:///x%00y", &buffer)); try testing.expectError(error.InvalidUri, filePath("file:///x%2", &buffer)); try testing.expectError(error.InvalidUri, filePath("file:///x%zz", &buffer)); try testing.expectError(error.InvalidUri, filePath("file:///a/very/long/path/indeed", &buffer)); }