diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 11:23:19 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 1ac8fbeedaf7cba881a7423b92ead1f739b8ac21 (patch) | |
| tree | c3916f9918fb98252987331195cd1b70712c890d /src | |
| parent | 2c98513a23d060093d690dbd7743dda5ad86c117 (diff) | |
| download | pardes-1ac8fbeedaf7cba881a7423b92ead1f739b8ac21.tar.gz pardes-1ac8fbeedaf7cba881a7423b92ead1f739b8ac21.zip | |
A script whose interpreter is not there is told apart from a missing shell, and Tty refuses it up front
Tty's up-front check found the script and let it through, so the pane was made, its exec failed ENOENT, and the log read new, msg shell: shell not found, del, then the err. The check now reads a script's #! line and refuses it, interpreter /no/such/interp not found, and an exec that fails ENOENT on a file that is there says the same.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/host_io.zig | 49 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 29 | ||||
| -rw-r--r-- | src/pardes.zig | 34 |
3 files changed, 100 insertions, 12 deletions
diff --git a/src/host_io.zig b/src/host_io.zig index 913930ac..85ba94f4 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -861,7 +861,13 @@ pub const Shell = struct { /// null when it is one. pub fn refusal(bin: []const u8, said: []u8) ?[]const u8 { var buf: [std.fs.max_path_bytes]u8 = undefined; - if (find(bin, &buf) != null) return null; + if (find(bin, &buf)) |found| { + // A script whose `#!` names a program that is not there: its + // exec fails ENOENT as a missing shell's does, so told apart. + var interp: [256]u8 = undefined; + const missing = missingInterpreter(found, &interp) orelse return null; + return std.fmt.bufPrint(said, "interpreter {s} not found", .{missing}) catch "interpreter not found"; + } if (std.mem.indexOfScalar(u8, bin, '/') != null and bin.len < buf.len) { @memcpy(buf[0..bin.len], bin); buf[bin.len] = 0; @@ -871,6 +877,43 @@ pub const Shell = struct { return std.fmt.bufPrint(said, "no shell \"{s}\", not found (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell"; } + /// The program a script's `#!` line names, when it is not there to + /// run; null for a script whose interpreter is there, or no script. + pub fn missingInterpreter(path: [*:0]const u8, out: []u8) ?[]const u8 { + pardes.turn.yield(); + defer pardes.turn.back(); + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); + if (fd < 0) return null; + defer _ = libc.close(fd); + var head: [256]u8 = undefined; + const n = libc.read(fd, &head, head.len); + if (n < 2) return null; + const got = head[0..@intCast(n)]; + if (!std.mem.startsWith(u8, got, "#!")) return null; + const line = got[2 .. std.mem.indexOfScalar(u8, got, '\n') orelse got.len]; + var words = std.mem.tokenizeAny(u8, line, " \t\r"); + const interp = words.next() orelse return null; + if (interp.len + 1 > out.len) return null; + @memcpy(out[0..interp.len], interp); + out[interp.len] = 0; + if (libc.access(@ptrCast(out.ptr), X_OK) == 0) return null; + return out[0..interp.len]; + } + + test "a script whose #! names a program not there is told apart from a missing shell" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp -x\n", .flags = .{ .permissions = .executable_file } }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "good", .data = "#!/bin/sh\n", .flags = .{ .permissions = .executable_file } }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var path: [4200]u8 = undefined; + var said: [320]u8 = undefined; + try std.testing.expectEqualStrings("interpreter /no/such/interp not found", refusal(try std.fmt.bufPrint(&path, "{s}/bad", .{dir}), &said).?); + try std.testing.expect(refusal(try std.fmt.bufPrint(&path, "{s}/good", .{dir}), &said) == null); + try std.testing.expect(std.mem.startsWith(u8, refusal(try std.fmt.bufPrint(&path, "{s}/none", .{dir}), &said).?, "no shell ")); + } + fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { for (fallbacks) |f| { @memcpy(buf[0..f.len], f); @@ -1203,7 +1246,9 @@ pub fn forkShell( const e: libc.E = @enumFromInt(why[1]); if (why[0] == 'c') return if (e == .NOENT or e == .NOTDIR) error.FileNotFound else error.AccessDenied; return switch (e) { - .NOENT => error.ShellNotFound, // itself, or its script's interpreter + // Itself, or its script's interpreter: told apart by whether it + // is there. + .NOENT => if (libc.access(spawn.path, 0) == 0) error.InterpreterNotFound else error.ShellNotFound, .ACCES, .PERM => error.ShellNotExecutable, .NOEXEC => error.ShellNotExecutable, else => error.ShellDidNotStart, diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 4b37c23e..31382279 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1996,6 +1996,35 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]); } +test "a script whose interpreter is not there: Tty refuses it up front, only an err logged" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const Starting = struct { + fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} + }; + p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; + defer p.host = .{}; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp\n", .flags = .{ .permissions = .executable_file } }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + const serial = serialOf(p); + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const refused = wr(p, Node.of(serial, .ctl), try std.fmt.bufPrint(&line, "Tty {s}/bad\n", .{dir})); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectEqualStrings("Tty: interpreter /no/such/interp not found", refused.reply.ename); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + // The log gained the err and nothing else: no new, msg or del. + try testing.expect(th.logHas(p, "Tty: interpreter /no/such/interp not found\n")); + try testing.expect(!th.logHas(p, "\nmsg ")); + try testing.expect(!th.logHas(p, "\ndel ")); +} + test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index d9c69921..d2fd795b 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5338,16 +5338,7 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; pane.shell_failed = true; ctlfs.pty.shellGone(p, pane, false); - if (err == error.FileNotFound or err == error.NotDir) - p.reportFailure(id, "shell: directory missing (no such directory)") - else - p.reportError(id, "shell", err); - // A 9P write that asked for the shell (pty/ctl's exec), waiting on - // it, fails with what was said, as a failed Save's does. - const said = pane.msg[0..pane.msg_len]; - const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); - @memcpy(p.fs.late_failure[0..kept.len], kept); - p.fs.late_failure_len = @intCast(kept.len); + p.sayShellFailure(id, err); // A command that never started ended: `exit 127`, as a shell says of // a command it could not run, so a follower waiting on its exit // hears one, its tag stops saying it runs, and Kill finds nothing. @@ -5362,6 +5353,29 @@ pub const Pardes = struct { } } + fn sayShellFailure(p: *Pardes, id: u8, err: anyerror) void { + const pane = p.panes[id] orelse return; + if (err == error.FileNotFound or err == error.NotDir) { + p.reportFailure(id, "shell: directory missing (no such directory)"); + } else if (err == error.InterpreterNotFound) { + // The script is there; the program its `#!` names is not. + var said: [320]u8 = undefined; + var interp: [256]u8 = undefined; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const Shell = if (comptime hosted) @import("host_io.zig").Shell else void; + const name: ?[]const u8 = if (comptime hosted) (if (Shell.find(pane.shell orelse p.shellBin(), &buf)) |f| Shell.missingInterpreter(f, &interp) else null) else null; + p.reportFailure(id, if (name) |n| std.fmt.bufPrint(&said, "shell: interpreter {s} not found", .{n}) catch "shell: interpreter not found" else "shell: interpreter not found"); + } else { + p.reportError(id, "shell", err); + } + // A 9P write that asked for the shell (pty/ctl's exec), waiting on + // it, fails with what was said, as a failed Save's does. + const said = pane.msg[0..pane.msg_len]; + const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); + @memcpy(p.fs.late_failure[0..kept.len], kept); + p.fs.late_failure_len = @intCast(kept.len); + } + pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { // The `-%` spelling fs.zig already uses for "make this dirty". |
