diff --git a/issues/15-builtins.md b/issues/15-builtins.md new file mode 100644 index 00000000..7c285d3a --- /dev/null +++ b/issues/15-builtins.md @@ -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? diff --git a/lbuiltin.c b/lbuiltin.c new file mode 100644 index 00000000..ec3f7abe --- /dev/null +++ b/lbuiltin.c @@ -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 +#include +#include +#include +#include + +#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"); +} diff --git a/lbuiltin.h b/lbuiltin.h new file mode 100644 index 00000000..2eb49419 --- /dev/null +++ b/lbuiltin.h @@ -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 diff --git a/lcmd.c b/lcmd.c index 925ec2d2..8a6677fb 100644 --- a/lcmd.c +++ b/lcmd.c @@ -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; diff --git a/linit.c b/linit.c index 42972d6e..a2110d63 100644 --- a/linit.c +++ b/linit.c @@ -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 */ } diff --git a/makefile b/makefile index 1e664585..b5f7a505 100644 --- a/makefile +++ b/makefile @@ -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 \ diff --git a/testes/lush/builtins.lua b/testes/lush/builtins.lua new file mode 100644 index 00000000..441845f8 --- /dev/null +++ b/testes/lush/builtins.lua @@ -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"