diff --git a/lib/std/Build/Cache/Path.zig b/lib/std/Build/Cache/Path.zig index a14c6cd7a4fc..e961cc44bbec 100644 --- a/lib/std/Build/Cache/Path.zig +++ b/lib/std/Build/Cache/Path.zig @@ -86,6 +86,16 @@ pub fn makeOpenPath(p: Path, sub_path: []const u8, opts: fs.Dir.OpenOptions) !fs return p.root_dir.handle.makeOpenPath(joined_path, opts); } +pub fn realpath(p: Path, sub_path: []const u8, out_buffer: []u8) std.fs.Dir.RealPathError![]u8 { + var buf: [fs.max_path_bytes]u8 = undefined; + const joined_path = if (p.sub_path.len == 0) sub_path else p: { + break :p std.fmt.bufPrint(&buf, "{s}" ++ fs.path.sep_str ++ "{s}", .{ + p.sub_path, sub_path, + }) catch return error.NameTooLong; + }; + return p.root_dir.handle.realpath(joined_path, out_buffer); +} + pub fn statFile(p: Path, sub_path: []const u8) !fs.Dir.Stat { var buf: [fs.max_path_bytes]u8 = undefined; const joined_path = if (p.sub_path.len == 0) sub_path else p: { diff --git a/lib/std/fs/path.zig b/lib/std/fs/path.zig index b2f95a937f05..c914844f1d82 100644 --- a/lib/std/fs/path.zig +++ b/lib/std/fs/path.zig @@ -1412,8 +1412,8 @@ pub fn ComponentIterator(comptime path_type: PathType, comptime T: type) type { /// After `init`, `next` will return the first component after the root /// (there is no need to call `first` after `init`). - /// To iterate backwards (from the end of the path to the beginning), call `last` - /// after `init` and then iterate via `previous` calls. + /// To iterate backwards (from the end of the path to the beginning), call either `last` + /// or `moveToEnd` after `init` and then iterate via `previous` calls. /// For Windows paths, `error.BadPathName` is returned if the `path` has an explicit /// namespace prefix (`\\.\`, `\\?\`, or `\??\`) or if it is a UNC path with more /// than two path separators at the beginning. @@ -1518,6 +1518,12 @@ pub fn ComponentIterator(comptime path_type: PathType, comptime T: type) type { }; } + /// Moves to the end of the path. Used to iterate backwards. + pub fn moveToEnd(self: *Self) void { + self.start_index = self.path.len; + self.end_index = self.path.len; + } + /// Returns the last component (from the end of the path). /// For example, if the path is `/a/b/c` then this will return the `c` component. /// After calling `last`, `next` will always return `null`, and `previous` will return @@ -1983,3 +1989,97 @@ pub const fmtAsUtf8Lossy = std.unicode.fmtUtf8; /// a lossy conversion if the path contains any unpaired surrogates. /// Unpaired surrogates are replaced by the replacement character (U+FFFD). pub const fmtWtf16LeAsUtf8Lossy = std.unicode.fmtUtf16Le; + +test "filesystem case sensitivity" { + // disabled on wasi because realpath is not implemented + if (builtin.os.tag == .wasi) return; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const fs_case_sensitive = try testIsFilesystemCaseSensitive(tmp.dir); + switch (builtin.os.tag) { + .windows, .macos => try std.testing.expectEqual(false, fs_case_sensitive), + else => {}, + } + + { + { + const f = try tmp.dir.createFile("foo.zig", .{}); + f.close(); + } + try std.testing.expect(try realpathMatches(tmp.dir, "foo.zig")); + if (fs_case_sensitive) { + try std.testing.expectError(error.FileNotFound, realpathMatches(tmp.dir, "Foo.zig")); + } else { + try std.testing.expect(!try realpathMatches(tmp.dir, "Foo.zig")); + } + } + + try tmp.dir.makeDir("subdir"); + { + { + const f = try tmp.dir.createFile("subdir/bar.zig", .{}); + f.close(); + } + try std.testing.expect(try realpathMatches(tmp.dir, "subdir/bar.zig")); + inline for (&.{ + "Subdir/bar.zig", + "subdir/Bar.zig", + }) |sub_path| { + if (fs_case_sensitive) { + try std.testing.expectError(error.FileNotFound, realpathMatches(tmp.dir, sub_path)); + } else { + try std.testing.expect(!try realpathMatches(tmp.dir, sub_path)); + } + } + } +} + +fn realpathMatches(dir: std.fs.Dir, sub_path: []const u8) !bool { + var real_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = try dir.realpath(sub_path, &real_path_buf); + var sub_path_it = try std.fs.path.NativeComponentIterator.init(sub_path); + var real_path_it = std.fs.path.NativeComponentIterator.init(real_path) catch unreachable; + sub_path_it.moveToEnd(); + real_path_it.moveToEnd(); + var match = true; + while (true) { + const sub_path_component = sub_path_it.previous() orelse break; + const real_path_component = real_path_it.previous() orelse unreachable; + std.debug.assert(std.ascii.eqlIgnoreCase(sub_path_component.name, real_path_component.name)); + match = match and std.mem.eql(u8, sub_path_component.name, real_path_component.name); + } + return match; +} + +fn testIsFilesystemCaseSensitive(test_dir: std.fs.Dir) !bool { + const name_lower = "case-sensitivity-test-file"; + const name_upper = "CASE-SENSITIVITY-TEST-FILE"; + + test_dir.deleteFile(name_lower) catch |err| switch (err) { + error.FileNotFound => {}, + else => |e| return e, + }; + test_dir.deleteFile(name_upper) catch |err| switch (err) { + error.FileNotFound => {}, + else => |e| return e, + }; + + { + const file = try test_dir.createFile(name_lower, .{}); + file.close(); + } + defer test_dir.deleteFile(name_lower) catch |err| std.debug.panic( + "failed to delete test file '{s}' with {s}\n", + .{ name_lower, @errorName(err) }, + ); + { + const file = test_dir.openFile(name_upper, .{}) catch |err| switch (err) { + error.FileNotFound => return true, + else => |e| return e, + }; + file.close(); + } + return false; +} diff --git a/src/Sema.zig b/src/Sema.zig index b30f42c2d7b5..2d664dca0b83 100644 --- a/src/Sema.zig +++ b/src/Sema.zig @@ -14047,6 +14047,9 @@ fn zirImport(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air. const operand = sema.code.nullTerminatedString(extra.path); const result = pt.importFile(block.getFileScope(zcu), operand) catch |err| switch (err) { + error.ImportCaseMismatch => { + return sema.fail(block, operand_src, "import string '{s}' case does not match the filename", .{operand}); + }, error.ImportOutsideModulePath => { return sema.fail(block, operand_src, "import of file outside module path: '{s}'", .{operand}); }, @@ -14109,6 +14112,9 @@ fn zirEmbedFile(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!A } const ef_idx = pt.embedFile(block.getFileScope(zcu), name) catch |err| switch (err) { + error.ImportCaseMismatch => { + return sema.fail(block, operand_src, "embed string '{s}' case does not match the filename", .{name}); + }, error.ImportOutsideModulePath => { return sema.fail(block, operand_src, "embed of file outside package path: '{s}'", .{name}); }, diff --git a/src/Zcu/PerThread.zig b/src/Zcu/PerThread.zig index 699315835a75..6bd4b4d67ce1 100644 --- a/src/Zcu/PerThread.zig +++ b/src/Zcu/PerThread.zig @@ -1950,6 +1950,70 @@ pub fn importPkg(pt: Zcu.PerThread, mod: *Module) Allocator.Error!Zcu.ImportFile }; } +fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { + if (builtin.os.tag == .windows) + return std.os.windows.eqlIgnoreCaseWtf8(a, b); + + var a_utf8_it = std.unicode.Utf8View.initUnchecked(a).iterator(); + var b_utf8_it = std.unicode.Utf8View.initUnchecked(b).iterator(); + while (true) { + const a_cp = a_utf8_it.nextCodepoint() orelse break; + const b_cp = b_utf8_it.nextCodepoint() orelse return false; + if (a_cp != b_cp) { + const a_upper = std.ascii.toUpper(std.math.cast(u8, a_cp) orelse return false); + const b_upper = std.ascii.toUpper(std.math.cast(u8, b_cp) orelse return false); + if (a_upper != b_upper) + return false; + } + } + if (b_utf8_it.nextCodepoint() != null) return false; + + return true; +} + +fn previousResolveDots(it: *std.fs.path.NativeComponentIterator) ?std.fs.path.NativeComponentIterator.Component { + var dot_dot_count: usize = 0; + while (true) { + const component = it.previous() orelse return null; + if (std.mem.eql(u8, component.name, ".")) { + // ignore + } else if (std.mem.eql(u8, component.name, "..")) { + dot_dot_count += 1; + } else { + if (dot_dot_count == 0) return component; + dot_dot_count -= 1; + } + } +} + +fn checkImportCase(path: Cache.Path, sub_path: []const u8, import_string: []const u8) enum { ok, mismatch } { + // disabled on wasi because realpath is not implemented + if (builtin.os.tag == .wasi) return .ok; + + var import_it = std.fs.path.NativeComponentIterator.init(import_string) catch return .ok; + import_it.moveToEnd(); + + var real_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const real_path = path.realpath(sub_path, &real_path_buf) catch |err| switch (err) { + error.FileNotFound => return .ok, + else => |e| std.debug.panic("realpath '{}{s}' failed with {s}", .{ path, sub_path, @errorName(e) }), + }; + var real_path_it = std.fs.path.NativeComponentIterator.init(real_path) catch unreachable; + real_path_it.moveToEnd(); + + var match = true; + while (true) { + const import_component = previousResolveDots(&import_it) orelse break; + const real_path_component = previousResolveDots(&real_path_it) orelse unreachable; + if (!eqlIgnoreCase(import_component.name, real_path_component.name)) { + std.debug.panic("real path '{s}' does not end with import path '{s}'", .{ real_path, import_string }); + } + match = match and std.mem.eql(u8, import_component.name, real_path_component.name); + } + + return if (match) .ok else .mismatch; +} + /// Called from a worker thread during AstGen (with the Compilation mutex held). /// Also called from Sema during semantic analysis. /// Does not attempt to load the file from disk; just returns a corresponding `*Zcu.File`. @@ -1960,6 +2024,7 @@ pub fn importFile( ) error{ OutOfMemory, ModuleNotFound, + ImportCaseMismatch, ImportOutsideModulePath, CurrentWorkingDirectoryUnlinked, }!Zcu.ImportFileResult { @@ -1993,6 +2058,15 @@ pub fn importFile( import_string, }); + { + const relative_path = try std.fs.path.resolve(gpa, &.{ cur_file.sub_file_path, "..", import_string }); + defer gpa.free(relative_path); + switch (checkImportCase(mod.root, relative_path, import_string)) { + .ok => {}, + .mismatch => return error.ImportCaseMismatch, + } + } + var keep_resolved_path = false; defer if (!keep_resolved_path) gpa.free(resolved_path); @@ -2077,6 +2151,7 @@ pub fn embedFile( import_string: []const u8, ) error{ OutOfMemory, + ImportCaseMismatch, ImportOutsideModulePath, CurrentWorkingDirectoryUnlinked, }!Zcu.EmbedFile.Index { @@ -2114,6 +2189,15 @@ pub fn embedFile( }); errdefer gpa.free(resolved_path); + { + const relative_path = try std.fs.path.resolve(gpa, &.{ cur_file.sub_file_path, "..", import_string }); + defer gpa.free(relative_path); + switch (checkImportCase(cur_file.mod.root, relative_path, import_string)) { + .ok => {}, + .mismatch => return error.ImportCaseMismatch, + } + } + const gop = try zcu.embed_table.getOrPut(gpa, resolved_path); errdefer assert(std.mem.eql(u8, zcu.embed_table.pop().?.key, resolved_path)); diff --git a/test/standalone/build.zig.zon b/test/standalone/build.zig.zon index db1c7125a774..cfcdaaeea725 100644 --- a/test/standalone/build.zig.zon +++ b/test/standalone/build.zig.zon @@ -189,6 +189,9 @@ .config_header = .{ .path = "config_header", }, + .case_sensitivity = .{ + .path = "case_sensitivity", + }, }, .paths = .{ "build.zig", diff --git a/test/standalone/case_sensitivity/build.zig b/test/standalone/case_sensitivity/build.zig new file mode 100644 index 000000000000..773cb8f99d30 --- /dev/null +++ b/test/standalone/case_sensitivity/build.zig @@ -0,0 +1,75 @@ +const builtin = @import("builtin"); +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const fs_case_sensitive = try isFilesystemCaseSensitive(b.build_root); + switch (builtin.os.tag) { + .windows, .macos => try std.testing.expectEqual(false, fs_case_sensitive), + else => {}, + } + addCase(b, fs_case_sensitive, .import, .good); + addCase(b, fs_case_sensitive, .import, .bad); + addCase(b, fs_case_sensitive, .embed, .good); + addCase(b, fs_case_sensitive, .embed, .bad); +} + +fn addCase( + b: *std.Build, + fs_case_sensitive: bool, + comptime kind: enum { import, embed }, + comptime variant: enum { good, bad }, +) void { + const name = @tagName(kind) ++ @tagName(variant); + const compile = b.addSystemCommand(&.{ + b.graph.zig_exe, + "build-exe", + "-fno-emit-bin", + name ++ ".zig", + }); + if (variant == .bad) { + if (fs_case_sensitive) { + switch (kind) { + .import => compile.addCheck(.{ .expect_stderr_match = "unable to load" }), + .embed => compile.addCheck(.{ .expect_stderr_match = "unable to open" }), + } + compile.addCheck(.{ .expect_stderr_match = "Foo.zig" }); + compile.addCheck(.{ .expect_stderr_match = "FileNotFound" }); + } else { + compile.addCheck(.{ + .expect_stderr_match = b.fmt("{s} string 'Foo.zig' case does not match the filename", .{@tagName(kind)}), + }); + } + } + b.default_step.dependOn(&compile.step); +} + +fn isFilesystemCaseSensitive(test_dir: std.Build.Cache.Directory) !bool { + const name_lower = "case-sensitivity-test-file"; + const name_upper = "CASE-SENSITIVITY-TEST-FILE"; + + test_dir.handle.deleteFile(name_lower) catch |err| switch (err) { + error.FileNotFound => {}, + else => |e| return e, + }; + test_dir.handle.deleteFile(name_upper) catch |err| switch (err) { + error.FileNotFound => {}, + else => |e| return e, + }; + + { + const file = try test_dir.handle.createFile(name_lower, .{}); + file.close(); + } + defer test_dir.handle.deleteFile(name_lower) catch |err| std.debug.panic( + "failed to delete test file '{s}' in directory '{}' with {s}\n", + .{ name_lower, test_dir, @errorName(err) }, + ); + { + const file = test_dir.handle.openFile(name_upper, .{}) catch |err| switch (err) { + error.FileNotFound => return true, + else => |e| return e, + }; + file.close(); + } + return false; +} diff --git a/test/standalone/case_sensitivity/embedbad.zig b/test/standalone/case_sensitivity/embedbad.zig new file mode 100644 index 000000000000..a3c1317a93f2 --- /dev/null +++ b/test/standalone/case_sensitivity/embedbad.zig @@ -0,0 +1,3 @@ +pub fn main() u8 { + return @embedFile("Foo.zig").len; +} diff --git a/test/standalone/case_sensitivity/embedgood.zig b/test/standalone/case_sensitivity/embedgood.zig new file mode 100644 index 000000000000..919f1f989feb --- /dev/null +++ b/test/standalone/case_sensitivity/embedgood.zig @@ -0,0 +1,3 @@ +pub fn main() u8 { + return @embedFile("foo.zig").len; +} diff --git a/test/standalone/case_sensitivity/foo.zig b/test/standalone/case_sensitivity/foo.zig new file mode 100644 index 000000000000..da61ab900ad5 --- /dev/null +++ b/test/standalone/case_sensitivity/foo.zig @@ -0,0 +1 @@ +pub const value = 42; diff --git a/test/standalone/case_sensitivity/importbad.zig b/test/standalone/case_sensitivity/importbad.zig new file mode 100644 index 000000000000..417fd9e9a2f6 --- /dev/null +++ b/test/standalone/case_sensitivity/importbad.zig @@ -0,0 +1,3 @@ +pub fn main() u8 { + return @import("Foo.zig").value; +} diff --git a/test/standalone/case_sensitivity/importgood.zig b/test/standalone/case_sensitivity/importgood.zig new file mode 100644 index 000000000000..fb5c4aca068a --- /dev/null +++ b/test/standalone/case_sensitivity/importgood.zig @@ -0,0 +1,3 @@ +pub fn main() u8 { + return @import("foo.zig").value; +}