Implement shell builtins: cd, exec, umask (issue #15)

Add builtin dispatch in lcmd.c that checks __builtins table before
fork(), so cd/exec/umask operate on the shell process itself.
This commit is contained in:
Cormac Shannon
2026-03-04 19:20:42 +00:00
parent e8756d5d78
commit 4cc352cbec
7 changed files with 431 additions and 3 deletions

54
issues/15-builtins.md Normal file
View File

@@ -0,0 +1,54 @@
# 15 — Shell builtins
**Status:** done
Some commands must run inside the shell process itself to have any effect. An external `cd` binary exists on most systems but it spawns a subprocess, changes directory there, and exits — leaving lush's own working directory unchanged.
Lush only needs builtins for operations that **cannot be implemented in pure Lua or via external binaries** — things that require modifying the shell process state via syscalls with no Lua API.
## Assessment
Every builtin from `man 1 builtin` was evaluated against two questions:
1. Can it be done in Lua? (e.g. `export``$VAR = "val"`, `source``dofile()`, `exit``os.exit()`)
2. Does the external binary work? (e.g. `/bin/pwd`, `/usr/bin/which`, `/usr/bin/kill`)
If either answer is yes, it's not needed as a builtin. What remains:
| Builtin | Why it must be a builtin |
|---------|-------------------------|
| **`cd`** | `chdir(2)` must happen in the lush process. No Lua API. |
| **`exec`** | `execvp(2)` replaces the current process. `os.execute()` spawns a subprocess. No Lua equivalent. |
| **`umask`** | `umask(2)` is per-process. The external `/usr/bin/umask` is a shell script that calls the shell's own builtin. No Lua API. |
## Builtins
### `cd [dir]`
Change working directory via `chdir(2)`.
- No argument: `cd $HOME`
- `cd -`: change to `$OLDPWD`
- Updates `$PWD` and `$OLDPWD` environment variables
- Errors if directory doesn't exist or isn't accessible
### `exec command [args...]`
Replace the shell process with the given command via `execvp(2)`. Does not return on success. On failure, prints an error and the shell continues.
### `umask [mode]`
Get or set the file creation mask via `umask(2)`.
- No argument: print current umask (octal)
- With argument: set umask to the given octal value
## Design considerations
- **Dispatch order:** When a command is entered (backtick or `!` prefix), check builtins *before* searching `$PATH`. A builtin named `cd` must intercept the command before it reaches `execvp`.
- **Implementation:** Expose the syscalls to Lua (e.g. `os.chdir()`, `os.exec()`, `os.umask()`), then implement the builtins as Lua functions registered at startup. This keeps the C side minimal and lets users compose with the primitives directly.
- **Interaction with backticks:** `` `cd /tmp` `` and `!cd /tmp` should both trigger the builtin, not spawn an external process.
## Open questions
- Should builtins be overridable by the user?
- Should there be a `builtin` command to bypass user overrides?

159
lbuiltin.c Normal file
View File

@@ -0,0 +1,159 @@
/*
** $Id: lbuiltin.c $
** Shell builtins (cd, exec, umask)
** See Copyright Notice in lua.h
*/
#define lbuiltin_c
#define LUA_LIB
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "lua.h"
#include "lauxlib.h"
#include "lbuiltin.h"
static void push_builtin_result (lua_State *L, int code,
const char *out, const char *err) {
lua_createtable(L, 0, 3);
lua_pushinteger(L, code);
lua_setfield(L, -2, "code");
lua_pushstring(L, out ? out : "");
lua_setfield(L, -2, "stdout");
lua_pushstring(L, err ? err : "");
lua_setfield(L, -2, "stderr");
}
/*
** builtin_cd(cmd, [dir])
** Change working directory. No arg → $HOME, "-" → $OLDPWD.
** Updates $PWD and $OLDPWD.
*/
static int builtin_cd (lua_State *L) {
const char *dir;
char oldpwd[4096];
char newpwd[4096];
if (lua_gettop(L) < 2 || lua_isnil(L, 2)) {
/* no dir argument: use $HOME */
dir = getenv("HOME");
if (dir == NULL) {
push_builtin_result(L, 1, NULL, "cd: HOME not set");
return 1;
}
}
else {
dir = luaL_checkstring(L, 2);
if (strcmp(dir, "-") == 0) {
dir = getenv("OLDPWD");
if (dir == NULL) {
push_builtin_result(L, 1, NULL, "cd: OLDPWD not set");
return 1;
}
}
}
/* save current directory */
if (getcwd(oldpwd, sizeof(oldpwd)) == NULL)
oldpwd[0] = '\0';
if (chdir(dir) != 0) {
char errbuf[4200];
snprintf(errbuf, sizeof(errbuf), "cd: %s: %s", dir, strerror(errno));
push_builtin_result(L, 1, NULL, errbuf);
return 1;
}
/* get resolved path */
if (getcwd(newpwd, sizeof(newpwd)) == NULL)
newpwd[0] = '\0';
setenv("OLDPWD", oldpwd, 1);
setenv("PWD", newpwd, 1);
push_builtin_result(L, 0, NULL, NULL);
return 1;
}
/*
** builtin_exec(cmd, program, [args...])
** Replace the shell process via execvp(2).
*/
static int builtin_exec (lua_State *L) {
int nargs = lua_gettop(L);
int i;
char **argv;
char errbuf[4200];
if (nargs < 2) {
push_builtin_result(L, 1, NULL, "exec: command required");
return 1;
}
/* build argv from args 2..nargs (skip "exec" at arg 1) */
argv = (char **)malloc((size_t)(nargs) * sizeof(char *));
if (argv == NULL)
return luaL_error(L, "out of memory");
for (i = 2; i <= nargs; i++)
argv[i - 2] = (char *)luaL_checkstring(L, i);
argv[nargs - 1] = NULL;
execvp(argv[0], argv);
/* execvp only returns on failure */
snprintf(errbuf, sizeof(errbuf), "exec: %s: %s", argv[0], strerror(errno));
free(argv);
push_builtin_result(L, 1, NULL, errbuf);
return 1;
}
/*
** builtin_umask(cmd, [mode])
** No arg: query and print current umask. With arg: set umask from octal string.
*/
static int builtin_umask (lua_State *L) {
if (lua_gettop(L) < 2 || lua_isnil(L, 2)) {
/* query mode */
mode_t old = umask(0);
char buf[16];
umask(old); /* restore */
snprintf(buf, sizeof(buf), "%04o\n", (unsigned)old);
push_builtin_result(L, 0, buf, NULL);
return 1;
}
else {
const char *modestr = luaL_checkstring(L, 2);
char *endp;
unsigned long val;
errno = 0;
val = strtoul(modestr, &endp, 8);
if (errno != 0 || *endp != '\0' || endp == modestr || val > 0777) {
push_builtin_result(L, 1, NULL, "umask: invalid mode");
return 1;
}
umask((mode_t)val);
push_builtin_result(L, 0, NULL, NULL);
return 1;
}
}
void luaopen_builtins (lua_State *L) {
lua_createtable(L, 0, 3);
lua_pushcfunction(L, builtin_cd);
lua_setfield(L, -2, "cd");
lua_pushcfunction(L, builtin_exec);
lua_setfield(L, -2, "exec");
lua_pushcfunction(L, builtin_umask);
lua_setfield(L, -2, "umask");
lua_setglobal(L, "__builtins");
}

14
lbuiltin.h Normal file
View File

@@ -0,0 +1,14 @@
/*
** $Id: lbuiltin.h $
** Shell builtins (cd, exec, umask)
** See Copyright Notice in lua.h
*/
#ifndef lbuiltin_h
#define lbuiltin_h
#include "lua.h"
void luaopen_builtins(lua_State *L);
#endif

38
lcmd.c
View File

@@ -20,6 +20,7 @@
#include "lua.h"
#include "lauxlib.h"
#include "lcmd.h"
#include "lbuiltin.h"
/* ===== argv parser ===== */
@@ -555,6 +556,30 @@ static int exec_pipeline (lua_State *L, char **stages, int nstages,
}
/* ===== builtin dispatch ===== */
/*
** Check if argv[0] is a shell builtin. If so, call it and push the
** result table. Returns 1 if handled, 0 if not a builtin.
*/
static int try_builtin (lua_State *L, ParsedArgs *pa) {
int i;
if (lua_getglobal(L, "__builtins") != LUA_TTABLE) {
lua_pop(L, 1);
return 0;
}
if (lua_getfield(L, -1, pa->argv[0]) != LUA_TFUNCTION) {
lua_pop(L, 2);
return 0;
}
lua_remove(L, -2); /* remove __builtins, keep function */
for (i = 0; i < pa->argc; i++)
lua_pushstring(L, pa->argv[i]);
lua_call(L, pa->argc, 1);
return 1;
}
/* ===== luaB_command ===== */
int luaB_command (lua_State *L) {
@@ -599,6 +624,12 @@ int luaB_command (lua_State *L) {
return 1;
}
/* check for shell builtin */
if (try_builtin(L, &pa)) {
free_argv(&pa);
return 1;
}
/* create pipes */
if (pipe(out_pipe) != 0) {
free_argv(&pa);
@@ -731,6 +762,13 @@ int luaB_interactive (lua_State *L) {
return 0;
}
/* check for shell builtin */
if (try_builtin(L, &pa)) {
free_argv(&pa);
lua_setglobal(L, "_");
return 0;
}
/* ignore SIGINT, SIGQUIT, SIGPIPE in parent */
memset(&sa_new, 0, sizeof(sa_new));
sa_new.sa_handler = SIG_IGN;

View File

@@ -21,6 +21,7 @@
#include "lauxlib.h"
#include "llimits.h"
#include "lcmd.h"
#include "lbuiltin.h"
/*
@@ -100,5 +101,6 @@ LUALIB_API void luaL_openselectedlibs (lua_State *L, int load, int preload) {
lua_assert((mask >> 1) == LUA_UTF8LIBK);
lua_pop(L, 1); /* remove PRELOAD table */
opencommand(L); /* register __command global */
luaopen_builtins(L); /* register __builtins table */
}

View File

@@ -104,7 +104,7 @@ CORE_O= lapi.o lcode.o lctype.o ldebug.o ldo.o ldump.o lfunc.o lgc.o llex.o \
ltm.o lundump.o lvm.o lzio.o ltests.o
AUX_O= lauxlib.o
LIB_O= lbaselib.o ldblib.o liolib.o lmathlib.o loslib.o ltablib.o lstrlib.o \
lutf8lib.o loadlib.o lcorolib.o lcmd.o linit.o
lutf8lib.o loadlib.o lcorolib.o lcmd.o lbuiltin.o linit.o
LUA_T= lua
LUA_O= lua.o
@@ -157,7 +157,8 @@ lapi.o: lapi.c lprefix.h lua.h luaconf.h lapi.h llimits.h lstate.h \
lauxlib.o: lauxlib.c lprefix.h lua.h luaconf.h lauxlib.h llimits.h
lbaselib.o: lbaselib.c lprefix.h lua.h luaconf.h lauxlib.h lualib.h \
llimits.h
lcmd.o: lcmd.c lprefix.h lua.h luaconf.h lauxlib.h lcmd.h
lbuiltin.o: lbuiltin.c lbuiltin.h lprefix.h lua.h luaconf.h lauxlib.h
lcmd.o: lcmd.c lprefix.h lua.h luaconf.h lauxlib.h lcmd.h lbuiltin.h
lcode.o: lcode.c lprefix.h lua.h luaconf.h lcode.h llex.h lobject.h \
llimits.h lzio.h lmem.h lopcodes.h lparser.h ldebug.h lstate.h ltm.h \
ldo.h lgc.h lstring.h ltable.h lvm.h lopnames.h
@@ -177,7 +178,7 @@ lfunc.o: lfunc.c lprefix.h lua.h luaconf.h ldebug.h lstate.h lobject.h \
llimits.h ltm.h lzio.h lmem.h ldo.h lfunc.h lgc.h
lgc.o: lgc.c lprefix.h lua.h luaconf.h ldebug.h lstate.h lobject.h \
llimits.h ltm.h lzio.h lmem.h ldo.h lfunc.h lgc.h lstring.h ltable.h
linit.o: linit.c lprefix.h lua.h luaconf.h lualib.h lauxlib.h llimits.h lcmd.h
linit.o: linit.c lprefix.h lua.h luaconf.h lualib.h lauxlib.h llimits.h lcmd.h lbuiltin.h
liolib.o: liolib.c lprefix.h lua.h luaconf.h lauxlib.h lualib.h llimits.h
llex.o: llex.c lprefix.h lua.h luaconf.h lctype.h llimits.h ldebug.h \
lstate.h lobject.h ltm.h lzio.h lmem.h ldo.h lgc.h llex.h lparser.h \

160
testes/lush/builtins.lua Normal file
View File

@@ -0,0 +1,160 @@
-- testes/lush/builtins.lua
-- Tests for shell builtins (issue #15): cd, exec, umask.
print "testing shell builtins"
-- === __builtins table ===
do
assert(type(__builtins) == "table")
assert(type(__builtins.cd) == "function")
assert(type(__builtins.exec) == "function")
assert(type(__builtins.umask) == "function")
end
-- === cd ===
-- cd to absolute path
do
local before = `pwd`.stdout:gsub("\n$", "")
local r = `cd /tmp`
assert(r.code == 0, "cd /tmp failed: " .. r.stderr)
local after = `pwd`.stdout:gsub("\n$", "")
assert(after == "/private/tmp" or after == "/tmp",
"expected /tmp, got: " .. after)
`cd ${before}`
end
-- cd with no arg goes to $HOME
do
local before = `pwd`.stdout:gsub("\n$", "")
local home = os.getenv("HOME")
local r = `cd`
assert(r.code == 0, "cd (no arg) failed: " .. r.stderr)
local after = `pwd`.stdout:gsub("\n$", "")
assert(after == home, "expected HOME=" .. home .. ", got: " .. after)
`cd ${before}`
end
-- cd - goes to OLDPWD
do
local start = `pwd`.stdout:gsub("\n$", "")
`cd /tmp`
local r = `cd -`
assert(r.code == 0, "cd - failed: " .. r.stderr)
local after = `pwd`.stdout:gsub("\n$", "")
assert(after == start, "expected " .. start .. ", got: " .. after)
`cd ${start}`
end
-- cd updates $PWD and $OLDPWD
do
local before = `pwd`.stdout:gsub("\n$", "")
`cd /tmp`
local pwd = os.getenv("PWD")
local oldpwd = os.getenv("OLDPWD")
assert(pwd == "/private/tmp" or pwd == "/tmp",
"PWD not updated: " .. tostring(pwd))
assert(oldpwd == before,
"OLDPWD not updated: expected " .. before .. ", got: " .. tostring(oldpwd))
`cd ${before}`
end
-- cd to nonexistent directory returns error
do
local r = `cd /nonexistent_dir_xyz_999`
assert(r.code == 1)
assert(r.stderr:find("cd:"), "expected cd error, got: " .. r.stderr)
end
-- cd to relative path
do
local before = `pwd`.stdout:gsub("\n$", "")
`cd /tmp`
os.execute("mkdir -p /tmp/_lush_test_cd")
local r = `cd _lush_test_cd`
assert(r.code == 0, "cd relative failed: " .. r.stderr)
local after = `pwd`.stdout:gsub("\n$", "")
assert(after:find("_lush_test_cd"),
"expected to be in _lush_test_cd, got: " .. after)
`cd ${before}`
os.execute("rmdir /tmp/_lush_test_cd")
end
-- === umask ===
-- query umask (no arg)
do
local r = `umask`
assert(r.code == 0, "umask query failed: " .. r.stderr)
assert(r.stdout:match("^%d+"), "expected octal, got: " .. r.stdout)
end
-- set and restore umask
do
local orig = `umask`.stdout:gsub("%s+$", "")
local r = `umask 0077`
assert(r.code == 0, "umask set failed: " .. r.stderr)
local now = `umask`.stdout:gsub("%s+$", "")
assert(now == "0077", "expected 0077, got: " .. now)
-- restore original
`umask ${orig}`
local restored = `umask`.stdout:gsub("%s+$", "")
assert(restored == orig,
"expected " .. orig .. ", got: " .. restored)
end
-- invalid octal
do
local r = `umask banana`
assert(r.code == 1)
assert(r.stderr:find("invalid mode"),
"expected 'invalid mode', got: " .. r.stderr)
end
-- === exec ===
-- exec with no command returns error
do
local r = __builtins.exec("exec")
assert(r.code == 1)
assert(r.stderr:find("command required"),
"expected 'command required', got: " .. r.stderr)
end
-- exec nonexistent command returns error
do
local r = __builtins.exec("exec", "nonexistent_cmd_xyz_999")
assert(r.code == 1)
assert(r.stderr:find("exec:"),
"expected exec error, got: " .. r.stderr)
end
-- exec replaces process (test in subprocess)
do
local r = `sh -c './lua -e "os.exit(42)"'`
assert(r.code == 42, "subprocess exit code: " .. tostring(r.code))
end
-- === builtins are overridable ===
do
local old_cd = __builtins.cd
local called = false
__builtins.cd = function(...)
called = true
return old_cd(...)
end
local before = `pwd`.stdout:gsub("\n$", "")
`cd /tmp`
assert(called, "custom cd was not called")
__builtins.cd = old_cd
`cd ${before}`
end
print "OK"