Crafter.Review/tests/Platform/main.cpp
Jorijn van der Graaf aaa0bb1059
fix(platform): in the Flatpak a pass's shell is bash
Flatpak sets SHELL to /bin/sh, the host's (zsh here) being no file of
the Sdk's, and claude's Bash tool runs commands with bash or zsh. In the
sandbox SHELL becomes /bin/bash unless it names a bash or zsh that is
there. Found by the sandbox checks: a pass's environment had SHELL=/bin/sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:09:40 +01:00

234 lines
12 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The machine as the driver sees it.
//
// - Shared files: an atomic write never leaves half a file and gives the
// temp file the mode asked for; LockedUpdate re-reads under the lock, keeps
// keys it does not know, falls back on a damaged file, and serialises
// concurrent writers — including another process holding flock(2) on the
// same side file, as every driver on the machine does.
// - /proc: the start time read after the last ')' (a command name may hold
// spaces and parentheses), a zombie reading as gone, MemInfo in MB.
// - Identity: natively a driver's own pid and start time; in the sandbox the
// instance's host pid from bwrapinfo.json with the start time of /proc/1.
// On a fake /proc and a fake instance directory, because the suite itself
// runs inside `flatpak build` in the Flatpak.
// - The lifetime lock: held while its driver lives, refused to a second
// driver under the same name, gone when it ends, and a stale one reads as
// dead.
// - The host XDG directories coming back in the sandbox.
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
import std;
import Crafter.Review;
namespace fs = std::filesystem;
using namespace Crafter::Review;
using namespace std::chrono_literals;
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
void Write(const fs::path& path, std::string_view text) {
fs::create_directories(path.parent_path());
std::ofstream(path, std::ios::binary) << text;
}
// A /proc/<pid>/stat whose command name is designed to trip a naive split.
std::string Stat(std::int64_t pid, char state, std::int64_t start) {
std::string fields = std::format("{} (we (ird) name) {} 1", pid, state);
for (std::int32_t field = 5; field <= 21; ++field) fields += " 0";
return std::format("{} {} 0 0\n", fields, start);
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / std::format("crafter-review-platform-{}", getpid());
fs::remove_all(dir);
fs::create_directories(dir);
Platform::SetMode(Platform::Mode::Native);
{
const mode_t previous = umask(022);
const fs::path file = dir / "atomic.json";
Platform::WriteAtomic(file, "{\"a\": 1}", 0600);
struct stat info {};
stat(file.c_str(), &info);
Check(Platform::ReadFile(file) == "{\"a\": 1}", "the atomic write lands");
Check((info.st_mode & 0777) == 0600, std::format("with the mode asked for ({:o})", info.st_mode & 0777));
Check(!fs::exists(std::format("{}.{}.tmp", file.string(), Platform::PublishedPid())), "and leaves no temp file");
umask(previous);
}
{
const fs::path file = dir / "board.json";
Write(file, "{\"version\": 1, \"cost7d\": 3.5, \"seats\": {}}");
const Json seen = Platform::LockedUpdate(file, [](Json& data) {
data["seats"]["local"] = { { "id", "local" } };
return true;
}, Json::object(), 0600, {});
const Json back = Json::parse(*Platform::ReadFile(file));
Check(back["cost7d"] == 3.5 && back["seats"]["local"]["id"] == "local", "a key this driver does not know survives the rewrite");
Check(*Platform::ReadFile(file) == DumpFile(back), "written in DumpFile's layout");
Check(seen == back, "LockedUpdate returns what the change saw");
Write(file, "{not json");
const Json fallback = { { "version", 1 } };
Platform::LockedUpdate(file, [](Json& data) {
return data.contains("version");
}, fallback, 0600, {});
Check(Json::parse(*Platform::ReadFile(file)) == fallback, "a damaged file is replaced by the fallback the change kept");
const std::string before = *Platform::ReadFile(file);
Platform::LockedUpdate(file, [](Json& data) {
data["ignored"] = true;
return false;
}, Json::object(), 0600, {});
Check(*Platform::ReadFile(file) == before, "a change that returns false leaves the file alone");
}
{
// Two writers in one process: flock is per open file description, so
// two FileLocks exclude each other just as two processes would.
const fs::path file = dir / "counter.json";
Write(file, "{\"n\": 0}");
auto bump = [&file] {
for (std::int32_t i = 0; i < 100; ++i) {
Platform::LockedUpdate(file, [](Json& data) {
data["n"] = data["n"].get<std::int64_t>() + 1;
return true;
}, Json::object(), 0600, {});
}
};
std::thread a(bump);
std::thread b(bump);
a.join();
b.join();
Check(Json::parse(*Platform::ReadFile(file))["n"] == 200, "concurrent read-modify-writes lose nothing");
}
{
// Another process holds flock on the side file for a moment (flock(1),
// util-linux): the lock here must wait for it, as every driver on the
// machine shares these files.
const fs::path file = dir / "shared.json";
const fs::path lock = std::format("{}.lock", file.string());
const fs::path ready = dir / "other-holds";
try {
const Process::Child other = Process::Start({ .argv = { "flock", lock.string(), "sh", "-c", "touch \"$1\" && sleep 0.4", "sh", ready.string() } });
for (std::int32_t i = 0; i < 100 && !fs::exists(ready); ++i) std::this_thread::sleep_for(20ms);
const auto start = std::chrono::steady_clock::now();
{
const Platform::FileLock ours(lock);
Check(ours.Held(), "the lock is taken once the other process lets go");
}
const auto waited = std::chrono::steady_clock::now() - start;
Process::Wait(other.pid);
Check(waited >= 200ms, std::format("and not before: flock(2) excludes the other process's flock ({} ms)", std::chrono::duration_cast<std::chrono::milliseconds>(waited).count()));
} catch (const std::runtime_error&) {
std::println("SKIP no flock here to hold the other end of the lock");
}
}
{
const fs::path proc = dir / "proc";
Write(proc / "4242" / "stat", Stat(4242, 'S', 987654));
Write(proc / "4243" / "stat", Stat(4243, 'Z', 5));
Write(proc / "sys" / "kernel" / "random" / "boot_id", "0123456789abcdef-the-rest\n");
Write(dir / "meminfo", "MemTotal: 65536000 kB\nMemAvailable: 32768000 kB\nHugePages_Total: 0\n");
Platform::SetPaths({ .proc = proc, .flatpakInfo = dir / "no-flatpak-info", .runtimeDir = dir / "run" });
Check(Platform::ProcStart(4242) == 987654, "the start time is field 22, read after the last ')'");
Check(!Platform::ProcStart(4243).has_value(), "a zombie reads as gone");
Check(!Platform::ProcStart(4244).has_value(), "so does a pid that is not there");
Check(Platform::BootId() == "01234567", "boot_id's first eight characters");
const auto memory = Platform::MemInfo(dir / "meminfo");
Check(memory.at("MemTotal") == 64000 && memory.at("MemAvailable") == 32000 && memory.at("HugePages_Total") == 0, "meminfo in MB, unitless fields as they are");
Write(proc / std::to_string(getpid()) / "stat", Stat(getpid(), 'R', 111));
Platform::SetMode(Platform::Mode::Native);
const std::optional<Platform::Identity> native = Platform::Self();
Check(native && native->pid == getpid() && native->start == 111 && native->Key() == std::format("{}:111", getpid()), "natively a driver is its own pid and start time");
Write(dir / "flatpak-info", "[Application]\nname=net.catcrafts.CrafterReview\n\n[Instance]\ninstance-id=2509661707\napp-path=/x\n");
Write(dir / "run" / ".flatpak" / "2509661707" / "bwrapinfo.json", "{\n \"child-pid\": 416559,\n \"pid-namespace\": 4026536671\n}\n");
Write(proc / "1" / "stat", Stat(1, 'S', 841601));
Platform::SetPaths({ .proc = proc, .flatpakInfo = dir / "flatpak-info", .runtimeDir = dir / "run" });
Platform::SetMode(Platform::Mode::Sandbox);
const std::optional<Platform::Identity> sandboxed = Platform::Self();
Check(sandboxed && sandboxed->Key() == "416559:841601", "in the sandbox: the instance's host pid and /proc/1's start time");
fs::remove(dir / "run" / ".flatpak" / "2509661707" / "bwrapinfo.json");
Check(!Platform::Self().has_value(), "no bwrapinfo.json (flatpak build): no identity a host driver could check");
Platform::SetMode(Platform::Mode::Native);
}
{
const fs::path drivers = dir / "drivers";
const Platform::Identity me { .pid = 416559, .start = 841601 };
{
const Platform::LifetimeLock lock(drivers, me);
Check(fs::exists(drivers / "416559-841601.lock"), "the lifetime lock is named <pid>-<start>.lock");
Check(Platform::ProbeLifetimeLock(drivers, me.pid, me.start) == true, "a probe finds it held");
bool refused = false;
try {
const Platform::LifetimeLock second(drivers, me);
} catch (const std::runtime_error&) {
refused = true;
}
Check(refused, "a second driver under the same name is refused");
const std::vector<fs::directory_entry> entries(fs::directory_iterator(drivers), {});
Check(entries.size() == 1, "and leaves no temp file behind");
}
Check(!Platform::ProbeLifetimeLock(drivers, me.pid, me.start).has_value(), "a driver that ended leaves no lock file");
Write(Platform::LifetimeLockFile(drivers, 7, 8), "");
Check(Platform::ProbeLifetimeLock(drivers, 7, 8) == false, "a lock file nobody holds is a dead driver's");
Check(!Platform::ProbeLifetimeLock(drivers, 9, 9).has_value(), "no lock file: a driver that takes none");
}
{
const std::string home = Platform::Home();
setenv("XDG_CONFIG_HOME", "/home/x/.var/app/net.catcrafts.CrafterReview/config", 1);
setenv("XDG_CACHE_HOME", "/home/x/.var/app/net.catcrafts.CrafterReview/cache", 1);
setenv("HOST_XDG_CONFIG_HOME", "/elsewhere/config", 1);
unsetenv("HOST_XDG_CACHE_HOME");
Platform::RestoreHostXdg();
Check(Platform::GetEnv("XDG_CONFIG_HOME") == "/elsewhere/config", "a host XDG directory the host set comes back");
Check(Platform::GetEnv("XDG_CACHE_HOME") == Dyn::JoinPath(home, ".cache"), "one it left unset is the XDG default under $HOME");
Check(Platform::GetEnv("XDG_STATE_HOME") == Dyn::JoinPath(home, ".local/state"), "state too");
// The shell: the sandbox's /bin/sh becomes bash; a bash or zsh that is
// there stays.
const std::optional<std::string> shell = Platform::GetEnv("SHELL");
setenv("SHELL", "/bin/sh", 1);
Platform::RestoreShell();
const std::optional<std::string> fromSh = Platform::GetEnv("SHELL");
setenv("SHELL", "/no/such/zsh", 1);
Platform::RestoreShell();
const std::optional<std::string> fromMissing = Platform::GetEnv("SHELL");
setenv("SHELL", "/bin/bash", 1);
Platform::RestoreShell();
const std::optional<std::string> fromBash = Platform::GetEnv("SHELL");
if (shell) setenv("SHELL", shell->c_str(), 1);
Check(!fs::exists("/bin/bash") || (fromSh == "/bin/bash" && fromMissing == "/bin/bash" && fromBash == "/bin/bash"),
"SHELL: /bin/sh and a shell that is not there become /bin/bash; bash stays");
}
{
const std::string today = Platform::TodayIso();
Check(today.size() == 10 && today[4] == '-' && today[7] == '-', "date.today().isoformat()");
Check(Platform::LocalTime("%H:%M:%S", 0).size() == 8, "strftime in local time");
Check(Platform::SelfExe().is_absolute(), "the running binary's path");
}
fs::remove_all(dir);
return Failures == 0 ? 0 : 1;
}