summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 11:23:19 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit1ac8fbeedaf7cba881a7423b92ead1f739b8ac21 (patch)
treec3916f9918fb98252987331195cd1b70712c890d
parent2c98513a23d060093d690dbd7743dda5ad86c117 (diff)
downloadpardes-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]>
-rw-r--r--docs/fs.md9
-rw-r--r--src/host_io.zig49
-rw-r--r--src/ninep/ctl.zig29
-rw-r--r--src/pardes.zig34
4 files changed, 106 insertions, 15 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 544a9f84..4e3428d1 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -956,9 +956,12 @@ INT|TERM|HUP|QUIT|KILL` and `exec`, which starts the pane's shell again in
its directory: one that is gone is refused before anything runs, `exec:
<dir>: no such directory` (ENOENT), and a shell the host cannot start (not
there, not executable, a script whose interpreter is not there) fails the
-write with why -- `shell: shell not found`, ENOENT -- keeping the
-terminal; a `Tty` whose shell cannot start fails the same way and leaves
-no pane. The host knows before it answers: the child reports a failed exec
+write with why -- `shell: shell not found`, or `shell: interpreter
+/no/such/interp not found` for a script whose `#!` names a program that is
+not there, ENOENT -- keeping the terminal; a `Tty` naming such a shell or
+script is refused before anything runs (`Tty: interpreter ... not found`,
+its `err` the only record), and one whose shell cannot start fails the
+same way and leaves no pane. The host knows before it answers: the child reports a failed exec
through a close-on-exec pipe. A directory removed
under a running shell leaves the pane its name (never `... (deleted)`), so
an `exec` works there once the directory is back. The size, and `pty/ctl`'s `winsize` read back, is
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".