diff options
| -rw-r--r-- | nihil/CMakeLists.txt | 5 | ||||
| -rw-r--r-- | nihil/argv.ccm | 107 | ||||
| -rw-r--r-- | nihil/exec.ccm | 154 | ||||
| -rw-r--r-- | nihil/find_in_path.ccm | 60 | ||||
| -rw-r--r-- | nihil/nihil.ccm | 7 | ||||
| -rw-r--r-- | nihil/process.ccm | 137 | ||||
| -rw-r--r-- | nihil/spawn.ccm | 207 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/spawn.cc | 65 |
9 files changed, 742 insertions, 1 deletions
diff --git a/nihil/CMakeLists.txt b/nihil/CMakeLists.txt index 1e9962b..81f11c6 100644 --- a/nihil/CMakeLists.txt +++ b/nihil/CMakeLists.txt @@ -4,14 +4,19 @@ add_library(nihil STATIC) target_sources(nihil PUBLIC FILE_SET modules TYPE CXX_MODULES FILES nihil.ccm + argv.ccm command_map.ccm ctype.ccm + exec.ccm fd.ccm + find_in_path.ccm generator.ccm generic_error.ccm getenv.ccm guard.ccm next_word.ccm + process.ccm skipws.ccm + spawn.ccm tabulate.ccm usage_error.ccm) diff --git a/nihil/argv.ccm b/nihil/argv.ccm new file mode 100644 index 0000000..a9c254e --- /dev/null +++ b/nihil/argv.ccm @@ -0,0 +1,107 @@ +/* + * This source code is released into the public domain. + */ + +module; + +#include <memory> +#include <ranges> +#include <string> +#include <vector> + +export module nihil:argv; + +namespace nihil { + +/* + * argv: stores a null-terminated array of nul-terminated C strings. + * argv::data() is suitable for passing to ::execv(). + * + * Create an argv using argv::from_range(), which takes a range of + * string-like objects. + */ + +export struct argv { + /* + * Create a new argv from a range. + */ + static auto from_range(std::ranges::range auto &&args) -> argv + { + auto ret = argv{}; + + for (auto &&arg : args) + ret._add_arg(std::string_view(arg)); + + ret._args.push_back(nullptr); + return ret; + } + + template<typename T> + static auto from_args(std::initializer_list<T> &&args) + { + return from_range(std::move(args)); + } + + argv(argv &&) noexcept = default; + auto operator=(this argv &, argv &&other) -> argv& = default; + + // Not copyable. TODO: for completeness, it probably should be. + argv(argv const &) = delete; + auto operator=(this argv &, argv const &other) -> argv& = delete; + + ~argv() + { + for (auto *arg : _args) + delete[] arg; + } + + // Access the stored arguments. + auto data(this argv const &self) -> char const * const * + { + return self._args.data(); + } + + auto data(this argv &self) -> char * const * + { + return self._args.data(); + } + + auto size(this argv const &self) + { + return self._args.size(); + } + + // Range access + auto begin(this argv const &self) + { + return self._args.begin(); + } + + auto end(this argv const &self) + { + return self._args.end(); + } + +private: + // Use the from_range() factory method to create new instances. + argv() = default; + + // The argument pointers, including the null terminator. + std::vector<char *> _args; + + // Add a new argument to the array. + auto _add_arg(this argv &self, std::string_view arg) -> void + { + // Create a nul-terminated C string. + auto ptr = std::make_unique<char[]>(arg.size() + 1); + std::ranges::copy(arg, ptr.get()); + ptr[arg.size()] = '\0'; + + // Ensure we won't throw when emplacing the pointer. + self._args.reserve(self._args.size() + 1); + self._args.emplace_back(ptr.release()); + } +}; + +} // namespace nihil + diff --git a/nihil/exec.ccm b/nihil/exec.ccm new file mode 100644 index 0000000..5718a04 --- /dev/null +++ b/nihil/exec.ccm @@ -0,0 +1,154 @@ +/* + * This source code is released into the public domain. + */ + +module; + +/* + * Exec providers, mostly used for spawn(). + */ + +#include <format> +#include <string> +#include <utility> + +#include <err.h> +#include <fcntl.h> +#include <unistd.h> + +extern char **environ; + +export module nihil:exec; + +import :argv; +import :fd; +import :find_in_path; +import :generic_error; + +namespace nihil { + +/* + * Generic error, what() should be descriptive. + */ +export struct exec_error : generic_error { + template<typename... Args> + exec_error(std::format_string<Args...> fmt, Args &&...args) + : generic_error(fmt, std::forward<Args>(args)...) + {} +}; + +/* + * We tried to execute a path or filename and the file was not found. + */ +export struct executable_not_found : exec_error { + std::string executable; + + executable_not_found(std::string_view executable_) + : exec_error("{}: command not found", executable_) + , executable(executable_) + {} +}; + +/* + * A concept to mark spawn executors. + */ + +export struct exec_tag{}; + +export template<typename T> +concept executor = + requires (T e) { + std::same_as<exec_tag, typename T::tag>; + { e.exec() }; + }; + +/* + * fexecv: use a file descriptor and an argument vector to call ::fexecve(). + * This is the lowest-level executor which all others are implemented + * in terms of. + * + * TODO: Should have a way to pass the environment (envp). + */ +export struct fexecv final { + using tag = exec_tag; + + fexecv(fd &&execfd, argv &&args) noexcept + : _execfd(std::move(execfd)) + , _args(std::move(args)) + {} + + [[noreturn]] auto exec(this fexecv &self) noexcept -> void + { + ::fexecve(self._execfd.get(), self._args.data(), environ); + ::err(1, "fexecve"); + } + + // Movable + fexecv(fexecv &&) noexcept = default; + auto operator=(this fexecv &, fexecv &&) noexcept -> fexecv& = default; + + // Not copyable (because we hold the open fd object) + fexecv(fexecv const &) = delete; + auto operator=(this fexecv &, fexecv const &) -> fexecv& = delete; + +private: + fd _execfd; + argv _args; +}; + +static_assert(executor<fexecv>); + +/* + * execv: equivalent to fexecv(), except the command is passed as + * a pathname instead of a file descriptor. Does not search $PATH. + */ +export auto execv(std::string_view path, argv &&argv) -> fexecv +{ + auto cpath = std::string(path); + auto const ret = ::open(cpath.c_str(), O_EXEC); + if (ret == -1) + throw executable_not_found(path); + return {fd(ret), std::move(argv)}; +} + +/* + * execvp: equivalent to fexecv(), except the command is passed as + * a filename instead of a file descriptor. If the filename is not + * an absolute path, it will be searched for in $PATH. + */ +export auto execvp(std::string_view file, argv &&argv) -> fexecv +{ + auto execfd = find_in_path(file); + if (!execfd) + throw executable_not_found(file); + return {std::move(*execfd), std::move(argv)}; +} + +/* + * execl: equivalent to execv, except the arguments are passed as a + * variadic pack of string-like objects. + */ +export auto execl(std::string_view path, auto &&...args) -> fexecv +{ + return execv(path, argv::from_args({std::string_view(args)...})); +} + +/* + * execlp: equivalent to execvp, except the arguments are passed as a + * variadic pack of string-like objects. + */ +export auto execlp(std::string_view file, auto &&...args) -> fexecv +{ + return execvp(file, argv::from_args({std::string_view(args)...})); +} + +/* + * shell: run the process by invoking /bin/sh -c with the single argument, + * equivalent to system(3). + */ +export auto shell(std::string_view const &command) -> fexecv +{ + return execl("/bin/sh", "sh", "-c", command); +} + +} // namespace nihil diff --git a/nihil/find_in_path.ccm b/nihil/find_in_path.ccm new file mode 100644 index 0000000..1e72d0b --- /dev/null +++ b/nihil/find_in_path.ccm @@ -0,0 +1,60 @@ +/* + * This source code is released into the public domain. + */ + +module; + +#include <format> +#include <ranges> +#include <string> + +#include <fcntl.h> +#include <paths.h> +#include <unistd.h> + +export module nihil:find_in_path; + +import :fd; +import :getenv; + +namespace nihil { + +/* + * Find an executable in $PATH, open it with O_EXEC and return the fd. + * If $PATH is not set, uses _PATH_DEFPATH. + */ +auto find_in_path(std::string_view file) -> std::optional<fd> { + using namespace std::literals; + + auto try_open = [](std::string_view path) -> std::optional<fd> { + auto cpath = std::string(path); + auto const ret = ::open(cpath.c_str(), O_EXEC); + if (ret != -1) + return fd(ret); + return {}; + }; + + if (file.empty()) + return {}; + + // Absolute pathname skips the search. + if (file[0] == '/') + return try_open(file); + + auto path = getenv("PATH").value_or(_PATH_DEFPATH); + + for (auto &&dir : std::views::split(path, ':')) { + // An empty $PATH element means cwd. + auto sdir = std::string_view(dir); + if (sdir.empty()) + sdir = "."; + + auto const path = std::format("{}/{}", sdir, file); + if (auto ret = try_open(path); ret) + return ret; + } + + return {}; +} + +} // namespace lfjail diff --git a/nihil/nihil.ccm b/nihil/nihil.ccm index 0daf931..21790c8 100644 --- a/nihil/nihil.ccm +++ b/nihil/nihil.ccm @@ -6,14 +6,19 @@ module; export module nihil; +export import :argv; export import :command_map; export import :ctype; +export import :exec; +export import :fd; +export import :find_in_path; export import :generator; export import :generic_error; export import :getenv; export import :guard; -export import :fd; export import :next_word; +export import :process; export import :skipws; +export import :spawn; export import :tabulate; export import :usage_error; diff --git a/nihil/process.ccm b/nihil/process.ccm new file mode 100644 index 0000000..7a4f1f0 --- /dev/null +++ b/nihil/process.ccm @@ -0,0 +1,137 @@ +/* + * This source code is released into the public domain. + */ + +module; + +#include <cerrno> +#include <cstring> +#include <optional> +#include <utility> + +#include <sys/types.h> +#include <sys/wait.h> + +export module nihil:process; + +import :generic_error; + +namespace nihil { + +/* + * wait_result: the exit status of a process. + */ +struct wait_result final { + // Return true if the process exited normally with an exit code of + // zero, otherwise false. + auto okay(this wait_result const &self) -> bool + { + return self.status() == 0; + } + + explicit operator bool(this wait_result const &self) + { + return self.okay(); + } + + // Return the exit status, if any. + auto status(this wait_result const &self) -> std::optional<int> + { + if (WIFEXITED(self._status)) + return WEXITSTATUS(self._status); + return {}; + } + + // Return the exit signal, if any. + auto signal(this wait_result const &self) -> std::optional<int> + { + if (WIFSIGNALED(self._status)) + return WTERMSIG(self._status); + return {}; + } + +private: + friend struct process; + + int _status; + + // Construct a new wait_result from the output of waitpid(). + wait_result(int status) + : _status(status) + {} +}; + +/* + * process: represents a process we created, which can be waited for. + */ +struct process final { + process() = delete; + + /* + * Create a new process from a pid, which must be a child of the + * current process. + */ + process(::pid_t pid) + : _pid(pid) + {} + + // When destroyed, we automatically wait for the process to + // avoid creating zombie processes. + ~process() { + if (_pid == -1) + return; + + auto status = int{}; + std::ignore = waitpid(_pid, &status, WEXITED); + } + + // Movable. + process(process &&) noexcept = default; + auto operator=(process &&) noexcept -> process& = default; + + // Not copyable. + process(process const &) = delete; + auto operator=(process const &) -> process& = delete; + + // Get the child's process id. + auto pid(this process const &self) noexcept -> ::pid_t; + + /* + * Wait for this process to exit (by calling waitpid()) and return + * its exit status. This destroys the process state, leaving this + * object in a moved-from state. + */ + auto wait(this process &&self) -> wait_result + { + auto status = int{}; + auto ret = waitpid(self._pid, &status, WEXITED); + + self._pid = -1; + + switch (ret) { + case -1: + throw generic_error("waitpid({}): failed: {}", + self._pid, strerror(errno)); + case 0: + throw generic_error("waitpid({}): no child to wait", + self._pid); + } + + return wait_result(status); + } + + /* + * Release this process so we won't try to wait for it when + * destroying this object. + */ + auto release(this process &&self) -> ::pid_t { + auto const ret = self._pid; + self._pid = -1; + return ret; + } + +private: + ::pid_t _pid; +}; + +} // namespace nihil diff --git a/nihil/spawn.ccm b/nihil/spawn.ccm new file mode 100644 index 0000000..f33eaa0 --- /dev/null +++ b/nihil/spawn.ccm @@ -0,0 +1,207 @@ +/* + * This source code is released into the public domain. + */ + +module; + +/* + * spawn(): fork and execute a child process. + */ + +#include <algorithm> +#include <cerrno> +#include <iterator> +#include <string> +#include <utility> + +#include <sys/types.h> +#include <sys/wait.h> + +#include <fcntl.h> +#include <unistd.h> + +export module nihil:spawn; + +import :argv; +import :exec; +import :fd; +import :process; + +namespace nihil { + +// Useful constants +export inline int constexpr stdin_fileno = STDIN_FILENO; +export inline int constexpr stdout_fileno = STDOUT_FILENO; +export inline int constexpr stderr_fileno = STDERR_FILENO; + +/* + * fd_pipe: create a pipe for the given fd. The first end of the pipe will + * be returned in the given fd object, and the second will be set to the + * given fd number in the child. + */ +export struct fd_pipe final { + fd_pipe(int fdno, fd &ret) : _fdno(fdno), _fd(&ret) { + auto fds = pipe(); + if (!fds) + throw exec_error("pipe: {}", fds.error().message()); + std::tie(_parent_fd, _child_fd) = std::move(*fds); + } + + auto run_in_child(process &) -> void { + _parent_fd.close(); + + ::dup2(_child_fd.get(), _fdno); + _child_fd.close(); + } + + auto run_in_parent(process &) -> void { + _child_fd.close(); + *_fd = std::move(_parent_fd); + } + +private: + int _fdno; + fd *_fd; + fd _parent_fd; + fd _child_fd; +}; + +/* + * fd_file: open the given file in the child as the given fd. + * open_flags and open_mode are as for ::open(). + */ +export struct fd_file final { + fd_file(int fdno, + std::string path, + int open_flags, + int open_mode = 0777) + : _fdno(fdno) + , _path(std::move(path)) + , _open_flags(open_flags) + , _open_mode(open_mode) + { } + + auto run_in_parent(process &) -> void {} + + auto run_in_child(process &) -> void { + int newfd = ::open(_path.c_str(), _open_flags, _open_mode); + if (newfd != -1) { + ::dup2(newfd, _fdno); + ::close(newfd); + } + } + +private: + int _fdno; + std::string _path; + int _open_flags; + int _open_mode; +}; + +/* + * Shorthand for fd_file with /dev/null as the file. + */ + +export inline auto stdin_devnull() -> fd_file +{ + return {STDIN_FILENO, "/dev/null", O_RDONLY, 0777}; +} + +export inline auto stdout_devnull() -> fd_file +{ + return {STDOUT_FILENO, "/dev/null", O_WRONLY, 0777}; +} + +export inline auto stderr_devnull() -> fd_file +{ + return {STDERR_FILENO, "/dev/null", O_WRONLY, 0777}; +} + +/* + * Capture the output of a given file descriptor and write it to the given + * output iterator. + */ +export template<std::output_iterator<char> Iterator> +struct capture final { + capture(int fdno, Iterator it) + : _fdno(fdno) + , _iterator(std::move(it)) + { + auto fds = pipe(); + if (!fds) + throw exec_error("pipe: {}", fds.error().message()); + + std::tie(_parent_fd, _child_fd) = std::move(*fds); + } + + capture(int fdno, std::string &str) + : capture(fdno, std::back_inserter(str)) + {} + + auto run_in_child(process &) -> void + { + _parent_fd.close(); + + ::dup2(_child_fd.get(), _fdno); + _child_fd.close(); + } + + auto run_in_parent(process &) -> void + { + _child_fd.close(); + + constexpr std::size_t bufsize = 1024; + std::array<char, bufsize> buffer; + auto ret = ssize_t{}; + + while ((ret = ::read(_parent_fd.get(), + buffer.data(), + buffer.size())) > 0) { + auto data = std::span(buffer).subspan(0, ret); + std::ranges::copy(data, _iterator); + } + + // We probably want to handle errors here somehow, + // but it's not clear what would be useful behaviour. + } + +private: + int _fdno; + fd *_fd; + fd _parent_fd; + fd _child_fd; + Iterator _iterator; +}; + +capture(int, std::string) -> capture<std::back_insert_iterator<std::string>>; + +/* + * Spawn a new process with the given arguments and return a struct process. + * Throws exec_error() on failure. + */ +export auto spawn(executor auto &&executor, auto &&...actions) -> process +{ + auto const pid = ::fork(); + if (pid == -1) + throw exec_error("fork: {}", std::strerror(errno)); + + auto proc = process(pid); + + if (pid == 0) { + // We are in the child. Release the process so we don't + // try to wait for ourselves, then run child handlers and + // exec the process. + + std::move(proc).release(); + (actions.run_in_child(proc), ...); + + executor.exec(); + _exit(1); + } + + (actions.run_in_parent(proc), ...); + + return proc; +} + +} // namespace nihil diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 13974aa..abeab88 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(nihil.test guard.cc next_word.cc skipws.cc + spawn.cc tabulate.cc) target_link_libraries(nihil.test PRIVATE diff --git a/tests/spawn.cc b/tests/spawn.cc new file mode 100644 index 0000000..455223e --- /dev/null +++ b/tests/spawn.cc @@ -0,0 +1,65 @@ +/* + * This source code is released into the public domain. + */ + +#include <catch2/catch_test_macros.hpp> + +import nihil; + +TEST_CASE("spawn: system", "[spawn]") { + using namespace nihil; + auto output = std::string(); + auto result = spawn(shell("x=1; echo $x"), + capture(stdout_fileno, output)).wait(); + + REQUIRE(result.okay()); + REQUIRE(output == "1\n"); +} + +TEST_CASE("spawn: execv", "[spawn]") { + using namespace nihil; + auto output = std::string(); + auto args = argv::from_args({"sh", "-c", "x=1; echo $x"}); + auto result = spawn(execv("/bin/sh", std::move(args)), + capture(stdout_fileno, output)).wait(); + + REQUIRE(result.okay()); + REQUIRE(output == "1\n"); +} + +TEST_CASE("spawn: execvp", "[spawn]") { + using namespace nihil; + auto output = std::string(); + auto args = argv::from_args({"sh", "-c", "x=1; echo $x"}); + auto result = spawn(execvp("sh", std::move(args)), + capture(stdout_fileno, output)).wait(); + + REQUIRE(result.okay()); + REQUIRE(output == "1\n"); +} + +TEST_CASE("spawn: execl", "[spawn]") { + using namespace nihil; + auto output = std::string(); + auto result = spawn(execl("/bin/sh", "sh", "-c", "x=1; echo $x"), + capture(stdout_fileno, output)).wait(); + + REQUIRE(result.okay()); + REQUIRE(output == "1\n"); +} + +TEST_CASE("spawn: execlp", "[spawn]") { + using namespace nihil; + auto output = std::string(); + auto result = spawn(execlp("sh", "sh", "-c", "x=1; echo $x"), + capture(stdout_fileno, output)).wait(); + + REQUIRE(result.okay()); + REQUIRE(output == "1\n"); +} + +TEST_CASE("spawn: execlp failure", "[spawn]") { + using namespace nihil; + REQUIRE_THROWS_AS(execlp("lfjail_nonesuch_executable", "x"), + executable_not_found); +} |
