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:
54
issues/15-builtins.md
Normal file
54
issues/15-builtins.md
Normal 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
159
lbuiltin.c
Normal 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
14
lbuiltin.h
Normal 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
38
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;
|
||||
|
||||
2
linit.c
2
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 */
|
||||
}
|
||||
|
||||
|
||||
7
makefile
7
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 \
|
||||
|
||||
160
testes/lush/builtins.lua
Normal file
160
testes/lush/builtins.lua
Normal 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"
|
||||
Reference in New Issue
Block a user