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
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).

View file

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

View file

@ -1,7 +1,9 @@
// config.cpp - parser for the bajia .rc language.
#include "config.hpp"
#include <algorithm>
#include <fstream>
#include <memory>
namespace bajia {
@ -13,6 +15,9 @@ std::vector<std::string> tokenize(const std::string& line) {
std::vector<std::string> 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<std::string>& files) {
Config cfg;
cfg.sources = files;
namespace {
// resolve an import path relative to the file containing the directive
// (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;
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<std::istream> 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<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;
}

View file

@ -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:<name>=<value>,
// 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
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <vector>
namespace bajia {
// --------------------------------------------------------------------------
// version
// --------------------------------------------------------------------------
constexpr const char* kBajiaVersion = "0.1";
// --------------------------------------------------------------------------
// Service
// --------------------------------------------------------------------------
// service
enum class RespawnPolicy {
Never,
OnFailure,
@ -62,7 +60,7 @@ struct Service {
std::vector<std::string> 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<Command> commands;
};
// --------------------------------------------------------------------------
// Config: everything parsed from all loaded .rc files.
// --------------------------------------------------------------------------
// config: everything parsed from all loaded .rc files.
struct Config {
std::vector<Service> services;
std::vector<Action> 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<std::string>& files);

View file

@ -14,6 +14,10 @@
#include <unistd.h>
#include <vector>
#if defined(__GLIBC__)
#include <malloc.h>
#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), ",

View file

@ -30,6 +30,9 @@
#ifdef BAJIA_SELINUX
#include <selinux/selinux.h>
#endif
#if defined(__GLIBC__)
#include <malloc.h>
#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<std::string> kvs = svc.env;
std::vector<char*> 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<char*>(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;

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,
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)