diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 945a127db..e411bafe7 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -8,6 +8,7 @@ const posix = std.posix; const fastmem = @import("../../fastmem.zig"); const animation = @import("graphics_animation.zig"); const command = @import("graphics_command.zig"); +const kitty_windows = @import("windows.zig"); const PageList = @import("../PageList.zig"); const sys = @import("../sys.zig"); const LimitedAllocator = @import("../../datastruct/main.zig").LimitedAllocator; @@ -345,6 +346,25 @@ pub const LoadingImage = struct { else => @compileError("readFile only supports file and temporary_file"), } + // Some Windows paths are dangerous to even open, so the raw path + // is checked before the open. The canonical path of the opened + // file is checked again in validatedFilePath. See kitty_windows + // for what is refused and why. + if (comptime builtin.os.tag == .windows) { + kitty_windows.checkPath(path) catch |err| { + log.warn("invalid image path: {}", .{err}); + return error.InvalidData; + }; + } + + // The canonical path buffer is sized for the longest path the + // platform allows, which is about 96 KiB on Windows, so it is + // heap allocated rather than placed on the stack of whichever + // host thread feeds the stream. It is allocated before the open + // so the deferred temporary file deletion below can still use it. + const abs_buf = try alloc.alloc(u8, std.fs.max_path_bytes); + defer alloc.free(abs_buf); + // Open our file right away before we do validation. This avoids // TOCTOU issues. var file = std.Io.Dir.cwd().openFile( @@ -370,11 +390,10 @@ pub const LoadingImage = struct { // Derive the path from the open handle so the file we validate is the // exact file we read. Resolving a path before opening it would allow a // cooperating process to swap a symlink or directory entry in between. - var abs_buf: [std.fs.max_path_bytes]u8 = undefined; const abs_path = validatedFilePath( io, file, - &abs_buf, + abs_buf, ) catch |err| { log.warn("failed to validate image file path: {}", .{err}); return error.InvalidData; @@ -383,8 +402,9 @@ pub const LoadingImage = struct { // Temporary file logic if (medium == .temporary_file) { assert(self.temporary_directory != null); - if (!isPathInTempDir( + if (!try isPathInTempDir( io, + alloc, self.temporary_directory.?, abs_path, )) return error.TemporaryFileNotInTempDir; @@ -453,6 +473,11 @@ pub const LoadingImage = struct { fn validatedFilePath(io: std.Io, file: std.Io.File, buf: []u8) ![]const u8 { const path = buf[0..try file.realPath(io, buf)]; + if (comptime builtin.os.tag == .windows) { + try kitty_windows.checkCanonicalPath(path); + return path; + } + // This is logic copied directly from Kitty, mostly. This is really // rough but it will catch obvious bad actors. if (std.mem.startsWith(u8, path, "/proc/") or @@ -468,18 +493,28 @@ pub const LoadingImage = struct { /// Returns true if path appears to be in a temporary directory. /// Copies logic from Kitty. - fn isPathInTempDir(io: std.Io, dir: []const u8, path: []const u8) bool { + fn isPathInTempDir( + io: std.Io, + alloc: Allocator, + dir: []const u8, + path: []const u8, + ) Allocator.Error!bool { if (isPathInDir("/tmp", path)) return true; if (isPathInDir("/dev/shm", path)) return true; if (isPathInDir(dir, path)) return true; // The temporary dir is sometimes a symlink. On macOS for - // example /tmp is /private/var/... - var buf: [std.fs.max_path_bytes]u8 = undefined; + // example /tmp is /private/var/... On Windows the directory a + // host passes (typically GetTempPath output) regularly differs + // from the canonical path in case or uses 8.3 short names, and + // resolving it covers both. The buffer is heap allocated for + // the reason given in readFile. + const buf = try alloc.alloc(u8, std.fs.max_path_bytes); + defer alloc.free(buf); const real_dir = buf[0 .. std.Io.Dir.cwd().realPathFile( io, dir, - &buf, + buf, ) catch return false]; if (isPathInDir(real_dir, path)) return true; @@ -858,6 +893,10 @@ fn isPathInDir(dir: []const u8, path: []const u8) bool { return std.fs.path.isSep(path[dir.len]); } +test { + _ = kitty_windows; +} + test "temporary file path must be inside directory" { const testing = std.testing; @@ -1516,6 +1555,328 @@ test "image load: blocklist applies to opened file after symlink swap" { _ = try LoadingImage.validatedFilePath(io, safe_file, &path_buf); } +test "image load: windows UNC path is rejected before open" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + // Opening a UNC path would resolve the host name and authenticate to + // it over SMB, so the rejection has to happen on the raw path. The + // host name is in the reserved .invalid TLD so it can never resolve. + const paths = [_][]const u8{ + "\\\\nonexistent-host.invalid\\share\\tty-graphics-protocol-image.data", + "//nonexistent-host.invalid/share/tty-graphics-protocol-image.data", + "\\\\?\\UNC\\nonexistent-host.invalid\\share\\image.data", + }; + for (paths) |path| { + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError( + error.InvalidData, + LoadingImage.init(io, alloc, &cmd, .{ + .file = true, + .temporary_file = .disabled, + .shared_memory = false, + }), + ); + } +} + +test "image load: windows device namespace paths are rejected before open" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const filename = "tty-graphics-protocol-image.data"; + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(io, .{ .sub_path = filename, .data = data }); + + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path = dir_buf[0..try tmp_dir.dir.realPath(io, &dir_buf)]; + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = path_buf[0..try tmp_dir.dir.realPathFile(io, filename, &path_buf)]; + + // The verbatim, local device and NT namespace spellings all resolve + // to the same real file, so rejecting them proves the check runs on + // the raw path rather than on the canonical one. + const prefixes = [_][]const u8{ "\\\\?\\", "\\\\.\\", "\\??\\" }; + for (prefixes) |prefix| { + const path = try std.mem.concat(alloc, u8, &.{ prefix, real_path }); + defer alloc.free(path); + + const mediums = [_]command.Transmission.Medium{ .file, .temporary_file }; + for (mediums) |medium| { + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = medium, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError( + error.InvalidData, + LoadingImage.init(io, alloc, &cmd, .allWithTempDir(dir_path)), + ); + } + } + + // A named pipe reached through the local device namespace. + { + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "\\\\.\\pipe\\ghostty-kitty-graphics-test"), + }; + defer cmd.deinit(alloc); + try testing.expectError( + error.InvalidData, + LoadingImage.init(io, alloc, &cmd, .allWithTempDir(dir_path)), + ); + } + + // Nothing above reached the temporary file deletion. + try tmp_dir.dir.access(io, filename, .{}); +} + +test "image load: windows reserved device names are rejected before open" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path = dir_buf[0..try tmp_dir.dir.realPath(io, &dir_buf)]; + + const names = [_][]const u8{ "CON", "NUL.data", "com1", "LPT9.png", "AUX", "PRN." }; + for (names) |name| { + const path = try std.fs.path.join(alloc, &.{ dir_path, name }); + defer alloc.free(path); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError( + error.InvalidData, + LoadingImage.init(io, alloc, &cmd, .{ + .file = true, + .temporary_file = .disabled, + .shared_memory = false, + }), + ); + } +} + +test "image load: windows local file accepted in forward slash and upper case spellings" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const filename = "image.data"; + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(io, .{ .sub_path = filename, .data = data }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = path_buf[0..try tmp_dir.dir.realPathFile(io, filename, &path_buf)]; + + // Both spellings open the same file; the canonical path the checks + // see is the backslash, on-disk case form either way. + const forward = try alloc.dupe(u8, real_path); + defer alloc.free(forward); + std.mem.replaceScalar(u8, forward, '\\', '/'); + const upper = try alloc.dupe(u8, real_path); + defer alloc.free(upper); + _ = std.ascii.upperString(upper, real_path); + + const spellings = [_][]const u8{ forward, upper }; + for (spellings) |path| { + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(io, alloc, &cmd, .{ + .file = true, + .temporary_file = .disabled, + .shared_memory = false, + }); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + } + + try tmp_dir.dir.access(io, filename, .{}); +} + +test "image load: windows temporary file with differently spelled directory" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const filename = "tty-graphics-protocol-image.data"; + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(io, .{ .sub_path = filename, .data = data }); + + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path = dir_buf[0..try tmp_dir.dir.realPath(io, &dir_buf)]; + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = path_buf[0..try tmp_dir.dir.realPathFile(io, filename, &path_buf)]; + + // Hosts hand over GetTempPath output, which regularly differs from + // the canonical path in case (`C:\WINDOWS\TEMP\`), so the directory + // prefix check only passes through the real-path fallback. + const dir_upper = try alloc.dupe(u8, dir_path); + defer alloc.free(dir_upper); + _ = std.ascii.upperString(dir_upper, dir_path); + const path_upper = try alloc.dupe(u8, real_path); + defer alloc.free(path_upper); + _ = std.ascii.upperString(path_upper, real_path); + try testing.expect(!isPathInDir(dir_upper, real_path) or + std.mem.eql(u8, dir_upper, dir_path)); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path_upper), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(io, alloc, &cmd, .allWithTempDir(dir_upper)); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + + // Temporary file should be gone + try testing.expectError(error.FileNotFound, tmp_dir.dir.access(io, filename, .{})); +} + +test "image load: windows canonical path check accepts a local file opened through a device spelling" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const filename = "image.data"; + try tmp_dir.dir.writeFile(io, .{ .sub_path = filename, .data = "safe" }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = path_buf[0..try tmp_dir.dir.realPathFile(io, filename, &path_buf)]; + + // readFile refuses this spelling before the open, but the post-open + // check works from the canonical path and so does not care how the + // file was reached: `\\.\C:\...` canonicalizes back to `C:\...`. + const device_path = try std.mem.concat(alloc, u8, &.{ "\\\\.\\", real_path }); + defer alloc.free(device_path); + const file = try std.Io.Dir.cwd().openFile(io, device_path, .{}); + defer file.close(io); + + const canon_buf = try alloc.alloc(u8, std.fs.max_path_bytes); + defer alloc.free(canon_buf); + const canon = try LoadingImage.validatedFilePath(io, file, canon_buf); + try testing.expectEqualStrings(real_path, canon); +} + +test "image load: windows canonical path check rejects a file reached through a UNC share" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const filename = "image.data"; + try tmp_dir.dir.writeFile(io, .{ .sub_path = filename, .data = "safe" }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = path_buf[0..try tmp_dir.dir.realPathFile(io, filename, &path_buf)]; + try testing.expect(kitty_windows.isDriveAbsolute(real_path)); + + // Reach the same local file through the loopback administrative + // share, which is how a junction or symlink into a share would look + // to the post-open check. The share needs the server service and an + // administrative token, so the test is skipped when the open fails. + const unc_path = try std.fmt.allocPrint( + alloc, + "\\\\localhost\\{c}$\\{s}", + .{ real_path[0], real_path[3..] }, + ); + defer alloc.free(unc_path); + const file = std.Io.Dir.cwd().openFile(io, unc_path, .{}) catch + return error.SkipZigTest; + defer file.close(io); + + const canon_buf = try alloc.alloc(u8, std.fs.max_path_bytes); + defer alloc.free(canon_buf); + try testing.expectError( + error.NotDriveAbsolute, + LoadingImage.validatedFilePath(io, file, canon_buf), + ); +} + test "image load: png, not compressed, regular file" { if (sys.decode_png == null) return error.SkipZigTest; diff --git a/src/terminal/kitty/windows.zig b/src/terminal/kitty/windows.zig new file mode 100644 index 000000000..41d2b62d0 --- /dev/null +++ b/src/terminal/kitty/windows.zig @@ -0,0 +1,277 @@ +//! Windows path checks for the Kitty graphics protocol file and +//! temporary file mediums. +//! +//! On POSIX the file transmission blocklist is a few prefixes +//! (`/proc`, `/sys`, `/dev`) applied to the canonical path of the +//! opened file. Windows needs more care because some paths are +//! dangerous to even open: a UNC path makes this process resolve and +//! authenticate to the named SMB server, and the Win32 device and NT +//! object namespaces reach raw volumes and named pipes, where the +//! open or the read can block. +//! +//! Everything here is pure string logic, so it is tested on every host. + +const std = @import("std"); + +pub const Error = error{ + /// A UNC path such as `\\server\share`, in any mix of `\` and `/`. + /// Opening it resolves the host name and authenticates to the + /// server over SMB. + UncPath, + + /// A Win32 device path (`\\.\`, `\\?\`) or an NT object path + /// (`\??\`). These reach raw volumes, named pipes, and through + /// `GLOBALROOT` any kernel object. + DevicePath, + + /// A component names a reserved DOS device such as CON or COM1. + /// Windows resolves these to the device from inside any directory. + ReservedDeviceName, + + /// The canonical path of an opened file is not `X:\...`, so the + /// file is not on a local volume with a drive letter. The canonical + /// path of a file on an SMB share is a UNC path, for example. + NotDriveAbsolute, +}; + +/// Check a path supplied by a client before it is opened. This never +/// touches the filesystem. A plain local path of any shape passes: +/// drive-absolute, drive-relative, rooted, or relative. Whether it +/// resolves to a local regular file is decided after the open with +/// `checkCanonicalPath`. +pub fn checkPath(path: []const u8) Error!void { + if (path.len >= 2 and isSep(path[0]) and isSep(path[1])) { + // `\\?\` and `\\.\` are the Win32 device namespace. Anything + // else after two separators is a server name. + if (path.len >= 4 and + (path[2] == '?' or path[2] == '.') and + isSep(path[3])) return error.DevicePath; + return error.UncPath; + } + + if (path.len >= 4 and + isSep(path[0]) and + path[1] == '?' and + path[2] == '?' and + isSep(path[3])) return error.DevicePath; + + var it = std.mem.splitAny(u8, path, "\\/"); + while (it.next()) |component| { + if (isReservedDeviceName(component)) return error.ReservedDeviceName; + } +} + +/// Check the canonical path of an opened file, as produced by +/// `GetFinalPathNameByHandle`. The file must be on a local volume with +/// a drive letter, and the checks of `checkPath` apply as well. +pub fn checkCanonicalPath(path: []const u8) Error!void { + if (!isDriveAbsolute(path)) return error.NotDriveAbsolute; + try checkPath(path); +} + +/// Returns true if `path` is a drive-absolute path (`C:\...`), the +/// only shape the canonical path of a file on a local volume with a +/// drive letter takes. +pub fn isDriveAbsolute(path: []const u8) bool { + return path.len >= 3 and + std.ascii.isAlphabetic(path[0]) and + path[1] == ':' and + path[2] == '\\'; +} + +/// Returns true if `c` is a Windows path separator. +fn isSep(c: u8) bool { + return std.fs.path.PathType.windows.isSep(u8, c); +} + +/// Returns true if a path component names a reserved DOS device: CON, +/// PRN, AUX, NUL, COM0 to COM9, LPT0 to LPT9, and the superscript digit +/// variants. +fn isReservedDeviceName(component: []const u8) bool { + // The name ends at the extension or stream separator. + const end = std.mem.findAny(u8, component, ".:") orelse component.len; + const stem = std.mem.trimEnd(u8, component[0..end], " "); + if (stem.len < 3) return false; + + const base = stem[0..3]; + const rest = stem[3..]; + if (rest.len == 0) { + return std.ascii.eqlIgnoreCase(base, "CON") or + std.ascii.eqlIgnoreCase(base, "PRN") or + std.ascii.eqlIgnoreCase(base, "AUX") or + std.ascii.eqlIgnoreCase(base, "NUL"); + } + + if (!std.ascii.eqlIgnoreCase(base, "COM") and + !std.ascii.eqlIgnoreCase(base, "LPT")) return false; + return switch (rest.len) { + 1 => std.ascii.isDigit(rest[0]), + // U+00B9, U+00B2, U+00B3 (¹ ² ³) encoded as WTF-8. + 2 => rest[0] == 0xC2 and + (rest[1] == 0xB9 or rest[1] == 0xB2 or rest[1] == 0xB3), + else => false, + }; +} + +test "checkPath rejects UNC paths" { + const testing = std.testing; + + const unc = [_][]const u8{ + "\\\\server\\share\\tty-graphics-protocol-image.data", + "//server/share/tty-graphics-protocol-image.data", + "\\/server/share/image.data", + "/\\server\\share\\image.data", + "\\\\.x\\share\\image.data", + "\\\\", + }; + for (unc) |path| { + try testing.expectError(error.UncPath, checkPath(path)); + } +} + +test "checkPath rejects device and NT namespaces" { + const testing = std.testing; + + const device = [_][]const u8{ + "\\\\?\\UNC\\server\\share\\image.data", + "\\\\?\\C:\\Windows\\Temp\\image.data", + "//?/C:/Windows/Temp/image.data", + "\\\\.\\C:\\Windows\\Temp\\image.data", + "\\\\.\\pipe\\image", + "\\\\.\\PhysicalDrive0", + "\\\\?\\GLOBALROOT\\Device\\HarddiskVolume1\\image.data", + "//?/GLOBALROOT/Device/HarddiskVolume1/image.data", + "\\??\\C:\\Windows\\Temp\\image.data", + "\\??\\Device\\HarddiskVolume1\\image.data", + "\\??\\UNC\\server\\share\\image.data", + "/??/C:/image.data", + }; + for (device) |path| { + try testing.expectError(error.DevicePath, checkPath(path)); + } +} + +test "checkPath accepts plain local paths" { + const accepted = [_][]const u8{ + "C:\\Windows\\Temp\\tty-graphics-protocol-image.data", + "C:/Windows/Temp/tty-graphics-protocol-image.data", + "c:\\image.data", + "C:image.data", + "\\Windows\\Temp\\image.data", + "/Windows/Temp/image.data", + "image.data", + ".zig-cache\\tmp\\image.data", + "C:\\Device\\HarddiskVolume1\\image.data", + "C:\\GLOBALROOT\\image.data", + "C:\\dir\\console.png", + "\\?\\image.data", + "", + }; + for (accepted) |path| { + try checkPath(path); + } +} + +test "checkPath rejects reserved device names in any component" { + const testing = std.testing; + + const reserved = [_][]const u8{ + "CON", + "con", + "CON.png", + "CON.tar.gz", + "CON.", + "CON ", + "CON .png", + "NUL:stream", + "PRN", + "AUX", + "nul", + "COM1", + "COM0", + "COM9.rgba", + "com5", + "LPT1", + "LPT9", + "lpt0.png", + "COM\xC2\xB9", + "LPT\xC2\xB3.png", + "C:\\Windows\\Temp\\NUL.rgba", + "C:\\CON\\image.data", + "dir/aux/image.data", + "C:/Windows/Temp/com1", + "..\\PRN", + }; + for (reserved) |path| { + try testing.expectError(error.ReservedDeviceName, checkPath(path)); + } + + const plain = [_][]const u8{ + "CONSOLE", + "CON1", + "xCON", + " CON", + "COM", + "COM10", + "COMA", + "COM\xC2\xB9\xC2\xB9", + "COM\xC2\xB0", + "LPT", + "LPT.png", + "conx.png", + "C:", + "C:\\", + "image.data", + "C:\\Windows\\Temp\\tty-graphics-protocol-image.data", + "C:\\Windows\\Temp\\console.png", + "", + }; + for (plain) |path| { + try checkPath(path); + } +} + +test "checkCanonicalPath requires a drive-absolute local path" { + const testing = std.testing; + + try checkCanonicalPath("C:\\Windows\\Temp\\image.data"); + try checkCanonicalPath("c:\\image.data"); + + // Shapes GetFinalPathNameByHandle produces for files that are not + // on a local drive-letter volume. + try testing.expectError( + error.NotDriveAbsolute, + checkCanonicalPath("\\\\server\\share\\image.data"), + ); + try testing.expectError( + error.NotDriveAbsolute, + checkCanonicalPath("\\\\?\\UNC\\server\\share\\image.data"), + ); + try testing.expectError( + error.NotDriveAbsolute, + checkCanonicalPath("\\\\?\\Volume{383da0b0-717f-41b6-8c36-00500992b58d}\\image.data"), + ); + + // The raw path checks still apply to the canonical form. + try testing.expectError( + error.ReservedDeviceName, + checkCanonicalPath("C:\\Windows\\Temp\\CON"), + ); +} + +test isDriveAbsolute { + const testing = std.testing; + + try testing.expect(isDriveAbsolute("C:\\Windows\\Temp\\image.data")); + try testing.expect(isDriveAbsolute("c:\\image.data")); + try testing.expect(isDriveAbsolute("Z:\\")); + + try testing.expect(!isDriveAbsolute("\\\\server\\share\\image.data")); + try testing.expect(!isDriveAbsolute("UNC\\server\\share\\image.data")); + try testing.expect(!isDriveAbsolute("C:/Windows/image.data")); + try testing.expect(!isDriveAbsolute("C:image.data")); + try testing.expect(!isDriveAbsolute("\\Windows\\image.data")); + try testing.expect(!isDriveAbsolute("1:\\image.data")); + try testing.expect(!isDriveAbsolute("C:")); + try testing.expect(!isDriveAbsolute("")); +}