From 8c9834e9beff9cda8b53de09f405c78cee566b0e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 1 Oct 2026 20:10:46 +0800 Subject: [PATCH 1/3] A started program receives the three streams and its grants, and nothing else Clause 7.13 of the specification, and the port of openkal-linux 0.15.1's change to this kernel. This kernel starts a program by its whole name, so no program kept a descriptor for the directory it was found in; the other departures were present here as they were there. What the caller itself inherited without the close-on-exec flag is no longer passed on. This kernel has no close_range: the descriptors are read from /dev/fd and each one from the first position above the grants is marked; where /dev/fd cannot be read, every number below the descriptor limit is marked, an unlimited limit being taken as OPEN_MAX. Grants arrive. Every source is moved above the positions being filled before any is placed, so one placement no longer overwrites another grant, a stream, or the directory the program runs in; a grant already at its own position no longer keeps the flag that closed it. The names travel in KAL_PREOPENS={;,,}, spelled as openkal-linux spells it and bound to the started process by its pid, and kal_fs_preopen in the started program enumerates exactly the grants, in order and by name. A count of zero leaves none; not asking leaves the working directory and / as before. tests/conformance_spawn.cpp observes each of these from the started program. Run on Linux against the same sources before the change, seven of its observations fail. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/fs.cpp | 69 +++++++++- src/process.cpp | 221 ++++++++++++++++++++++++++++--- src/sys.h | 7 + tests/conformance_spawn.cpp | 256 ++++++++++++++++++++++++++++++++++++ 4 files changed, 535 insertions(+), 18 deletions(-) create mode 100644 tests/conformance_spawn.cpp diff --git a/src/fs.cpp b/src/fs.cpp index cd564cb..ae7f7eb 100644 --- a/src/fs.cpp +++ b/src/fs.cpp @@ -3,6 +3,8 @@ #include #include +namespace okm { extern char** g_envp; } + namespace { // The directories this implementation supplies. A hosted system does not @@ -19,11 +21,74 @@ struct preopen { const char* name; kal_uintptr len; okm_uptr handle; }; char g_cwd[1024]; +constexpr kal_uintptr kMaxGrants = 16; + +// THE DIRECTORIES A STARTER GRANTED, WHEN THE PROGRAM WAS STARTED WITH GRANTS. +// +// They arrive as descriptors at 3 and upward, and their names in the variable +// `kal_process_spawn' writes (see `vector::build_env' in process.cpp): +// +// KAL_PREOPENS={;,,} +// +// The value is read only when `' is this process, so one inherited through +// a program that does not read it is not mistaken for a grant. A descriptor that +// is not a directory is reported as a preopen this program may not use, which is +// how an entry that could not be opened is reported anyway. Each descriptor is +// marked to close on replacement as it is taken over, so that a grant reaches +// the program it was made to and not that program's own children (SPEC.md +// clause 7.13). The names point into the environment, which lives as long as +// the program does. +// +// Answers false when there is no such value, which leaves the directories this +// implementation supplies by default. +bool granted(preopen* t, kal_uintptr* n) { + constexpr char key[] = "KAL_PREOPENS="; + constexpr okm_uptr key_len = sizeof key - 1; + const char* v = nullptr; + for (char** e = okm::g_envp; e && *e; ++e) { + okm_uptr i = 0; + while (i < key_len && (*e)[i] == key[i]) ++i; + if (i == key_len) { v = *e + key_len; break; } + } + if (v == nullptr) return false; + + auto number = [&](okm_uptr& out) { + if (*v < '0' || *v > '9') return false; + out = 0; + while (*v >= '0' && *v <= '9') out = out * 10 + static_cast(*v++ - '0'); + return true; + }; + okm_uptr pid = 0; + if (!number(pid) || static_cast(pid) != okm::sys(okm::nr_getpid)) return false; + + kal_uintptr k = 0; + while (*v == ';' && k < kMaxGrants) { + ++v; + okm_uptr fd = 0, len = 0; + if (!number(fd) || *v++ != ',' || !number(len) || *v++ != ',') return false; + for (okm_uptr i = 0; i < len; ++i) if (v[i] == '\0') return false; + + okm::kstat64 st{}; + const bool dir = !okm::failed(okm::sys(okm::nr_fstat64, static_cast(fd), + reinterpret_cast(&st))) + && (okm::stat_mode(st) & okm::s_ifmt) == okm::s_ifdir; + if (dir) okm::sys(okm::nr_fcntl, static_cast(fd), okm::f_setfd, okm::fd_cloexec); + t[k++] = { v, len, dir ? okm::pack(static_cast(fd)) : 0u }; + v += len; + } + if (*v != '\0') return false; + *n = k; + return true; +} + preopen* table(kal_uintptr* count) { - static preopen t[2]; + static preopen t[kMaxGrants]; + static kal_uintptr n = 2; static bool opened = false; if (!opened) { opened = true; + if (granted(t, &n)) { if (count) *count = n; return t; } + n = 2; // The name of the working directory. This kernel has no call that // reports it: the directory is opened and asked what name it was // reached by, which is the operation this system supplies instead and @@ -51,7 +116,7 @@ preopen* table(kal_uintptr* count) { t[0] = { g_cwd, cwd_len, okm::failed(fd0) ? 0u : okm::pack(static_cast(fd0)) }; t[1] = { "/", 1, okm::failed(fd1) ? 0u : okm::pack(static_cast(fd1)) }; } - if (count) *count = 2; + if (count) *count = n; return t; } diff --git a/src/process.cpp b/src/process.cpp index f7feca2..d7dcb97 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -17,6 +17,75 @@ namespace { constexpr kal_uintptr kMaxEntries = 512; +constexpr char kPreopens[] = "KAL_PREOPENS="; +constexpr okm_uptr kPreopensLen = sizeof kPreopens - 1; +constexpr okm_uptr kPidDigits = 10; + +bool names_preopens(const char* s, kal_uintptr n) { + if (n < kPreopensLen) return false; + for (okm_uptr i = 0; i < kPreopensLen; ++i) + if (s[i] != kPreopens[i]) return false; + return true; +} + +okm_uptr digits(okm_uptr v) { + okm_uptr n = 1; + while (v >= 10) { v /= 10; ++n; } + return n; +} + +char* put_decimal(char* o, okm_uptr v) { + char b[24]; int i = 0; + do { b[i++] = static_cast('0' + v % 10); v /= 10; } while (v); + while (i) *o++ = b[--i]; + return o; +} + +// Every descriptor from `from' upward is marked to close when the image is +// replaced. This kernel has no `close_range'. What it has is `/dev/fd', which +// lists the descriptors of the process reading it, so exactly those are marked; +// where it cannot be read, every number below the descriptor limit is tried, +// and an unlimited limit is taken as OPEN_MAX, which is the bound this system's +// own C library applies in the same case. +void close_on_exec_from(okm_long from) { + const okm_long dir = okm::sys(okm::nr_openat, okm::at_fdcwd, + reinterpret_cast("/dev/fd"), + okm::o_rdonly | okm::o_directory | okm::o_cloexec, 0); + if (!okm::failed(dir)) { + alignas(8) char buf[4096]; + okm_i64 position = 0; + for (;;) { + const okm_long n = okm::sys(okm::nr_getdirentries64, dir, + reinterpret_cast(buf), + static_cast(sizeof buf), + reinterpret_cast(&position)); + if (okm::interrupted(n)) continue; + if (okm::failed(n) || n == 0) break; + for (okm_long at = 0; at < n; ) { + const auto* d = reinterpret_cast(buf + at); + okm_long fd = 0; bool number = d->namlen > 0; + for (unsigned short c = 0; c < d->namlen; ++c) { + if (d->name[c] < '0' || d->name[c] > '9') { number = false; break; } + fd = fd * 10 + (d->name[c] - '0'); + } + if (number && fd >= from && fd != dir) + okm::sys(okm::nr_fcntl, fd, okm::f_setfd, okm::fd_cloexec); + if (d->reclen == 0) break; + at += d->reclen; + } + } + okm::sys(okm::nr_close, dir); + return; + } + + okm_u64 lim[2] = { 256, 256 }; + okm::sys(okm::nr_getrlimit, okm::rlimit_nofile, reinterpret_cast(lim)); + const okm_u64 bound = lim[0] > static_cast(okm::open_max) + ? static_cast(okm::open_max) : lim[0]; + for (okm_long fd = from; static_cast(fd) < bound; ++fd) + okm::sys(okm::nr_fcntl, fd, okm::f_setfd, okm::fd_cloexec); +} + // The counted arrays the interface takes become the terminated arrays this // kernel takes. Every allocation happens before the program is duplicated, so // that the duplicate performs nothing but a few calls: a duplicate of a program @@ -49,6 +118,83 @@ struct vector { return true; } + // THE ENVIRONMENT, WHICH CARRIES THE NAMES OF THE GRANTED DIRECTORIES. + // + // A granted directory reaches the started program as a descriptor, and a + // descriptor carries no name. The names travel in one variable, + // + // KAL_PREOPENS={;,,} + // + // which the started program reads when it first enumerates its preopens + // (`table' in fs.cpp). The arrangement and its spelling are openkal-linux's, + // which takes them from systemd's LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID; + // one spelling on both kernels is what lets a program read its grants + // without knowing which implementation started it. It is bound to the + // process it was written for in the same way: `' is ten digits written + // by the duplicate once it knows its own number, so a value inherited + // through a program that does not read it, a shell for example, names no + // one when it arrives one generation further down. + // + // Every variable of that name the caller supplied is left out, so a value + // is never forwarded; one is added only when the caller asked for grants, + // and asking for none (a count of zero) is a value with no entries. + char* pid_digits = nullptr; + + bool build_env(const char** items, const kal_uintptr* lens, kal_uintptr n, + const kal_preopen* grants, kal_uintptr g) { + if (n > kMaxEntries) { ok = false; return false; } + kal_uintptr kept = 0; + okm_uptr total = 0; + for (kal_uintptr i = 0; i < n; ++i) { + if (names_preopens(items[i], lens[i])) continue; + ++kept; total += lens[i] + 1; + } + okm_uptr extra = 0; + if (grants) { + extra = kPreopensLen + kPidDigits + 1; + for (kal_uintptr i = 0; i < g; ++i) + extra += 3 + digits(3 + i) + digits(grants[i].len) + grants[i].len; + } + slots_bytes = (kept + (grants ? 1 : 0) + 1) * sizeof(char*); + bytes_bytes = total + extra == 0 ? 1 : total + extra; + slots = static_cast(kal_alloc(slots_bytes, alignof(char*))); + bytes = static_cast(kal_alloc(bytes_bytes, 1)); + if (!slots || !bytes) { ok = false; return false; } + okm_uptr at = 0, k = 0; + for (kal_uintptr i = 0; i < n; ++i) { + if (names_preopens(items[i], lens[i])) continue; + okm::copy(bytes + at, items[i], lens[i]); + bytes[at + lens[i]] = '\0'; + slots[k++] = bytes + at; + at += lens[i] + 1; + } + if (grants) { + char* o = bytes + at; + slots[k++] = o; + okm::copy(o, kPreopens, kPreopensLen); o += kPreopensLen; + pid_digits = o; + okm::fill(o, '0', kPidDigits); o += kPidDigits; + for (kal_uintptr i = 0; i < g; ++i) { + *o++ = ';'; o = put_decimal(o, 3 + i); + *o++ = ','; o = put_decimal(o, grants[i].len); + *o++ = ','; + okm::copy(o, grants[i].name, grants[i].len); o += grants[i].len; + } + *o = '\0'; + } + slots[k] = nullptr; + return true; + } + + // In the duplicate, which is the first point at which the number is known. + void stamp(okm_long pid) const { + if (!pid_digits) return; + for (int i = static_cast(kPidDigits) - 1; i >= 0; --i) { + pid_digits[i] = static_cast('0' + pid % 10); + pid /= 10; + } + } + ~vector() { if (slots) kal_free(slots, slots_bytes, alignof(char*)); if (bytes) kal_free(bytes, bytes_bytes, 1); @@ -205,18 +351,27 @@ int kal_process_spawn(const kal_spawn* how, okm::terminated p(path, path_len); if (!p.ok) return kal_err_invalid; - vector args, envs; - if (!args.build(argv, argv_lens, argc)) return kal_err_no_memory; - if (!envs.build(envp, envp_lens, envc)) return kal_err_no_memory; - + // Resolved before the duplication, because a failure after it would leave a + // duplicate to be reaped and a caller with an error it cannot act upon. A + // name travels in the environment, which cannot carry a zero byte. constexpr kal_uintptr max_grants = 16; if (how->grant_count > max_grants) return kal_err_invalid; - int granted[max_grants]; + okm_long granted[max_grants]; for (kal_uintptr i = 0; i < how->grant_count; ++i) { - granted[i] = okm::unpack(how->grants[i].dir.h); - if (granted[i] < 0) return kal_err_invalid; + const kal_preopen& g = how->grants[i]; + const int fd = okm::unpack(g.dir.h); + if (fd < 0) return kal_err_invalid; + if (g.len > okm::max_name || (g.len > 0 && g.name == nullptr)) return kal_err_invalid; + for (kal_uintptr c = 0; c < g.len; ++c) + if (g.name[c] == '\0') return kal_err_invalid; + granted[i] = fd; } + vector args, envs; + if (!args.build(argv, argv_lens, argc)) return kal_err_no_memory; + if (!envs.build_env(envp, envp_lens, envc, how->grants, how->grant_count)) + return kal_err_no_memory; + // THE PROGRAM'S NAME IS MADE ABSOLUTE BEFORE THE DIRECTORY MOVES, because // with no `execveat' the two things `base' and `work' now mean cannot both be // served by one `fchdir'. `F_GETPATH' answers the path of an open directory, @@ -264,14 +419,48 @@ int kal_process_spawn(const kal_spawn* how, // reason, and what happens to an implementation that tests the first alone, // are in src/sys.h beside the call. if (is_duplicate) { - if (in != 0) okm::sys(okm::nr_dup2, in, 0); - if (ou != 0) okm::sys(okm::nr_dup2, ou, 1); - if (er != 0) okm::sys(okm::nr_dup2, er, 2); - - for (kal_uintptr i = 0; i < how->grant_count; ++i) { - const okm_long want = static_cast(3 + i); - if (granted[i] != want) okm::sys(okm::nr_dup2, granted[i], want); - } + // WHAT THE STARTED PROGRAM RECEIVES IS THE THREE STREAMS AND THE GRANTED + // DIRECTORIES, AND NOTHING ELSE (SPEC.md clause 7.13). + // + // Every source is first moved above the positions being filled, because + // placing one source on its position must not overwrite another that + // still has to be read: a granted directory, a stream or the working + // directory may each occupy a number between 0 and 3+n. Placing them one + // at a time where they stood let one grant arrive as a copy of another, + // and let a placement overwrite the directory the program was to run + // in. A source that is moved has its own descriptor flag; `dup2' onto a + // different number clears it on the copy it places, including where the + // source happened to be the position itself --- which `dup2' left + // untouched, still marked to close, so that the grant never arrived. + const okm_long top = static_cast(3 + how->grant_count); + auto lift = [&](okm_long fd) -> okm_long { + const okm_long r = okm::sys(okm::nr_fcntl, fd, okm::f_dupfd_cloexec, top); + if (okm::failed(r)) { + report.say(r); + okm::sys(okm::nr_exit, 127); + for (;;) { } + } + return r; + }; + const okm_long sin = in != 0 ? lift(in) : 0; + const okm_long sou = ou != 0 ? lift(ou) : 0; + const okm_long ser = er != 0 ? lift(er) : 0; + for (kal_uintptr i = 0; i < how->grant_count; ++i) granted[i] = lift(granted[i]); + const okm_long work = lift(w); + + if (sin != 0) okm::sys(okm::nr_dup2, sin, 0); + if (sou != 0) okm::sys(okm::nr_dup2, sou, 1); + if (ser != 0) okm::sys(okm::nr_dup2, ser, 2); + for (kal_uintptr i = 0; i < how->grant_count; ++i) + okm::sys(okm::nr_dup2, granted[i], static_cast(3 + i)); + + // What the CALLER itself inherited without the flag is not a handle it + // granted. A program that starts others inside a sandbox passes every + // descriptor it holds to them, and one of those reaching beyond the + // sandbox is exactly what the sandbox was for. + close_on_exec_from(top); + + envs.stamp(okm::sys(okm::nr_getpid)); // The directory the program RUNS in --- `whole' already carries where it // is named from, so this no longer has to serve both. @@ -280,7 +469,7 @@ int kal_process_spawn(const kal_spawn* how, // the wrong directory is the silent wrongness this pipe exists to // remove, so it is reported through the same channel an exec failure // uses. - if (const okm_long e = okm::sys(nr_fchdir, w); okm::failed(e)) { + if (const okm_long e = okm::sys(nr_fchdir, work); okm::failed(e)) { report.say(e); okm::sys(okm::nr_exit, 127); for (;;) { } diff --git a/src/sys.h b/src/sys.h index 01dcf84..3f24e39 100644 --- a/src/sys.h +++ b/src/sys.h @@ -189,6 +189,9 @@ enum : okm_long { nr_fstatat64 = 470, nr_unlinkat = 472, nr_readlinkat = 473, nr_symlinkat = 474, nr_fstatfs64 = 346, nr_sysctl = 202, nr_mkdirat = 475, + // The descriptor limit, read when the descriptors a process holds cannot be + // listed (src/process.cpp). This kernel has no `close_range'. + nr_getrlimit = 194, // openkal 0.13: whether a node may be started nr_fchmodat = 467, nr_ulock_wait = 515, nr_ulock_wake = 516, @@ -292,6 +295,10 @@ enum : okm_long { // 67 is this kernel's own value and is not the other kernel's 1030. f_dupfd_cloexec = 67, + // The resource whose limit bounds a descriptor's number, and the bound this + // system's own C library applies when that limit is unlimited. + rlimit_nofile = 8, open_max = 10240, + // sysctl: how many processors this machine runs at once. ctl_hw = 6, hw_ncpu = 3, o_creat = 0x0200, o_excl = 0x0800, o_trunc = 0x0400, o_append = 0x0008, diff --git a/tests/conformance_spawn.cpp b/tests/conformance_spawn.cpp new file mode 100644 index 0000000..034a6d1 --- /dev/null +++ b/tests/conformance_spawn.cpp @@ -0,0 +1,256 @@ +// What a started program receives: the three streams and the directories it was +// granted, and nothing else (SPEC.md clause 7.13). +// +// Each case starts a program and has the PROGRAM report what it received, since +// the claim is about the started image and not about the caller. Two programs +// are used. `/bin/sh' needs nothing to survive the start, so any descriptor +// above 2 it holds was conveyed by the start. This test itself, started again +// with `--grant-child', reads its preopens through `kal_fs_preopen', which is +// how a granted directory is defined to arrive. +// +// The three names declared below are the system's own and are reached without +// its headers, as src/unwind.cpp reaches dyld: `_NSGetExecutablePath' is how a +// program on this system learns where it is, and `realpath' makes that a name +// the root preopen can resolve. `dup2' places a descriptor WITHOUT the +// close-on-exec flag, which is the descriptor a caller inherits and did not grant. +import openkal.types; +import openkal.fs; +import openkal.stream; +import openkal.process; + +extern "C" int _NSGetExecutablePath(char* buf, unsigned* size); +extern "C" char* realpath(const char* path, char* resolved); +extern "C" int dup2(int from, int to); + +namespace { + +int failures = 0; + +kal_uintptr len(const char* s) { kal_uintptr n = 0; while (s[n]) ++n; return n; } + +bool same(const char* a, kal_uintptr n, const char* b) { + if (len(b) != n) return false; + for (kal_uintptr i = 0; i < n; ++i) if (a[i] != b[i]) return false; + return true; +} + +void say(const char* s) { kal::write(kal::err(), s, len(s)); } + +void check(bool ok, const char* what) { + if (ok) return; + ++failures; + say("FAIL: "); say(what); say("\n"); +} + +const char kMarker[] = "okm-grant-marker"; + +kal_dir working() { return kal::fs::working(); } + +kal_dir root() { + for (kal_uintptr i = 0; i < kal_fs_preopen_count(); ++i) { + kal_dir d{}; char n[8]; kal_uintptr l = 0; + if (kal_fs_preopen(i, &d, n, sizeof n, &l) == kal_ok && l == 1 && n[0] == '/') return d; + } + return kal_dir{}; +} + +bool write_marker(kal_dir d, const char* text) { + kal_file f{}; + const auto flags = kal::fs::open::write | kal::fs::open::create | kal::fs::open::truncate; + if (kal::fs::open_file(d, kMarker, sizeof kMarker - 1, flags, &f) != kal_ok) return false; + const kal_intptr r = kal_stream_write(kal_fs_stream(f), text, len(text)); + kal_fs_close_file(f); + return r == static_cast(len(text)); +} + +// --- the started side -------------------------------------------------------- +// +// argv: --grant-child { } +// is the content of the marker file in that directory, or `@' +// for a directory entry that must exist there. The status names the first +// disagreement, so that a failure in the caller says which one it was. +int grant_child(char** argv) { + const kal_uintptr n = kal_fs_preopen_count(); + if (same(argv[2], len(argv[2]), "ambient")) { + kal_dir d{}; char nm[8]; kal_uintptr l = 0; + if (n != 2) return 10; + if (kal_fs_preopen(1, &d, nm, sizeof nm, &l) != kal_ok || l != 1 || nm[0] != '/') return 11; + return 0; + } + kal_uintptr want = 0; + for (const char* c = argv[2]; *c; ++c) want = want * 10 + static_cast(*c - '0'); + if (n != want) return 20; + for (kal_uintptr i = 0; i < want; ++i) { + const char* name = argv[3 + 2 * i]; + const char* expect = argv[4 + 2 * i]; + kal_dir d{}; char nm[256]; kal_uintptr l = 0; + if (kal_fs_preopen(i, &d, nm, sizeof nm, &l) != kal_ok) return 30 + static_cast(i); + if (!same(nm, l, name)) return 40 + static_cast(i); + if (expect[0] == '@') { + kal_node_info info = kal::fs::info_for_caller(); + if (kal_fs_info(d, expect + 1, len(expect + 1), kal::fs::field::kind, 0, &info) != kal_ok + || info.kind != kal_node_directory) + return 50 + static_cast(i); + } else { + kal_file f{}; char buf[64]; + if (kal::fs::open_file(d, kMarker, sizeof kMarker - 1, kal::fs::open::read, &f) != kal_ok) + return 60 + static_cast(i); + const kal_intptr r = kal_stream_read(kal_fs_stream(f), buf, sizeof buf); + kal_fs_close_file(f); + if (r < 0 || !same(buf, static_cast(r), expect)) + return 70 + static_cast(i); + } + } + return 0; +} + +// --- the starting side ------------------------------------------------------- + +char g_self[1024]; + +// Starts this test again under the root preopen and answers the status it +// finished with, or -1 when the start itself was refused. +int run_child(const kal_preopen* grants, kal_uintptr count, + const char* const* args, kal_uintptr nargs, + const char** envp = nullptr, kal_uintptr envc = 0) { + const char* argv[16] = { "conformance_spawn", "--grant-child" }; + kal_uintptr lens[16] = { 17, 13 }; + for (kal_uintptr i = 0; i < nargs; ++i) { argv[2 + i] = args[i]; lens[2 + i] = len(args[i]); } + kal_uintptr elens[4] = {}; + for (kal_uintptr i = 0; i < envc; ++i) elens[i] = len(envp[i]); + const kal_spawn how{ root(), working(), nullptr, grants, count, 0 }; + kal_process p{}; + const char* rel = g_self + 1; + if (kal_process_spawn(&how, rel, len(rel), argv, lens, 2 + nargs, + envp, elens, envc, nullptr, &p) != kal_ok) + return -1; + int status = -1, terminated = -1; + kal_process_wait(p, &status, &terminated); + kal_process_close(p); + return terminated == 0 ? status : -1; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc > 2 && same(argv[1], len(argv[1]), "--grant-child")) return grant_child(argv); + + char raw[1024]; unsigned size = sizeof raw; + const bool named = _NSGetExecutablePath(raw, &size) == 0 && realpath(raw, g_self) != nullptr; + check(named && g_self[0] == '/' && g_self[1] != '\0', "the test can name its own program"); + check(root().h != 0, "a directory covering the file system is supplied"); + + // Two directories with distinct contents, so that a grant arriving under + // the wrong name is told apart from one arriving under the right name. + const char* da = "okm-grant-a.tmp"; + const char* db = "okm-grant-b.tmp"; + kal_fs_mkdir(working(), da, len(da)); + kal_fs_mkdir(working(), db, len(db)); + kal_dir a{}, b{}; + check(kal_fs_open_dir(working(), da, len(da), &a) == kal_ok + && kal_fs_open_dir(working(), db, len(db), &b) == kal_ok, + "two directories are made"); + check(write_marker(a, "A") && write_marker(b, "B"), "each holds its own marker"); + + // Named grants arrive in order, under their names, as the directories named. + // The second name holds both separators the variable uses. + { + const kal_preopen g[2] = { { a, "/first", 6 }, { b, "b;2,x", 5 } }; + const char* args[] = { "2", "/first", "A", "b;2,x", "B" }; + check(run_child(g, 2, args, 5) == 0, + "granted directories arrive in order, under their names, as the directories named"); + } + { + const kal_preopen g[2] = { { b, "/b", 2 }, { a, "/a", 2 } }; + const char* args[] = { "2", "/b", "B", "/a", "A" }; + check(run_child(g, 2, args, 5) == 0, "and in the other order"); + } + + // The caller's own preopens occupy the numbers grants are placed at. Placing + // one at a time where it stood made the second of these a copy of the first, + // and made the first, already at its own position, never arrive at all. + { + const kal_preopen g[2] = { { root(), "/", 1 }, { working(), "/work", 5 } }; + const char* args[] = { "2", "/", "@usr", "/work", "@okm-grant-a.tmp" }; + check(run_child(g, 2, args, 5) == 0, + "grants whose sources occupy each other's positions arrive as themselves"); + const kal_preopen h[2] = { { working(), "/work", 5 }, { root(), "/", 1 } }; + const char* hargs[] = { "2", "/work", "@okm-grant-a.tmp", "/", "@usr" }; + check(run_child(h, 2, hargs, 5) == 0, "and in the other order"); + } + + // A count of zero is a request for no preopens, which is different from not + // asking. Not asking leaves what the implementation supplies by default. + { + const kal_preopen none[1] = {}; + const char* zero[] = { "0" }; + check(run_child(none, 0, zero, 1) == 0, "a count of zero starts a program with no preopens"); + const char* ambient[] = { "ambient" }; + check(run_child(nullptr, 0, ambient, 1) == 0, + "not asking leaves the directories supplied by default"); + } + + // A value of the variable the caller supplies is not forwarded, whether or + // not it names the process: it is the implementation's to write. + { + const char* env[] = { "KAL_PREOPENS=0000000001;3,1,/" }; + const char* ambient[] = { "ambient" }; + check(run_child(nullptr, 0, ambient, 1, env, 1) == 0, + "a variable the caller supplies is not taken for a grant"); + } + + // A name travels in the environment, which cannot carry a zero byte. + { + const kal_preopen g[1] = { { a, "x\0y", 3 } }; + const char* args[] = { "1", "x", "A" }; + check(run_child(g, 1, args, 3) == -1, "a name holding a zero byte is refused"); + } + + // An ordinary program needs nothing to survive the start, so it receives + // nothing above the three streams --- including a descriptor this process + // holds without the close-on-exec flag, which is placed at 77 for the + // purpose. The glob is expanded before the loop, so the descriptor the shell + // had open to list the directory is gone by the time it is tested. + { + kal_stream mine{}, theirs{}; + const bool planted = kal_process_channel(&mine, &theirs) == kal_ok + && dup2(static_cast(theirs.h), 77) == 77; + check(planted, "a descriptor without the close-on-exec flag is held"); + + const char* body = "for f in /dev/fd/*; do [ -e \"$f\" ] || continue; " + "[ \"${f##*/}\" -gt 2 ] && { echo \"inherited $f\" >&2; exit 1; }; " + "done; exit 0"; + const char* sargv[3] = { "sh", "-c", body }; + const kal_uintptr slens[3] = { 2, 2, len(body) }; + const char* paths[2] = { "bin/sh", "usr/bin/sh" }; + const kal_spawn how{ root(), working(), nullptr, nullptr, 0, 0 }; + kal_process p{}; + int rc = kal_err_invalid; + for (int i = 0; i < 2 && rc != kal_ok; ++i) + rc = kal_process_spawn(&how, paths[i], len(paths[i]), sargv, slens, 3, + nullptr, nullptr, 0, nullptr, &p); + check(rc == kal_ok, "an ordinary program is started"); + if (rc == kal_ok) { + int status = -1, terminated = -1; + kal_process_wait(p, &status, &terminated); + kal_process_close(p); + check(terminated == 0 && status == 0, + "an ordinary program receives no descriptor above the three streams"); + } + if (planted) { + kal_process_channel_close(kal_stream{ 77 }); + kal_process_channel_close(mine); + kal_process_channel_close(theirs); + } + } + + kal_fs_remove(a, kMarker, sizeof kMarker - 1); + kal_fs_remove(b, kMarker, sizeof kMarker - 1); + kal_fs_close_dir(a); + kal_fs_close_dir(b); + kal_fs_remove(working(), da, len(da)); + kal_fs_remove(working(), db, len(db)); + + say("openkal-macos: what a started program receives\n"); + return failures == 0 ? 0 : 1; +} From f715aace98ee29315e3a3a40346f5acc1c16103f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 1 Oct 2026 20:10:46 +0800 Subject: [PATCH 2/3] 0.12.1 --- what a started program receives, and the README that says so The version is a patch: the changes bring the implementation into line with contracts already declared, and no declaration changes. The specification is named by its development line until openkal 0.14.1 is published. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- README.md | 26 ++++++++++++++++++++++++-- mcpp.toml | 4 ++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c88c11..daf97d7 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ written on the kernel's own calls. ```toml [dependencies] -openkal = "0.14.0" +openkal = "0.14.1" [target.'cfg(os = "macos")'.dependencies] -openkal-macos = "0.12.0" +openkal-macos = "0.12.1" ``` Its purpose is as much to test the specification as to be used. A specification @@ -124,6 +124,28 @@ receives, which is exactly what openkal declines to offer as an operation of its own. The caller's working directory is untouched, which is the property the interface requires. +**A started program receives the three streams and its grants, and nothing +else.** Clause 7.13. Every source is first moved above the positions being +filled, so that placing one cannot overwrite another; everything above the +grants is then marked to close on replacement, including what the calling +program itself inherited. This kernel has no `close_range`: the descriptors are +read from `/dev/fd`, which lists those of the process reading it, and where it +cannot be read every number below the descriptor limit is marked, an unlimited +limit being taken as `OPEN_MAX`. No program keeps a descriptor for its own file, +because the program is started by its whole name rather than through a +descriptor, and an interpreter reopens that name. + +**Granted directories are named in the environment.** A grant arrives as a +descriptor at 3 and upward, and its name in the variable +`KAL_PREOPENS={;,,}`, spelled as openkal-linux spells it. +`` is written by the started process itself, so a value inherited through a +program that does not read it names no one. A program started with grants +enumerates exactly those directories, the first of which is the directory it +regards as the one it was started in; a program started without them enumerates +the working directory and `/`, as before. A grant names directories to a program +that confines itself to its preopens; it does not confine a program that opens +`/` on its own, which is the environment's responsibility (clause 11, entry 6). + **The suspension primitive exists here too.** `openkal.task` declares its boundary as a wait upon a word. This kernel offers that operation under a different name and with no shared ancestry with the one Linux offers. That two diff --git a/mcpp.toml b/mcpp.toml index 3e2b55a..5b4b81b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-macos" -version = "0.12.0" +version = "0.12.1" description = "An implementation of openkal for macOS, written on the kernel's own calls. Its purpose is as much to test the specification as to be used." license = "Apache-2.0" @@ -57,7 +57,7 @@ provides-interfaces = [ ] [dependencies] -openkal = "0.14.0" +openkal = { git = "https://github.com/mcpplibs/openkal.git", branch = "openkal-0.14.1" } [build] # The flags are attached to this package's own sources rather than to the whole From e87e0a6b3c6f24b53d0a51c2a7d5c5fd9e47294c Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 1 Oct 2026 20:15:50 +0800 Subject: [PATCH 3/3] The specification is named by its released version, 0.14.1 Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- mcpp.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpp.toml b/mcpp.toml index 5b4b81b..1998a33 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -57,7 +57,7 @@ provides-interfaces = [ ] [dependencies] -openkal = { git = "https://github.com/mcpplibs/openkal.git", branch = "openkal-0.14.1" } +openkal = "0.14.1" [build] # The flags are attached to this package's own sources rather than to the whole