Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enforce import/embed case sensitivity #23163

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/std/Build/Cache/Path.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
104 changes: 102 additions & 2 deletions lib/std/fs/path.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions src/Sema.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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});
},
Expand Down Expand Up @@ -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});
},
Expand Down
84 changes: 84 additions & 0 deletions src/Zcu/PerThread.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +1957 to +1969
Copy link
Collaborator

@squeek502 squeek502 Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this codepoint iteration loop has no practical difference to std.ascii.eqlIgnoreCase (besides doing a lot of extra work), since UTF-8 multibyte codepoints will never contain bytes within the ASCII range. In other words, its always safe to iterate over bytes with UTF-8 if you only care about the ASCII range.

(but see #23163 (comment))


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`.
Expand All @@ -1960,6 +2024,7 @@ pub fn importFile(
) error{
OutOfMemory,
ModuleNotFound,
ImportCaseMismatch,
ImportOutsideModulePath,
CurrentWorkingDirectoryUnlinked,
}!Zcu.ImportFileResult {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -2077,6 +2151,7 @@ pub fn embedFile(
import_string: []const u8,
) error{
OutOfMemory,
ImportCaseMismatch,
ImportOutsideModulePath,
CurrentWorkingDirectoryUnlinked,
}!Zcu.EmbedFile.Index {
Expand Down Expand Up @@ -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));

Expand Down
3 changes: 3 additions & 0 deletions test/standalone/build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@
.config_header = .{
.path = "config_header",
},
.case_sensitivity = .{
.path = "case_sensitivity",
},
},
.paths = .{
"build.zig",
Expand Down
75 changes: 75 additions & 0 deletions test/standalone/case_sensitivity/build.zig
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions test/standalone/case_sensitivity/embedbad.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub fn main() u8 {
return @embedFile("Foo.zig").len;
}
3 changes: 3 additions & 0 deletions test/standalone/case_sensitivity/embedgood.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub fn main() u8 {
return @embedFile("foo.zig").len;
}
1 change: 1 addition & 0 deletions test/standalone/case_sensitivity/foo.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub const value = 42;
3 changes: 3 additions & 0 deletions test/standalone/case_sensitivity/importbad.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub fn main() u8 {
return @import("Foo.zig").value;
}
3 changes: 3 additions & 0 deletions test/standalone/case_sensitivity/importgood.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub fn main() u8 {
return @import("foo.zig").value;
}
Loading