diff --git a/poc/rfd3-workload/main.c b/poc/rfd3-workload/main.c new file mode 100644 index 0000000..216c381 --- /dev/null +++ b/poc/rfd3-workload/main.c @@ -0,0 +1,259 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define COMMAND_BYTES 256 +#define TFTP_BYTES 516 +#define TOKEN_BYTES 128 + +#ifndef FIXTURE_PEER_ADDRESS +#define FIXTURE_PEER_ADDRESS "10.0.2.2" +#endif + +#ifndef FIXTURE_TFTP_PORT +#define FIXTURE_TFTP_PORT 69 +#endif + +static const char *const peer_address = FIXTURE_PEER_ADDRESS; +static const char *const state_path = "/tmp/simferret-fixture-state"; + +static int valid_token(const char *value) { + size_t length = strlen(value); + + if (length == 0 || length >= TOKEN_BYTES) { + return 0; + } + for (size_t index = 0; index < length; index++) { + char character = value[index]; + if (!((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '-' || + character == '_')) { + return 0; + } + } + return 1; +} + +static int valid_request_id(const char *value) { + static const char prefix[] = "request-"; + + return strncmp(value, prefix, sizeof(prefix) - 1) == 0 && + valid_token(value) && value[sizeof(prefix) - 1] != '\0'; +} + +static int emit_state(void) { + int stale = access(state_path, F_OK) == 0; + FILE *state; + + if (!stale && errno != ENOENT) { + perror("fixture: inspect state"); + return -1; + } + state = fopen(state_path, "wx"); + if (state == NULL) { + if (!stale) { + perror("fixture: create state"); + return -1; + } + } else if (fclose(state) != 0) { + perror("fixture: close state"); + return -1; + } + printf("state value=%s\n", stale ? "stale" : "fresh"); + return fflush(stdout); +} + +static int spawn_escaped_descendant(void) { + int ready[2]; + pid_t child; + char marker; + + if (pipe(ready) != 0) { + perror("fixture: pipe"); + return -1; + } + child = fork(); + if (child < 0) { + perror("fixture: fork"); + return -1; + } + if (child == 0) { + pid_t descendant = fork(); + + close(ready[0]); + if (descendant < 0) { + _exit(1); + } + if (descendant > 0) { + _exit(0); + } + if (setsid() < 0 || signal(SIGTERM, SIG_IGN) == SIG_ERR || + write(ready[1], "R", 1) != 1) { + _exit(1); + } + close(ready[1]); + for (;;) { + pause(); + } + } + + close(ready[1]); + if (waitpid(child, NULL, 0) != child || read(ready[0], &marker, 1) != 1 || + marker != 'R') { + fprintf(stderr, "fixture: descendant did not become ready\n"); + close(ready[0]); + return -1; + } + close(ready[0]); + printf("descendant state=escaped\n"); + return fflush(stdout); +} + +static int tftp_request(const char *request_id, const char *expected_payload) { + struct sockaddr_in peer = { + .sin_family = AF_INET, + .sin_port = htons(FIXTURE_TFTP_PORT), + }; + struct sockaddr_in server; + socklen_t server_length = sizeof(server); + struct timeval timeout = {.tv_sec = 2, .tv_usec = 0}; + unsigned char request[TOKEN_BYTES + 9] = {0, 1}; + unsigned char response[TFTP_BYTES]; + char expected[TFTP_BYTES]; + size_t request_length = strlen(request_id); + ssize_t received; + int socket_fd; + int expected_length; + + if (!valid_request_id(request_id) || !valid_token(expected_payload) || + request_length + 9 > sizeof(request)) { + fprintf(stderr, "fixture: invalid request expectation\n"); + return -1; + } + if (inet_pton(AF_INET, peer_address, &peer.sin_addr) != 1) { + fprintf(stderr, "fixture: invalid fixed peer address\n"); + return -1; + } + memcpy(request + 2, request_id, request_length); + memcpy(request + 2 + request_length, "\0octet\0", 7); + + socket_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (socket_fd < 0 || + setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, + sizeof(timeout)) != 0) { + perror("fixture: socket"); + if (socket_fd >= 0) { + close(socket_fd); + } + return -1; + } + if (sendto(socket_fd, request, request_length + 9, 0, + (const struct sockaddr *)&peer, sizeof(peer)) < 0) { + int send_errno = errno; + close(socket_fd); + if (send_errno == EACCES) { + printf("network state=unavailable request=%s errno=%d\n", request_id, + send_errno); + return fflush(stdout); + } + errno = send_errno; + perror("fixture: send request"); + return -1; + } + + received = recvfrom(socket_fd, response, sizeof(response), 0, + (struct sockaddr *)&server, &server_length); + if (received < 4 || server.sin_family != AF_INET || + server.sin_addr.s_addr != peer.sin_addr.s_addr || response[0] != 0 || + response[1] != 3 || response[2] != 0 || response[3] != 1) { + close(socket_fd); + fprintf(stderr, "fixture: invalid TFTP response\n"); + return -1; + } + if (sendto(socket_fd, (unsigned char[]){0, 4, 0, 1}, 4, 0, + (const struct sockaddr *)&server, server_length) != 4) { + close(socket_fd); + perror("fixture: acknowledge response"); + return -1; + } + close(socket_fd); + + expected_length = snprintf(expected, sizeof(expected), + "request_id=%s\npayload=%s\n", request_id, + expected_payload); + if (expected_length < 0 || (size_t)expected_length != (size_t)received - 4 || + memcmp(response + 4, expected, (size_t)expected_length) != 0) { + fprintf(stderr, "fixture: response content mismatch\n"); + return -1; + } + printf("network state=ok request=%s\n", request_id); + return fflush(stdout); +} + +int main(void) { + char command[COMMAND_BYTES]; + int descendant_spawned = 0; + + setvbuf(stdout, NULL, _IOLBF, 0); + printf("ready version=1\n"); + while (fgets(command, sizeof(command), stdin) != NULL) { + char *argument; + char *second_argument = NULL; + size_t length = strlen(command); + + if (length == 0 || command[length - 1] != '\n') { + fprintf(stderr, "fixture: command exceeds limit\n"); + return 2; + } + command[length - 1] = '\0'; + argument = strchr(command, ' '); + if (argument != NULL) { + *argument++ = '\0'; + second_argument = strchr(argument, ' '); + if (second_argument != NULL) { + *second_argument++ = '\0'; + } + } + + if (strcmp(command, "echo") == 0 && argument != NULL && + second_argument == NULL && valid_token(argument)) { + printf("echo value=%s\n", argument); + } else if (strcmp(command, "state") == 0 && argument == NULL) { + if (emit_state() != 0) { + return 1; + } + } else if (strcmp(command, "spawn-descendant") == 0 && argument == NULL) { + if (descendant_spawned) { + fprintf(stderr, "fixture: descendant already spawned\n"); + return 2; + } + if (spawn_escaped_descendant() != 0) { + return 1; + } + descendant_spawned = 1; + } else if (strcmp(command, "fetch") == 0 && argument != NULL && + second_argument != NULL && + strchr(second_argument, ' ') == NULL) { + if (tftp_request(argument, second_argument) != 0) { + return 1; + } + } else if (strcmp(command, "exit") == 0 && argument == NULL) { + printf("stopped status=0\n"); + return 0; + } else { + fprintf(stderr, "fixture: invalid command\n"); + return 2; + } + } + return ferror(stdin) ? 1 : 0; +} diff --git a/rfd/0003/EVIDENCE.adoc b/rfd/0003/EVIDENCE.adoc new file mode 100644 index 0000000..2628675 --- /dev/null +++ b/rfd/0003/EVIDENCE.adoc @@ -0,0 +1,100 @@ += RFD 3 Evidence + +== Entry gate + +The entry gate selects `poc/rfd3-workload/main.c` as the acceptance workload. +It is a bounded C program with no SimFerret library dependency. Its line protocol +can echo asymmetric input, create and detect invocation state under `/tmp`, +double-fork a session-leader descendant that ignores `SIGTERM` and retains +standard output, and fetch one bounded TFTP response from the existing +`10.0.2.2` RFD 2 peer. It recognizes `EACCES` from the peer-specific prohibited +route separately from malformed or unavailable responses. The `fetch` command +accepts the scenario's independently generated expected payload rather than +deriving it from the request identifier. These behaviors cover the process, +fresh-root, live-output, and network assertions required by the RFD without +putting application interpretation in the guest agent. + +This selection does not claim that RFD 3 process supervision exists. Booting and +supervising the external executable remains the first Phase 0 task. + +=== Representative artifact capture + +The checked-in capture command is: + +[source,shell] +---- +.agents/dev ./scripts/rfd3-entry-gate.sh +---- + +It compiles the fixture as a static little-endian x86-64 Linux ELF and emits two +uncommitted inputs below one unique `.poc/rfd3-entry-gate/capture.*` directory: +the executable itself and a local OCI image layout containing that executable in +one uncompressed layer. Unique directories make concurrent captures independent +instead of replacing a shared `current` path. The OCI config selects +`/bin/simferret-workload-fixture`, an exact environment, `/` as working +directory, and `65534:65534`. The layout contains `oci-layout`, `index.json`, and +selected config, manifest, and layer blobs. The script uses canonical JSON, +USTAR metadata with zero timestamps, and a fixed entry order; it neither pulls +an image nor invokes a container daemon. + +The capture ran with `x86_64-unknown-linux-musl-gcc (GCC) 15.3.0` and Python +3.14.7 from the pinned development environment. Repeating the command into a +second output directory and recursively comparing the results produced no +difference. The executable reported a 64-bit little-endian AMD x86-64 ELF with +no interpreter program header. A host-side protocol smoke produced: + +[source,text] +---- +ready version=1 +echo value=asymmetric_42 +state value=fresh +state value=stale +stopped status=0 +---- + +The focused regression command is: + +[source,shell] +---- +.agents/dev ./scripts/rfd3-entry-gate-test.sh +---- + +It serves the actual RFD 2 response shape with an independently supplied +32-hex-character payload, confirms the fixture accepts an exact response and +rejects a corrupted payload, proves a second descendant-spawn command exits with +status 2 without creating another process, and runs two captures concurrently +into distinct byte-identical directories. The test compiles only its network +peer address and ephemeral UDP port as test constants; the captured fixture +retains the fixed `10.0.2.2:69` peer. + +The captured measurements and identities are: + +[cols="2,1,3",options="header"] +|=== +| Object | Bytes | SHA-256 + +| Fixture source +| 8,049 +| `c7958b06f3ddbd292f8abd9b0466afa04e3afef13e5e5f7f219d1da37d1c2305` + +| Standalone executable +| 70,760 +| `d54d4b2a92ace28707ce282a9734fbcdfec0cf30cf1bf0c4c7102bf7d95b6e5c` + +| OCI config +| 276 +| `63234a974712b148203f72a3336392013f55ab3505d032eaba829567ca229692` + +| OCI manifest +| 399 +| `a143552c9fc2c01504eeb7c531d5661c4ab139fc488ca2175746e024787ee429` + +| OCI layer and DiffID +| 81,920 +| `f18056523d5c0125fffaf16b26766c7160fa01ef38a047a9cda41ae5a92dcbab` +|=== + +The complete five-file OCI layout is 82,932 bytes. These observations establish +representative lower bounds only. They deliberately precede and do not choose +descriptor-count, path, file, compressed-byte, expanded-byte, or cache limits; +Phase 0 captures broader representative images before those limits are fixed. diff --git a/rfd/0003/IMPLEMENTATION.org b/rfd/0003/IMPLEMENTATION.org new file mode 100644 index 0000000..2670c3e --- /dev/null +++ b/rfd/0003/IMPLEMENTATION.org @@ -0,0 +1,178 @@ +#+TITLE: RFD 0003 implementation checklist +#+STARTUP: showall + +Implements [[file:README.adoc][RFD 3: Reproducible workload packaging]]. + +* Objective + +Accept one standalone binary or local OCI image layout, normalize it into one +content-addressed guest workload, and preserve exact passive replay. + +* Entry gate +:PROPERTIES: +:STATUS: DONE +:END: + +- [X] Complete RFD 2 and review its replay fidelity, runtime, failure bundles, + network constraints, and next-increment recommendation. +- [X] Select one auditable static workload fixture that can exercise process + supervision and traffic through the existing network boundary. +- [X] Capture representative standalone and minimal OCI artifacts before fixing + parser, expansion, and cache limits. + +* Phase 0 — Workload and OCI spikes +:PROPERTIES: +:STATUS: TODO +:END: + +- [ ] Boot and supervise an external static x86-64 Linux executable that is not + the SimFerret agent. +- [ ] Build a minimal local OCI image layout containing the same executable and + launch it without a registry or container daemon. +- [ ] Build a second minimal OCI layout whose executable is dynamically linked + and whose interpreter and shared-library closure exist only in the image. +- [ ] Select existing Rust parsing, archive, compression, and safe-filesystem + primitives before writing custom format handling. +- [ ] Apply representative base and upper layers twice and verify a stable + normalized filesystem digest, including metadata, replacement, whiteout, and + opaque-directory cases. +- [ ] Prove that record mode can remove all live workload sources before QEMU + starts and that replay needs only the retained raw and derived + content-addressed closure. +- [ ] Record source sizes, expanded sizes, assembly duration, initramfs growth, + unsupported OCI constructs, and all observed nondeterminism. + +* Phase 1 — Typed specification and canonical assembler +:PROPERTIES: +:STATUS: TODO +:END: + +- [ ] Add a strict versioned workload specification with tagged binary and OCI + source variants, explicit non-root credentials, and a shared launch identity. +- [ ] Validate static ELF architecture and derive binary content identity from + opened bytes rather than caller claims. +- [ ] Read binary and OCI source objects as bounded regular files beneath an + opened source root, reject links and special files, and use immutable copied + bytes for verification and assembly. +- [ ] Select only the requested direct ~linux/amd64~ descriptor, require absent + variant and OS extensions, ignore unrelated entries, and validate its manifest, + config, ordered stored layers, and uncompressed DiffIDs independently. +- [ ] Normalize OCI launch fields exactly, including Entrypoint/Cmd composition, + environment, working directory, non-root numeric credentials, and rejected + volume, stop-signal, group, and privileged semantics. +- [ ] Implement bounded, beneath-root layer application with per-layer duplicate + detection, legal lower-layer replacement, order-independent whiteout and + opaque-directory semantics, and rejection of every hard link and unsupported + file type. +- [ ] Produce a canonical filesystem identity covering every guest-visible path, + type, byte, link target, mode, owner, group, and timestamp decision. +- [ ] Retain the canonical specification and complete binary or OCI selection + evidence, manifest, config, stored layers, and DiffID relationships as a raw + content-addressed replay closure. +- [ ] Build and verify derived canonical-tree and guest-template cache entries + that name the complete raw closure through atomic private staging. +- [ ] Reject concurrent publication conflicts, corrupted cache entries, source + mutation, missing raw evidence, path traversal, unsafe links, expansion + limits, and partial output. + +* Phase 2 — Guest process runtime +:PROPERTIES: +:STATUS: TODO +:END: + +- [ ] Assemble binary and OCI workloads below the same reserved initramfs root + while keeping the PID-1 agent and tools outside it. +- [ ] Materialize a fresh writable root from an immutable template for every + invocation, synthesize deterministic ~/tmp~, ~/dev/null~, and ~/dev/zero~ + metadata, and reject overlay collisions. +- [ ] In the child only, connect standard pipes, close inherited descriptors, + chroot and change directory, clear groups, set ~no_new_privs~, drop to the + normalized nonzero GID and UID, and exec without a shell. +- [ ] Add generic versioned start, stdin-write, stdin-EOF, and fixed-SIGKILL + termination commands with stable invocation identifiers and contiguous input + offsets. +- [ ] Emit bounded live stdout and stderr frames with invocation, stream offset, + cross-stream sequence, exact bytes, and independently checked final totals and + digests; make overflow or transport failure explicit and fatal. +- [ ] Treat every non-agent guest process as invocation membership, kill and + reap through a process-table-and-pipe-EOF cleanup barrier, and refuse restart + before cleanup completes. +- [ ] Prove an escaped double-fork descendant retaining an output descriptor and + first-invocation root mutations cannot satisfy later recovery. +- [ ] Keep application protocol interpretation and assertions in the host + scenario checker; limit the guest agent to generic process and byte facts. +- [ ] Separate owner-only replay closure and optional bounded output bytes from + default shareable failure metadata, which contains no environment values or + stream bytes. + +* Phase 3 — Scenario and passive replay integration +:PROPERTIES: +:STATUS: TODO +:END: + +- [ ] Run the same acceptance fixture from standalone binary and OCI sources + through the shared guest runtime. +- [ ] Run the focused dynamically linked OCI fixture from its in-image loader and + library closure and passively replay it. +- [ ] Materialize a process termination and restart in the seeded choice plan and + evaluate ordered process safety, cleanup, fresh-root, and recovery properties. +- [ ] Drive the live workload exclusively through recorded input commands and + derive response-integrity assertions from ordered output frames in the host + checker before process exit. +- [ ] Make workload-originated application traffic traverse the RFD 2 NIC and + observe the bounded peer-specific outage and restoration. +- [ ] Record each source form and passively replay it twice without live source, + registry, daemon, host mount, or fixture interaction. +- [ ] Compare normalized events, assertions, workload-output identities, and + semantic outcome digests on every replay. +- [ ] Reject independently changed workload, launch, cache, scenario, and + network identities before QEMU starts. +- [ ] Require raw source evidence on replay even when a valid derived cache entry + exists, and recheck selection and stored-layer-to-DiffID relationships before + reusing that entry. +- [ ] Preserve intentional output corruption as a failing safety assertion and + nonzero CLI result for both source forms. + +* Phase 4 — Acceptance and evidence +:PROPERTIES: +:STATUS: TODO +:END: + +- [ ] Automate both record-and-two-replay demonstrations on GitHub-hosted x86-64 + Linux without elevated privileges or a container daemon. +- [ ] Cover every workload identity class with compatibility or tampering tests. +- [ ] Cover adversarial descriptor, layer-archive, whiteout, path, link, file + type, per-layer duplicate, expansion, immutable-read, and raw/derived cache + cases before VM launch. +- [ ] Verify canonical modes, owners, modification times, links, replacements, + whiteouts, and opacity survive CPIO encoding and are visible in the guest. +- [ ] Verify no ambient host environment, mutable source, or unrelated host file + enters the assembled workload. +- [ ] Verify shareable successful-run metadata and failure bundles exclude raw + config, lock values, environment values, and workload bytes, while private + artifacts are owner-only and explicitly classified. +- [ ] Document supported OCI and binary profiles, self-hosted requirements, and + exact invocations. +- [ ] Record assembly cost, initramfs and replay-log growth, record and replay + duration, workload traffic and output volume, and all observed divergence in + ~EVIDENCE.adoc~. +- [ ] Decide whether checkpoint branching, OCI-profile expansion, or serial + control optimization is the next evidence-driven RFD. + +* Exit criteria + +- [ ] Every acceptance criterion in the RFD has linked automated or captured + evidence. +- [ ] Binary and OCI inputs converge before the guest runtime boundary. +- [ ] Replay reconstructs workload identity independently and uses no live or + ambient workload input after QEMU starts; derived cache alone is insufficient. +- [ ] The packaged workload demonstrably owns the process and network behavior + used by acceptance assertions. +- [ ] The agent exposes only an unprivileged child chroot and generic protocol; + the dedicated VM remains the host security boundary. +- [ ] The implementation preserves RFD 1 and RFD 2's VM adapter, replay, + artifact, and normalization ownership boundaries and documents the intentional + host-only refinement for workload-specific assertions. +- [ ] No registry runtime, container daemon, host mount, second workload, + persistent disk, richer fault model, or exploration engine exists without new + evidence and an RFD. diff --git a/rfd/0003/README.adoc b/rfd/0003/README.adoc new file mode 100644 index 0000000..ab0bd56 --- /dev/null +++ b/rfd/0003/README.adoc @@ -0,0 +1,602 @@ +:authors: Darwin Wu +:state: discussion +:discussion: https://github.com/chaba-dev/simferret/pull/13 +:labels: applications, binaries, determinism, oci, packaging, replay + += RFD 3 Reproducible workload packaging + +== Goal + +Run one user-supplied Linux workload inside SimFerret without making users build +a guest image or link against a SimFerret API. Accept either a standalone static +executable or a local OCI image layout, normalize both into one content-addressed +guest workload model, and preserve RFD 1 and RFD 2's exact replay contract. + +The increment should prove that packaging is an input to deterministic execution, +not a second runtime boundary. All workload bytes and launch semantics are fixed +before QEMU starts. Replay must reject changed workload content or configuration +before guest execution and must not contact a registry, read a live host +filesystem, or depend on a container daemon. + +== Motivation + +RFD 1 established exact whole-system record and replay. RFD 2 added an explicit +replay-filtered network and a deterministic outage. Both acceptance scenarios +still run repository-built fixture behavior from SimFerret's own initramfs. This +proves the execution and network boundaries but not that a user can bring an +application. + +OCI is useful here as a content-addressed distribution format. Existing build +pipelines already produce image manifests, configs, and layers with digests, and +those identities fit SimFerret's replay validation. SimFerret does not need OCI +namespaces, registry behavior, a daemon, or orchestration to consume those +artifacts. + +OCI alone would impose unnecessary work on users with a single static service or +test binary. A standalone executable is also the smallest way to prove that the +guest agent can supervise code other than itself. Supporting both inputs through +one normalized model avoids making either source format part of the execution +engine. + +The RFD 2 evidence recommends packaging before checkpoint exploration. A real +workload gives later snapshot, branching, and search measurements a more +representative filesystem and process than the built-in fixture. + +== Proposal + +Add a versioned workload specification selected by `simferret run`. Its source +is one of two tagged forms: + +* `binary`: one statically linked, little-endian x86-64 Linux ELF executable; +* `oci`: one manifest selected by digest from a local OCI image-layout directory. + +Both sources produce a canonical workload containing: + +* an ordered filesystem tree rooted below a reserved guest path; +* an executable path and exact argument vector; +* an exact environment without host inheritance; +* an absolute working directory inside the workload root; +* numeric user and group identifiers; and +* source and normalized-content identities. + +The canonical workload is assembled into the existing deterministic initramfs. +The SimFerret agent remains PID 1 outside the workload root and supervises one +workload process. The process receives no host bind mounts or ambient host +environment. OCI and binary inputs share the same guest launch and supervision +path after normalization. + +The intended boundary is: + +[source,text] +---- +local binary or OCI image layout + -> bounded parser and independent digest validation + -> canonical workload filesystem and launch identity + -> deterministic initramfs assembler + -> SimFerret guest agent as PID 1 + -> one workload process in a private filesystem root + -> existing deterministic process and network boundaries +---- + +== Workload specification + +The exact CLI and field names may change during implementation. The initial +shape is approximately: + +[source,toml] +---- +version = 1 +kind = "binary" +path = "bin/example-server" +args = ["--listen", "0.0.0.0:8080"] +env = ["MODE=acceptance"] +working_directory = "/" +user = "1000:1000" +---- + +or: + +[source,toml] +---- +version = 1 +kind = "oci" +layout = "images/example" +manifest_digest = "sha256:" +---- + +Source paths are resolved relative to the workload specification and are +operational locators, not semantic identity. SimFerret computes identities from +opened bytes and never accepts a caller-provided content digest as proof of +those bytes. The OCI manifest digest selects content but is still independently +verified. + +Binary specifications provide launch values explicitly. They require a static +ELF so no undeclared host or guest library closure can affect execution. The +executable is installed at one fixed canonical path with normalized ownership, +executable mode, and zero timestamp. The initial binary profile has no auxiliary +file tree; users that need libraries or application data use OCI. + +OCI specifications use the selected image config for `Entrypoint`, `Cmd`, `Env`, +`WorkingDir`, and `User`. The initial profile does not allow per-run overrides. +Shell expansion, host environment interpolation, named user lookup, and +supplementary groups are excluded. + +Launch normalization uses these rules: + +[cols="1,3",options="header"] +|=== +| OCI field | Initial SimFerret rule + +| `Entrypoint`, `Cmd` +| Treat null as absent. Use nonempty `Entrypoint` followed by `Cmd`, or nonempty + `Cmd` alone. Require the resulting first argument to be an absolute in-root + executable path; reject an empty result and do not search `PATH`. + +| `Env` +| Treat null or absence as empty. Require bounded `NAME=VALUE` UTF-8 entries, + reject NUL and duplicate names, preserve order, and inherit nothing from the + host or agent. + +| `WorkingDir` +| Treat null, absence, or an empty string as `/`. Otherwise require an absolute + path that resolves to a directory inside the final workload root without + traversing a symbolic link. + +| `User` +| Treat null, absence, or an empty string as the SimFerret default + `65534:65534`. Otherwise require an explicit nonzero decimal `uid:gid` pair. + Reject names, UID-only values, root credentials, and supplementary groups. + +| `Volumes`, `StopSignal`, `ArgsEscaped` +| Reject nonempty volumes, a configured stop signal, and `ArgsEscaped = true`. + The process-fault signal is fixed by the scenario contract. + +| `ExposedPorts`, `Labels`, `History` +| Treat as non-execution metadata. Their raw config bytes remain source identity, + but they do not change the normalized launch. +|=== + +Binary specifications use the same normalized launch rules and require an +explicit nonzero `uid:gid`. Every selected default and rejection policy is +versioned and included in workload identity. + +== OCI input profile + +The first OCI implementation accepts a local image-layout directory containing +`oci-layout`, `index.json`, and local blobs. It first finds exactly one direct +image-manifest descriptor with the requested digest. That selected descriptor +must declare `linux/amd64`; `variant`, `os.version`, and `os.features` must be +absent, and the selected config must agree. Duplicate selected descriptors, +platform-less selected descriptors, nested selection, or a selected descriptor +with remote or missing content is rejected. Unselected descriptors and blobs do +not enter the execution closure and are ignored even when they use unsupported +media types or refer to unavailable content. + +Before parsing or decompressing referenced content, SimFerret checks each +descriptor's media type, size, and SHA-256 digest against the opened bytes. It +then checks that the config uses `rootfs.type = "layers"`, that layer and DiffID +counts agree, and that each uncompressed layer stream matches its ordered DiffID. +Layers are applied from base to top into an empty private staging root. + +The supported layer profile includes bounded plain or gzip-compressed tar, +directories, regular files, safe relative symbolic links, and OCI whiteout and +opaque-directory semantics. Paths are normalized once. Absolute paths, NUL, +escape through `..`, traversal through an existing symbolic link, and duplicate +normalized archive paths within one layer are rejected. A later layer may +legally replace an earlier path: directory-over-directory updates attributes, +while other type or content replacements remove and recreate the path. +Whiteouts and opaque markers affect only the lower-layer view regardless of tar +member order; same-layer additions survive, and markers never enter the final +root. + +Device nodes, sockets, FIFOs, setuid and setgid modes, all hard links, sparse +files, ACLs, unsupported extended attributes, unknown layer +encodings, descriptor URLs, and embedded descriptor data are rejected in the +initial profile. Accepted whole-second OCI modification times are preserved and +included in canonical identity; unsupported extended timestamp encodings are +rejected. The implementation must not delegate extraction to an unrestricted +`tar` process. Limits cover descriptor count, nesting, compressed and expanded +bytes, file count, path length, and individual file size. A limit failure occurs +before QEMU starts and produces bounded diagnostics. + +All source access uses bounded regular-file reads beneath an already opened real +layout directory. `oci-layout`, `index.json`, digest directories, and selected +blob files may not be symlinks, FIFOs, devices, or other special files. Digest +components map to one validated canonical blob path. SimFerret copies bytes into +private immutable staging, then verifies, parses, decompresses, and extracts +those same bytes; it never verifies one path and later reopens mutable source +content. The binary source and existing cache follow the same +verification-to-use rule. + +Unknown OCI JSON fields and unrelated layout files are ignored where the OCI +specification requires forward compatibility. Unsupported selected media types +or execution semantics are rejected explicitly rather than interpreted +approximately. + +== Canonical filesystem and identity + +The normalized filesystem identity is a domain-separated digest over sorted +entries. Each entry encodes its canonical absolute workload path, type, mode, +numeric owner, numeric group, timestamp policy, and either file bytes or symbolic +link target. Directory entries are explicit. Serialization uses bounded lengths +and unambiguous type tags; it does not depend on host directory enumeration, +inode numbers, tar header order where OCI semantics do not make order +significant, locale, or host umask. + +The workload replay identity includes at least: + +* workload-specification version and source kind; +* the canonical workload specification and exact source-selection evidence; +* source executable digest, or selected OCI manifest, config, stored-layer, and + uncompressed DiffID identities in order; +* canonical normalized-filesystem digest and assembler format version; +* executable path, ordered arguments, ordered environment, working directory, + numeric credentials, and all defaulting decisions; +* supported OCI media types, extraction-policy version, and runtime-overlay + version; and +* the resulting initial-state digest already owned by the VM identity. + +The source identity preserves useful provenance while the canonical identity +defines guest-visible content. Two source forms are not required to have the +same identity even when they contain the same executable. They are required to +enter the same runtime path and obey the same process contract. + +Host source paths, staging paths, cache paths, process identifiers, and archive +inode metadata are diagnostic only. Any source metadata visible to the guest is +canonicalized or included in identity. + +== Content cache and replay + +Large workload content is not copied into every run directory. The content store +contains both raw evidence and derived content. For a binary, raw evidence is the +opened executable and canonical workload specification. For OCI, it is +`oci-layout`, `index.json`, the selected index descriptor, and the exact selected +manifest, config, and stored layer blobs. Every object is addressed by digest; +the selected descriptor order and relationships form one raw replay closure. +Operational source locators are not part of it. + +Derived entries contain the canonical filesystem tree and encoded immutable +guest template. Each derived entry names its complete raw closure, normalized +launch digest, policy versions, and canonical filesystem digest. Assembly writes +raw and derived entries through private staging, verifies complete bytes and +relationships, and publishes atomically. Concurrent builders must either +produce the same bytes or fail without replacing a verified entry. + +The run manifest records only workload, launch, and resulting guest-image +identities. A canonical workload lock is a digested private run artifact and +names the entire raw and derived closure. Replay verifies every required raw +object against its digest, reparses the specification and selected OCI graph, +rechecks stored-layer-to-DiffID relationships, and then either rebuilds the +canonical tree or verifies a derived entry against those independently computed +identities. A derived tree alone is insufficient. Missing raw or derived content +produces an actionable preflight error rather than falling back to a live source. + +Once QEMU starts, replay behaves exactly as in RFD 2. It provides no live +workload files, registry interaction, or host mounts. QEMU replays recorded guest +inputs and network packets, while SimFerret compares normalized events, +assertions, and the semantic outcome digest. + +== Guest runtime + +The initramfs keeps PID 1, the agent, and its tools outside an immutable workload +template. For every start, including restart within one boot, the agent creates a +new writable RAM root from that template. It reproduces canonical bytes and +metadata, then applies a versioned runtime overlay: `/tmp` is an empty root-owned +mode-`01777` directory; `/dev` is root-owned mode `0755`; and `/dev/null` and +`/dev/zero` are mode-`0666` character devices with fixed owners and device +numbers. Package entries at `/tmp` and `/dev` must be directories and have their +metadata replaced by the overlay; entries below those paths collide and are +rejected except for an empty directory tree. No `/proc`, `/sys`, entropy device, +or other guest interface is exposed. Overlay paths, metadata, collision rules, +copy algorithm, and timestamp handling are workload identity. + +The agent itself never changes root. It forks a workload child, connects only +fresh standard-input/output/error pipes, closes every other inherited descriptor, +changes the child root and then its in-root working directory, clears +supplementary groups, sets `no_new_privs`, applies the nonzero GID and UID, and +executes the exact argument vector without a shell. A setup failure exits the +child without executing workload code and emits a typed launch failure. The +immutable template and previous invocation roots are never reachable from the +child. + +The versioned process protocol accepts only these semantic commands: + +* `start(invocation, workload_identity)`; +* `stdin-write(invocation, offset, bytes)` with bounded frames and strictly + contiguous offsets; +* `stdin-eof(invocation)`; and +* `terminate(invocation)`, whose initial-profile meaning is unconditional + `SIGKILL`, independent of OCI `StopSignal`. + +The agent emits start, input-accepted, requested-termination, exit, launch-failure, +and cleanup-complete events. While the process is live it also emits bounded +output frames containing invocation identifier, `stdout` or `stderr`, per-stream +byte offset, one monotonic cross-stream sequence number, and exact bytes. Final +exit records contain independent per-stream totals and digests. Fixed per-frame, +queued-output, and per-invocation limits are identified by the scenario. The +agent drains both streams while the child runs; a limit, offset, pipe, or control +transport failure kills the invocation and is an infrastructure failure. Output +is never silently truncated and host read chunking is not exposed as a semantic +event. + +The initial single-workload profile defines every guest process other than PID 1 +as a member of the active invocation. On termination or primary-process exit, +the agent repeatedly enumerates the guest process table, sends `SIGKILL` to every +remaining member, and reaps until only PID 1 remains and all workload output +pipes reach EOF. It emits `cleanup-complete` only after that barrier and refuses +another start before it. This deliberately includes double-forked, reparented, +or process-group-escaped descendants. + +The agent reports process and byte facts but does not parse fixture protocols or +evaluate application properties. The host scenario checker incrementally parses +recorded output frames, correlates them with commands and network events, and +owns workload assertions. This is an explicit refinement of the earlier +guest/host assertion mirror: the guest still enforces and reports generic +protocol invariants, while application meaning exists only in the versioned host +checker. Passive replay compares the resulting frames and assertions exactly. + +This boundary supplies deterministic execution, not container isolation. Source +parsing and extraction treat inputs as hostile, and the VM protects the host, +but an accepted workload is trusted not to exhaust the entire guest. `chroot` +limits the non-root child namespace; it is not the host security boundary or a +multi-tenant sandbox. Linux namespaces, cgroups, seccomp, OCI capabilities and +hooks, terminals, and general resource controls remain separate concerns. + +== Acceptance scenario + +The acceptance fixture is built once as a static x86-64 Linux executable and +packaged in both supported forms: directly as a binary workload and inside a +minimal OCI image layout. It is ordinary Linux software and does not link to a +SimFerret library. Fixture-specific output interpretation remains in the +scenario checker rather than the guest agent. + +For each source form, the checked-in acceptance scenario: + +. assembles and boots the workload through the canonical runtime path; +. observes a typed workload-start event; +. sends bounded, recorded `stdin-write` commands and parses live, ordered output + frames whose response is derived from that input; +. terminates the workload at a materialized process-fault choice point and + confirms exit and cleanup completion, including an escaped descendant that + retains an output descriptor; +. restarts the same workload identity into a fresh root, proves a mutation from + the first invocation is absent, observes a matching response, and exercises + recorded `stdin-eof`; +. performs a request through the RFD 2 RTL8139 and restricted replay-filtered + backend before, during, and after the existing peer-specific outage; +. evaluates structured process, response-integrity, outage, and recovery + properties; and +. shuts down normally. + +The workload, rather than built-in agent request code, must originate the +acceptance application traffic. Any fixture protocol exists only to make this +fact auditable. General adapters for arbitrary service protocols are not claimed +by this RFD. + +A focused OCI conformance fixture independently covers preserved modes, numeric +owners, whole-second modification times, safe symbolic links, legal +cross-layer replacement, whiteouts, and opaque directories. It verifies the +guest-visible metadata after initramfs encoding rather than only the host staging +tree. A second minimal OCI fixture contains a dynamically linked executable, +interpreter, and shared library and must execute and passively replay; standalone +dynamic binaries remain unsupported. + +Record mode is followed by two passive replays for each source form. Replays +must produce byte-identical normalized event and assertion artifacts and matching +semantic outcome digests. The acceptance harness records assembly size and +duration, initramfs growth, QEMU record and replay duration, replay-log growth, +workload output volume, and all observed divergence. + +== Assertions + +The scenario passes only when: + +* the started executable and launch identity match the materialized workload; +* bounded live workload frames preserve per-stream bytes and cross-stream order, + and the host checker derives the expected application response; +* the intended invocation reaches the cleanup barrier and no escaped descendant + or retained output descriptor survives; +* the restarted invocation uses the same workload identity, begins from the same + immutable root, and responds within its event bound; +* application traffic demonstrably crosses the configured NIC and observes the + typed administrative rejection while the outage is active; +* matching application traffic recovers after route restoration; and +* all required process and network events occur in command-correlated order. + +An exec error, unsupported dynamic binary, malformed OCI object, extraction +limit, missing output, output truncation, infrastructure deadline, or agent-made +response cannot satisfy a workload property. + +== Tampering and negative tests + +Preflight tests change each workload identity class independently. At minimum +they cover the specification, standalone executable, manifest, config, stored +layer, DiffID, normalized filesystem, launch arguments, environment, working +directory, and credentials. Changing any object while keeping the recorded lock +fixed must fail. Self-consistently changing one subgraph, such as a manifest and +stored layer, must still fail against the unchanged config, DiffIDs, lock, or +derived identity. Missing raw closure content fails even when a derived +filesystem is present. + +Adversarial OCI fixtures cover absolute and escaping paths, path traversal +through symbolic links, duplicate normalized names, unsafe hard links, whiteout +ordering, opaque directories, decompression expansion, oversized content, +unsupported file types, missing blobs, digest and size mismatch, wrong platform, +ambiguous selection, and unsupported media types. Every failure must occur before +QEMU launch and leave no partially published cache entry. + +Binary tests reject dynamically linked, wrong-architecture, malformed, mutable, +oversized, and non-regular inputs. Runtime tests reject privileged credentials, +forbidden inherited descriptors and interfaces, stale writable roots, malformed +input offsets, output overflow, and an escaped descendant retaining a pipe. +Replay tests reject missing or altered raw and derived content and prove that +unrelated host files and environment variables do not enter the workload. + +Content digests provide corruption detection and replay identity, not publisher +authentication. A change to one object, relationship, lock, or derived artifact +against the rest of a recorded closure must fail. An attacker able to replace +the complete closure and all expected identities can instead create a different +self-consistent recording; detecting that requires signatures, transparency, or +an external trusted digest, all excluded from this increment. + +== CLI and diagnostics + +The intended command is approximately: + +[source,shell] +---- +simferret run \ + --workload workloads/example-binary.toml \ + --scenario scenarios/workload-network-outage.toml \ + --seed 42 +---- + +Success output retains the RFD 2 run identifier, assertion summary, artifact +directory, and replay command. The manifest and bounded failure bundle add the +workload source kind, canonical identity, launch identity, assembly/cache mode, +and first incompatible field without printing field values that may be secret. + +Replay closure, workload lock, raw OCI config, and exact workload streams are +private local artifacts: they may contain user-supplied environment values or +application data, use owner-only permissions, and are never uploaded by default. +The default shareable failure bundle contains bounded protocol metadata, counts, +digests, typed errors, and sanitized source labels, but excludes raw source +objects, lock contents, environment values, and output bytes. An explicit local +opt-in may collect bounded private stream bytes for debugging; SimFerret does not +claim it can infer or redact secrets from arbitrary workload output. + +== GitHub Actions and supported hosts + +The complete binary and OCI acceptance demonstration runs on a GitHub-hosted +x86-64 Linux runner without root, KVM, TAP, a container daemon, or network +access beyond dependency acquisition already owned by the pinned development +environment. Test workload artifacts are built or materialized reproducibly in +the repository environment rather than pulled from a registry during the test. + +Assembly may run on another host architecture when it is byte-reproducible, but +execution remains x86-64 Linux QEMU TCG. Cross-host assembly identity is evidence +to collect, not an initial acceptance claim. + +== Acceptance criteria + +This increment is complete when: + +* one versioned workload schema represents binary and OCI sources as a tagged + union and both normalize into one runtime model; +* a standalone static x86-64 Linux binary records and passively replays twice; +* a local `linux/amd64` OCI image layout records and passively replays twice; +* a focused dynamically linked OCI workload and its complete in-image dependency + closure execute and passively replay; +* both source forms use the same guest process supervision and event protocol; +* workload-originated traffic passes through the RFD 2 NIC and replay filter and + observes bounded outage and recovery; +* a materialized process fault terminates and restarts the packaged workload + through a whole-guest cleanup barrier and a fresh writable root, without a + stale process or mutation satisfying recovery; +* live input commands and ordered output frames let the host checker evaluate + application properties before exit without fixture logic in the agent; +* normalized event and assertion artifacts are byte-identical across each pair + of replays and semantic outcome digests match; +* all workload and launch identity changes are rejected before replay; +* adversarial descriptor, layer-archive, link, whiteout, limit, and cache tests + fail before QEMU starts without escaping staging or publishing partial content; +* OCI metadata and layer semantics survive guest-image encoding and are observed + correctly inside the workload root; +* replay requires and independently verifies the complete raw source closure as + well as any reused derived cache entry; +* no registry, daemon, host bind mount, ambient environment, or live workload + source is consulted after QEMU starts; +* intentional workload-output corruption fails safety with a nonzero CLI result; +* default shareable diagnostics contain no raw environment values or workload + stream bytes, while private artifacts are explicitly identified; +* the complete demonstration passes in unprivileged GitHub-hosted x86-64 Linux; + and +* evidence records supported formats, assembly and execution cost, artifact + growth, operational limits, and every observed divergence. + +== Excluded from this increment + +This proposal does not include: + +* registry clients, mutable tags, signature policy, transparency logs, or image + provenance verification beyond content digests; +* Docker archive compatibility, Docker or containerd daemons, BuildKit, or + building Dockerfiles; +* dynamically linked standalone binaries or automatic host library discovery; +* arbitrary host directory trees, bind mounts, persistent volumes, block + devices, or mutable root filesystems retained across runs; +* multiple workloads, sidecars, Docker Compose, Kubernetes, service discovery, + or general topology configuration; +* full OCI Runtime Specification behavior, namespaces, cgroups, capabilities, + seccomp, hooks, terminal handling, or resource controls; +* named user and group resolution, shell command interpretation, or host + environment inheritance; +* non-Linux, non-amd64, privileged, setuid, or device-dependent workloads; +* general protocol adapters or automatic property inference for arbitrary + applications; +* richer network faults, a second guest, persistent storage faults, checkpoint + branching, or exploration workers; or +* performance or security claims beyond the bounded accepted profile. + +== Alternatives considered + +OCI-only support maximizes compatibility with container build pipelines but +forces users with a single static artifact to construct layers and image +metadata. The binary source adds little runtime complexity once both normalize +to the same model and provides the smallest end-to-end acceptance case. + +Binary-only support avoids layer extraction but cannot represent dynamically +linked applications and their filesystem dependencies without inventing another +bundle format. OCI already provides a content-addressed answer for that case. + +A custom SimFerret archive could encode exactly the supported semantics, but it +would create a new build ecosystem before OCI demonstrates a concrete mismatch. +The canonical internal model remains available if such a format is justified +later. + +Mounting a host directory into the guest would be operationally simple but adds +live filesystem input outside QEMU replay and makes host metadata semantic by +accident. Copying and canonicalizing content before launch is required. + +Running Docker, containerd, or a complete OCI runtime inside the guest adds a +daemon, mutable state, namespaces, and additional nondeterministic behavior that +does not help answer the packaging question. SimFerret needs deterministic +process launch, not a nested container platform. + +Using Nix closures directly would provide strong identities for Nix users but +would not accept the dominant OCI artifact format and would couple the workload +contract to SimFerret's development environment. + +Attaching a writable disk image would support larger images and persistent state +but introduces block-device replay, filesystem initialization, and storage-fault +questions. The initial bounded workload remains in the deterministic initramfs; +disk-backed workloads require a later RFD. + +== Follow-up decision + +Completion provides the first representative user workload for measuring +checkpoint size, restore cost, and branching throughput. If assembly and replay +remain reliable, checkpoint-tree branching and deterministic exploration should +be the next RFD. + +If OCI compatibility failures dominate because the bounded layer profile is too +narrow, expand only the observed filesystem semantics before exploration. If +acceptance runtime rather than workload size dominates, optimize the recorded +serial control protocol as a separate focused change. Richer packet faults or +multiple guests move earlier only when packaged workload evidence requires them. + +== Implementation + +Delivery phases are tracked in +link:IMPLEMENTATION.org[RFD 3 implementation checklist]. Captured measurements +and validation results are retained in link:EVIDENCE.adoc[RFD 3 evidence]. + +== References + +* https://github.com/opencontainers/image-spec/blob/main/image-layout.md[OCI image layout] +* https://github.com/opencontainers/image-spec/blob/main/descriptor.md[OCI descriptors] +* https://github.com/opencontainers/image-spec/blob/main/manifest.md[OCI image manifest] +* https://github.com/opencontainers/image-spec/blob/main/config.md[OCI image configuration] +* https://github.com/opencontainers/image-spec/blob/main/layer.md[OCI image layers] +* link:../0001/README.adoc[RFD 1: Deterministic execution proof of concept] +* link:../0002/README.adoc[RFD 2: Deterministic network fault model] diff --git a/rfd/README.adoc b/rfd/README.adoc index 6aa13c3..aeb00dd 100644 --- a/rfd/README.adoc +++ b/rfd/README.adoc @@ -13,6 +13,9 @@ authoritative merely because it exists; its `state` says how it should be read. | link:0002/README.adoc[2: Deterministic network fault model] | Record and replay a bounded network outage through an explicit QEMU network boundary + +| link:0003/README.adoc[3: Reproducible workload packaging] +| Normalize standalone binaries and local OCI images into one deterministic guest workload |=== == Source format diff --git a/scripts/rfd3-entry-gate-test.sh b/scripts/rfd3-entry-gate-test.sh new file mode 100755 index 0000000..2f76692 --- /dev/null +++ b/scripts/rfd3-entry-gate-test.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$repo_root" <<'PY' +import os +from pathlib import Path +import signal +import socket +import subprocess +import sys +import tempfile +import unittest + + +REPO = Path(sys.argv[1]) +sys.argv = [sys.argv[0]] +SOURCE = REPO / "poc" / "rfd3-workload" / "main.c" +CAPTURE = REPO / "scripts" / "rfd3-entry-gate.sh" +REQUEST_ID = "request-0000-0123456789abcdef" +PAYLOAD = "00112233445566778899aabbccddeeff" + + +class EntryGateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="simferret-rfd3-entry-") + cls.root = Path(cls.temporary.name) + cls.server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + cls.server.bind(("127.0.0.1", 0)) + cls.server.settimeout(1) + port = cls.server.getsockname()[1] + cls.binary = cls.root / "fixture" + subprocess.run( + [ + os.environ.get("CC", "cc"), + "-static", + "-Os", + "-Wall", + "-Wextra", + "-Werror", + '-DFIXTURE_PEER_ADDRESS="127.0.0.1"', + f"-DFIXTURE_TFTP_PORT={port}", + str(SOURCE), + "-o", + str(cls.binary), + ], + check=True, + ) + + @classmethod + def tearDownClass(cls): + cls.server.close() + cls.temporary.cleanup() + + def start_fixture(self): + process = subprocess.Popen( + [self.binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertEqual(process.stdout.readline(), "ready version=1\n") + return process + + def close_streams(self, process): + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + + def serve_response(self, process, payload): + process.stdin.write(f"fetch {REQUEST_ID} {PAYLOAD}\n") + process.stdin.flush() + request, client = self.server.recvfrom(512) + self.assertEqual(request, b"\x00\x01" + REQUEST_ID.encode() + b"\x00octet\x00") + contents = f"request_id={REQUEST_ID}\npayload={payload}\n".encode() + self.server.sendto(b"\x00\x03\x00\x01" + contents, client) + acknowledgement, source = self.server.recvfrom(512) + self.assertEqual(source, client) + self.assertEqual(acknowledgement, b"\x00\x04\x00\x01") + + def test_fetch_accepts_rfd2_payload_and_rejects_corruption(self): + success = self.start_fixture() + try: + self.serve_response(success, PAYLOAD) + self.assertEqual( + success.stdout.readline(), f"network state=ok request={REQUEST_ID}\n" + ) + success.stdin.write("exit\n") + success.stdin.flush() + self.assertEqual(success.stdout.readline(), "stopped status=0\n") + self.assertEqual(success.wait(timeout=1), 0) + finally: + if success.poll() is None: + success.kill() + success.wait() + self.close_streams(success) + + corrupt = self.start_fixture() + try: + self.serve_response(corrupt, PAYLOAD + "-corrupted") + self.assertEqual(corrupt.wait(timeout=1), 1) + self.assertEqual(corrupt.stderr.read(), "fixture: response content mismatch\n") + finally: + if corrupt.poll() is None: + corrupt.kill() + corrupt.wait() + self.close_streams(corrupt) + + def matching_processes(self): + expected = self.binary.resolve() + matches = set() + for candidate in Path("/proc").iterdir(): + if not candidate.name.isdigit(): + continue + try: + if (candidate / "exe").resolve() == expected: + matches.add(int(candidate.name)) + except (FileNotFoundError, PermissionError): + pass + return matches + + def test_only_one_escaped_descendant_is_allowed(self): + baseline = self.matching_processes() + process = self.start_fixture() + descendants = set() + try: + process.stdin.write("spawn-descendant\n") + process.stdin.flush() + self.assertEqual(process.stdout.readline(), "descendant state=escaped\n") + descendants = self.matching_processes() - baseline - {process.pid} + self.assertEqual(len(descendants), 1) + + process.stdin.write("spawn-descendant\n") + process.stdin.flush() + self.assertEqual(process.wait(timeout=1), 2) + self.assertEqual(self.matching_processes() - baseline, descendants) + for descendant in descendants: + os.kill(descendant, signal.SIGKILL) + descendants.clear() + self.assertEqual(process.stderr.read(), "fixture: descendant already spawned\n") + finally: + if process.poll() is None: + process.kill() + process.wait() + for descendant in self.matching_processes() - baseline: + try: + os.kill(descendant, signal.SIGKILL) + except ProcessLookupError: + pass + self.close_streams(process) + + def test_concurrent_captures_publish_distinct_directories(self): + output = self.root / "captures" + environment = os.environ | {"SIMFERRET_RFD3_ENTRY_OUTPUT": str(output)} + processes = [ + subprocess.Popen( + [CAPTURE], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=environment, + ) + for _ in range(2) + ] + results = [process.communicate(timeout=30) for process in processes] + for process, (_, stderr) in zip(processes, results): + self.assertEqual(process.returncode, 0, stderr) + captures = sorted(output.glob("capture.*")) + self.assertEqual(len(captures), 2) + snapshots = [ + { + path.relative_to(capture): path.read_bytes() + for path in capture.rglob("*") + if path.is_file() + } + for capture in captures + ] + self.assertEqual(snapshots[0], snapshots[1]) + expected = (captures[0] / "capture.json").read_text() + self.assertEqual([stdout for stdout, _ in results], [expected, expected]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) +PY diff --git a/scripts/rfd3-entry-gate.sh b/scripts/rfd3-entry-gate.sh new file mode 100755 index 0000000..d0ed363 --- /dev/null +++ b/scripts/rfd3-entry-gate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_root="${SIMFERRET_RFD3_ENTRY_OUTPUT:-$repo_root/.poc/rfd3-entry-gate}" +cc="${CC:-cc}" + +if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then + echo "The RFD 3 entry gate supports x86-64 Linux only." >&2 + exit 1 +fi +for command in "$cc" python3; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +umask 077 +mkdir -p "$output_root" +capture="$(mktemp -d "$output_root/capture.XXXXXXXX")" +trap 'rm -rf "$capture"' EXIT + +binary="$capture/simferret-workload-fixture" +layout="$capture/oci-layout" +"$cc" -static -Os -Wall -Wextra -Werror \ + "$repo_root/poc/rfd3-workload/main.c" -o "$binary" +mkdir -p "$layout/blobs/sha256" + +python3 - "$repo_root" "$binary" "$layout" <<'PY' +import hashlib +import io +import json +from pathlib import Path +import sys +import tarfile + +repo = Path(sys.argv[1]) +binary = Path(sys.argv[2]) +layout = Path(sys.argv[3]) + + +def encoded(value): + return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def store(data): + identity = digest(data) + (layout / "blobs" / "sha256" / identity).write_bytes(data) + return identity + + +binary_bytes = binary.read_bytes() +layer_buffer = io.BytesIO() +with tarfile.open(fileobj=layer_buffer, mode="w", format=tarfile.USTAR_FORMAT) as archive: + directory = tarfile.TarInfo("bin/") + directory.type = tarfile.DIRTYPE + directory.mode = 0o755 + directory.uid = 0 + directory.gid = 0 + directory.mtime = 0 + directory.uname = "" + directory.gname = "" + archive.addfile(directory) + + executable = tarfile.TarInfo("bin/simferret-workload-fixture") + executable.mode = 0o755 + executable.uid = 65534 + executable.gid = 65534 + executable.mtime = 0 + executable.uname = "" + executable.gname = "" + executable.size = len(binary_bytes) + archive.addfile(executable, io.BytesIO(binary_bytes)) + +layer = layer_buffer.getvalue() +layer_digest = store(layer) +config = encoded({ + "architecture": "amd64", + "config": { + "Entrypoint": ["/bin/simferret-workload-fixture"], + "Env": ["MODE=acceptance"], + "User": "65534:65534", + "WorkingDir": "/", + }, + "os": "linux", + "rootfs": {"diff_ids": [f"sha256:{layer_digest}"], "type": "layers"}, +}) +config_digest = store(config) +manifest = encoded({ + "config": { + "digest": f"sha256:{config_digest}", + "mediaType": "application/vnd.oci.image.config.v1+json", + "size": len(config), + }, + "layers": [{ + "digest": f"sha256:{layer_digest}", + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "size": len(layer), + }], + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "schemaVersion": 2, +}) +manifest_digest = store(manifest) +index = encoded({ + "manifests": [{ + "annotations": {"org.opencontainers.image.ref.name": "rfd3-entry-gate"}, + "digest": f"sha256:{manifest_digest}", + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"architecture": "amd64", "os": "linux"}, + "size": len(manifest), + }], + "schemaVersion": 2, +}) +(layout / "index.json").write_bytes(index) +(layout / "oci-layout").write_bytes(encoded({"imageLayoutVersion": "1.0.0"})) + +source = (repo / "poc" / "rfd3-workload" / "main.c").read_bytes() +layout_files = sorted(path for path in layout.rglob("*") if path.is_file()) +capture = { + "binary": {"bytes": len(binary_bytes), "sha256": digest(binary_bytes)}, + "fixture_source_sha256": digest(source), + "format_version": 1, + "oci": { + "config": {"bytes": len(config), "sha256": config_digest}, + "files": len(layout_files), + "layer": { + "bytes": len(layer), + "diff_id": f"sha256:{layer_digest}", + "sha256": layer_digest, + }, + "layout_bytes": sum(path.stat().st_size for path in layout_files), + "manifest": {"bytes": len(manifest), "sha256": manifest_digest}, + }, +} +(layout.parent / "capture.json").write_bytes(encoded(capture)) +PY + +trap - EXIT +cat "$capture/capture.json"