Skip to content

Commit d94b4bb

Browse files
committed
enforce import/embed case sensitivity
closes #9786
1 parent 3b3c189 commit d94b4bb

File tree

11 files changed

+293
-2
lines changed

11 files changed

+293
-2
lines changed

lib/std/Build/Cache/Path.zig

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ pub fn makeOpenPath(p: Path, sub_path: []const u8, opts: fs.Dir.OpenOptions) !fs
8686
return p.root_dir.handle.makeOpenPath(joined_path, opts);
8787
}
8888

89+
pub fn realpath(p: Path, sub_path: []const u8, out_buffer: []u8) std.fs.Dir.RealPathError![]u8 {
90+
var buf: [fs.max_path_bytes]u8 = undefined;
91+
const joined_path = if (p.sub_path.len == 0) sub_path else p: {
92+
break :p std.fmt.bufPrint(&buf, "{s}" ++ fs.path.sep_str ++ "{s}", .{
93+
p.sub_path, sub_path,
94+
}) catch return error.NameTooLong;
95+
};
96+
return p.root_dir.handle.realpath(joined_path, out_buffer);
97+
}
98+
8999
pub fn statFile(p: Path, sub_path: []const u8) !fs.Dir.Stat {
90100
var buf: [fs.max_path_bytes]u8 = undefined;
91101
const joined_path = if (p.sub_path.len == 0) sub_path else p: {

lib/std/fs/path.zig

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,8 +1412,8 @@ pub fn ComponentIterator(comptime path_type: PathType, comptime T: type) type {
14121412

14131413
/// After `init`, `next` will return the first component after the root
14141414
/// (there is no need to call `first` after `init`).
1415-
/// To iterate backwards (from the end of the path to the beginning), call `last`
1416-
/// after `init` and then iterate via `previous` calls.
1415+
/// To iterate backwards (from the end of the path to the beginning), call either `last`
1416+
/// or `moveToEnd` after `init` and then iterate via `previous` calls.
14171417
/// For Windows paths, `error.BadPathName` is returned if the `path` has an explicit
14181418
/// namespace prefix (`\\.\`, `\\?\`, or `\??\`) or if it is a UNC path with more
14191419
/// than two path separators at the beginning.
@@ -1518,6 +1518,12 @@ pub fn ComponentIterator(comptime path_type: PathType, comptime T: type) type {
15181518
};
15191519
}
15201520

1521+
/// Moves to the end of the path. Used to iterate backwards.
1522+
pub fn moveToEnd(self: *Self) void {
1523+
self.start_index = self.path.len;
1524+
self.end_index = self.path.len;
1525+
}
1526+
15211527
/// Returns the last component (from the end of the path).
15221528
/// For example, if the path is `/a/b/c` then this will return the `c` component.
15231529
/// After calling `last`, `next` will always return `null`, and `previous` will return
@@ -1983,3 +1989,97 @@ pub const fmtAsUtf8Lossy = std.unicode.fmtUtf8;
19831989
/// a lossy conversion if the path contains any unpaired surrogates.
19841990
/// Unpaired surrogates are replaced by the replacement character (U+FFFD).
19851991
pub const fmtWtf16LeAsUtf8Lossy = std.unicode.fmtUtf16Le;
1992+
1993+
test "filesystem case sensitivity" {
1994+
// disabled on wasi because realpath is not implemented
1995+
if (builtin.os.tag == .wasi) return;
1996+
1997+
var tmp = std.testing.tmpDir(.{});
1998+
defer tmp.cleanup();
1999+
2000+
const fs_case_sensitive = try testIsFilesystemCaseSensitive(tmp.dir);
2001+
switch (builtin.os.tag) {
2002+
.windows, .macos => try std.testing.expectEqual(false, fs_case_sensitive),
2003+
else => {},
2004+
}
2005+
2006+
{
2007+
{
2008+
const f = try tmp.dir.createFile("foo.zig", .{});
2009+
f.close();
2010+
}
2011+
try std.testing.expect(try realpathMatches(tmp.dir, "foo.zig"));
2012+
if (fs_case_sensitive) {
2013+
try std.testing.expectError(error.FileNotFound, realpathMatches(tmp.dir, "Foo.zig"));
2014+
} else {
2015+
try std.testing.expect(!try realpathMatches(tmp.dir, "Foo.zig"));
2016+
}
2017+
}
2018+
2019+
try tmp.dir.makeDir("subdir");
2020+
{
2021+
{
2022+
const f = try tmp.dir.createFile("subdir/bar.zig", .{});
2023+
f.close();
2024+
}
2025+
try std.testing.expect(try realpathMatches(tmp.dir, "subdir/bar.zig"));
2026+
inline for (&.{
2027+
"Subdir/bar.zig",
2028+
"subdir/Bar.zig",
2029+
}) |sub_path| {
2030+
if (fs_case_sensitive) {
2031+
try std.testing.expectError(error.FileNotFound, realpathMatches(tmp.dir, sub_path));
2032+
} else {
2033+
try std.testing.expect(!try realpathMatches(tmp.dir, sub_path));
2034+
}
2035+
}
2036+
}
2037+
}
2038+
2039+
fn realpathMatches(dir: std.fs.Dir, sub_path: []const u8) !bool {
2040+
var real_path_buf: [std.fs.max_path_bytes]u8 = undefined;
2041+
const real_path = try dir.realpath(sub_path, &real_path_buf);
2042+
var sub_path_it = try std.fs.path.NativeComponentIterator.init(sub_path);
2043+
var real_path_it = std.fs.path.NativeComponentIterator.init(real_path) catch unreachable;
2044+
sub_path_it.moveToEnd();
2045+
real_path_it.moveToEnd();
2046+
var match = true;
2047+
while (true) {
2048+
const sub_path_component = sub_path_it.previous() orelse break;
2049+
const real_path_component = real_path_it.previous() orelse unreachable;
2050+
std.debug.assert(std.ascii.eqlIgnoreCase(sub_path_component.name, real_path_component.name));
2051+
match = match and std.mem.eql(u8, sub_path_component.name, real_path_component.name);
2052+
}
2053+
return match;
2054+
}
2055+
2056+
fn testIsFilesystemCaseSensitive(test_dir: std.fs.Dir) !bool {
2057+
const name_lower = "case-sensitivity-test-file";
2058+
const name_upper = "CASE-SENSITIVITY-TEST-FILE";
2059+
2060+
test_dir.deleteFile(name_lower) catch |err| switch (err) {
2061+
error.FileNotFound => {},
2062+
else => |e| return e,
2063+
};
2064+
test_dir.deleteFile(name_upper) catch |err| switch (err) {
2065+
error.FileNotFound => {},
2066+
else => |e| return e,
2067+
};
2068+
2069+
{
2070+
const file = try test_dir.createFile(name_lower, .{});
2071+
file.close();
2072+
}
2073+
defer test_dir.deleteFile(name_lower) catch |err| std.debug.panic(
2074+
"failed to delete test file '{s}' with {s}\n",
2075+
.{ name_lower, @errorName(err) },
2076+
);
2077+
{
2078+
const file = test_dir.openFile(name_upper, .{}) catch |err| switch (err) {
2079+
error.FileNotFound => return true,
2080+
else => |e| return e,
2081+
};
2082+
file.close();
2083+
}
2084+
return false;
2085+
}

src/Sema.zig

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14047,6 +14047,9 @@ fn zirImport(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.
1404714047
const operand = sema.code.nullTerminatedString(extra.path);
1404814048

1404914049
const result = pt.importFile(block.getFileScope(zcu), operand) catch |err| switch (err) {
14050+
error.ImportCaseMismatch => {
14051+
return sema.fail(block, operand_src, "import string '{s}' case does not match the filename", .{operand});
14052+
},
1405014053
error.ImportOutsideModulePath => {
1405114054
return sema.fail(block, operand_src, "import of file outside module path: '{s}'", .{operand});
1405214055
},
@@ -14109,6 +14112,9 @@ fn zirEmbedFile(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!A
1410914112
}
1411014113

1411114114
const ef_idx = pt.embedFile(block.getFileScope(zcu), name) catch |err| switch (err) {
14115+
error.ImportCaseMismatch => {
14116+
return sema.fail(block, operand_src, "embed string '{s}' case does not match the filename", .{name});
14117+
},
1411214118
error.ImportOutsideModulePath => {
1411314119
return sema.fail(block, operand_src, "embed of file outside package path: '{s}'", .{name});
1411414120
},

src/Zcu/PerThread.zig

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1950,6 +1950,70 @@ pub fn importPkg(pt: Zcu.PerThread, mod: *Module) Allocator.Error!Zcu.ImportFile
19501950
};
19511951
}
19521952

1953+
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
1954+
if (builtin.os.tag == .windows)
1955+
return std.os.windows.eqlIgnoreCaseWtf8(a, b);
1956+
1957+
var a_utf8_it = std.unicode.Utf8View.initUnchecked(a).iterator();
1958+
var b_utf8_it = std.unicode.Utf8View.initUnchecked(b).iterator();
1959+
while (true) {
1960+
const a_cp = a_utf8_it.nextCodepoint() orelse break;
1961+
const b_cp = b_utf8_it.nextCodepoint() orelse return false;
1962+
if (a_cp != b_cp) {
1963+
const a_upper = std.ascii.toUpper(std.math.cast(u8, a_cp) orelse return false);
1964+
const b_upper = std.ascii.toUpper(std.math.cast(u8, b_cp) orelse return false);
1965+
if (a_upper != b_upper)
1966+
return false;
1967+
}
1968+
}
1969+
if (b_utf8_it.nextCodepoint() != null) return false;
1970+
1971+
return true;
1972+
}
1973+
1974+
fn previousResolveDots(it: *std.fs.path.NativeComponentIterator) ?std.fs.path.NativeComponentIterator.Component {
1975+
var dot_dot_count: usize = 0;
1976+
while (true) {
1977+
const component = it.previous() orelse return null;
1978+
if (std.mem.eql(u8, component.name, ".")) {
1979+
// ignore
1980+
} else if (std.mem.eql(u8, component.name, "..")) {
1981+
dot_dot_count += 1;
1982+
} else {
1983+
if (dot_dot_count == 0) return component;
1984+
dot_dot_count -= 1;
1985+
}
1986+
}
1987+
}
1988+
1989+
fn checkImportCase(path: Cache.Path, sub_path: []const u8, import_string: []const u8) enum { ok, mismatch } {
1990+
// disabled on wasi because realpath is not implemented
1991+
if (builtin.os.tag == .wasi) return .ok;
1992+
1993+
var import_it = std.fs.path.NativeComponentIterator.init(import_string) catch return .ok;
1994+
import_it.moveToEnd();
1995+
1996+
var real_path_buf: [std.fs.max_path_bytes]u8 = undefined;
1997+
const real_path = path.realpath(sub_path, &real_path_buf) catch |err| switch (err) {
1998+
error.FileNotFound => return .ok,
1999+
else => |e| std.debug.panic("realpath '{}{s}' failed with {s}", .{ path, sub_path, @errorName(e) }),
2000+
};
2001+
var real_path_it = std.fs.path.NativeComponentIterator.init(real_path) catch unreachable;
2002+
real_path_it.moveToEnd();
2003+
2004+
var match = true;
2005+
while (true) {
2006+
const import_component = previousResolveDots(&import_it) orelse break;
2007+
const real_path_component = previousResolveDots(&real_path_it) orelse unreachable;
2008+
if (!eqlIgnoreCase(import_component.name, real_path_component.name)) {
2009+
std.debug.panic("real path '{s}' does not end with import path '{s}'", .{ real_path, import_string });
2010+
}
2011+
match = match and std.mem.eql(u8, import_component.name, real_path_component.name);
2012+
}
2013+
2014+
return if (match) .ok else .mismatch;
2015+
}
2016+
19532017
/// Called from a worker thread during AstGen (with the Compilation mutex held).
19542018
/// Also called from Sema during semantic analysis.
19552019
/// Does not attempt to load the file from disk; just returns a corresponding `*Zcu.File`.
@@ -1960,6 +2024,7 @@ pub fn importFile(
19602024
) error{
19612025
OutOfMemory,
19622026
ModuleNotFound,
2027+
ImportCaseMismatch,
19632028
ImportOutsideModulePath,
19642029
CurrentWorkingDirectoryUnlinked,
19652030
}!Zcu.ImportFileResult {
@@ -1993,6 +2058,15 @@ pub fn importFile(
19932058
import_string,
19942059
});
19952060

2061+
{
2062+
const relative_path = try std.fs.path.resolve(gpa, &.{ cur_file.sub_file_path, "..", import_string });
2063+
defer gpa.free(relative_path);
2064+
switch (checkImportCase(mod.root, relative_path, import_string)) {
2065+
.ok => {},
2066+
.mismatch => return error.ImportCaseMismatch,
2067+
}
2068+
}
2069+
19962070
var keep_resolved_path = false;
19972071
defer if (!keep_resolved_path) gpa.free(resolved_path);
19982072

@@ -2077,6 +2151,7 @@ pub fn embedFile(
20772151
import_string: []const u8,
20782152
) error{
20792153
OutOfMemory,
2154+
ImportCaseMismatch,
20802155
ImportOutsideModulePath,
20812156
CurrentWorkingDirectoryUnlinked,
20822157
}!Zcu.EmbedFile.Index {
@@ -2114,6 +2189,15 @@ pub fn embedFile(
21142189
});
21152190
errdefer gpa.free(resolved_path);
21162191

2192+
{
2193+
const relative_path = try std.fs.path.resolve(gpa, &.{ cur_file.sub_file_path, "..", import_string });
2194+
defer gpa.free(relative_path);
2195+
switch (checkImportCase(cur_file.mod.root, relative_path, import_string)) {
2196+
.ok => {},
2197+
.mismatch => return error.ImportCaseMismatch,
2198+
}
2199+
}
2200+
21172201
const gop = try zcu.embed_table.getOrPut(gpa, resolved_path);
21182202
errdefer assert(std.mem.eql(u8, zcu.embed_table.pop().?.key, resolved_path));
21192203

test/standalone/build.zig.zon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@
189189
.config_header = .{
190190
.path = "config_header",
191191
},
192+
.case_sensitivity = .{
193+
.path = "case_sensitivity",
194+
},
192195
},
193196
.paths = .{
194197
"build.zig",
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const builtin = @import("builtin");
2+
const std = @import("std");
3+
4+
pub fn build(b: *std.Build) !void {
5+
const fs_case_sensitive = try isFilesystemCaseSensitive(b.build_root);
6+
switch (builtin.os.tag) {
7+
.windows, .macos => try std.testing.expectEqual(false, fs_case_sensitive),
8+
else => {},
9+
}
10+
addCase(b, fs_case_sensitive, .import, .good);
11+
addCase(b, fs_case_sensitive, .import, .bad);
12+
addCase(b, fs_case_sensitive, .embed, .good);
13+
addCase(b, fs_case_sensitive, .embed, .bad);
14+
}
15+
16+
fn addCase(
17+
b: *std.Build,
18+
fs_case_sensitive: bool,
19+
comptime kind: enum { import, embed },
20+
comptime variant: enum { good, bad },
21+
) void {
22+
const name = @tagName(kind) ++ @tagName(variant);
23+
const compile = b.addSystemCommand(&.{
24+
b.graph.zig_exe,
25+
"build-exe",
26+
"-fno-emit-bin",
27+
name ++ ".zig",
28+
});
29+
if (variant == .bad) {
30+
if (fs_case_sensitive) {
31+
switch (kind) {
32+
.import => compile.addCheck(.{ .expect_stderr_match = "unable to load" }),
33+
.embed => compile.addCheck(.{ .expect_stderr_match = "unable to open" }),
34+
}
35+
compile.addCheck(.{ .expect_stderr_match = "Foo.zig" });
36+
compile.addCheck(.{ .expect_stderr_match = "FileNotFound" });
37+
} else {
38+
compile.addCheck(.{
39+
.expect_stderr_match = b.fmt("{s} string 'Foo.zig' case does not match the filename", .{@tagName(kind)}),
40+
});
41+
}
42+
}
43+
b.default_step.dependOn(&compile.step);
44+
}
45+
46+
fn isFilesystemCaseSensitive(test_dir: std.Build.Cache.Directory) !bool {
47+
const name_lower = "case-sensitivity-test-file";
48+
const name_upper = "CASE-SENSITIVITY-TEST-FILE";
49+
50+
test_dir.handle.deleteFile(name_lower) catch |err| switch (err) {
51+
error.FileNotFound => {},
52+
else => |e| return e,
53+
};
54+
test_dir.handle.deleteFile(name_upper) catch |err| switch (err) {
55+
error.FileNotFound => {},
56+
else => |e| return e,
57+
};
58+
59+
{
60+
const file = try test_dir.handle.createFile(name_lower, .{});
61+
file.close();
62+
}
63+
defer test_dir.handle.deleteFile(name_lower) catch |err| std.debug.panic(
64+
"failed to delete test file '{s}' in directory '{}' with {s}\n",
65+
.{ name_lower, test_dir, @errorName(err) },
66+
);
67+
{
68+
const file = test_dir.handle.openFile(name_upper, .{}) catch |err| switch (err) {
69+
error.FileNotFound => return true,
70+
else => |e| return e,
71+
};
72+
file.close();
73+
}
74+
return false;
75+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub fn main() u8 {
2+
return @embedFile("Foo.zig").len;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub fn main() u8 {
2+
return @embedFile("foo.zig").len;
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub const value = 42;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub fn main() u8 {
2+
return @import("Foo.zig").value;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub fn main() u8 {
2+
return @import("foo.zig").value;
3+
}

0 commit comments

Comments
 (0)