diff --git a/README.md b/README.md index 61f5e71..edd6019 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,21 @@ roadmap. Services run as `root` by default; `user`/`group` trigger a full privilege drop (supplementary groups, then gid, then uid) before exec. +### imports + +Configs can be split across files with `@import PATH` (column 0, before any +section in that file): + +```rc +@import extra-services.rc +``` + +The path resolves relative to the importing file's directory (absolute paths +pass through). A file reached by several imports is only parsed once; +self/cyclic imports are reported as errors. Imported files may import other +files and define services and actions like any other rc. `reload` re-parses +the whole import tree, so imported changes take effect on `bctl reload`. + ## control A running init listens on an abstract unix socket (`@bajia`). The bundled @@ -150,3 +165,40 @@ policy with `checkpolicy`/`audit2allow`, then flip to `enforcing=1`. Limitations: uses the dynamic libselinux (no static build on Fedora), so the `--selinux` init is dynamically linked and the loader + libs (`libselinux`, `libpcre2-8`, glibc) are bundled into the initramfs. + +## development & testing + +Host-side unit tests (no framework, no dependencies) cover the rc parser, the +`@import` machinery, and the pure supervisor helpers: + +```sh +make test # builds build/unit_tests and runs it +``` + +The parser is also fuzz-tested with libFuzzer (needs clang): + +```sh +python3 tools/fuzz.py --seconds 300 +``` + +This drives random bytes through the same `parse_rc_stream` path the real init +uses, with `@import` rejected so fuzz input can never open real files (e.g. +`/dev/zero`). Crashes are saved under `build/fuzz-`; seeds accumulate in +`build/fuzz-corpus` and grow between runs. A grammar dictionary (auto-seeded +at `build/fuzz.dict`, overridable via `--dict`, disabled with `--no-dict`) +guides coverage toward real rc keywords. Leak detection is on by default: +`tools/lsan.supp` silences the spurious `strdup` that a torsocks `LD_PRELOAD` +on the dev host allocates at startup, so any real leak in `parse_rc_stream` is +saved as a `leak-*` artifact; pass `--no-detect-leaks` to disable it on a +clean host. Peak fuzz RSS is driven mostly by ASan's freed-memory quarantine +(256MiB default); fuzz.py pins it to 64MiB (`--quarantine-mb N`, 0 to +disable), which roughly halves peak RSS. + +To leak-check the host-side unit tests under ASan/LSan instead: + +```sh +make test-asan +``` + +(clang++ and a `leak:tsocks_once` suppression are used automatically; the +default `make test` runs the same assertions without the sanitizer). diff --git a/configure.py b/configure.py index 034e32e..e0d1177 100644 --- a/configure.py +++ b/configure.py @@ -38,16 +38,20 @@ def write_makefile_convenience(): # # make -> configure + build # make configure -> regenerate build.ninja +# make test -> build + run the host-side unit tests # make clean -> remove build dir # make format -> clang-format all sources (if available) # -.PHONY: all configure clean format +.PHONY: all configure test clean format all: configure \t@ninja -C build configure: \t@python3 configure.py +test: configure +\t@ninja -C build unit_tests && ./build/unit_tests + clean: \t@rm -rf build @@ -68,9 +72,15 @@ def emit_ninja(cxx, cxxflags, dst, write_cc=False): ctl_source = SRC / "bctl_main.cpp" ctl_obj = "obj/bctl_main.o" + # host-side unit tests: the test TU #includes src/*.cpp directly, so a + # single object covers it. + test_source = ROOT / "tests" / "unit_tests.cpp" + test_obj = "obj/unit_tests.o" + # (source, output) pairs for compile_commands.json. cc_entries = [(SRC / s, o) for s, o in zip(sources, objs)] cc_entries.append((ctl_source, ctl_obj)) + cc_entries.append((test_source, test_obj)) rule_cxx = ( "rule cxx\n" @@ -91,13 +101,16 @@ def emit_ninja(cxx, cxxflags, dst, write_cc=False): lines.append(rule_link) lines.append('build {target}: link {objs}'.format(target=target, objs=" ".join(objs))) lines.append('build {ctl}: link {ctl_obj}'.format(ctl=ctl_target, ctl_obj=ctl_obj)) + lines.append('build unit_tests: link {test_obj}'.format(test_obj=test_obj)) lines.append("") for o, s in zip(objs, sources): lines.append('build {o}: cxx {src}/{s}'.format(o=o, src=SRC, s=s)) lines.append('build {ctl_obj}: cxx {ctl_src}'.format(ctl_obj=ctl_obj, ctl_src=ctl_source)) + lines.append('build {test_obj}: cxx {test_src}'.format(test_obj=test_obj, test_src=test_source)) lines.append("") lines.append( 'build all: phony {target} {ctl}'.format(target=target, ctl=ctl_target)) + lines.append('build test: phony unit_tests') lines.append("default all") lines.append("") diff --git a/src/config.cpp b/src/config.cpp index 8345812..348b1d7 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,7 +1,9 @@ // config.cpp - parser for the bajia .rc language. #include "config.hpp" +#include #include +#include namespace bajia { @@ -13,6 +15,9 @@ std::vector tokenize(const std::string& line) { std::vector out; std::string cur; + out.reserve(8); // most rc lines are 2-6 tokens; avoid realloc churn + cur.reserve(16); + bool in_q = false; bool need_quote_close = false; @@ -83,144 +88,260 @@ const Service* Config::find_service(const std::string& name) const { return nullptr; } -// public entry point -Config parse_config(const std::vector& files) { - Config cfg; - cfg.sources = files; +namespace { + +// resolve an import path relative to the file containing the directive +// (absolute paths pass through). No here -- it bloats the static +// init, and a manual dirname is plenty. +std::string resolve_import_path(const std::string& from_file, const std::string& imp) { + if (imp.empty() || imp[0] == '/') + return imp; + const size_t slash = from_file.find_last_of('/'); + if (slash == std::string::npos) + return imp; // no directory component -> CWD-relative + return from_file.substr(0, slash + 1) + imp; +} + +void parse_rc_file(Config& cfg, const std::string& file, + std::vector& chain, + std::vector& seen); + +// parse an already-open stream into `cfg`, following `@import` directives +// recursively. `file` is a logical name used for error messages and for +// resolving relative imports; `opener` materializes imported files (real +// ifstreams in production, in-memory strings in tests, a hard error in the +// fuzzer). `chain` = files currently being parsed (cycle detection); `seen` = +// every file already loaded, so diamond imports aren't replayed. +template +void parse_rc_stream(Config& cfg, std::istream& in, const std::string& file, + std::vector& chain, + std::vector& seen, const Opener& opener) { + chain.push_back(file); + if (std::find(seen.begin(), seen.end(), file) == seen.end()) + seen.push_back(file); + int line = 0; std::string section_kind; // "service" or "action" Service* cur_svc = nullptr; // service being configured Action* cur_act = nullptr; // action being configured + std::string raw; - for (const auto& file : files) { - std::ifstream in(file); - if (!in) { - throw std::runtime_error("cannot open config file: " + file); - } - std::string raw; - line = 0; - section_kind.clear(); - cur_svc = nullptr; - cur_act = nullptr; + while (std::getline(in, raw)) { + ++line; + auto toks = tokenize(raw); + if (toks.empty()) + continue; - while (std::getline(in, raw)) { - ++line; - auto toks = tokenize(raw); - if (toks.empty()) - continue; + const std::string& first = toks[0]; // toks is not mutated below + size_t indent = raw.find_first_not_of(" \t"); - std::string first = toks[0]; - size_t indent = raw.find_first_not_of(" \t"); - - if (first == "service" && indent == 0) { - if (toks.size() < 3) { - throw std::runtime_error(file + ":" + std::to_string(line) + - ": 'service' requires name + executable"); - } - Service svc; - svc.name = toks[1]; - svc.args.assign(toks.begin() + 2, toks.end()); - cfg.services.push_back(std::move(svc)); - cur_svc = &cfg.services.back(); - cur_act = nullptr; - section_kind = "service"; - continue; + // `@import PATH` splices another rc; allowed at column 0 outside any + // service/action section. The path is resolved relative to this file. + if (first == "@import") { + if (indent != 0 || !section_kind.empty()) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": '@import' is only valid at column 0 " + "outside a section"); } - - if (first == "on") { - if (toks.size() < 2) { - throw std::runtime_error(file + ":" + std::to_string(line) + - ": 'on' requires a trigger"); - } - cfg.actions.push_back(Action{toks[1], {}}); - cur_act = &cfg.actions.back(); - cur_svc = nullptr; - section_kind = "action"; - continue; + if (toks.size() != 2) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": '@import' requires exactly one path " + "(quote it if it contains spaces)"); } - - // service options (must already be inside a service section). - // value-taking keywords require `keyword = value` syntax. - if (section_kind == "service" && cur_svc) { - if (first == "oneshot") - cur_svc->oneshot = true; - else if (first == "disabled") - cur_svc->disabled = true; - else if (first == "console") - cur_svc->console = true; - else if (first == "user" || first == "group" || first == "class" || - first == "respawn" || first == "crash-threshold" || - first == "crash-window" || first == "setenv" || - first == "cwd" || first == "seclabel") { - if (toks.size() < 3 || toks[1] != "=") { - throw std::runtime_error(file + ":" + std::to_string(line) + - ": option '" + first + - "' requires '= value' syntax"); + const std::string imp = resolve_import_path(file, toks[1]); + if (std::find(chain.begin(), chain.end(), imp) != chain.end()) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": circular import '" + imp + "'"); + } + if (std::find(seen.begin(), seen.end(), imp) == seen.end()) { + std::unique_ptr child = opener(imp); + if (!child || !*child) { + std::string ctx; + for (size_t i = 0; i < chain.size(); ++i) { + if (i) + ctx += " -> "; + ctx += chain[i]; } - if (first == "user") - cur_svc->uid = toks[2]; - else if (first == "group") { - cur_svc->gid = toks[2]; - for (size_t i = 3; i < toks.size(); ++i) - cur_svc->groups.push_back(toks[i]); - } else if (first == "class") - cur_svc->service_class = toks[2]; - else if (first == "respawn") - cur_svc->respawn = parse_respawn(toks[2]); - else if (first == "crash-threshold") - cur_svc->crash_threshold = std::stoi(toks[2]); - else if (first == "crash-window") - cur_svc->crash_window_secs = std::stoi(toks[2]); - else if (first == "setenv") - cur_svc->env.push_back(toks[2]); - else if (first == "cwd") - cur_svc->cwd = toks[2]; - else if (first == "seclabel") - cur_svc->seclabel = toks[2]; + throw std::runtime_error( + "cannot open config file: " + imp + + (ctx.empty() ? "" : " (imported from " + ctx + ")")); } - // unknown option keys are ignored. - continue; - } - - // action command (must be inside an action section). - if (section_kind == "action" && cur_act) { - Command cmd; - if (first == "start" && toks.size() >= 2) - cmd.kind = Command::Kind::Start; - else if (first == "stop" && toks.size() >= 2) - cmd.kind = Command::Kind::Stop; - else if (first == "restart" && toks.size() >= 2) - cmd.kind = Command::Kind::Restart; - else if (first == "exec") - cmd.kind = Command::Kind::Exec; - else if (first == "mkdir") - cmd.kind = Command::Kind::Mkdir; - else if (first == "chmod") - cmd.kind = Command::Kind::Chmod; - else if (first == "chown") - cmd.kind = Command::Kind::Chown; - else if (first == "setenv") - cmd.kind = Command::Kind::Setenv; - else if (first == "write") - cmd.kind = Command::Kind::Write; - else if (first == "symlink") - cmd.kind = Command::Kind::Symlink; - else if (first == "mount") - cmd.kind = Command::Kind::Mount; - else if (first == "log") - cmd.kind = Command::Kind::Log; - else { - throw std::runtime_error(file + ":" + std::to_string(line) + - ": unknown action command '" + first + "'"); + try { + parse_rc_stream(cfg, *child, imp, chain, seen, opener); + } catch (const std::exception& e) { + throw std::runtime_error(std::string(e.what()) + " (at " + + file + ":" + std::to_string(line) + + ")"); } - cmd.args.assign(toks.begin() + 1, toks.end()); - cur_act->commands.push_back(std::move(cmd)); - continue; } - - throw std::runtime_error(file + ":" + std::to_string(line) + - ": unexpected directive '" + first + "'"); + continue; } + + if (first == "service" && indent == 0) { + if (toks.size() < 3) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": 'service' requires name + executable"); + } + Service svc; + svc.name = toks[1]; + svc.args.assign(toks.begin() + 2, toks.end()); + cfg.services.push_back(std::move(svc)); + cur_svc = &cfg.services.back(); + cur_act = nullptr; + section_kind = "service"; + continue; + } + + if (first == "on") { + if (toks.size() < 2) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": 'on' requires a trigger"); + } + cfg.actions.push_back(Action{toks[1], {}}); + cur_act = &cfg.actions.back(); + cur_svc = nullptr; + section_kind = "action"; + continue; + } + + // service options (must already be inside a service section). + // value-taking keywords require `keyword = value` syntax. + if (section_kind == "service" && cur_svc) { + if (first == "oneshot") + cur_svc->oneshot = true; + else if (first == "disabled") + cur_svc->disabled = true; + else if (first == "console") + cur_svc->console = true; + else if (first == "user" || first == "group" || first == "class" || + first == "respawn" || first == "crash-threshold" || + first == "crash-window" || first == "setenv" || + first == "cwd" || first == "seclabel") { + if (toks.size() < 3 || toks[1] != "=") { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": option '" + first + + "' requires '= value' syntax"); + } + if (first == "user") + cur_svc->uid = toks[2]; + else if (first == "group") { + cur_svc->gid = toks[2]; + for (size_t i = 3; i < toks.size(); ++i) + cur_svc->groups.push_back(toks[i]); + } else if (first == "class") + cur_svc->service_class = toks[2]; + else if (first == "respawn") + cur_svc->respawn = parse_respawn(toks[2]); + else if (first == "crash-threshold") + cur_svc->crash_threshold = std::stoi(toks[2]); + else if (first == "crash-window") + cur_svc->crash_window_secs = std::stoi(toks[2]); + else if (first == "setenv") + cur_svc->env.push_back(toks[2]); + else if (first == "cwd") + cur_svc->cwd = toks[2]; + else if (first == "seclabel") + cur_svc->seclabel = toks[2]; + } + // unknown option keys are ignored. + continue; + } + + // action command (must be inside an action section). + if (section_kind == "action" && cur_act) { + Command cmd; + if (first == "start" && toks.size() >= 2) + cmd.kind = Command::Kind::Start; + else if (first == "stop" && toks.size() >= 2) + cmd.kind = Command::Kind::Stop; + else if (first == "restart" && toks.size() >= 2) + cmd.kind = Command::Kind::Restart; + else if (first == "exec") + cmd.kind = Command::Kind::Exec; + else if (first == "mkdir") + cmd.kind = Command::Kind::Mkdir; + else if (first == "chmod") + cmd.kind = Command::Kind::Chmod; + else if (first == "chown") + cmd.kind = Command::Kind::Chown; + else if (first == "setenv") + cmd.kind = Command::Kind::Setenv; + else if (first == "write") + cmd.kind = Command::Kind::Write; + else if (first == "symlink") + cmd.kind = Command::Kind::Symlink; + else if (first == "mount") + cmd.kind = Command::Kind::Mount; + else if (first == "log") + cmd.kind = Command::Kind::Log; + else { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": unknown action command '" + first + "'"); + } + cmd.args.assign(toks.begin() + 1, toks.end()); + cur_act->commands.push_back(std::move(cmd)); + continue; + } + + throw std::runtime_error(file + ":" + std::to_string(line) + + ": unexpected directive '" + first + "'"); + } + chain.pop_back(); +} + +// Opener for production: real files. +struct OpenFile { + std::unique_ptr operator()(const std::string& path) const { + return std::unique_ptr(new std::ifstream(path)); + } +}; + +// Parse a single .rc file from disk, following `@import` directives +// recursively. +void parse_rc_file(Config& cfg, const std::string& file, + std::vector& chain, + std::vector& seen) { + std::ifstream in(file); + if (!in) { + std::string ctx; + for (size_t i = 0; i < chain.size(); ++i) { + if (i) + ctx += " -> "; + ctx += chain[i]; + } + throw std::runtime_error("cannot open config file: " + file + + (ctx.empty() ? "" + : " (imported from " + ctx + ")")); + } + parse_rc_stream(cfg, in, file, chain, seen, OpenFile{}); +} + +} // namespace + +// public entry point +Config parse_config(const std::vector& files) { + Config cfg; + cfg.sources = files; + std::vector chain; // files currently being parsed + std::vector seen; // files already loaded (dedupes imports) + for (const auto& file : files) { + parse_rc_file(cfg, file, chain, seen); + } + + // the whole parsed tree outlives boot (reload, triggers, shutdown), so + // shed the capacity slack the push_back path left in every collected + // vector before handing the Config to the supervisor. + cfg.services.shrink_to_fit(); + for (auto& svc : cfg.services) { + svc.args.shrink_to_fit(); + svc.groups.shrink_to_fit(); + svc.env.shrink_to_fit(); + } + cfg.actions.shrink_to_fit(); + for (auto& action : cfg.actions) { + action.commands.shrink_to_fit(); + for (auto& cmd : action.commands) + cmd.args.shrink_to_fit(); } return cfg; } diff --git a/src/config.hpp b/src/config.hpp index cf18fbe..583a946 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -1,7 +1,7 @@ // config.hpp - data model for the bajia .rc language. // -// The language is a small, embedded-oriented dialect inspired by Android init. -// Two top-level constructs: +// the language is a small, embedded-oriented dialect inspired by Android init. +// two top-level constructs: // // service NAME /path/to/exec args... // user = root # uid after the privilege drop @@ -21,24 +21,22 @@ // // TRIGGER events: early-init, init, boot, shutdown, property:=, // service-started:, service-stopped:. +// +// imports: `@import PATH` (column 0, before any section in that file) splices +// in another .rc. Relative paths resolve against the importing file's +// directory; a file already loaded via a different import is not replayed +// (diamond imports are safe), and self/cyclic imports are rejected. #pragma once -#include -#include -#include #include #include namespace bajia { -// -------------------------------------------------------------------------- // version -// -------------------------------------------------------------------------- constexpr const char* kBajiaVersion = "0.1"; -// -------------------------------------------------------------------------- -// Service -// -------------------------------------------------------------------------- +// service enum class RespawnPolicy { Never, OnFailure, @@ -62,7 +60,7 @@ struct Service { std::vector env; // "K=V" pairs std::string seclabel; // SELinux exec context (optional) - // Transient runtime flag: this service was stopped by a config reload + // transient runtime flag: this service was stopped by a config reload // because its definition changed; respawn it once with the new definition. bool restart_on_reap = false; @@ -72,9 +70,7 @@ struct Service { bool running = false; }; -// -------------------------------------------------------------------------- -// Action: a list of commands to run when a trigger fires. -// -------------------------------------------------------------------------- +// action: a list of commands to run when a trigger fires. struct Command { enum class Kind { Start, @@ -99,9 +95,7 @@ struct Action { std::vector commands; }; -// -------------------------------------------------------------------------- -// Config: everything parsed from all loaded .rc files. -// -------------------------------------------------------------------------- +// config: everything parsed from all loaded .rc files. struct Config { std::vector services; std::vector actions; @@ -113,7 +107,7 @@ struct Config { const Service* find_service(const std::string& name) const; }; -// Parse a set of .rc files into a Config. Throws std::runtime_error on +// parse a set of .rc files into a Config. throws std::runtime_error on // malformed input (reported with file:line context). Config parse_config(const std::vector& files); diff --git a/src/main.cpp b/src/main.cpp index f9f46a1..30ce149 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,10 @@ #include #include +#if defined(__GLIBC__) +#include +#endif + using namespace bajia; namespace { @@ -87,6 +91,14 @@ int main(int argc, char** argv) { // logger targets the console; falls back to stderr until the console is // ready. log_init("/dev/console", LogLevel::Info); + + // parse_config's transient allocations (token buffers, import chains, + // error strings) have been freed but still occupy the glibc heap arena, + // keeping their stack of pages resident for the life of the process. + // Return them to the kernel now that the long-lived Config is built. +#if defined(__GLIBC__) + ::malloc_trim(0); +#endif log_status(LogStatus::Banner, std::string("Welcome to bajia ") + kBajiaVersion); log_info("init", "loaded ", std::to_string(files.size()), " config file(s), ", diff --git a/src/supervisor.cpp b/src/supervisor.cpp index 0e627bf..beafd8f 100644 --- a/src/supervisor.cpp +++ b/src/supervisor.cpp @@ -30,6 +30,9 @@ #ifdef BAJIA_SELINUX #include #endif +#if defined(__GLIBC__) +#include +#endif namespace bajia { @@ -222,11 +225,10 @@ void Supervisor::spawn_service(Service& svc, bool missing_ok) { } // environment: inherit, then apply K=V entries. - std::vector kvs = svc.env; std::vector envp; for (char** e = environ; e && *e; ++e) envp.push_back(*e); - for (auto& kv : kvs) + for (const auto& kv : svc.env) envp.push_back(const_cast(kv.c_str())); envp.push_back(nullptr); @@ -395,6 +397,12 @@ void Supervisor::reload_config() { // are not replayed. config_.actions = std::move(fresh.actions); config_.hostname = std::move(fresh.hostname); + + // reload re-ran the whole parser; return its transient arena churn to the + // kernel on a long-lived init. +#if defined(__GLIBC__) + ::malloc_trim(0); +#endif } void Supervisor::reap_children() { @@ -545,8 +553,8 @@ bool Supervisor::run_command(Command& cmd) { } if (::setenv(cmd.args[0].c_str(), cmd.args[1].c_str(), 1) != 0) { log_status(LogStatus::Failed, - "Failed to set environment variable " + cmd.args[0] + - ": " + std::strerror(errno)); + "Failed to set environment variable " + cmd.args[0] + + ": " + std::strerror(errno)); return false; } return true; diff --git a/tests/fuzz_config.cpp b/tests/fuzz_config.cpp new file mode 100644 index 0000000..d6c20dd --- /dev/null +++ b/tests/fuzz_config.cpp @@ -0,0 +1,39 @@ +// fuzz_config.cpp - libFuzzer harness for the .rc parser. +// +// Built and driven by tools/fuzz.py with clang++ -fsanitize=fuzzer. The +// parser is exercised on raw bytes through the same `parse_rc_stream` path +// used by the real init. `@import` is rejected inside the harness (the +// opener throws) so fuzz input can never open real files such as /dev/zero. +#include "../src/config.cpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +struct NoImports { + std::unique_ptr operator()(const std::string&) const { + throw std::runtime_error("fuzz: @import disabled"); + } +}; + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + const std::string text(reinterpret_cast(data), size); + bajia::Config cfg; + std::vector chain; + std::vector seen; + try { + std::istringstream in(text); + bajia::parse_rc_stream(cfg, in, "/fuzz/main.rc", chain, seen, + NoImports{}); + } catch (const std::exception&) { + // every parse error is a valid outcome; crashes are the bugs. + } + return 0; +} \ No newline at end of file diff --git a/tests/unit_tests.cpp b/tests/unit_tests.cpp new file mode 100644 index 0000000..2171ed5 --- /dev/null +++ b/tests/unit_tests.cpp @@ -0,0 +1,277 @@ +// unit_tests.cpp - host-side unit tests for the bajia parser and pure helpers. +#include "../src/config.cpp" +#include "../src/logger.cpp" +#include "../src/supervisor.cpp" + +#include +#include +#include +#include +#include +#include + +using namespace bajia; + +namespace { + +int g_checks = 0; +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + ++g_checks; \ + if (!(cond)) { \ + ++g_failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +#define CHECK_EQ(a, b) \ + do { \ + ++g_checks; \ + const auto va = (a); \ + const auto vb = (b); \ + if (!(va == vb)) { \ + ++g_failures; \ + std::printf("FAIL %s:%d: %s != %s\n", __FILE__, __LINE__, #a, \ + #b); \ + } \ + } while (0) + +#define CHECK_THROWS(expr, substr) \ + do { \ + ++g_checks; \ + bool caught = false; \ + try { \ + expr; \ + } catch (const std::exception& e) { \ + caught = true; \ + if (std::string(e.what()).find(substr) == std::string::npos) { \ + ++g_failures; \ + std::printf("FAIL %s:%d: threw wrong error: %s\n", __FILE__, \ + __LINE__, e.what()); \ + } \ + } \ + if (!caught) { \ + ++g_failures; \ + std::printf("FAIL %s:%d: did not throw: %s\n", __FILE__, __LINE__, \ + #expr); \ + } \ + } while (0) + +// In-memory replacement for the production opener: imports resolve against a +// logical file tree instead of the real filesystem. +struct VirtualFS { + std::map files; + + std::unique_ptr operator()(const std::string& path) const { + auto it = files.find(path); + if (it == files.end()) + return nullptr; + return std::make_unique(it->second); + } +}; + +Config parse_string(const std::string& text, const VirtualFS& fs, + const std::string& name = "/t/main.rc") { + Config cfg; + std::vector chain, seen; + std::istringstream in(text); + parse_rc_stream(cfg, in, name, chain, seen, fs); + return cfg; +} + +void test_tokenize() { + CHECK(tokenize("").empty()); + CHECK(tokenize(" \t ").empty()); + CHECK(tokenize("# only a comment").empty()); + CHECK(tokenize("; semicolon comment").empty()); + CHECK_EQ(tokenize("a b c").size(), 3u); + CHECK_EQ(tokenize("a \t b").size(), 2u); + CHECK_EQ(tokenize("service foo /bin/true ").size(), 3u); + CHECK_EQ(tokenize("a # rest is a comment").size(), 1u); + CHECK_EQ(tokenize("a ; rest is a comment").size(), 1u); + // quoted strings survive as one token + CHECK_EQ(tokenize("write /x \"hello world\"").size(), 3u); + // escaped characters inside quotes + const auto t = tokenize("\"a\\\"b\" c"); + CHECK_EQ(t.size(), 2u); + CHECK_EQ(t[0], std::string("a\"b")); + // unterminated quote: best-effort keeps what we have + CHECK_EQ(tokenize("\"unterminated").size(), 1u); +} + +void test_parse_respawn() { + CHECK(parse_respawn("always") == RespawnPolicy::Always); + CHECK(parse_respawn("on-failure") == RespawnPolicy::OnFailure); + CHECK(parse_respawn("never") == RespawnPolicy::Never); + CHECK(parse_respawn("bogus") == RespawnPolicy::Never); +} + +void test_resolve_import_path() { + CHECK_EQ(resolve_import_path("/a/b/main.rc", "sub/x.rc"), + std::string("/a/b/sub/x.rc")); + CHECK_EQ(resolve_import_path("/a/b/main.rc", "/abs/y.rc"), + std::string("/abs/y.rc")); + CHECK_EQ(resolve_import_path("main.rc", "x.rc"), std::string("x.rc")); + CHECK_EQ(resolve_import_path("/a/main.rc", ""), std::string("")); +} + +void test_parse_basic() { + VirtualFS fs; + auto cfg = parse_string( + "service sshd /usr/sbin/sshd\n" + " user = root\n" + " respawn = on-failure\n" + " class = main\n" + " setenv = FOO=bar\n" + " oneshot\n" + " disabled\n" + " console\n" + "on early-init\n" + " mkdir /dev/pts 0755\n" + " log hello\n", + fs); + CHECK_EQ(cfg.services.size(), 1u); + CHECK_EQ(cfg.actions.size(), 1u); + const auto& svc = cfg.services[0]; + CHECK_EQ(svc.name, std::string("sshd")); + CHECK_EQ(svc.args.size(), 1u); + CHECK_EQ(svc.args[0], std::string("/usr/sbin/sshd")); + CHECK_EQ(svc.uid, std::string("root")); + CHECK_EQ(svc.service_class, std::string("main")); + CHECK(svc.respawn == RespawnPolicy::OnFailure); + CHECK(svc.oneshot); + CHECK(svc.disabled); + CHECK(svc.console); + CHECK_EQ(svc.env.size(), 1u); + const auto& act = cfg.actions[0]; + CHECK_EQ(act.trigger, std::string("early-init")); + CHECK_EQ(act.commands.size(), 2u); + CHECK(act.commands[0].kind == Command::Kind::Mkdir); + CHECK(act.commands[1].kind == Command::Kind::Log); + + // option values require `= value` + CHECK_THROWS(parse_string("service s /bin/true\n respawn always\n", fs), + "requires '= value'"); + // unknown option keys are silently ignored (by design) + CHECK_EQ(parse_string("service s /bin/true\n color = red\n", fs).services.size(), + 1u); + // service needs name + executable + CHECK_THROWS(parse_string("service lonely\n", fs), "'service' requires"); + // action needs a trigger + CHECK_THROWS(parse_string("on\n", fs), "'on' requires a trigger"); + // unknown command inside an action + CHECK_THROWS(parse_string("on boot\n frobnicate /x\n", fs), + "unknown action command"); + // unknown directive at column 0 + CHECK_THROWS(parse_string("BROKEN = yes\n", fs), "unexpected directive"); +} + +void test_imports() { + VirtualFS fs; + fs.files["/t/lib/base.rc"] = "service base /bin/true\non boot\n log base\n"; + fs.files["/t/lib/net.rc"] = "service net /bin/true\n"; + + // relative + transitive imports resolve against the importing file's dir + auto cfg = parse_string("@import lib/base.rc\n@import lib/net.rc\n", fs); + CHECK_EQ(cfg.services.size(), 2u); + CHECK_EQ(cfg.actions.size(), 1u); + + // absolute import + fs.files["/t/abs.rc"] = "service abs /bin/true\n"; + cfg = parse_string("@import /t/abs.rc\n", fs); + CHECK_EQ(cfg.services.size(), 1u); + + // diamond imports dedupe: the shared file is not replayed + fs.files["/t/a.rc"] = "@import lib/base.rc\n"; + fs.files["/t/b.rc"] = "@import lib/base.rc\n"; + cfg = parse_string("@import a.rc\n@import b.rc\n", fs); + CHECK_EQ(cfg.services.size(), 1u); // base defined once, not twice + CHECK_EQ(cfg.actions.size(), 1u); + + // missing import carries the full ancestry + fs.files["/t/mid.rc"] = "@import /t/nope.rc\n"; + CHECK_THROWS(parse_string("@import mid.rc\n", fs), + "cannot open config file: /t/nope.rc (imported from " + "/t/main.rc -> /t/mid.rc)"); + + // nested errors get "(at file:line)" context chained upward + fs.files["/t/bad.rc"] = "BROKEN = yes\n"; + CHECK_THROWS(parse_string("@import bad.rc\n", fs), + "/t/bad.rc:1: unexpected directive 'BROKEN' (at /t/main.rc:1)"); + + // circular imports + fs.files["/t/x.rc"] = "@import /t/y.rc\n"; + fs.files["/t/y.rc"] = "@import /t/x.rc\n"; + CHECK_THROWS(parse_string("@import /t/x.rc\n", fs), "circular import"); + fs.files["/t/self.rc"] = "@import self.rc\n"; + CHECK_THROWS(parse_string("@import self.rc\n", fs), "circular import"); + + // placement rules + CHECK_THROWS(parse_string("on boot\n log x\n@import lib/base.rc\n", fs), + "only valid at column 0 outside a section"); + CHECK_THROWS(parse_string("@import a b\n", fs), "exactly one path"); + CHECK_THROWS(parse_string(" @import lib/base.rc\n", fs), + "only valid at column 0"); +} + +void test_supervisor_helpers() { + // numeric ids pass through; unknown names resolve to -1 + CHECK(resolve_user("0") == 0); + CHECK(resolve_user("65534") == 65534u); + CHECK(resolve_user("definitely-not-a-user") == static_cast(-1)); + CHECK(resolve_group("1") == 1); + CHECK(resolve_group("garbage-group-name") == static_cast(-1)); + + // service_changed: equal copies are unchanged, each field flips it + Service a; + a.name = "x"; + a.args = {"/bin/true"}; + CHECK(!service_changed(a, a)); + Service b = a; + b.args = {"/bin/false"}; + CHECK(service_changed(a, b)); + b = a; + b.cwd = "/tmp"; + CHECK(service_changed(a, b)); + b = a; + b.uid = "nobody"; + CHECK(service_changed(a, b)); + b = a; + b.respawn = RespawnPolicy::Never; + CHECK(service_changed(a, b)); + b = a; + b.env = {"A=1"}; + CHECK(service_changed(a, b)); + b = a; + b.service_class = "other"; + CHECK(service_changed(a, b)); + + // status_line renders running/stopped state + flags + Service svc; + svc.name = "web"; + svc.service_class = "default"; + CHECK_EQ(status_line(svc), std::string("web stopped (last exit 0) class default\n")); + svc.running = true; + svc.pid = 42; + CHECK_EQ(status_line(svc), std::string("web running pid 42 class default\n")); + svc.oneshot = true; + svc.service_class = "tools"; + CHECK_EQ(status_line(svc), + std::string("web running pid 42 oneshot class tools\n")); +} + +} // namespace + +int main() { + test_tokenize(); + test_parse_respawn(); + test_resolve_import_path(); + test_parse_basic(); + test_imports(); + test_supervisor_helpers(); + + std::printf("%d checks, %d failures\n", g_checks, g_failures); + return g_failures == 0 ? 0 : 1; +} \ No newline at end of file diff --git a/tools/fuzz.py b/tools/fuzz.py new file mode 100755 index 0000000..4fb2982 --- /dev/null +++ b/tools/fuzz.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""build and run the libFuzzer harness for the .rc config parser. + +usage: + python3 tools/fuzz.py [--seconds N] [--build-only] [--corpus DIR] + [--dict PATH|--no-dict] [--no-detect-leaks] + [--quarantine-mb N] + +defaults to 60 seconds of fuzzing against a small seed corpus. Seed corpus +files land in build/fuzz-corpus (recreated only when empty so you can add your +own); crashing inputs are saved under build/fuzz- and reported at the end. +Leak detection is on by default (tools/lsan.supp silences a torsocks +LD_PRELOAD false positive on the dev host); --no-detect-leaks disables it. +ASan's freed-memory quarantine (the fuzz peak-RSS driver) defaults to 64MiB; +override with --quarantine-mb. + +requires clang++ (libFuzzer's -fsanitize=fuzzer is a clang feature). +""" +from __future__ import annotations + +import argparse +import glob +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TESTS = ROOT / "tests" +BUILD = ROOT / "build" +OUT = BUILD / "fuzz_config" +CORPUS = BUILD / "fuzz-corpus" +DICT = BUILD / "fuzz.dict" + +# restart wildcard entries are not allowed in a dict; keep tokens plain ASCII. +SEED_DICT = r"""# bajia rc grammar: libFuzzer dictionary (-dict), regenerated only if missing +"service" +"on" +"@import" +"oneshot" +"disabled" +"console" +"user" +"group" +"class" +"respawn" +"crash-threshold" +"crash-window" +"setenv" +"cwd" +"seclabel" +"=" +"start" +"stop" +"restart" +"exec" +"mkdir" +"chmod" +"chown" +"write" +"symlink" +"mount" +"log" +"early-init" +"init" +"boot" +"shutdown" +"always" +"never" +"on-failure" +"root" +"nobody" +"daemon" +"/bin/true" +"/bin/false" +"/bin/sh" +"/usr/sbin/sshd" +"/etc/bajia/init.rc" +"/proc" +"/sys" +"/dev" +"/tmp" +"0" +"0755" +"#" +";" +# __bajia_seed_dict__ +""" + +SEEDS = { + "tiny.rc": b"on boot\n log hi\n", + "service.rc": b"service sshd /usr/sbin/sshd -D\n user = root\n respawn = always\n console\n", + "alloptions.rc": ( + b"service s /bin/true\n" + b" user = nobody\n group = nobody daemon\n" + b" class = main\n oneshot\n disabled\n console\n" + b" respawn = never\n crash-threshold = 4\n crash-window = 30\n" + b" setenv = FOO=bar\n cwd = /tmp\n seclabel = system_u:object_r:root_t:s0\n" + ), + "quotes.rc": b"write /tmp/x \"a b\\\"c\"\non boot\n exec /bin/sh -c \"echo quoted\"\n", + "import.rc": b"@import /etc/bajia/init.rc\non init\n log imported\n", + "nested.rc": ( + b"on early-init\n mount proc /proc proc\n mount sysfs /sys sysfs\n" + b"on init\n write /proc/sys/kernel/hostname bajia\n" + b"on boot\n start console-serial\n" + ), +} + +def find_clang() -> str | None: + explicit = os.environ.get("BAJIA_FUZZ_CXX") + if explicit and shutil.which(explicit): + return explicit + if shutil.which("clang++"): + return "clang++" + return None + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--seconds", type=int, default=60, help="fuzz time budget") + ap.add_argument("--corpus", type=Path, + help="corpus dir (default: build/fuzz-corpus)") + ap.add_argument("--dict", type=Path, + help="custom fuzzer dictionary (default: build/fuzz.dict, " + "seeded from the rc grammar on first use)") + ap.add_argument("--no-dict", action="store_true", + help="run without a dictionary") + ap.add_argument("--build-only", action="store_true", + help="build the harness and stop") + ap.add_argument("--detect-leaks", action="store_true", + help=argparse.SUPPRESS) # historical no-op; already default + ap.add_argument("--no-detect-leaks", action="store_true", + help="disable leak detection (default on: exits nonzero on " + "leaks; tools/lsan.supp silences a torsocks " + "LD_PRELOAD false positive on the dev host)") + ap.add_argument("--quarantine-mb", type=int, default=64, metavar="N", + help="ASan freed-memory quarantine in MiB (default 64; " + "ASan's own default of 256 dominates fuzz peak RSS. " + "0 disables quarantine, which recycles freed memory " + "immediately but weakens use-after-free detection)") + args = ap.parse_args() + + clang = find_clang() + if not clang: + sys.exit("clang++ not found: libFuzzer (-fsanitize=fuzzer) requires clang") + + BUILD.mkdir(exist_ok=True) + subprocess.run([ + clang, "-std=c++20", "-O1", "-g", + "-fsanitize=fuzzer,address,undefined", "-fno-omit-frame-pointer", + str(TESTS / "fuzz_config.cpp"), "-o", str(OUT), + ], check=True) + print("built:", OUT) + if args.build_only: + return 0 + + corpus = args.corpus or CORPUS + if corpus.is_dir() and any(corpus.iterdir()): + print("using existing corpus:", corpus) + else: + corpus.mkdir(parents=True, exist_ok=True) + for name, blob in SEEDS.items(): + (corpus / name).write_bytes(blob) + print("seeded corpus:", corpus) + + prefix = BUILD / "fuzz-" + prefix.mkdir(exist_ok=True) + flags = [f"-artifact_prefix={prefix}/", "-print_final_stats=1"] + if not args.no_dict: + if args.dict: + dict_path = args.dict + else: + if not DICT.is_file() or "__bajia_seed_dict__" not in DICT.read_text(errors="ignore"): + DICT.write_text(SEED_DICT) # (re)seed a missing or stale copy + dict_path = DICT + if not dict_path.is_file(): + sys.exit(f"--dict: file not found: {dict_path}") + flags.append(f"-dict={dict_path}") + print("dictionary:", dict_path) + env = dict(os.environ) + asan = f"quarantine_size_mb={args.quarantine_mb}" + if args.no_detect_leaks: + flags.append("-detect_leaks=0") + asan = "detect_leaks=0:" + asan + else: + asan = "detect_leaks=1:" + asan + supp = ROOT / "tools" / "lsan.supp" + env["LSAN_OPTIONS"] = f"suppressions={supp}" + (":" + env["LSAN_OPTIONS"] + if env.get("LSAN_OPTIONS") else "") + print("leak detection on (suppression:", supp, ")") + env["ASAN_OPTIONS"] = asan + cmd = [str(OUT), f"-max_total_time={args.seconds}"] + flags + [str(corpus)] + print("$", " ".join(cmd)) + r = subprocess.run(cmd, env=env) + + crashes = sorted(glob.glob(str(prefix) + "crash-*")) + leaks = sorted(glob.glob(str(prefix) + "leak-*")) + timeouts = sorted(glob.glob(str(prefix) + "timeout-*")) + if crashes or leaks or timeouts: + for art in crashes + leaks + timeouts: + print("artifact:", art) + print("!! fuzzer found problems (see artifact files above)") + return 1 + print("no crashes in", args.seconds, "seconds") + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tools/lsan.supp b/tools/lsan.supp new file mode 100644 index 0000000..7959cd4 --- /dev/null +++ b/tools/lsan.supp @@ -0,0 +1,2 @@ +# LeakSanitizer suppressions for bajia dev/test tooling. +leak:tsocks_once \ No newline at end of file diff --git a/tools/run_vm.py b/tools/run_vm.py index bc49989..0e70be9 100644 --- a/tools/run_vm.py +++ b/tools/run_vm.py @@ -273,7 +273,8 @@ on boot """ def build_initramfs(init: Path, busybox: Path, rc_text: str, root_password: str | None, - selinux: bool, keep: bool) -> Path: + selinux: bool, keep: bool, + bundles: list[tuple[str, Path]] | None = None) -> Path: if not shutil.which("cpio"): sys.exit("cpio not found (install cpio)") root = Path(tempfile.mkdtemp(prefix="bajia-root-")) @@ -320,6 +321,12 @@ def build_initramfs(init: Path, busybox: Path, rc_text: str, root_password: str "nobody:x:65534:\n" "daemon:x:1:\n") + for rel, src in (bundles or []): + dst = root / rel.lstrip("/") + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + dst.chmod(0o644) + # The staging tree is owned by the host user and mkdtemp makes the # top dir 0700; GNU cpio preserves both, so without this the guest's # "/" would be mode 0700 owned by uid 1000 -- fine for root services, @@ -380,6 +387,11 @@ def main() -> int: "build it static, cache in ~/.cache/bajia") ap.add_argument("--config", type=Path, help="use this init.rc instead of the bundled test config") + ap.add_argument("--bundle", action="append", default=[], + metavar="REL=HOSTPATH", + help="copy HOSTPATH into the initramfs at absolute REL " + "(repeatable; e.g. --bundle " + "etc/bajia/extra.rc=/tmp/extra.rc for @import tests)") ap.add_argument("--root-password", default=None, help="password for the root account in the guest " "(default: passwordless login)") @@ -435,8 +447,20 @@ def main() -> int: "safest inside an initramfs") rc_text = args.config.read_text() if args.config else DEFAULT_RC + bundles: list[tuple[str, Path]] = [] + for spec in args.bundle: + rel, _, path = spec.partition("=") + src = Path(path) + if not src.is_file(): + sys.exit(f"--bundle: host file not found: {src}") + if not rel.startswith("/"): + sys.exit(f"--bundle: REL must be absolute, got: {rel!r}") + if ".." in [c for c in Path(rel).parts]: + sys.exit(f"--bundle: REL must not contain '..': {rel}") + bundles.append((rel, src)) initrd = build_initramfs(init, busybox, rc_text, args.root_password, - selinux=args.selinux, keep=args.keep_initramfs) + selinux=args.selinux, keep=args.keep_initramfs, + bundles=bundles) print("initramfs:", initrd, f"({initrd.stat().st_size / 1024:.0f} KB)") cmd = qemu_command(kernel, initrd, args)