diff --git a/src/Command.zig b/src/Command.zig index b0bf579..633e247 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -23,7 +23,7 @@ execute: *const fn ( allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Error!void, @@ -190,7 +190,7 @@ pub const TestExecuteSettings = struct { stdin: ?std.io.AnyReader = null, stdout: ?std.io.AnyWriter = null, stderr: ?std.io.AnyWriter = null, - cwd: ?std.fs.Dir = null, + system_description: System.TestBackend.Description = .{}, }; pub fn testExecute( @@ -200,11 +200,15 @@ pub fn testExecute( ) ExposedError!void { std.debug.assert(builtin.is_test); - const cwd_provided = settings.cwd != null; - - var tmp_dir: std.testing.TmpDir = if (!cwd_provided) std.testing.tmpDir(.{}) else undefined; - defer if (!cwd_provided) tmp_dir.cleanup(); - const cwd = if (settings.cwd) |c| c else tmp_dir.dir; + const system: System = .{ + ._backend = System.TestBackend.create( + std.testing.allocator, + settings.system_description, + ) catch |err| { + std.debug.panic("unable to create system backend: {s}", .{@errorName(err)}); + }, + }; + defer system._backend.destroy(); var arg_iter: Arg.Iterator = .{ .slice = .{ .slice = arguments } }; @@ -218,7 +222,7 @@ pub fn testExecute( std.testing.allocator, io, &arg_iter, - cwd, + system, command.name, ) catch |full_err| command.narrowError(io, command.name, full_err); } @@ -356,6 +360,8 @@ pub const TestFuzzOptions = struct { /// If true the command is expected to output something to stderr on failure. expect_stderr_output_on_failure: bool = true, + + system_description: System.TestBackend.Description = .{}, }; pub fn testFuzz(command: Command, options: TestFuzzOptions) !void { @@ -388,7 +394,11 @@ pub fn testFuzz(command: Command, options: TestFuzzOptions) !void { context.inner_command.testExecute( arguments, - .{ .stdout = stdout.writer().any(), .stderr = stderr.writer().any() }, + .{ + .stdout = stdout.writer().any(), + .stderr = stderr.writer().any(), + .system_description = context.options.system_description, + }, ) catch |err| { switch (err) { error.OutOfMemory => { @@ -491,6 +501,7 @@ pub const enabled_command_lookup: std.StaticStringMap(Command) = .initComptime(b const Arg = @import("Arg.zig"); const IO = @import("IO.zig"); const shared = @import("shared.zig"); +const System = @import("system/System.zig"); const builtin = @import("builtin"); const std = @import("std"); diff --git a/src/commands/basename.zig b/src/commands/basename.zig index 1f0696c..d56d5f3 100644 --- a/src/commands/basename.zig +++ b/src/commands/basename.zig @@ -42,13 +42,13 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); defer z.end(); - _ = cwd; + _ = system; const options = try parseArguments(allocator, io, args, exe_path); log.debug("{}", .{options}); @@ -387,6 +387,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.basename); diff --git a/src/commands/clear.zig b/src/commands/clear.zig index ebeb8df..3606bd9 100644 --- a/src/commands/clear.zig +++ b/src/commands/clear.zig @@ -35,13 +35,13 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); defer z.end(); - _ = cwd; + _ = system; const options = try parseArguments(allocator, io, args, exe_path); log.debug("{}", .{options}); @@ -194,6 +194,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.clear); diff --git a/src/commands/dirname.zig b/src/commands/dirname.zig index 896ce59..0ff8b8e 100644 --- a/src/commands/dirname.zig +++ b/src/commands/dirname.zig @@ -37,13 +37,13 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); defer z.end(); - _ = cwd; + _ = system; const options = try parseArguments(allocator, io, args, exe_path); log.debug("{}", .{options}); @@ -251,6 +251,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.dirname); diff --git a/src/commands/false.zig b/src/commands/false.zig index 4750d7b..6d4e2c1 100644 --- a/src/commands/false.zig +++ b/src/commands/false.zig @@ -28,7 +28,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); @@ -36,7 +36,7 @@ const impl = struct { _ = io; _ = exe_path; - _ = cwd; + _ = system; _ = allocator; _ = try args.nextWithHelpOrVersion(true); @@ -84,6 +84,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const std = @import("std"); const tracy = @import("tracy"); diff --git a/src/commands/groups.zig b/src/commands/groups.zig index 2c4fb43..7b766b2 100644 --- a/src/commands/groups.zig +++ b/src/commands/groups.zig @@ -36,7 +36,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); @@ -46,25 +46,50 @@ const impl = struct { const opt_arg = try args.nextWithHelpOrVersion(true); - const passwd_file = try shared.mapFile(command, allocator, io, cwd, "/etc/passwd"); - defer passwd_file.close(); + const mapped_passwd_file = blk: { + const passwd_file = system.cwd().openFile("/etc/passwd", .{}) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to open '/etc/passwd': {s}", + .{@errorName(err)}, + ); + errdefer if (shared.free_on_close) passwd_file.close(); + + const stat = passwd_file.stat() catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to stat '/etc/passwd': {s}", + .{@errorName(err)}, + ); + + break :blk passwd_file.mapReadonly(stat.size) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to map '/etc/passwd': {s}", + .{@errorName(err)}, + ); + }; + defer if (shared.free_on_close) mapped_passwd_file.close(); return if (opt_arg) |arg| - namedUser(allocator, io, arg.raw, passwd_file.file_contents, cwd) + namedUser(allocator, io, arg.raw, mapped_passwd_file.file_contents, system) else - currentUser(allocator, io, passwd_file.file_contents, cwd); + currentUser(allocator, io, mapped_passwd_file.file_contents, system); } fn currentUser( allocator: std.mem.Allocator, io: IO, passwd_file_contents: []const u8, - cwd: std.fs.Dir, + system: System, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = "current user" }); defer z.end(); - const euid = std.os.linux.geteuid(); + const euid = system.getEffectiveUserId(); log.debug("currentUser called, euid: {}", .{euid}); @@ -98,7 +123,7 @@ const impl = struct { "format of '/etc/passwd' is invalid", ); - return printGroups(allocator, entry.user_name, primary_group_id, io, cwd); + return printGroups(allocator, entry.user_name, primary_group_id, io, system); } return command.printError( @@ -112,7 +137,7 @@ const impl = struct { io: IO, user: []const u8, passwd_file_contents: []const u8, - cwd: std.fs.Dir, + system: System, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = "namedUser" }); defer z.end(); @@ -140,7 +165,7 @@ const impl = struct { "format of '/etc/passwd' is invalid", ); - return printGroups(allocator, entry.user_name, primary_group_id, io, cwd); + return printGroups(allocator, entry.user_name, primary_group_id, io, system); } return command.printErrorAlloc(allocator, io, "unknown user '{s}'", .{user}); @@ -151,7 +176,7 @@ const impl = struct { user: []const u8, primary_group_id: std.posix.uid_t, io: IO, - cwd: std.fs.Dir, + system: System, ) !void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = "print groups" }); defer z.end(); @@ -162,10 +187,35 @@ const impl = struct { .{ user, primary_group_id }, ); - const group_file = try shared.mapFile(command, allocator, io, cwd, "/etc/group"); - defer group_file.close(); + const mapped_group_file = blk: { + const group_file = system.cwd().openFile("/etc/group", .{}) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to open '/etc/group': {s}", + .{@errorName(err)}, + ); + errdefer if (shared.free_on_close) group_file.close(); + + const stat = group_file.stat() catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to stat '/etc/group': {s}", + .{@errorName(err)}, + ); - var group_file_iter = shared.groupFileIterator(group_file.file_contents); + break :blk group_file.mapReadonly(stat.size) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to map '/etc/group': {s}", + .{@errorName(err)}, + ); + }; + defer if (shared.free_on_close) mapped_group_file.close(); + + var group_file_iter = shared.groupFileIterator(mapped_group_file.file_contents); var first = true; @@ -211,14 +261,55 @@ const impl = struct { try command.testVersion(); } - // TODO: How do we test this without introducing the amount of complexity that https://github.com/leecannon/zsw does? - // https://github.com/leecannon/zig-coreutils/issues/7 + test "groups" { + const passwd_contents = + \\root:x:0:0::/root:/usr/bin/bash + \\daemon:x:1:1::/:/usr/sbin/nologin + \\bin:x:2:2::/:/usr/sbin/nologin + \\sys:x:3:3::/:/usr/sbin/nologin + \\user:x:1001:1001:A User:/home/user:/usr/bin/zsh + \\ + ; + + const group_contents = + \\root:x:0: + \\daemon:x:1: + \\bin:x:2: + \\sys:x:3:user + \\user:x:1001: + \\wheel:x:10:user + \\ + ; + + const file_system: *System.TestBackend.Description.FileSystemDescription = try .create(std.testing.allocator); + defer file_system.destroy(); + + const etc_dir = try file_system.root.addDirectory("etc"); + _ = try etc_dir.addFile("passwd", passwd_contents); + _ = try etc_dir.addFile("group", group_contents); + + var stdout: std.ArrayList(u8) = .init(std.testing.allocator); + defer stdout.deinit(); + + try command.testExecute(&.{}, .{ + .stdout = stdout.writer().any(), + .system_description = .{ + .file_system = file_system, + .user_group = .{ + .effective_user_id = 1001, + }, + }, + }); + + try std.testing.expectEqualStrings("sys user wheel\n", stdout.items); + } }; const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.groups); diff --git a/src/commands/nproc.zig b/src/commands/nproc.zig index ef919e2..288328e 100644 --- a/src/commands/nproc.zig +++ b/src/commands/nproc.zig @@ -29,7 +29,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); @@ -40,15 +40,29 @@ const impl = struct { _ = try args.nextWithHelpOrVersion(true); const path = "/sys/devices/system/cpu/online"; + var buffer: [8]u8 = undefined; - const file_contents = try shared.readFileIntoBuffer( - command, - allocator, - io, - cwd, - path, - &buffer, - ); + + const file_contents = blk: { + const file = system.cwd().openFile(path, .{}) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to open '{s}': {s}", + .{ path, @errorName(err) }, + ); + defer if (shared.free_on_close) file.close(); + + const read = file.readAll(&buffer) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to read file '{s}': {s}", + .{ path, @errorName(err) }, + ); + + break :blk buffer[0..read]; + }; const last_cpu_index = getLastCpuIndex( std.mem.trim(u8, file_contents, &std.ascii.whitespace), @@ -95,14 +109,35 @@ const impl = struct { try command.testVersion(); } - // TODO: How do we test this without introducing the amount of complexity that https://github.com/leecannon/zsw does? - // https://github.com/leecannon/zig-coreutils/issues/7 + test "nproc" { + const file_system: *System.TestBackend.Description.FileSystemDescription = try .create(std.testing.allocator); + defer file_system.destroy(); + + const sys_dir = try file_system.root.addDirectory("sys"); + const devices_dir = try sys_dir.addDirectory("devices"); + const system_dir = try devices_dir.addDirectory("system"); + const cpu_dir = try system_dir.addDirectory("cpu"); + _ = try cpu_dir.addFile("online", "0-15"); + + var stdout: std.ArrayList(u8) = .init(std.testing.allocator); + defer stdout.deinit(); + + try command.testExecute(&.{}, .{ + .stdout = stdout.writer().any(), + .system_description = .{ + .file_system = file_system, + }, + }); + + try std.testing.expectEqualStrings("16\n", stdout.items); + } }; const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const std = @import("std"); const tracy = @import("tracy"); diff --git a/src/commands/template.zig b/src/commands/template.zig index f509b06..e9214e9 100644 --- a/src/commands/template.zig +++ b/src/commands/template.zig @@ -40,13 +40,13 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); defer z.end(); - _ = cwd; + _ = system; const options = try parseArguments(allocator, io, args, exe_path); log.debug("{}", .{options}); @@ -160,6 +160,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.template); // CHANGE THIS diff --git a/src/commands/touch.zig b/src/commands/touch.zig index 4086213..dd3a8c2 100644 --- a/src/commands/touch.zig +++ b/src/commands/touch.zig @@ -14,14 +14,10 @@ pub const command: Command = .{ \\ \\A FILE argument that does not exist is created empty, unless -c or -h is supplied. \\ - \\A FILE argument string of '-' is handled specially and causes 'touch' to change - \\the times of the file associated with standard output. - \\ \\Mandatory arguments to long options are mandatory for short options too. \\ -a change only the access time \\ -c, --no-create do not create any files \\ -f (ignored) - \\ -h, --no-dereference affect symbolic link instead of any referenced file \\ -m change only the modification time \\ -r, --reference=FILE use this file's times instead of the current time \\ --time=WORD change the specified time: @@ -32,6 +28,8 @@ pub const command: Command = .{ \\ , + // TODO: support `-h, --no-dereference affect symbolic link instead of any referenced file` + .extended_help = \\Examples: \\ touch FILE @@ -50,7 +48,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); @@ -59,7 +57,7 @@ const impl = struct { const options = try parseArguments(allocator, io, args, exe_path); log.debug("{}", .{options}); - return performTouch(allocator, io, args, options, cwd); + return performTouch(allocator, io, args, options, system); } fn performTouch( @@ -67,11 +65,13 @@ const impl = struct { io: IO, args: *Arg.Iterator, options: TouchOptions, - cwd: std.fs.Dir, + system: System, ) !void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = "perform touch" }); defer z.end(); + const cwd = system.cwd(); + const times = try getTimes(allocator, io, options.time_to_use, cwd); log.debug("times to be used for touch: {}", .{times}); @@ -82,11 +82,9 @@ const impl = struct { defer file_zone.end(); file_zone.text(file_path); - const file: std.fs.File = blk: { - if (std.mem.eql(u8, file_path, "-")) break :blk std.io.getStdOut(); - + const file: System.File = blk: { const file_or_error = switch (options.create) { - true => cwd.createFile(file_path, .{}), + true => cwd.createFile(file_path, .{ .truncate = false }), false => cwd.openFile(file_path, .{}), }; @@ -136,7 +134,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, time_to_use: TouchOptions.TimeToUse, - cwd: std.fs.Dir, + cwd: System.Dir, ) !FileTimes { switch (time_to_use) { .current_time => { @@ -180,7 +178,6 @@ const impl = struct { const TouchOptions = struct { update: Update = .both, create: bool = true, - dereference: bool = true, time_to_use: TimeToUse = .current_time, first_file_path: []const u8 = undefined, @@ -214,9 +211,6 @@ const impl = struct { const create = if (value.create) "true" else "false"; try writer.writeAll(create); - try writer.writeAll(comptime ",\n" ++ shared.option_log_indentation ++ ".dereference = "); - try writer.writeAll(if (value.dereference) "true" else "false"); - try writer.writeAll(comptime ",\n" ++ shared.option_log_indentation ++ ".time_to_use = "); switch (value.time_to_use) { @@ -237,6 +231,7 @@ const impl = struct { const z: tracy.Zone = .begin(.{ .src = @src(), .name = "parse arguments" }); defer z.end(); + // `-h` not supported to allow for future no dereference shorthand var opt_arg: ?Arg = try args.nextWithHelpOrVersion(false); var touch_options: TouchOptions = .{}; @@ -268,9 +263,6 @@ const impl = struct { if (std.mem.eql(u8, longhand, "no-create")) { touch_options.create = false; log.debug("got do not create file longhand", .{}); - } else if (std.mem.eql(u8, longhand, "no-dereference")) { - touch_options.dereference = false; - log.debug("got do not dereference longhand", .{}); } else if (std.mem.eql(u8, longhand, "reference-file")) { state = .reference_file; log.debug("got reference file longhand", .{}); @@ -300,10 +292,6 @@ const impl = struct { log.debug("got do not create file shorthand", .{}); }, 'f' => {}, // ignored - 'h' => { - touch_options.dereference = false; - log.debug("got do not dereference shorthand", .{}); - }, 'm' => { touch_options.update = .modification_only; log.debug("got modification time shorthand", .{}); @@ -455,14 +443,26 @@ const impl = struct { try command.testVersion(); } - // TODO: How do we test this without introducing the amount of complexity that https://github.com/leecannon/zsw does? - // https://github.com/leecannon/zig-coreutils/issues/7 + test "touch simple" { + const file_system: *System.TestBackend.Description.FileSystemDescription = try .create(std.testing.allocator); + defer file_system.destroy(); + + try command.testExecute( + &.{"hello"}, + .{ + .system_description = .{ .file_system = file_system }, + }, + ); + + // TODO: we need access to the `TestBackend.FileSystem` to check the file + } }; const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.touch); diff --git a/src/commands/true.zig b/src/commands/true.zig index 81a8964..389aabe 100644 --- a/src/commands/true.zig +++ b/src/commands/true.zig @@ -28,7 +28,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); @@ -36,7 +36,7 @@ const impl = struct { _ = io; _ = exe_path; - _ = cwd; + _ = system; _ = allocator; _ = try args.nextWithHelpOrVersion(true); @@ -80,6 +80,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const std = @import("std"); const tracy = @import("tracy"); diff --git a/src/commands/uname.zig b/src/commands/uname.zig index 69609ab..accef03 100644 --- a/src/commands/uname.zig +++ b/src/commands/uname.zig @@ -40,13 +40,13 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); defer z.end(); - _ = cwd; + _ = system; const options = try parseArguments(allocator, io, args, exe_path); log.debug("{}", .{options}); @@ -397,6 +397,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.uname); diff --git a/src/commands/whoami.zig b/src/commands/whoami.zig index 8506543..a0c327f 100644 --- a/src/commands/whoami.zig +++ b/src/commands/whoami.zig @@ -28,7 +28,7 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); @@ -38,12 +38,37 @@ const impl = struct { _ = try args.nextWithHelpOrVersion(true); - const euid = std.os.linux.geteuid(); + const euid = system.getEffectiveUserId(); - const passwd_file = try shared.mapFile(command, allocator, io, cwd, "/etc/passwd"); - defer passwd_file.close(); + const mapped_passwd_file = blk: { + const passwd_file = system.cwd().openFile("/etc/passwd", .{}) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to open '/etc/passwd': {s}", + .{@errorName(err)}, + ); + errdefer if (shared.free_on_close) passwd_file.close(); - var passwd_file_iter = shared.passwdFileIterator(passwd_file.file_contents); + const stat = passwd_file.stat() catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to stat '/etc/passwd': {s}", + .{@errorName(err)}, + ); + + break :blk passwd_file.mapReadonly(stat.size) catch |err| + return command.printErrorAlloc( + allocator, + io, + "unable to map '/etc/passwd': {s}", + .{@errorName(err)}, + ); + }; + defer if (shared.free_on_close) mapped_passwd_file.close(); + + var passwd_file_iter = shared.passwdFileIterator(mapped_passwd_file.file_contents); while (try passwd_file_iter.next(command, io)) |entry| { const user_id = std.fmt.parseUnsigned(std.posix.uid_t, entry.user_id, 10) catch @@ -77,14 +102,44 @@ const impl = struct { try command.testVersion(); } - // TODO: How do we test this without introducing the amount of complexity that https://github.com/leecannon/zsw does? - // https://github.com/leecannon/zig-coreutils/issues/7 + test "whoami" { + const passwd_contents = + \\root:x:0:0::/root:/usr/bin/bash + \\daemon:x:1:1::/:/usr/sbin/nologin + \\bin:x:2:2::/:/usr/sbin/nologin + \\sys:x:3:3::/:/usr/sbin/nologin + \\user:x:1001:1001:A User:/home/user:/usr/bin/zsh + \\ + ; + + const file_system: *System.TestBackend.Description.FileSystemDescription = try .create(std.testing.allocator); + defer file_system.destroy(); + + const etc_dir = try file_system.root.addDirectory("etc"); + _ = try etc_dir.addFile("passwd", passwd_contents); + + var stdout: std.ArrayList(u8) = .init(std.testing.allocator); + defer stdout.deinit(); + + try command.testExecute(&.{}, .{ + .stdout = stdout.writer().any(), + .system_description = .{ + .file_system = file_system, + .user_group = .{ + .effective_user_id = 1001, + }, + }, + }); + + try std.testing.expectEqualStrings("user\n", stdout.items); + } }; const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const log = std.log.scoped(.whoami); diff --git a/src/commands/yes.zig b/src/commands/yes.zig index 7d5fd36..f5ae285 100644 --- a/src/commands/yes.zig +++ b/src/commands/yes.zig @@ -28,14 +28,14 @@ const impl = struct { allocator: std.mem.Allocator, io: IO, args: *Arg.Iterator, - cwd: std.fs.Dir, + system: System, exe_path: []const u8, ) Command.Error!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = command.name }); defer z.end(); _ = exe_path; - _ = cwd; + _ = system; const string = try getString(allocator, args); defer if (shared.free_on_close) string.deinit(allocator); @@ -97,6 +97,7 @@ const Arg = @import("../Arg.zig"); const Command = @import("../Command.zig"); const IO = @import("../IO.zig"); const shared = @import("../shared.zig"); +const System = @import("../system/System.zig"); const std = @import("std"); const tracy = @import("tracy"); diff --git a/src/main.zig b/src/main.zig index 57f146c..44f7e52 100644 --- a/src/main.zig +++ b/src/main.zig @@ -62,7 +62,6 @@ pub fn main() if (shared.is_debug_or_test) Command.ExposedError!u8 else u8 { arg_iter, io, basename, - std.fs.cwd(), exe_path, ) catch |err| { switch (err) { @@ -87,7 +86,6 @@ fn tryExecute( os_arg_iter: std.process.ArgIterator, io: IO, basename: []const u8, - cwd: std.fs.Dir, exe_path: []const u8, ) Command.ExposedError!void { const z: tracy.Zone = .begin(.{ .src = @src(), .name = "tryExecute" }); @@ -95,6 +93,8 @@ fn tryExecute( var arg_iter: Arg.Iterator = .{ .args = os_arg_iter }; + const system: System = .{}; + // attempt to match the basename to a command if (Command.enabled_command_lookup.get(basename)) |command| { z.text(basename); @@ -103,7 +103,7 @@ fn tryExecute( allocator, io, &arg_iter, - cwd, + system, exe_path, ) catch |full_err| command.narrowError(io, basename, full_err); } @@ -170,7 +170,7 @@ fn tryExecute( allocator, io, &arg_iter, - cwd, + system, exe_path_with_command, ) catch |full_err| command.narrowError(io, exe_path_with_command, full_err); } @@ -237,6 +237,7 @@ const Arg = @import("Arg.zig"); const Command = @import("Command.zig"); const IO = @import("IO.zig"); const shared = @import("shared.zig"); +const System = @import("system/System.zig"); const log = std.log.scoped(.main); diff --git a/src/shared.zig b/src/shared.zig index 2bdcf5e..3686afc 100644 --- a/src/shared.zig +++ b/src/shared.zig @@ -10,102 +10,6 @@ pub const free_on_close = is_debug_or_test or options.trace; pub const option_log_indentation = " "; -pub fn mapFile( - command: Command, - allocator: std.mem.Allocator, - io: IO, - cwd: std.fs.Dir, - path: []const u8, -) error{ AlreadyHandled, OutOfMemory }!MappedFile { - const file = cwd.openFile(path, .{}) catch - return command.printErrorAlloc( - allocator, - io, - "unable to open '{s}'", - .{path}, - ); - errdefer if (free_on_close) file.close(); - - const stat = file.stat() catch |err| - return command.printErrorAlloc( - allocator, - io, - "unable to stat '{s}': {s}", - .{ path, @errorName(err) }, - ); - - if (stat.size == 0) { - @branchHint(.unlikely); - return .{ - .file = file, - .file_contents = &.{}, - }; - } - - const file_contents = std.posix.mmap( - null, - stat.size, - std.posix.PROT.READ, - .{ .TYPE = .PRIVATE }, - file.handle, - 0, - ) catch |err| - return command.printErrorAlloc( - allocator, - io, - "unable to map '{s}': {s}", - .{ path, @errorName(err) }, - ); - - return .{ - .file = file, - .file_contents = file_contents, - }; -} - -pub const MappedFile = struct { - file: std.fs.File, - file_contents: []align(std.heap.page_size_min) const u8, - - pub fn close(self: MappedFile) void { - if (free_on_close) { - if (self.file_contents.len != 0) { - @branchHint(.likely); - std.posix.munmap(self.file_contents); - } - self.file.close(); - } - } -}; - -pub fn readFileIntoBuffer( - command: Command, - allocator: std.mem.Allocator, - io: IO, - cwd: std.fs.Dir, - path: []const u8, - buffer: []u8, -) error{ AlreadyHandled, OutOfMemory }![]const u8 { - const file = cwd.openFile(path, .{}) catch - return command.printErrorAlloc( - allocator, - io, - "unable to open '{s}'", - .{path}, - ); - defer if (free_on_close) file.close(); - - const read = file.readAll(buffer) catch |err| - return command.printErrorAlloc( - allocator, - io, - "unable to read file '{s}': {s}", - .{ path, @errorName(err) }, - ); - - return buffer[0..read]; -} - pub fn passwdFileIterator(passwd_file_contents: []const u8) PasswdFileIterator { return .{ .passwd_file_contents = passwd_file_contents, diff --git a/src/system/System.zig b/src/system/System.zig new file mode 100644 index 0000000..8bced75 --- /dev/null +++ b/src/system/System.zig @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2025 Lee Cannon + +//! Proivides a platform-agnostic interface to the system. +//! +//! During tests automatically uses a mock implementation. + +const System = @This(); + +_backend: if (is_test) *TestBackend else void = if (is_test) undefined else {}, + +/// Get a calendar timestamp, in nanoseconds, relative to UTC 1970-01-01. +pub inline fn nanoTimestamp(self: System) i128 { + if (is_test) { + return self._backend.time.nanoTimestamp(); + } + + return std.time.nanoTimestamp(); +} + +/// Returns a handle to the current working directory. +pub inline fn cwd(system: System) Dir { + if (is_test) { + if (system._backend.file_system) |*file_system| { + return .{ ._data = .{ + .file_system = file_system, + .ptr = TestBackend.FileSystem.CWD, + } }; + } + + @panic("`cwd` called with no file system configured"); + } + + return .{ ._data = std.fs.cwd() }; +} + +pub inline fn getEffectiveUserId(system: System) UserId { + if (is_test) { + if (system._backend.user_group) |user_group| { + return user_group.getEffectiveUserId(); + } + + @panic("`getEffectiveUserId` called with no user/group configured"); + } + + return switch (target_os) { + .linux => std.os.linux.getuid(), + .macos => { + const c = struct { + pub extern "c" fn geteuid(void) std.c.uid_t; + }; + return c.geteuid(); + }, + .windows => @panic("getUserId not implemented for windows"), // FIXME: support windows + }; +} + +pub const UserId = blk: { + if (is_test) break :blk u32; + break :blk switch (target_os) { + .linux => std.os.linux.uid_t, + .macos => std.c.uid_t, + .windows => noreturn, // FIXME: support windows + }; +}; + +pub const Dir = struct { + _data: Data, + + /// Opens a file relative to the directory without creating it. + pub inline fn openFile(self: Dir, sub_path: []const u8, options: File.OpenOptions) !File { + if (is_test) { + return .{ ._data = .{ + .file_system = self._data.file_system, + .ptr = try self._data.file_system.openFileFromDir( + self._data.ptr, + sub_path, + options, + ), + } }; + } + + return .{ ._data = try self._data.openFile(sub_path, options.toStd()) }; + } + + /// Opens a file relative to the directory, creates it if it does not exist. + pub inline fn createFile(self: Dir, sub_path: []const u8, options: File.CreateOptions) !File { + if (is_test) { + return .{ ._data = .{ + .file_system = self._data.file_system, + .ptr = try self._data.file_system.createFileFromDir( + self._data.ptr, + sub_path, + options, + ), + } }; + } + + return .{ ._data = try self._data.createFile(sub_path, options.toStd()) }; + } + + const Data = if (is_test) struct { + file_system: *TestBackend.FileSystem, + ptr: *anyopaque, + } else std.fs.Dir; +}; + +pub const File = struct { + _data: Data, + + /// Close the file and deallocate any related resources. + pub inline fn close(self: File) void { + if (is_test) { + self._data.file_system.closeFile(self._data.ptr); + return; + } + + self._data.close(); + } + + /// Reads up to `buffer.len` bytes from the file into `buffer`. + /// + /// Returns the number of bytes read. + /// + /// If the number read is smaller than `buffer.len`, it means the file reached the end. + pub inline fn readAll(self: File, buffer: []u8) !usize { + if (is_test) { + return try self._data.file_system.readAllFromFile(self._data.ptr, buffer); + } + + return self._data.readAll(buffer); + } + + pub const Stat = struct { + size: u64, + + atime: i128, + mtime: i128, + }; + + /// Returns basic information about the file. + pub inline fn stat(self: File) !Stat { + if (is_test) { + return self._data.file_system.statFile(self._data.ptr); + } + + const s = try self._data.stat(); + + return .{ + .size = s.size, + .atime = s.atime, + .mtime = s.mtime, + }; + } + + pub fn mapReadonly(self: File, size: u64) !FileMap { + if (size == 0) { + @branchHint(.unlikely); + return .{ + ._data = undefined, + .file_contents = &.{}, + }; + } + + if (is_test) { + return try self._data.file_system.mapFileReadonly(self._data.ptr, size); + } + + const file_contents = switch (target_os) { + .linux, .macos => try std.posix.mmap( + null, + size, + std.posix.PROT.READ, + .{ .TYPE = .PRIVATE }, + self._data.handle, + 0, + ), + .windows => @panic("mapReadonly not implemented for windows"), // FIXME: support windows + }; + + return .{ + ._data = self._data, + .file_contents = file_contents, + }; + } + + pub fn updateTimes(self: File, access_time: i128, modification_time: i128) !void { + if (is_test) { + return self._data.file_system.updateTimes(self._data.ptr, access_time, modification_time); + } + + return try self._data.updateTimes(access_time, modification_time); + } + + pub const OpenOptions = struct { + mode: std.fs.File.OpenMode = .read_only, + + inline fn toStd(options: OpenOptions) std.fs.File.OpenFlags { + return .{ + .mode = options.mode, + }; + } + }; + + pub const CreateOptions = struct { + /// Whether the file will be created with read access. + read: bool = false, + + /// If the file already exists, and is a regular file, and the access + /// mode allows writing, it will be truncated to length 0. + truncate: bool = true, + + inline fn toStd(options: CreateOptions) std.fs.File.CreateFlags { + return .{ + .read = options.read, + .truncate = options.truncate, + }; + } + }; + + const Data = if (is_test) struct { + file_system: *TestBackend.FileSystem, + ptr: *anyopaque, + } else std.fs.File; +}; + +pub const FileMap = struct { + _data: Data, + file_contents: []align(std.heap.page_size_min) const u8, + + pub fn close(self: FileMap) void { + if (shared.free_on_close) { + if (self.file_contents.len == 0) { + @branchHint(.unlikely); + return; + } + + if (is_test) { + self._data.file_system.closeFileMap(self._data.ptr); + return; + } + + switch (target_os) { + .linux, .macos => std.posix.munmap(self.file_contents), + .windows => @panic("FileMap.close not implemented for windows"), // FIXME: support windows + } + } + } + + const Data = if (is_test) struct { + file_system: *TestBackend.FileSystem, + ptr: *anyopaque, + } else std.fs.File; +}; + +pub const TestBackend = @import("backend/TestBackend.zig"); + +const log = std.log.scoped(.system); + +const is_test = @import("builtin").is_test; +const target_os = @import("target_os").target_os; +const std = @import("std"); +const shared = @import("../shared.zig"); diff --git a/src/system/backend/Description.zig b/src/system/backend/Description.zig new file mode 100644 index 0000000..401c169 --- /dev/null +++ b/src/system/backend/Description.zig @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2025 Lee Cannon + +time: TimeSource = .host, +file_system: ?*FileSystemDescription = null, +user_group: ?UserGroupDescription = null, + +pub const FileSystemDescription = @import("FileSystem.zig").FileSystemDescription; +pub const TimeSource = @import("Time.zig").Source; +pub const UserGroupDescription = @import("UserGroup.zig").UserGroupDescription; diff --git a/src/system/backend/FileSystem.zig b/src/system/backend/FileSystem.zig new file mode 100644 index 0000000..5b1a009 --- /dev/null +++ b/src/system/backend/FileSystem.zig @@ -0,0 +1,759 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2025 Lee Cannon + +const FileSystem = @This(); + +backend: *TestBackend, + +entries: std.AutoHashMapUnmanaged(*Entry, void), +views: std.AutoHashMapUnmanaged(*View, void), + +root: *Entry, +cwd_entry: *Entry, + +pub fn init(self: *FileSystem, backend: *TestBackend, description: *const FileSystemDescription) !void { + self.* = .{ + .backend = backend, + .entries = .empty, + .views = .empty, + .root = undefined, + .cwd_entry = undefined, + }; + + try self.entries.ensureTotalCapacity(backend.allocator, @intCast(description.entries.items.len)); + + var opt_root: ?*Entry = null; + var opt_cwd_entry: ?*Entry = null; + + _ = try self.initAddDirAndRecurse( + description, + description.root, + description.cwd, + &opt_root, + &opt_cwd_entry, + backend.time.nanoTimestamp(), + ); + + if (opt_root) |root| { + self.root = root; + self.root.incrementReference(); + } else return error.NoRootDirectory; + + if (opt_cwd_entry) |cwd_entry| { + try self.setCwd(cwd_entry, false); + } else return error.NoCwd; +} + +fn initAddDirAndRecurse( + self: *FileSystem, + description: *const FileSystemDescription, + current_dir: *const FileSystemDescription.EntryDescription, + ptr_to_inital_cwd: *const FileSystemDescription.EntryDescription, + opt_root: *?*Entry, + opt_cwd_entry: *?*Entry, + current_time: i128, +) (error{DuplicateEntry} || std.mem.Allocator.Error)!*Entry { + std.debug.assert(current_dir.subdata == .dir); + + const dir_entry = try self.addDirEntry(current_dir.name, current_time); + + if (opt_root.* == null) opt_root.* = dir_entry; + if (opt_cwd_entry.* == null and current_dir == ptr_to_inital_cwd) opt_cwd_entry.* = dir_entry; + + for (current_dir.subdata.dir.entries.values()) |entry| { + const new_entry: *Entry = switch (entry.subdata) { + .file => |file| try self.addFileEntry(entry.name, file.contents, current_time), + .dir => try self.initAddDirAndRecurse( + description, + entry, + ptr_to_inital_cwd, + opt_root, + opt_cwd_entry, + current_time, + ), + }; + + try dir_entry.addEntry(new_entry, current_time); + } + + return dir_entry; +} + +pub fn deinit(self: *FileSystem) void { + { + var iter = self.views.keyIterator(); + while (iter.next()) |view| { + view.*.destroy(); + } + self.views.deinit(self.backend.allocator); + } + + { + var iter = self.entries.keyIterator(); + while (iter.next()) |entry| { + entry.*.destroy(); + } + self.entries.deinit(self.backend.allocator); + } +} + +/// Create a file entry and add it to the `entries` hash map +fn addFileEntry(self: *FileSystem, name: []const u8, contents: []const u8, current_time: i128) !*Entry { + const entry = try Entry.createFile(self, name, contents, current_time); + errdefer entry.destroy(); + + try self.entries.putNoClobber(self.backend.allocator, entry, {}); + + return entry; +} + +/// Create a dir entry and add it to the `entries` hash map +fn addDirEntry(self: *FileSystem, name: []const u8, current_time: i128) !*Entry { + const entry = try Entry.createDir(self, name, current_time); + errdefer entry.destroy(); + + try self.entries.putNoClobber(self.backend.allocator, entry, {}); + + return entry; +} + +/// Add a view to the given entry +fn addView(self: *FileSystem, entry: *Entry) !*View { + entry.incrementReference(); + errdefer _ = entry.decrementReference(); + + const view = try self.backend.allocator.create(View); + errdefer self.backend.allocator.destroy(view); + + view.* = .{ + .entry = entry, + .file_system = self, + }; + + try self.views.putNoClobber(self.backend.allocator, view, {}); + + return view; +} + +/// Remove a view from the given entry +fn removeView(self: *FileSystem, view: *View) void { + _ = view.entry.decrementReference(); + _ = self.views.remove(view); + view.destroy(); +} + +/// Set the current working directory +fn setCwd(self: *FileSystem, entry: *Entry, dereference_old_cwd: bool) !void { + entry.incrementReference(); + if (dereference_old_cwd) _ = self.cwd_entry.decrementReference(); + self.cwd_entry = entry; +} + +/// Check if the given pointer is the current working directory +inline fn isCwd(ptr: *anyopaque) bool { + return CWD == ptr; +} + +/// Cast the given `ptr` to an entry if it is one. +inline fn toEntry(self: *FileSystem, ptr: *anyopaque) ?*Entry { + const entry: *Entry = @ptrCast(@alignCast(ptr)); + if (self.entries.contains(entry)) { + return entry; + } + return null; +} + +/// Cast the given `ptr` to a view if it is one. +inline fn toView(self: *FileSystem, ptr: *anyopaque) ?*View { + const view: *View = @ptrCast(@alignCast(ptr)); + if (self.views.contains(view)) { + return view; + } + return null; +} + +/// The possible parent must be a directory entry. +fn toPath(self: *FileSystem, possible_parent: *Entry, str: []const u8) !Path { + std.debug.assert(possible_parent.subdata == .dir); + if (str.len == 0) return error.BadPathName; + return .{ + .path = str, + .search_root = if (std.fs.path.isAbsolute(str)) self.root else possible_parent, + }; +} + +/// Return the entry associated with the given view, if there is one. +fn cwdOrEntry(self: *FileSystem, ptr: *anyopaque) ?*Entry { + if (isCwd(ptr)) return self.cwd_entry; + if (self.toView(ptr)) |v| return v.entry; + return null; +} + +/// Searches from the path's search root for the entry specified by the given path, returns null +/// only if only the last section of the path is not found. +/// +/// If the `expected_parent` parameter is non-null and the function returns null (as specified in +/// the first paragraph) then the parent that was expected to hold the target entry is written to the +/// `expected_parent` pointer. +fn resolveEntry(self: *FileSystem, path: Path, expected_parent: ?**Entry) !?*Entry { + var entry: *Entry = path.search_root; + + var path_iter = std.mem.tokenizeAny(u8, path.path, std.fs.path.sep_str); + while (path_iter.next()) |path_section| { + if (path_section.len == 0) continue; + + if (std.mem.eql(u8, path_section, ".")) { + continue; + } + + if (std.mem.eql(u8, path_section, "..")) { + if (entry.parent) |entry_parent| { + entry = entry_parent; + } else if (entry != self.root) { + // TODO: This should instead return an error, but what error? FileNotFound? + @panic("attempted to traverse to parent of search entry with no parent"); + } + + continue; + } + + if (entry.subdata.dir.entries.get(path_section)) |child| { + switch (child.subdata) { + .dir => entry = child, + .file => { + if (path_iter.next() != null) { + // file encountered in middle of path + return error.NotDir; + } + entry = child; + }, + } + } else { + if (path_iter.next() != null) return error.FileNotFound; + if (expected_parent) |parent| { + parent.* = entry; + } + return null; + } + } + + return entry; +} + +pub const CWD: *anyopaque = @ptrFromInt(std.mem.alignBackward( + usize, + std.math.maxInt(usize), + @alignOf(View), +)); + +/// Opens a file relative to the directory without creating it. +pub fn openFileFromDir( + self: *FileSystem, + ptr: *anyopaque, + sub_path: []const u8, + options: System.File.OpenOptions, +) !*anyopaque { + if (target_os == .windows) { + // TODO: implement windows + @panic("Windows support is unimplemented"); + } + + if (options.mode != .read_only) { + // TODO: Implement *not* read_only + std.debug.panic("file mode '{s}' is unimplemented", .{@tagName(options.mode)}); + } + + const dir_entry = self.cwdOrEntry(ptr) orelse unreachable; // no such directory + + const path = try self.toPath(dir_entry, sub_path); + + const entry = (try self.resolveEntry(path, null)) orelse return error.FileNotFound; + + const view = self.addView(entry) catch return error.SystemResources; + + return view; +} + +pub fn createFileFromDir( + self: *FileSystem, + ptr: *anyopaque, + user_path: []const u8, + flags: System.File.CreateOptions, +) !*anyopaque { + if (target_os == .windows) { + // TODO: Implement windows + @panic("Windows support is unimplemented"); + } + + // TODO: Implement support for flags.mode + // TODO: Implement support for flags.read + + const dir_entry = self.cwdOrEntry(ptr) orelse unreachable; // no such directory + + const path = try self.toPath(dir_entry, user_path); + + const entry = blk: { + var expected_parent: *Entry = undefined; + if (try self.resolveEntry(path, &expected_parent)) |entry| { + // File already exists + + if (flags.truncate and entry.subdata == .file) { + // TODO: Check mode + entry.subdata.file.contents.items.len = 0; + } + + break :blk entry; + } + + // File doesn't exist + + const basename = std.fs.path.basename(path.path); + const current_time = self.backend.time.nanoTimestamp(); + + const file = self.addFileEntry( + basename, + "", + current_time, + ) catch return error.SystemResources; + errdefer { + _ = self.entries.remove(file); + file.destroy(); + } + + expected_parent.addEntry(file, current_time) catch |err| switch (err) { + error.OutOfMemory => return error.SystemResources, + error.DuplicateEntry => unreachable, // the entry was not found so this is impossible + }; + + break :blk file; + }; + + const view = self.addView(entry) catch return error.SystemResources; + + return view; +} + +/// Reads up to `buffer.len` bytes from the file into `buffer`. +/// +/// Returns the number of bytes read. +/// +/// If the number read is smaller than `buffer.len`, it means the file reached the end. +pub fn readAllFromFile(self: *FileSystem, ptr: *anyopaque, buffer: []u8) !usize { + const view = self.toView(ptr) orelse unreachable; // no such file + + const entry = view.entry; + + switch (entry.subdata) { + .dir => return error.IsDir, + .file => |file| { + const slice = file.contents.items; + + const size = @min(buffer.len, slice.len - view.position); + + @memcpy(buffer[0..size], slice[view.position..][0..size]); + + view.position += size; + + entry.atime = self.backend.time.nanoTimestamp(); + + return size; + }, + } +} + +/// Returns basic information about the file. +pub fn statFile(self: *FileSystem, ptr: *anyopaque) !System.File.Stat { + const view = self.toView(ptr) orelse unreachable; // no such file + + switch (view.entry.subdata) { + .dir => return error.IsDir, + .file => |f| { + return .{ + .size = f.contents.items.len, + .atime = view.entry.atime, + .mtime = view.entry.mtime, + }; + }, + } +} + +pub fn mapFileReadonly(self: *FileSystem, ptr: *anyopaque, size: usize) !System.FileMap { + const view = self.toView(ptr) orelse unreachable; // no such file + + switch (view.entry.subdata) { + .dir => return error.IsDir, + .file => |f| { + view.entry.incrementReference(); + return .{ + ._data = .{ + .file_system = self, + .ptr = view.entry, + }, + .file_contents = f.contents.items[0..size], + }; + }, + } +} + +pub fn closeFileMap(self: *FileSystem, ptr: *anyopaque) void { + const entry: *Entry = self.toEntry(ptr) orelse unreachable; // no such file + _ = entry.decrementReference(); +} + +pub fn closeFile(self: *FileSystem, ptr: *anyopaque) void { + const view = self.toView(ptr) orelse unreachable; // no such file + self.removeView(view); +} + +pub fn updateTimes(self: *FileSystem, ptr: *anyopaque, access_time: i128, modification_time: i128) !void { + const view = self.toView(ptr) orelse unreachable; // no such file + view.entry.atime = access_time; + view.entry.mtime = modification_time; +} + +const Entry = struct { + ref_count: usize = 0, + + name: []const u8, + subdata: SubData, + + parent: ?*Entry = null, + + /// time of last access + atime: i128 = 0, + /// time of last modification + mtime: i128 = 0, + /// time of last status change + ctime: i128 = 0, + + // TODO: implement permissions + + file_system: *FileSystem, + + const SubData = union(enum) { + file: File, + dir: Dir, + + const File = struct { + contents: std.ArrayListAlignedUnmanaged(u8, std.heap.page_size_min), + }; + + const Dir = struct { + entries: std.StringArrayHashMapUnmanaged(*Entry) = .{}, + }; + }; + + fn createFile( + file_system: *FileSystem, + name: []const u8, + contents: []const u8, + current_time: i128, + ) error{OutOfMemory}!*Entry { + const dupe_name = try file_system.backend.allocator.dupe(u8, name); + errdefer file_system.backend.allocator.free(dupe_name); + + var new_contents: std.ArrayListAlignedUnmanaged(u8, std.heap.page_size_min) = try .initCapacity( + file_system.backend.allocator, + contents.len, + ); + errdefer new_contents.deinit(file_system.backend.allocator); + + new_contents.insertSlice(file_system.backend.allocator, 0, contents) catch unreachable; + + const entry = try file_system.backend.allocator.create(Entry); + errdefer file_system.backend.allocator.destroy(entry); + + entry.* = .{ + .file_system = file_system, + .name = dupe_name, + .atime = current_time, + .mtime = current_time, + .ctime = current_time, + .subdata = .{ .file = .{ .contents = new_contents } }, + }; + + return entry; + } + + fn createDir( + file_system: *FileSystem, + name: []const u8, + current_time: i128, + ) error{OutOfMemory}!*Entry { + const dupe_name = try file_system.backend.allocator.dupe(u8, name); + errdefer file_system.backend.allocator.free(dupe_name); + + const entry = try file_system.backend.allocator.create(Entry); + errdefer file_system.backend.allocator.destroy(entry); + + entry.* = .{ + .file_system = file_system, + .name = dupe_name, + .atime = current_time, + .mtime = current_time, + .ctime = current_time, + .subdata = .{ .dir = .{} }, + }; + + return entry; + } + + /// Add an entry to the parent entry. + /// + /// The parent entry must be a directory. + fn addEntry( + parent: *Entry, + entry: *Entry, + current_time: i128, + ) error{ DuplicateEntry, OutOfMemory }!void { + std.debug.assert(parent.subdata == .dir); + + const get_or_put_result = try parent.subdata.dir.entries.getOrPut( + parent.file_system.backend.allocator, + entry.name, + ); + if (get_or_put_result.found_existing) return error.DuplicateEntry; + get_or_put_result.value_ptr.* = entry; + + if (entry.parent) |old_parent| { + old_parent.ctime = current_time; + } else { + entry.incrementReference(); + } + + entry.ctime = current_time; + + entry.parent = parent; + parent.ctime = current_time; + } + + /// Remove an entry from the given entry + /// + /// `self` must be a directory + /// + /// Returns true if the entry has been destroyed + fn removeEntry(self: *Entry, entry: *Entry, current_time: i128) bool { + std.debug.assert(self.subdata == .dir); + + if (self.subdata.dir.entries.swapRemove(entry)) { + self.ctime = current_time; + + if (entry.decrementReference()) { + return true; + } + + entry.ctime = current_time; + entry.parent = null; + return false; + } + + return false; + } + + fn incrementReference(self: *Entry) void { + self.ref_count += 1; + } + + /// Returns `true` if the entry has been destroyed + fn decrementReference(self: *Entry) bool { + self.ref_count -= 1; + + if (self.ref_count == 0) { + self.destroy(); + return true; + } + + return false; + } + + fn destroy(self: *Entry) void { + self.file_system.backend.allocator.free(self.name); + switch (self.subdata) { + .file => |*file| file.contents.deinit(self.file_system.backend.allocator), + .dir => |*dir| dir.entries.deinit(self.file_system.backend.allocator), + } + self.file_system.backend.allocator.destroy(self); + } +}; + +const Path = struct { + path: []const u8, + search_root: *Entry, +}; + +const View = struct { + entry: *Entry, + position: usize = 0, + + file_system: *FileSystem, + + pub fn destroy(self: *View) void { + self.file_system.backend.allocator.destroy(self); + } +}; + +pub const FileSystemDescription = struct { + allocator: std.mem.Allocator, + + /// This is only used to keep hold of all the created entries for them to be freed. + entries: std.ArrayListUnmanaged(*EntryDescription) = .{}, + + /// Do not assign directly. + root: *EntryDescription, + + /// Do not assign directly. + cwd: *EntryDescription, + + pub fn create(allocator: std.mem.Allocator) !*FileSystemDescription { + const self = try allocator.create(FileSystemDescription); + errdefer allocator.destroy(self); + + const root_name = try allocator.dupe(u8, "root"); + errdefer allocator.free(root_name); + + const root_dir = try allocator.create(EntryDescription); + errdefer allocator.destroy(root_dir); + + root_dir.* = .{ + .file_system_description = self, + .name = root_name, + .subdata = .{ .dir = .{ .entries = .empty } }, + }; + + self.* = .{ + .allocator = allocator, + .root = root_dir, + .cwd = root_dir, + }; + + try self.entries.append(allocator, root_dir); + + return self; + } + + pub fn destroy(self: *FileSystemDescription) void { + for (self.entries.items) |entry| entry.deinit(); + self.entries.deinit(self.allocator); + self.allocator.destroy(self); + } + + /// Set the current working directory. + /// + /// `entry` must be a directory. + pub fn setCwd(self: *FileSystemDescription, entry: *EntryDescription) void { + std.debug.assert(entry.subdata == .dir); // cwd must be a directory + self.cwd = entry; + } + + pub const EntryDescription = struct { + file_system_description: *FileSystemDescription, + name: []const u8, + + /// time of last access, if null is set to current time at construction + atime: ?i128 = null, + /// time of last modification, if null is set to current time at construction + mtime: ?i128 = null, + /// time of last status change, if null is set to current time at construction + ctime: ?i128 = null, + + /// contains data specific to different entry types + subdata: SubData, + + const SubData = union(enum) { + file: File, + dir: Dir, + + const File = struct { + contents: []const u8, + }; + + const Dir = struct { + entries: std.StringArrayHashMapUnmanaged(*EntryDescription) = .{}, + }; + }; + + /// Add a file entry description to this directory entry description. + /// + /// `self` must be a directory entry description. + /// + /// `name` and `content` are duplicated. + pub fn addFile(self: *EntryDescription, name: []const u8, content: []const u8) !void { + std.debug.assert(self.subdata == .dir); + const allocator = self.file_system_description.allocator; + + const duped_name = try allocator.dupe(u8, name); + errdefer allocator.free(duped_name); + + const duped_content = try allocator.dupe(u8, content); + errdefer allocator.free(duped_content); + + const file = try allocator.create(EntryDescription); + errdefer allocator.destroy(file); + + file.* = .{ + .file_system_description = self.file_system_description, + .name = duped_name, + .subdata = .{ .file = .{ .contents = duped_content } }, + }; + + const result = try self.subdata.dir.entries.getOrPut(allocator, duped_name); + if (result.found_existing) return error.DuplicateEntryName; + + result.value_ptr.* = file; + errdefer _ = self.subdata.dir.entries.pop(); + + try self.file_system_description.entries.append(allocator, file); + } + + /// Add a directory entry description to this directory entry description. + /// + /// `name` is duplicated. + /// + /// `self` must be a directory entry description. + pub fn addDirectory(self: *EntryDescription, name: []const u8) !*EntryDescription { + std.debug.assert(self.subdata == .dir); + const allocator = self.file_system_description.allocator; + + const duped_name = try allocator.dupe(u8, name); + errdefer allocator.free(duped_name); + + const dir = try allocator.create(EntryDescription); + errdefer allocator.destroy(dir); + + dir.* = .{ + .file_system_description = self.file_system_description, + .name = duped_name, + .subdata = .{ .dir = .{} }, + }; + + const result = try self.subdata.dir.entries.getOrPut(allocator, duped_name); + if (result.found_existing) return error.DuplicateEntryName; + + result.value_ptr.* = dir; + errdefer _ = self.subdata.dir.entries.pop(); + + try self.file_system_description.entries.append(allocator, dir); + + return dir; + } + + fn deinit(self: *EntryDescription) void { + const allocator = self.file_system_description.allocator; + + switch (self.subdata) { + .file => |file| allocator.free(file.contents), + .dir => |*dir| dir.entries.deinit(allocator), + } + + allocator.free(self.name); + + allocator.destroy(self); + } + }; +}; + +const target_os = @import("target_os").target_os; +const TestBackend = @import("TestBackend.zig"); +const System = @import("../System.zig"); + +const std = @import("std"); diff --git a/src/system/backend/TestBackend.zig b/src/system/backend/TestBackend.zig new file mode 100644 index 0000000..2a1f275 --- /dev/null +++ b/src/system/backend/TestBackend.zig @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2025 Lee Cannon + +const TestBackend = @This(); + +allocator: std.mem.Allocator, + +time: Time, +file_system: ?FileSystem, +user_group: ?UserGroup, + +pub fn create(allocator: std.mem.Allocator, description: Description) !*TestBackend { + const self = try allocator.create(TestBackend); + errdefer allocator.destroy(self); + + self.* = .{ + .allocator = allocator, + .time = .{ .source = description.time }, + .file_system = null, + .user_group = null, + }; + + if (description.user_group) |ug_description| { + self.user_group = @as(UserGroup, undefined); + self.user_group.?.init(ug_description); + } + + if (description.file_system) |fs_description| { + self.file_system = @as(FileSystem, undefined); + try self.file_system.?.init(self, fs_description); + } + + return self; +} + +pub fn destroy(self: *TestBackend) void { + if (self.file_system) |*fs| fs.deinit(); + self.allocator.destroy(self); +} + +pub const Description = @import("Description.zig"); + +pub const FileSystem = @import("FileSystem.zig"); +const Time = @import("Time.zig"); +const UserGroup = @import("UserGroup.zig"); +const System = @import("../System.zig"); + +const log = std.log.scoped(.system_test_backend); + +const is_test = @import("builtin").is_test; +const target_os = @import("target_os").target_os; +const std = @import("std"); +const tracy = @import("tracy"); diff --git a/src/system/backend/Time.zig b/src/system/backend/Time.zig new file mode 100644 index 0000000..1d9cadd --- /dev/null +++ b/src/system/backend/Time.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2025 Lee Cannon + +const Time = @This(); + +source: Source, + +pub inline fn nanoTimestamp(self: Time) i128 { + return switch (self.source) { + .host => std.time.nanoTimestamp(), + .backend => |ptr| @atomicLoad(i128, ptr, .acquire), + }; +} + +pub const Source = union(enum) { + host, + + /// A pointer to the source to be used as the current time in nanoseconds. + /// + /// Reads are atomic with Acquire ordering. + /// + /// Note: This pointer must be valid for as long as the `TestBackend` exists. + backend: *const i128, +}; + +const std = @import("std"); diff --git a/src/system/backend/UserGroup.zig b/src/system/backend/UserGroup.zig new file mode 100644 index 0000000..7b587e4 --- /dev/null +++ b/src/system/backend/UserGroup.zig @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2025 Lee Cannon + +const UserGroup = @This(); + +effective_user_id: System.UserId, + +pub fn init(self: *UserGroup, description: UserGroupDescription) void { + self.* = .{ + .effective_user_id = description.effective_user_id, + }; +} + +pub fn getEffectiveUserId(self: UserGroup) System.UserId { + return self.effective_user_id; +} + +pub const UserGroupDescription = struct { + effective_user_id: System.UserId, +}; + +const System = @import("../System.zig"); +const std = @import("std");