Commit d3a163f868

Abhinav Gupta <mail@abhinavg.net>
2024-01-05 00:47:28
build/LazyPath: Add dirname (#18371)
Adds a variant to the LazyPath union representing a parent directory of a generated path. ```zig const LazyPath = union(enum) { generated_dirname: struct { generated: *const GeneratedFile, up: usize, }, // ... } ``` These can be constructed with the new method: ```zig pub fn dirname(self: LazyPath) LazyPath ``` For the cases where the LazyPath is already known (`.path`, `.cwd_relative`, and `dependency`) this is evaluated right away. For dirnames of generated files and their dirnames, this is evaluated at getPath time. dirname calls can be chained, but for safety, they are not allowed to escape outside a root defined for each case: - path: This is relative to the build root, so dirname can't escape outside the build root. - generated: Can't escape the zig-cache. - cwd_relative: This can be a relative or absolute path. If relative, can't escape the current directory, and if absolute, can't go beyond root (/). - dependency: Can't escape the dependency's root directory. Testing: I've included a standalone case for many of the happy cases. I couldn't find an easy way to test the negatives, though, because tests cannot yet expect panics.
1 parent 501a235
Changed files (7)
lib/std/Build/Step/ConfigHeader.zig
@@ -59,7 +59,7 @@ pub fn create(owner: *std.Build, options: Options) *ConfigHeader {
     if (options.style.getPath()) |s| default_include_path: {
         const sub_path = switch (s) {
             .path => |path| path,
-            .generated => break :default_include_path,
+            .generated, .generated_dirname => break :default_include_path,
             .cwd_relative => |sub_path| sub_path,
             .dependency => |dependency| dependency.sub_path,
         };
lib/std/Build.zig
@@ -1871,6 +1871,36 @@ pub const GeneratedFile = struct {
     }
 };
 
+// dirnameAllowEmpty is a variant of fs.path.dirname
+// that allows "" to refer to the root for relative paths.
+//
+// For context, dirname("foo") and dirname("") are both null.
+// However, for relative paths, we want dirname("foo") to be ""
+// so that we can join it with another path (e.g. build root, cache root, etc.)
+//
+// dirname("") should still be null, because we can't go up any further.
+fn dirnameAllowEmpty(path: []const u8) ?[]const u8 {
+    return fs.path.dirname(path) orelse {
+        if (fs.path.isAbsolute(path) or path.len == 0) return null;
+
+        return "";
+    };
+}
+
+test dirnameAllowEmpty {
+    try std.testing.expectEqualStrings(
+        "foo",
+        dirnameAllowEmpty("foo" ++ fs.path.sep_str ++ "bar") orelse @panic("unexpected null"),
+    );
+
+    try std.testing.expectEqualStrings(
+        "",
+        dirnameAllowEmpty("foo") orelse @panic("unexpected null"),
+    );
+
+    try std.testing.expect(dirnameAllowEmpty("") == null);
+}
+
 /// A reference to an existing or future path.
 pub const LazyPath = union(enum) {
     /// A source file path relative to build root.
@@ -1882,6 +1912,17 @@ pub const LazyPath = union(enum) {
     /// not available until built by a build step.
     generated: *const GeneratedFile,
 
+    /// One of the parent directories of a file generated by an interface.
+    /// The path is not available until built by a build step.
+    generated_dirname: struct {
+        generated: *const GeneratedFile,
+
+        /// The number of parent directories to go up.
+        /// 0 means the directory of the generated file,
+        /// 1 means the parent of that directory, and so on.
+        up: usize,
+    },
+
     /// An absolute path or a path relative to the current working directory of
     /// the build runner process.
     /// This is uncommon but used for system environment paths such as `--zig-lib-dir` which
@@ -1902,12 +1943,72 @@ pub const LazyPath = union(enum) {
         return LazyPath{ .path = path };
     }
 
+    /// Returns a lazy path referring to the directory containing this path.
+    ///
+    /// The dirname is not allowed to escape the logical root for underlying path.
+    /// For example, if the path is relative to the build root,
+    /// the dirname is not allowed to traverse outside of the build root.
+    /// Similarly, if the path is a generated file inside zig-cache,
+    /// the dirname is not allowed to traverse outside of zig-cache.
+    pub fn dirname(self: LazyPath) LazyPath {
+        return switch (self) {
+            .generated => |gen| .{ .generated_dirname = .{ .generated = gen, .up = 0 } },
+            .generated_dirname => |gen| .{ .generated_dirname = .{ .generated = gen.generated, .up = gen.up + 1 } },
+            .path => |p| .{
+                .path = dirnameAllowEmpty(p) orelse {
+                    dumpBadDirnameHelp(null, null,
+                        \\dirname() attempted to traverse outside the build root.
+                        \\This is not allowed.
+                        \\
+                    , .{}) catch {};
+                    @panic("misconfigured build script");
+                },
+            },
+            .cwd_relative => |p| .{
+                .cwd_relative = dirnameAllowEmpty(p) orelse {
+                    // If we get null, it means one of two things:
+                    // - p was absolute, and is now root
+                    // - p was relative, and is now ""
+                    // In either case, the build script tried to go too far
+                    // and we should panic.
+                    if (fs.path.isAbsolute(p)) {
+                        dumpBadDirnameHelp(null, null,
+                            \\dirname() attempted to traverse outside the root.
+                            \\No more directories left to go up.
+                            \\
+                        , .{}) catch {};
+                        @panic("misconfigured build script");
+                    } else {
+                        dumpBadDirnameHelp(null, null,
+                            \\dirname() attempted to traverse outside the current working directory.
+                            \\This is not allowed.
+                            \\
+                        , .{}) catch {};
+                        @panic("misconfigured build script");
+                    }
+                },
+            },
+            .dependency => |dep| .{ .dependency = .{
+                .dependency = dep.dependency,
+                .sub_path = dirnameAllowEmpty(dep.sub_path) orelse {
+                    dumpBadDirnameHelp(null, null,
+                        \\dirname() attempted to traverse outside the dependency root.
+                        \\This is not allowed.
+                        \\
+                    , .{}) catch {};
+                    @panic("misconfigured build script");
+                },
+            } },
+        };
+    }
+
     /// Returns a string that can be shown to represent the file source.
     /// Either returns the path or `"generated"`.
     pub fn getDisplayName(self: LazyPath) []const u8 {
         return switch (self) {
             .path, .cwd_relative => self.path,
             .generated => "generated",
+            .generated_dirname => "generated",
             .dependency => "dependency",
         };
     }
@@ -1917,6 +2018,7 @@ pub const LazyPath = union(enum) {
         switch (self) {
             .path, .cwd_relative, .dependency => {},
             .generated => |gen| other_step.dependOn(gen.step),
+            .generated_dirname => |gen| other_step.dependOn(gen.generated.step),
         }
     }
 
@@ -1941,6 +2043,39 @@ pub const LazyPath = union(enum) {
                 dumpBadGetPathHelp(gen.step, stderr, src_builder, asking_step) catch {};
                 @panic("misconfigured build script");
             },
+            .generated_dirname => |gen| {
+                const cache_root_path = src_builder.cache_root.path orelse
+                    (src_builder.cache_root.join(src_builder.allocator, &.{"."}) catch @panic("OOM"));
+
+                const gen_step = gen.generated.step;
+                var path = getPath2(LazyPath{ .generated = gen.generated }, src_builder, asking_step);
+                var i: usize = 0;
+                while (i <= gen.up) : (i += 1) {
+                    // path is absolute.
+                    // dirname will return null only if we're at root.
+                    // Typically, we'll stop well before that at the cache root.
+                    path = fs.path.dirname(path) orelse {
+                        dumpBadDirnameHelp(gen_step, asking_step,
+                            \\dirname() reached root.
+                            \\No more directories left to go up.
+                            \\
+                        , .{}) catch {};
+                        @panic("misconfigured build script");
+                    };
+
+                    if (mem.eql(u8, path, cache_root_path) and i < gen.up) {
+                        // If we hit the cache root and there's still more to go,
+                        // the script attempted to go too far.
+                        dumpBadDirnameHelp(gen_step, asking_step,
+                            \\dirname() attempted to traverse outside the cache root.
+                            \\This is not allowed.
+                            \\
+                        , .{}) catch {};
+                        @panic("misconfigured build script");
+                    }
+                }
+                return path;
+            },
             .dependency => |dep| {
                 return dep.dependency.builder.pathJoin(&[_][]const u8{
                     dep.dependency.builder.build_root.path.?,
@@ -1956,11 +2091,53 @@ pub const LazyPath = union(enum) {
             .path => |p| .{ .path = b.dupePath(p) },
             .cwd_relative => |p| .{ .cwd_relative = b.dupePath(p) },
             .generated => |gen| .{ .generated = gen },
+            .generated_dirname => |gen| .{
+                .generated_dirname = .{
+                    .generated = gen.generated,
+                    .up = gen.up,
+                },
+            },
             .dependency => |dep| .{ .dependency = dep },
         };
     }
 };
 
+fn dumpBadDirnameHelp(
+    fail_step: ?*Step,
+    asking_step: ?*Step,
+    comptime msg: []const u8,
+    args: anytype,
+) anyerror!void {
+    debug.getStderrMutex().lock();
+    defer debug.getStderrMutex().unlock();
+
+    const stderr = io.getStdErr();
+    const w = stderr.writer();
+    try w.print(msg, args);
+
+    const tty_config = std.io.tty.detectConfig(stderr);
+
+    if (fail_step) |s| {
+        tty_config.setColor(w, .red) catch {};
+        try stderr.writeAll("    The step was created by this stack trace:\n");
+        tty_config.setColor(w, .reset) catch {};
+
+        s.dump(stderr);
+    }
+
+    if (asking_step) |as| {
+        tty_config.setColor(w, .red) catch {};
+        try stderr.writer().print("    The step '{s}' that is missing a dependency on the above step was created by this stack trace:\n", .{as.name});
+        tty_config.setColor(w, .reset) catch {};
+
+        as.dump(stderr);
+    }
+
+    tty_config.setColor(w, .red) catch {};
+    try stderr.writeAll("    Hope that helps. Proceeding to panic.\n");
+    tty_config.setColor(w, .reset) catch {};
+}
+
 /// In this function the stderr mutex has already been locked.
 pub fn dumpBadGetPathHelp(
     s: *Step,
test/standalone/dirname/build.zig
@@ -0,0 +1,84 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+    const target = b.standardTargetOptions(.{});
+
+    const test_step = b.step("test", "Test it");
+    b.default_step = test_step;
+
+    const touch_src = std.Build.LazyPath{
+        .path = "touch.zig",
+    };
+
+    const touch = b.addExecutable(.{
+        .name = "touch",
+        .root_source_file = touch_src,
+        .optimize = .Debug,
+        .target = target,
+    });
+    const generated = b.addRunArtifact(touch).addOutputFileArg("subdir" ++ std.fs.path.sep_str ++ "generated.txt");
+
+    const exists_in = b.addExecutable(.{
+        .name = "exists_in",
+        .root_source_file = .{ .path = "exists_in.zig" },
+        .optimize = .Debug,
+        .target = target,
+    });
+
+    const has_basename = b.addExecutable(.{
+        .name = "has_basename",
+        .root_source_file = .{ .path = "has_basename.zig" },
+        .optimize = .Debug,
+        .target = target,
+    });
+
+    // Known path:
+    addTestRun(test_step, exists_in, touch_src.dirname(), &.{"touch.zig"});
+
+    // Generated file:
+    addTestRun(test_step, exists_in, generated.dirname(), &.{"generated.txt"});
+
+    // Generated file multiple levels:
+    addTestRun(test_step, exists_in, generated.dirname().dirname(), &.{
+        "subdir" ++ std.fs.path.sep_str ++ "generated.txt",
+    });
+
+    // Cache root:
+    const cache_dir = b.cache_root.path orelse
+        (b.cache_root.join(b.allocator, &.{"."}) catch @panic("OOM"));
+    addTestRun(
+        test_step,
+        has_basename,
+        generated.dirname().dirname().dirname().dirname(),
+        &.{std.fs.path.basename(cache_dir)},
+    );
+
+    // Absolute path:
+    const abs_path = setup_abspath: {
+        const temp_dir = b.makeTempPath();
+
+        var dir = std.fs.openDirAbsolute(temp_dir, .{}) catch @panic("failed to open temp dir");
+        defer dir.close();
+
+        var file = dir.createFile("foo.txt", .{}) catch @panic("failed to create file");
+        file.close();
+
+        break :setup_abspath std.Build.LazyPath{ .cwd_relative = temp_dir };
+    };
+    addTestRun(test_step, exists_in, abs_path, &.{"foo.txt"});
+}
+
+// Runs exe with the parameters [dirname, args...].
+// Expects the exit code to be 0.
+fn addTestRun(
+    test_step: *std.Build.Step,
+    exe: *std.Build.Step.Compile,
+    dirname: std.Build.LazyPath,
+    args: []const []const u8,
+) void {
+    const run = test_step.owner.addRunArtifact(exe);
+    run.addDirectoryArg(dirname);
+    run.addArgs(args);
+    run.expectExitCode(0);
+    test_step.dependOn(&run.step);
+}
test/standalone/dirname/exists_in.zig
@@ -0,0 +1,46 @@
+//! Verifies that a file exists in a directory.
+//!
+//! Usage:
+//!
+//! ```
+//! exists_in <dir> <path>
+//! ```
+//!
+//! Where `<dir>/<path>` is the full path to the file.
+//! `<dir>` must be an absolute path.
+
+const std = @import("std");
+
+pub fn main() !void {
+    var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+    const arena = arena_state.allocator();
+    defer arena_state.deinit();
+
+    try run(arena);
+}
+
+fn run(allocator: std.mem.Allocator) !void {
+    var args = try std.process.argsWithAllocator(allocator);
+    defer args.deinit();
+    _ = args.next() orelse unreachable; // skip binary name
+
+    const dir_path = args.next() orelse {
+        std.log.err("missing <dir> argument", .{});
+        return error.BadUsage;
+    };
+
+    if (!std.fs.path.isAbsolute(dir_path)) {
+        std.log.err("expected <dir> to be an absolute path", .{});
+        return error.BadUsage;
+    }
+
+    const relpath = args.next() orelse {
+        std.log.err("missing <path> argument", .{});
+        return error.BadUsage;
+    };
+
+    var dir = try std.fs.openDirAbsolute(dir_path, .{});
+    defer dir.close();
+
+    _ = try dir.statFile(relpath);
+}
test/standalone/dirname/has_basename.zig
@@ -0,0 +1,50 @@
+//! Checks that the basename of the given path matches a string.
+//!
+//! Usage:
+//!
+//! ```
+//! has_basename <path> <basename>
+//! ```
+//!
+//! <path> must be absolute.
+//!
+//! Returns a non-zero exit code if basename
+//! does not match the given string.
+
+const std = @import("std");
+
+pub fn main() !void {
+    var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+    const arena = arena_state.allocator();
+    defer arena_state.deinit();
+
+    try run(arena);
+}
+
+fn run(allocator: std.mem.Allocator) !void {
+    var args = try std.process.argsWithAllocator(allocator);
+    defer args.deinit();
+    _ = args.next() orelse unreachable; // skip binary name
+
+    const path = args.next() orelse {
+        std.log.err("missing <path> argument", .{});
+        return error.BadUsage;
+    };
+
+    if (!std.fs.path.isAbsolute(path)) {
+        std.log.err("path must be absolute", .{});
+        return error.BadUsage;
+    }
+
+    const basename = args.next() orelse {
+        std.log.err("missing <basename> argument", .{});
+        return error.BadUsage;
+    };
+
+    const actual_basename = std.fs.path.basename(path);
+    if (std.mem.eql(u8, actual_basename, basename)) {
+        return;
+    }
+
+    return error.NotEqual;
+}
test/standalone/dirname/touch.zig
@@ -0,0 +1,44 @@
+//! Creates a file at the given path, if it doesn't already exist.
+//!
+//! ```
+//! touch <path>
+//! ```
+//!
+//! Path must be absolute.
+
+const std = @import("std");
+
+pub fn main() !void {
+    var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+    const arena = arena_state.allocator();
+    defer arena_state.deinit();
+
+    try run(arena);
+}
+
+fn run(allocator: std.mem.Allocator) !void {
+    var args = try std.process.argsWithAllocator(allocator);
+    defer args.deinit();
+    _ = args.next() orelse unreachable; // skip binary name
+
+    const path = args.next() orelse {
+        std.log.err("missing <path> argument", .{});
+        return error.BadUsage;
+    };
+
+    if (!std.fs.path.isAbsolute(path)) {
+        std.log.err("path must be absolute: {s}", .{path});
+        return error.BadUsage;
+    }
+
+    const dir_path = std.fs.path.dirname(path) orelse unreachable;
+    const basename = std.fs.path.basename(path);
+
+    var dir = try std.fs.openDirAbsolute(dir_path, .{});
+    defer dir.close();
+
+    _ = dir.statFile(basename) catch {
+        var file = try dir.createFile(basename, .{});
+        file.close();
+    };
+}
test/standalone.zig
@@ -179,6 +179,10 @@ pub const build_cases = [_]BuildCase{
         .build_root = "test/standalone/dep_shared_builtin",
         .import = @import("standalone/dep_shared_builtin/build.zig"),
     },
+    .{
+        .build_root = "test/standalone/dirname",
+        .import = @import("standalone/dirname/build.zig"),
+    },
     .{
         .build_root = "test/standalone/empty_env",
         .import = @import("standalone/empty_env/build.zig"),