mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
This introduces a syntax for `command` and `initial-command` that allows
the user to specify whether it should be run via `/bin/sh -c` or not.
The syntax is a prefix `direct:` or `shell:` prior to the command,
with no prefix implying a default behavior as documented.
Previously, we unconditionally ran commands via `/bin/sh -c`, primarily
to avoid having to do any shell expansion ourselves. We also leaned on
it as a crutch for PATH-expansion but this is an easy problem compared
to shell expansion.
For the principle of least surprise, this worked well for configurations
specified via the config file, and is still the default. However, these
configurations are also set via the `-e` special flag to the CLI, and it
is very much not the principle of least surprise to have the command run via
`/bin/sh -c` in that scenario since a shell has already expanded all the
arguments and given them to us in a nice separated format. But we had no
way to toggle this behavior.
This commit introduces the ability to do this, and changes the defaults
so that `-e` doesn't shell expand. Further, we also do PATH lookups
ourselves for the non-shell expanded case because thats easy (using
execvpe style extensions but implemented as part of the Zig stdlib). We don't
do path expansion (e.g. `~/`) because thats a shell expansion.
So to be clear, there are no two polar opposite behavioes here with
clear semantics:
1. Direct commands are passed to `execvpe` directly, space separated.
This will not handle quoted strings, environment variables, path
expansion (e.g. `~/`), command expansion (e.g. `$()`), etc.
2. Shell commands are passed to `/bin/sh -c` and will be shell expanded
as per the shell's rules. This will handle everything that `sh`
supports.
In doing this work, I also stumbled upon a variety of smaller
improvements that could be made:
- A number of allocations have been removed from the startup path that
only existed to add a null terminator to various strings. We now
have null terminators from the beginning since we are almost always
on a system that's going to need it anyways.
- For bash shell integration, we no longer wrap the new bash command
in a shell since we've formed a full parsed command line.
- The process of creating the command to execute by termio is now unit
tested, so we can test the various complex cases particularly on
macOS of wrapping commands in the login command.
- `xdg-terminal-exec` on Linux uses the `direct:` method by default
since it is also assumed to be executed via a shell environment.
158 lines
5.3 KiB
Zig
158 lines
5.3 KiB
Zig
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const internal_os = @import("main.zig");
|
|
const build_config = @import("../build_config.zig");
|
|
const Allocator = std.mem.Allocator;
|
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
|
const posix = std.posix;
|
|
|
|
const log = std.log.scoped(.passwd);
|
|
|
|
// We want to be extra sure since this will force bad symbols into our import table
|
|
comptime {
|
|
if (builtin.target.cpu.arch.isWasm()) {
|
|
@compileError("passwd is not available for wasm");
|
|
}
|
|
}
|
|
|
|
/// Used to determine the default shell and directory on Unixes.
|
|
const c = if (builtin.os.tag != .windows) @cImport({
|
|
@cInclude("sys/types.h");
|
|
@cInclude("unistd.h");
|
|
@cInclude("pwd.h");
|
|
}) else {};
|
|
|
|
// Entry that is retrieved from the passwd API. This only contains the fields
|
|
// we care about.
|
|
pub const Entry = struct {
|
|
shell: ?[:0]const u8 = null,
|
|
home: ?[:0]const u8 = null,
|
|
name: ?[:0]const u8 = null,
|
|
};
|
|
|
|
/// Get the passwd entry for the currently executing user.
|
|
pub fn get(alloc: Allocator) !Entry {
|
|
if (builtin.os.tag == .windows) @compileError("passwd is not available on windows");
|
|
|
|
var buf: [1024]u8 = undefined;
|
|
var pw: c.struct_passwd = undefined;
|
|
var pw_ptr: ?*c.struct_passwd = null;
|
|
const res = c.getpwuid_r(c.getuid(), &pw, &buf, buf.len, &pw_ptr);
|
|
if (res != 0) {
|
|
log.warn("error retrieving pw entry code={d}", .{res});
|
|
return Entry{};
|
|
}
|
|
|
|
if (pw_ptr == null) {
|
|
// Future: let's check if a better shell is available like zsh
|
|
log.warn("no pw entry to detect default shell, will default to 'sh'", .{});
|
|
return Entry{};
|
|
}
|
|
|
|
var result: Entry = .{};
|
|
|
|
// If we're in flatpak then our entry is always empty so we grab it
|
|
// by shelling out to the host. note that we do HAVE an entry in the
|
|
// sandbox but only the username is correct.
|
|
if (internal_os.isFlatpak()) flatpak: {
|
|
if (comptime !build_config.flatpak) {
|
|
log.warn("flatpak detected, but this build doesn't contain flatpak support", .{});
|
|
break :flatpak;
|
|
}
|
|
|
|
log.info("flatpak detected, will use host command to get our entry", .{});
|
|
|
|
// Note: we wrap our getent call in a /bin/sh login shell because
|
|
// some operating systems (NixOS tested) don't set the PATH for various
|
|
// utilities properly until we get a login shell.
|
|
const Pty = @import("../pty.zig").Pty;
|
|
var pty = try Pty.open(.{});
|
|
defer pty.deinit();
|
|
var cmd: internal_os.FlatpakHostCommand = .{
|
|
.argv = &[_][]const u8{
|
|
"/bin/sh",
|
|
"-l",
|
|
"-c",
|
|
try std.fmt.allocPrint(
|
|
alloc,
|
|
"getent passwd {s}",
|
|
.{std.mem.sliceTo(pw.pw_name, 0)},
|
|
),
|
|
},
|
|
.stdin = pty.slave,
|
|
.stdout = pty.slave,
|
|
.stderr = pty.slave,
|
|
};
|
|
_ = try cmd.spawn(alloc);
|
|
_ = try cmd.wait();
|
|
|
|
// Once started, we can close the child side. We do this after
|
|
// wait right now but that is fine too. This lets us read the
|
|
// parent and detect EOF.
|
|
_ = posix.close(pty.slave);
|
|
|
|
// Read all of our output
|
|
const output = output: {
|
|
var output: std.ArrayListUnmanaged(u8) = .{};
|
|
while (true) {
|
|
const n = posix.read(pty.master, &buf) catch |err| {
|
|
switch (err) {
|
|
// EIO is triggered at the end since we closed our
|
|
// child side. This is just EOF for this. I'm not sure
|
|
// if I'm doing this wrong.
|
|
error.InputOutput => break,
|
|
else => return err,
|
|
}
|
|
};
|
|
|
|
try output.appendSlice(alloc, buf[0..n]);
|
|
|
|
// Max total size is buf.len. We can do better here by trimming
|
|
// the front and continuing reading but we choose to just exit.
|
|
if (output.items.len > buf.len) break;
|
|
}
|
|
|
|
break :output try output.toOwnedSlice(alloc);
|
|
};
|
|
|
|
// Shell and home are the last two entries
|
|
var it = std.mem.splitBackwardsScalar(u8, std.mem.trimRight(u8, output, " \r\n"), ':');
|
|
result.shell = if (it.next()) |v| try alloc.dupeZ(u8, v) else null;
|
|
result.home = if (it.next()) |v| try alloc.dupeZ(u8, v) else null;
|
|
return result;
|
|
}
|
|
|
|
if (pw.pw_shell) |ptr| {
|
|
const source = std.mem.sliceTo(ptr, 0);
|
|
const value = try alloc.dupeZ(u8, source);
|
|
result.shell = value;
|
|
}
|
|
|
|
if (pw.pw_dir) |ptr| {
|
|
const source = std.mem.sliceTo(ptr, 0);
|
|
const value = try alloc.dupeZ(u8, source);
|
|
result.home = value;
|
|
}
|
|
|
|
if (pw.pw_name) |ptr| {
|
|
const source = std.mem.sliceTo(ptr, 0);
|
|
const value = try alloc.dupeZ(u8, source);
|
|
result.name = value;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
test {
|
|
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
|
const testing = std.testing;
|
|
var arena = ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const alloc = arena.allocator();
|
|
|
|
// We should be able to get an entry
|
|
const entry = try get(alloc);
|
|
try testing.expect(entry.shell != null);
|
|
try testing.expect(entry.home != null);
|
|
}
|