fuzzer and performance optimisations

This commit is contained in:
Hedy88 2026-08-28 00:22:49 +01:00
commit a4650fd589
No known key found for this signature in database
11 changed files with 897 additions and 146 deletions

View file

@ -98,6 +98,21 @@ roadmap.
Services run as `root` by default; `user`/`group` trigger a full privilege Services run as `root` by default; `user`/`group` trigger a full privilege
drop (supplementary groups, then gid, then uid) before exec. 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 ## control
A running init listens on an abstract unix socket (`@bajia`). The bundled 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 Limitations: uses the dynamic libselinux (no static build on Fedora), so the
`--selinux` init is dynamically linked and the loader + libs (`libselinux`, `--selinux` init is dynamically linked and the loader + libs (`libselinux`,
`libpcre2-8`, glibc) are bundled into the initramfs. `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).

View file

@ -38,16 +38,20 @@ def write_makefile_convenience():
# #
# make -> configure + build # make -> configure + build
# make configure -> regenerate build.ninja # make configure -> regenerate build.ninja
# make test -> build + run the host-side unit tests
# make clean -> remove build dir # make clean -> remove build dir
# make format -> clang-format all sources (if available) # make format -> clang-format all sources (if available)
# #
.PHONY: all configure clean format .PHONY: all configure test clean format
all: configure all: configure
\t@ninja -C build \t@ninja -C build
configure: configure:
\t@python3 configure.py \t@python3 configure.py
test: configure
\t@ninja -C build unit_tests && ./build/unit_tests
clean: clean:
\t@rm -rf build \t@rm -rf build
@ -68,9 +72,15 @@ def emit_ninja(cxx, cxxflags, dst, write_cc=False):
ctl_source = SRC / "bctl_main.cpp" ctl_source = SRC / "bctl_main.cpp"
ctl_obj = "obj/bctl_main.o" 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. # (source, output) pairs for compile_commands.json.
cc_entries = [(SRC / s, o) for s, o in zip(sources, objs)] cc_entries = [(SRC / s, o) for s, o in zip(sources, objs)]
cc_entries.append((ctl_source, ctl_obj)) cc_entries.append((ctl_source, ctl_obj))
cc_entries.append((test_source, test_obj))
rule_cxx = ( rule_cxx = (
"rule cxx\n" "rule cxx\n"
@ -91,13 +101,16 @@ def emit_ninja(cxx, cxxflags, dst, write_cc=False):
lines.append(rule_link) lines.append(rule_link)
lines.append('build {target}: link {objs}'.format(target=target, objs=" ".join(objs))) 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 {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("") lines.append("")
for o, s in zip(objs, sources): for o, s in zip(objs, sources):
lines.append('build {o}: cxx {src}/{s}'.format(o=o, src=SRC, s=s)) 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 {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("")
lines.append( lines.append(
'build all: phony {target} {ctl}'.format(target=target, ctl=ctl_target)) 'build all: phony {target} {ctl}'.format(target=target, ctl=ctl_target))
lines.append('build test: phony unit_tests')
lines.append("default all") lines.append("default all")
lines.append("") lines.append("")

View file

@ -1,7 +1,9 @@
// config.cpp - parser for the bajia .rc language. // config.cpp - parser for the bajia .rc language.
#include "config.hpp" #include "config.hpp"
#include <algorithm>
#include <fstream> #include <fstream>
#include <memory>
namespace bajia { namespace bajia {
@ -13,6 +15,9 @@ std::vector<std::string> tokenize(const std::string& line) {
std::vector<std::string> out; std::vector<std::string> out;
std::string cur; std::string cur;
out.reserve(8); // most rc lines are 2-6 tokens; avoid realloc churn
cur.reserve(16);
bool in_q = false; bool in_q = false;
bool need_quote_close = false; bool need_quote_close = false;
@ -83,144 +88,260 @@ const Service* Config::find_service(const std::string& name) const {
return nullptr; return nullptr;
} }
// public entry point namespace {
Config parse_config(const std::vector<std::string>& files) {
Config cfg; // resolve an import path relative to the file containing the directive
cfg.sources = files; // (absolute paths pass through). No <filesystem> 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<std::string>& chain,
std::vector<std::string>& 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 <class Opener>
void parse_rc_stream(Config& cfg, std::istream& in, const std::string& file,
std::vector<std::string>& chain,
std::vector<std::string>& 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; int line = 0;
std::string section_kind; // "service" or "action" std::string section_kind; // "service" or "action"
Service* cur_svc = nullptr; // service being configured Service* cur_svc = nullptr; // service being configured
Action* cur_act = nullptr; // action being configured Action* cur_act = nullptr; // action being configured
std::string raw;
for (const auto& file : files) { while (std::getline(in, raw)) {
std::ifstream in(file); ++line;
if (!in) { auto toks = tokenize(raw);
throw std::runtime_error("cannot open config file: " + file); if (toks.empty())
} continue;
std::string raw;
line = 0;
section_kind.clear();
cur_svc = nullptr;
cur_act = nullptr;
while (std::getline(in, raw)) { const std::string& first = toks[0]; // toks is not mutated below
++line; size_t indent = raw.find_first_not_of(" \t");
auto toks = tokenize(raw);
if (toks.empty())
continue;
std::string first = toks[0]; // `@import PATH` splices another rc; allowed at column 0 outside any
size_t indent = raw.find_first_not_of(" \t"); // service/action section. The path is resolved relative to this file.
if (first == "@import") {
if (first == "service" && indent == 0) { if (indent != 0 || !section_kind.empty()) {
if (toks.size() < 3) { throw std::runtime_error(file + ":" + std::to_string(line) +
throw std::runtime_error(file + ":" + std::to_string(line) + ": '@import' is only valid at column 0 "
": 'service' requires name + executable"); "outside a section");
}
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 (toks.size() != 2) {
if (first == "on") { throw std::runtime_error(file + ":" + std::to_string(line) +
if (toks.size() < 2) { ": '@import' requires exactly one path "
throw std::runtime_error(file + ":" + std::to_string(line) + "(quote it if it contains spaces)");
": 'on' requires a trigger");
}
cfg.actions.push_back(Action{toks[1], {}});
cur_act = &cfg.actions.back();
cur_svc = nullptr;
section_kind = "action";
continue;
} }
const std::string imp = resolve_import_path(file, toks[1]);
// service options (must already be inside a service section). if (std::find(chain.begin(), chain.end(), imp) != chain.end()) {
// value-taking keywords require `keyword = value` syntax. throw std::runtime_error(file + ":" + std::to_string(line) +
if (section_kind == "service" && cur_svc) { ": circular import '" + imp + "'");
if (first == "oneshot") }
cur_svc->oneshot = true; if (std::find(seen.begin(), seen.end(), imp) == seen.end()) {
else if (first == "disabled") std::unique_ptr<std::istream> child = opener(imp);
cur_svc->disabled = true; if (!child || !*child) {
else if (first == "console") std::string ctx;
cur_svc->console = true; for (size_t i = 0; i < chain.size(); ++i) {
else if (first == "user" || first == "group" || first == "class" || if (i)
first == "respawn" || first == "crash-threshold" || ctx += " -> ";
first == "crash-window" || first == "setenv" || ctx += chain[i];
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") throw std::runtime_error(
cur_svc->uid = toks[2]; "cannot open config file: " + imp +
else if (first == "group") { (ctx.empty() ? "" : " (imported from " + ctx + ")"));
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. try {
continue; parse_rc_stream(cfg, *child, imp, chain, seen, opener);
} } catch (const std::exception& e) {
throw std::runtime_error(std::string(e.what()) + " (at " +
// action command (must be inside an action section). file + ":" + std::to_string(line) +
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;
} }
continue;
throw std::runtime_error(file + ":" + std::to_string(line) +
": unexpected directive '" + first + "'");
} }
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<std::istream> operator()(const std::string& path) const {
return std::unique_ptr<std::istream>(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<std::string>& chain,
std::vector<std::string>& 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<std::string>& files) {
Config cfg;
cfg.sources = files;
std::vector<std::string> chain; // files currently being parsed
std::vector<std::string> 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; return cfg;
} }

View file

@ -1,7 +1,7 @@
// config.hpp - data model for the bajia .rc language. // config.hpp - data model for the bajia .rc language.
// //
// The language is a small, embedded-oriented dialect inspired by Android init. // the language is a small, embedded-oriented dialect inspired by Android init.
// Two top-level constructs: // two top-level constructs:
// //
// service NAME /path/to/exec args... // service NAME /path/to/exec args...
// user = root # uid after the privilege drop // user = root # uid after the privilege drop
@ -21,24 +21,22 @@
// //
// TRIGGER events: early-init, init, boot, shutdown, property:<name>=<value>, // TRIGGER events: early-init, init, boot, shutdown, property:<name>=<value>,
// service-started:<name>, service-stopped:<name>. // service-started:<name>, service-stopped:<name>.
//
// 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 #pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
namespace bajia { namespace bajia {
// --------------------------------------------------------------------------
// version // version
// --------------------------------------------------------------------------
constexpr const char* kBajiaVersion = "0.1"; constexpr const char* kBajiaVersion = "0.1";
// -------------------------------------------------------------------------- // service
// Service
// --------------------------------------------------------------------------
enum class RespawnPolicy { enum class RespawnPolicy {
Never, Never,
OnFailure, OnFailure,
@ -62,7 +60,7 @@ struct Service {
std::vector<std::string> env; // "K=V" pairs std::vector<std::string> env; // "K=V" pairs
std::string seclabel; // SELinux exec context (optional) 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. // because its definition changed; respawn it once with the new definition.
bool restart_on_reap = false; bool restart_on_reap = false;
@ -72,9 +70,7 @@ struct Service {
bool running = false; 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 { struct Command {
enum class Kind { enum class Kind {
Start, Start,
@ -99,9 +95,7 @@ struct Action {
std::vector<Command> commands; std::vector<Command> commands;
}; };
// -------------------------------------------------------------------------- // config: everything parsed from all loaded .rc files.
// Config: everything parsed from all loaded .rc files.
// --------------------------------------------------------------------------
struct Config { struct Config {
std::vector<Service> services; std::vector<Service> services;
std::vector<Action> actions; std::vector<Action> actions;
@ -113,7 +107,7 @@ struct Config {
const Service* find_service(const std::string& name) const; 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). // malformed input (reported with file:line context).
Config parse_config(const std::vector<std::string>& files); Config parse_config(const std::vector<std::string>& files);

View file

@ -14,6 +14,10 @@
#include <unistd.h> #include <unistd.h>
#include <vector> #include <vector>
#if defined(__GLIBC__)
#include <malloc.h>
#endif
using namespace bajia; using namespace bajia;
namespace { namespace {
@ -87,6 +91,14 @@ int main(int argc, char** argv) {
// logger targets the console; falls back to stderr until the console is // logger targets the console; falls back to stderr until the console is
// ready. // ready.
log_init("/dev/console", LogLevel::Info); 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, log_status(LogStatus::Banner,
std::string("Welcome to bajia ") + kBajiaVersion); std::string("Welcome to bajia ") + kBajiaVersion);
log_info("init", "loaded ", std::to_string(files.size()), " config file(s), ", log_info("init", "loaded ", std::to_string(files.size()), " config file(s), ",

View file

@ -30,6 +30,9 @@
#ifdef BAJIA_SELINUX #ifdef BAJIA_SELINUX
#include <selinux/selinux.h> #include <selinux/selinux.h>
#endif #endif
#if defined(__GLIBC__)
#include <malloc.h>
#endif
namespace bajia { namespace bajia {
@ -222,11 +225,10 @@ void Supervisor::spawn_service(Service& svc, bool missing_ok) {
} }
// environment: inherit, then apply K=V entries. // environment: inherit, then apply K=V entries.
std::vector<std::string> kvs = svc.env;
std::vector<char*> envp; std::vector<char*> envp;
for (char** e = environ; e && *e; ++e) for (char** e = environ; e && *e; ++e)
envp.push_back(*e); envp.push_back(*e);
for (auto& kv : kvs) for (const auto& kv : svc.env)
envp.push_back(const_cast<char*>(kv.c_str())); envp.push_back(const_cast<char*>(kv.c_str()));
envp.push_back(nullptr); envp.push_back(nullptr);
@ -395,6 +397,12 @@ void Supervisor::reload_config() {
// are not replayed. // are not replayed.
config_.actions = std::move(fresh.actions); config_.actions = std::move(fresh.actions);
config_.hostname = std::move(fresh.hostname); 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() { 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) { if (::setenv(cmd.args[0].c_str(), cmd.args[1].c_str(), 1) != 0) {
log_status(LogStatus::Failed, log_status(LogStatus::Failed,
"Failed to set environment variable " + cmd.args[0] + "Failed to set environment variable " + cmd.args[0] +
": " + std::strerror(errno)); ": " + std::strerror(errno));
return false; return false;
} }
return true; return true;

39
tests/fuzz_config.cpp Normal file
View file

@ -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 <cstddef>
#include <cstdint>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
namespace {
struct NoImports {
std::unique_ptr<std::istream> 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<const char*>(data), size);
bajia::Config cfg;
std::vector<std::string> chain;
std::vector<std::string> 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;
}

277
tests/unit_tests.cpp Normal file
View file

@ -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 <cstdio>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
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<std::string, std::string> files;
std::unique_ptr<std::istream> operator()(const std::string& path) const {
auto it = files.find(path);
if (it == files.end())
return nullptr;
return std::make_unique<std::istringstream>(it->second);
}
};
Config parse_string(const std::string& text, const VirtualFS& fs,
const std::string& name = "/t/main.rc") {
Config cfg;
std::vector<std::string> 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<uid_t>(-1));
CHECK(resolve_group("1") == 1);
CHECK(resolve_group("garbage-group-name") == static_cast<gid_t>(-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;
}

209
tools/fuzz.py Executable file
View file

@ -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())

2
tools/lsan.supp Normal file
View file

@ -0,0 +1,2 @@
# LeakSanitizer suppressions for bajia dev/test tooling.
leak:tsocks_once

View file

@ -273,7 +273,8 @@ on boot
""" """
def build_initramfs(init: Path, busybox: Path, rc_text: str, root_password: str | None, 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"): if not shutil.which("cpio"):
sys.exit("cpio not found (install cpio)") sys.exit("cpio not found (install cpio)")
root = Path(tempfile.mkdtemp(prefix="bajia-root-")) 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" "nobody:x:65534:\n"
"daemon:x:1:\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 # 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 # 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, # "/" 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") "build it static, cache in ~/.cache/bajia")
ap.add_argument("--config", type=Path, ap.add_argument("--config", type=Path,
help="use this init.rc instead of the bundled test config") 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, ap.add_argument("--root-password", default=None,
help="password for the root account in the guest " help="password for the root account in the guest "
"(default: passwordless login)") "(default: passwordless login)")
@ -435,8 +447,20 @@ def main() -> int:
"safest inside an initramfs") "safest inside an initramfs")
rc_text = args.config.read_text() if args.config else DEFAULT_RC 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, 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)") print("initramfs:", initrd, f"({initrd.stat().st_size / 1024:.0f} KB)")
cmd = qemu_command(kernel, initrd, args) cmd = qemu_command(kernel, initrd, args)