summaryrefslogtreecommitdiff
path: root/src/shell_bin.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/shell_bin.zig')
-rw-r--r--src/shell_bin.zig153
1 files changed, 139 insertions, 14 deletions
diff --git a/src/shell_bin.zig b/src/shell_bin.zig
index ecd80937..81ff90fa 100644
--- a/src/shell_bin.zig
+++ b/src/shell_bin.zig
@@ -9,6 +9,11 @@
//! keeping a copy each. Nothing here imports the core, which is also what lets
//! it be its own std-only test module.
//!
+//! Each host owns one `PromptRcs` for its lifetime. Its files are private
+//! `mkstemp` names, completely written and closed before resolve can expose
+//! them to a child. Concurrent launches therefore share neither a pathname nor
+//! an inode, and a shell can never source another user's predictable /tmp file.
+//!
//! ALL OF THIS RUNS IN THE PARENT. Between fork and exec a process may not
//! allocate, and a $PATH search does — which is the same reason the exec is
//! `execv` on an absolute path and never `execvp`. So the lookup is a handful
@@ -21,6 +26,8 @@ const builtin = @import("builtin");
const libc = std.c;
const X_OK: c_int = 1;
+extern "c" fn mkstemp(template: [*:0]u8) c_int;
+
/// Prompt integration, per shell FAMILY rather than per binary: pardes hides
/// prompt rows, moves the cursor by clicking one, and tells a command's output
/// from the line that asked for it, and all three read the OSC 133 marks a
@@ -54,10 +61,7 @@ pub fn shellRc(bin: []const u8) ShellRc {
return .none;
}
-pub const bash_rc_path = "/tmp/pardes-osc133.bash";
-pub const fish_rc_path = "/tmp/pardes-osc133.fish";
-
-pub const bash_rc =
+const bash_rc =
\\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
\\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
\\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
@@ -78,7 +82,7 @@ pub const bash_rc =
/// C and D come off fish's own `fish_preexec`/`fish_postexec` events rather
/// than being spliced into the prompt, which is what bash's DEBUG trap is
/// working around.
-pub const fish_rc =
+const fish_rc =
\\functions -c fish_prompt __pardes_user_prompt
\\function fish_prompt
\\ printf '\e]133;A;cl=line\a'
@@ -94,6 +98,87 @@ pub const fish_rc =
\\
;
+const rc_path_capacity = 64;
+
+/// The two complete, private prompt files a native host lends to every shell
+/// it spawns. No allocation and no global name: moving this value is safe
+/// because it stores lengths, never pointers into its own buffers.
+pub const PromptRcs = struct {
+ bash_path: [rc_path_capacity:0]u8 = @splat(0),
+ bash_len: u8 = 0,
+ fish_path: [rc_path_capacity:0]u8 = @splat(0),
+ fish_len: u8 = 0,
+ fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0),
+ fish_command_len: u8 = 0,
+
+ pub fn init() PromptRcs {
+ var rcs: PromptRcs = .{};
+ rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc);
+ rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc);
+ if (rcs.fishPath()) |path| {
+ const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch {
+ _ = libc.unlink(path.ptr);
+ rcs.fish_len = 0;
+ return rcs;
+ };
+ rcs.fish_command_len = @intCast(command.len);
+ }
+ return rcs;
+ }
+
+ pub fn deinit(rcs: *PromptRcs) void {
+ if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr);
+ if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr);
+ rcs.bash_len = 0;
+ rcs.fish_len = 0;
+ rcs.fish_command_len = 0;
+ }
+
+ fn bashPath(rcs: *const PromptRcs) ?[:0]const u8 {
+ if (rcs.bash_len == 0) return null;
+ return rcs.bash_path[0..rcs.bash_len :0];
+ }
+
+ fn fishPath(rcs: *const PromptRcs) ?[:0]const u8 {
+ if (rcs.fish_len == 0) return null;
+ return rcs.fish_path[0..rcs.fish_len :0];
+ }
+
+ fn fishCommand(rcs: *const PromptRcs) ?[:0]const u8 {
+ if (rcs.fish_command_len == 0) return null;
+ return rcs.fish_command[0..rcs.fish_command_len :0];
+ }
+};
+
+/// Create one private 0600 file and reveal its length only after the complete
+/// write and close. Failure leaves no pathname for resolve to hand to a shell.
+fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 {
+ const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0;
+ const fd = mkstemp(path.ptr);
+ if (fd < 0) return 0;
+ var off: usize = 0;
+ while (off < contents.len) {
+ const n = libc.write(fd, contents[off..].ptr, contents.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ _ = libc.close(fd);
+ _ = libc.unlink(path.ptr);
+ return 0;
+ }
+ if (n == 0) {
+ _ = libc.close(fd);
+ _ = libc.unlink(path.ptr);
+ return 0;
+ }
+ off += @intCast(n);
+ }
+ if (libc.close(fd) != 0) {
+ _ = libc.unlink(path.ptr);
+ return 0;
+ }
+ return @intCast(path.len);
+}
+
test "shell family is the basename's prefix, and anything else runs unadorned" {
try std.testing.expectEqual(ShellRc.fish, shellRc("fish"));
try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/bin/fish"));
@@ -143,17 +228,18 @@ pub const Spawn = struct {
/// `bin` is whatever the Shell builtin was given — a bare name to look up, or
/// a path (anything with a `/`) to take at its word. `buf` holds the resolved
/// path for as long as the returned Spawn is used, which for a caller that is
-/// about to fork means: until the child execs.
-pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) Spawn {
+/// about to fork means: until the child execs. `prompt_rcs` is host-lifetime
+/// storage and must likewise remain alive through that exec.
+pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptRcs) Spawn {
const path = find(bin, buf) orelse fallback(buf);
// the family comes off the path that will ACTUALLY be executed, not the
// name that was asked for — `Shell sh` on a system where that is a symlink
// to bash still has no `--rcfile` promise attached to it, and a resolved
// /usr/bin/fish reads as fish whether it was reached by name or by path
const marks: [2]?[*:0]const u8 = switch (shellRc(std.mem.span(path))) {
- .bash => .{ "--rcfile", bash_rc_path },
+ .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null },
// -C runs AFTER config.fish, which is the whole point (see fish_rc)
- .fish => .{ "-C", "source " ++ fish_rc_path },
+ .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null },
.none => .{ null, null },
};
return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } };
@@ -194,28 +280,67 @@ fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 {
test "a path is taken at its word, a name is looked up, and both pick their own marks" {
if (builtin.os.tag == .windows) return;
var buf: [std.fs.max_path_bytes]u8 = undefined;
+ var prompt_rcs = PromptRcs.init();
+ defer prompt_rcs.deinit();
// /bin/sh exists on every unix this builds for and is in no family, so it
// pins the resolve-by-path arm AND the unadorned argv
- const sh = resolve("/bin/sh", &buf);
+ const sh = resolve("/bin/sh", &buf, &prompt_rcs);
try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path));
try std.testing.expect(sh.argv[1] == null);
// a name with no slash is searched for; whatever it resolves to, it is a
// bash and so carries --rcfile pointing at the rc the shells write
- const bash = resolve("bash", &buf);
+ const bash = resolve("bash", &buf, &prompt_rcs);
try std.testing.expect(shellRc(std.mem.span(bash.path)) == .bash);
try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?));
- try std.testing.expectEqualStrings(bash_rc_path, std.mem.span(bash.argv[2].?));
+ try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?));
// nothing is installed under this name, so the fallback answers — and the
// fallback is a real executable, not the name that failed
- const missing = resolve("zznosuchshell", &buf);
+ const missing = resolve("zznosuchshell", &buf, &prompt_rcs);
try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path)));
try std.testing.expect(libc.access(missing.path, X_OK) == 0);
// an absolute path that does not exist falls back too, rather than being
// handed to exec to fail on
- const gone = resolve("/zz/no/such/shell", &buf);
+ const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs);
try std.testing.expect(libc.access(gone.path, X_OK) == 0);
}
+
+test "prompt rc owners have private complete files and clean them up" {
+ if (builtin.os.tag == .windows) return;
+ var a = PromptRcs.init();
+ defer a.deinit();
+ var b = PromptRcs.init();
+ defer b.deinit();
+ const a_bash = a.bashPath() orelse return error.TempCreateFailed;
+ const b_bash = b.bashPath() orelse return error.TempCreateFailed;
+ const a_fish = a.fishPath() orelse return error.TempCreateFailed;
+ try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash));
+ const fish_command = a.fishCommand() orelse return error.MissingFishCommand;
+ try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]);
+ try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]);
+
+ var buf: [bash_rc.len]u8 = undefined;
+ const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var len: usize = 0;
+ while (len < buf.len) {
+ const n = libc.read(fd, buf[len..].ptr, buf.len - len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ len += @intCast(n);
+ }
+ try std.testing.expectEqualStrings(bash_rc, buf[0..len]);
+
+ var removed: [rc_path_capacity:0]u8 = @splat(0);
+ @memcpy(removed[0..a_bash.len], a_bash);
+ removed[a_bash.len] = 0;
+ a.deinit();
+ try std.testing.expect(libc.access(&removed, 0) < 0);
+}