// 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 #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"); // switch_root parses as a SwitchRoot command auto sr = parse_string("on boot\n switch_root /mnt/root /sbin/init --stage2\n", fs); CHECK_EQ(sr.actions.size(), 1u); CHECK_EQ(sr.actions[0].commands.size(), 1u); CHECK(sr.actions[0].commands[0].kind == Command::Kind::SwitchRoot); CHECK_EQ(sr.actions[0].commands[0].args.size(), 3u); CHECK_EQ(sr.actions[0].commands[0].args[0], std::string("/mnt/root")); CHECK_EQ(sr.actions[0].commands[0].args[1], std::string("/sbin/init")); CHECK_EQ(sr.actions[0].commands[0].args[2], std::string("--stage2")); // `depends = A B ...` is a repeatable, multi-valued service option auto dp = parse_string( "service web /usr/sbin/httpd\n" " depends = net\n" " depends = db cache\n", fs); CHECK_EQ(dp.services.size(), 1u); CHECK_EQ(dp.services[0].depends.size(), 3u); CHECK_EQ(dp.services[0].depends[0], std::string("net")); CHECK_EQ(dp.services[0].depends[1], std::string("db")); CHECK_EQ(dp.services[0].depends[2], std::string("cache")); // setprop/getprop action commands and `on property:` triggers auto pp = parse_string( "on boot\n" " setprop net.up 1\n" " getprop net.up\n" "on property:net.up=1\n" " log network is up\n", fs); CHECK_EQ(pp.actions.size(), 2u); // property trigger string is captured verbatim CHECK_EQ(pp.actions[1].trigger, std::string("property:net.up=1")); CHECK_EQ(pp.actions[0].commands[0].kind, Command::Kind::Setprop); CHECK_EQ(pp.actions[0].commands[0].args.size(), 2u); CHECK_EQ(pp.actions[0].commands[0].args[0], std::string("net.up")); CHECK_EQ(pp.actions[0].commands[0].args[1], std::string("1")); CHECK(pp.actions[0].commands[1].kind == Command::Kind::Getprop); // `logfile = PATH` is an optional service option auto lf = parse_string( "service daemon /usr/sbin/daemon\n" " logfile = /var/log/daemon.log\n", fs); CHECK_EQ(lf.services.size(), 1u); CHECK_EQ(lf.services[0].logfile, std::string("/var/log/daemon.log")); auto nl = parse_string( "service plain /usr/sbin/plain\n", fs); CHECK_EQ(nl.services[0].logfile, std::string("")); } 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")); } void test_crash_window() { // a service that crashes repeatedly inside the window gets throttled at // the threshold, then recovers once the window elapses. using Clock = std::chrono::steady_clock; using namespace std::chrono; Service svc; svc.name = "boom"; svc.crash_threshold = 2; svc.crash_window_secs = 10; auto t = Clock::now(); // first two crashes within the window are allowed CHECK(record_crash(svc, t) == CrashAction::Respawn); CHECK_EQ(svc.crash_count, 1); CHECK(!svc.throttled); CHECK(record_crash(svc, t + seconds(1)) == CrashAction::Respawn); CHECK_EQ(svc.crash_count, 2); CHECK(!svc.throttled); // third crash crosses the threshold -> throttle CHECK(record_crash(svc, t + seconds(2)) == CrashAction::Throttle); CHECK_EQ(svc.crash_count, 3); CHECK(svc.throttled); // Once a fresh window opens (the crash was long after the last one), the // count resets but `throttled` stays set until an explicit start -- the // reaper stops respawning a throttled service, so the only recovery is a // manual `start`/`restart` (reset_crash_state below). CHECK(record_crash(svc, t + seconds(11)) == CrashAction::Respawn); CHECK_EQ(svc.crash_count, 1); CHECK(svc.throttled); // a manual start resets the throttle, opening a fresh window. reset_crash_state(svc); CHECK_EQ(svc.crash_count, 0); CHECK(!svc.throttled); // threshold=1 means a single crash then throttle. Service one; one.crash_threshold = 1; one.crash_window_secs = 10; CHECK(record_crash(one, t) == CrashAction::Respawn); CHECK(record_crash(one, t + seconds(1)) == CrashAction::Throttle); // a degenerate (unset) window never throttles. Service none; none.crash_threshold = 0; none.crash_window_secs = 0; for (int i = 0; i < 100; ++i) CHECK(record_crash(none, t + seconds(i)) == CrashAction::Respawn); CHECK_EQ(none.crash_count, 0); CHECK(!none.throttled); } void test_dependencies() { Config cfg; auto add = [&](const std::string& name, std::initializer_list deps) { Service s; s.name = name; for (auto* d : deps) s.depends.emplace_back(d); cfg.services.push_back(std::move(s)); }; add("a", {}); add("b", {"a"}); add("c", {"b"}); add("d", {"b", "c"}); // depends on a chain + a sibling add("e", {}); // linear chain: c -> b -> a std::vector order; CHECK(resolve_dependencies(cfg, "c", order) == DepResolve::Ok); CHECK_EQ(order.size(), 3u); CHECK(order[0] == "a"); CHECK(order[1] == "b"); CHECK(order[2] == "c"); // declared order is preserved; shared deps appear once (diamond / DAG) order.clear(); CHECK(resolve_dependencies(cfg, "d", order) == DepResolve::Ok); CHECK_EQ(order.size(), 4u); CHECK(order[0] == "a"); // b's dep CHECK(order[1] == "b"); // first declared dep of d CHECK(order[2] == "c"); // second declared dep of d (and c->b already done) CHECK(order[3] == "d"); // a service with no deps yields just itself order.clear(); CHECK(resolve_dependencies(cfg, "e", order) == DepResolve::Ok); CHECK_EQ(order.size(), 1u); CHECK(order[0] == "e"); // unknown dependency Service ghost; ghost.name = "ghost"; ghost.depends.push_back("nope"); cfg.services.push_back(std::move(ghost)); order.clear(); CHECK(resolve_dependencies(cfg, "ghost", order) == DepResolve::Unknown); // self-cycle Service self; self.name = "self"; self.depends.push_back("self"); cfg.services.push_back(std::move(self)); order.clear(); CHECK(resolve_dependencies(cfg, "self", order) == DepResolve::Cycle); // mutual cycle a<->b Service m1, m2; m1.name = "m1"; m1.depends.push_back("m2"); m2.name = "m2"; m2.depends.push_back("m1"); cfg.services.push_back(std::move(m1)); cfg.services.push_back(std::move(m2)); order.clear(); CHECK(resolve_dependencies(cfg, "m1", order) == DepResolve::Cycle); } } // namespace int main() { test_tokenize(); test_parse_respawn(); test_resolve_import_path(); test_parse_basic(); test_imports(); test_supervisor_helpers(); test_crash_window(); test_dependencies(); std::printf("%d checks, %d failures\n", g_checks, g_failures); return g_failures == 0 ? 0 : 1; }