pkgcache: reject 0-byte artifacts on store and lookup, self-heal torn writes (#10)
A torn producer write (e.g. disk-full mid-copy) could leave a 0-byte P.wwi or
P.o in out/.pkgcache under a self-consistent key; cache_lookup checked only
existence, so every later build HIT and served the empty artifact forever
(silent serve-wrong). Reject size==0 on both sides, symmetric across stages:
store refuses to commit a 0-byte temp before the key write, lookup treats a
0-byte cached artifact as a MISS so existing poison self-heals on re-derive.
A valid .wwi/.o is never 0 bytes, so the guard cannot misfire.
Regression: test/wcc/989_pkgcache_poison_run.c, table-driven over
{poison P.wwi | P.o | both}, non-vacuity proven by guard-neuter.
This commit is contained in:
247
test/wcc/989_pkgcache_poison_run.c
Normal file
247
test/wcc/989_pkgcache_poison_run.c
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 989_pkgcache_poison_run — out/.pkgcache 0-byte-artifact self-heal gate (#10,
|
||||
* BUG-B). A torn producer write (e.g. disk-full mid-copy) can leave a 0-byte
|
||||
* P.wwi/P.o under a SELF-CONSISTENT P.key. Pre-fix the cache served that empty
|
||||
* artifact forever (the key matched and access() saw the file), so every later
|
||||
* build link-failed or produced a wrong binary — a SILENT serve-wrong that
|
||||
* never surfaces on its own. The fix rejects size==0 on BOTH the lookup (read)
|
||||
* and the store (write) side: a poisoned entry self-heals (treated as MISS →
|
||||
* re-derived), and a torn store never commits the key. The guard is symmetric
|
||||
* cstage (filenonempty, stat) / wwstage (cachefilesize, open+filesize) — the
|
||||
* hit/miss DECISION must match (rule 10).
|
||||
*
|
||||
* Target: a 2-level graph root(main) -> leaf in a private temp tree. ROOT is
|
||||
* never cached (it is the build target); `leaf` is the cacheable package. We
|
||||
* seed a warm cache, POISON the cached artifact to 0 bytes, rebuild, and assert
|
||||
* the build self-heals: leaf re-compiles (MISS sentinel) AND the program runs
|
||||
* to its correct value (not an empty/link-failed binary) AND the cache is
|
||||
* re-stored non-empty.
|
||||
*
|
||||
* HIT/MISS sentinel (same as 989_pkgcache_run): a MISS runs w6c → writes
|
||||
* <scratch>/leaf.s; a HIT copies the cached .o/.wwi and skips compose+w6c+w6a,
|
||||
* so no leaf.s. The scratch is wiped before each build so leaf.s presence
|
||||
* reflects exactly the last build.
|
||||
*
|
||||
* Table-driven over WHICH artifact is poisoned: { P.wwi | P.o | both }. Each
|
||||
* row, per stage (ww, ww_ww):
|
||||
* - warm baseline: an unchanged rebuild HITs (leaf.s ABSENT, prog runs) —
|
||||
* proves the cache is genuinely warm before we poison it (non-vacuity);
|
||||
* - POISON the row's artifact(s) to 0 bytes;
|
||||
* - heal rebuild: leaf re-compiles (leaf.s PRESENT = MISS, not served poison)
|
||||
* AND prog runs == EXPECT_EXIT AND both cached artifacts are non-empty again
|
||||
* (re-stored). Pre-fix this row served the 0-byte artifact and the assert
|
||||
* on leaf.s (and on prog exit) fails.
|
||||
*
|
||||
* Light wwstage-driver test (CLAUDE.md rule 14): all build outputs and caches
|
||||
* live under a private /tmp tree, wiped on exit, so it is parallel-safe and off
|
||||
* every byte-id/bootstrap gate. Models 989_pkgcache_run conventions.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#define EXPECT_EXIT 7
|
||||
|
||||
static int
|
||||
runwait(const char *cmd)
|
||||
{
|
||||
int rc = system(cmd);
|
||||
if (rc == -1) return -1;
|
||||
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const char *
|
||||
absbin(void)
|
||||
{
|
||||
const char *b = getenv("BIN");
|
||||
if (!b) b = "out/bin";
|
||||
if (b[0] == '/') return b;
|
||||
static char buf[2048];
|
||||
char cwd[1024];
|
||||
if (getcwd(cwd, sizeof cwd) == NULL) return NULL;
|
||||
snprintf(buf, sizeof buf, "%s/%s", cwd, b);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int
|
||||
write_file(const char *path, const char *body)
|
||||
{
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return -1;
|
||||
fputs(body, f);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Truncate `path` to 0 bytes (models a torn producer write under a valid key);
|
||||
* "wb" opens-and-truncates, leaving the file present but empty. */
|
||||
static int
|
||||
truncate0(const char *path)
|
||||
{
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return -1;
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static long
|
||||
fsize(const char *path)
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) return -1;
|
||||
return (long)st.st_size;
|
||||
}
|
||||
|
||||
/* <scratch>/<pkg>.s present ⇒ that package compiled this build (MISS). */
|
||||
static int
|
||||
sdot(const char *scratch, const char *pkg)
|
||||
{
|
||||
char p[1100];
|
||||
snprintf(p, sizeof p, "%s/%s.s", scratch, pkg);
|
||||
return access(p, 0) == 0;
|
||||
}
|
||||
|
||||
static const char *leaf_src =
|
||||
"package leaf;\n"
|
||||
"export fn base() i32 = { return 7; };\n";
|
||||
|
||||
static const char *root_src =
|
||||
"package main;\n"
|
||||
"import leaf;\n"
|
||||
"fn main() i32 = { return leaf.base(); };\n";
|
||||
|
||||
int
|
||||
main(void)
|
||||
{
|
||||
const char *bin = absbin();
|
||||
if (!bin) return 1;
|
||||
int fail = 0;
|
||||
char td[64], cmd[8192];
|
||||
char rootww[1024], leafww[1024];
|
||||
char scratch[1024], prog[1024];
|
||||
|
||||
snprintf(td, sizeof td, "/tmp/wwpkgpoison_%d", getpid());
|
||||
snprintf(cmd, sizeof cmd, "rm -rf %s", td);
|
||||
runwait(cmd);
|
||||
snprintf(cmd, sizeof cmd, "mkdir -p %s/leaf", td);
|
||||
runwait(cmd);
|
||||
|
||||
snprintf(rootww, sizeof rootww, "%s/root.ww", td);
|
||||
snprintf(leafww, sizeof leafww, "%s/leaf/leaf.ww", td);
|
||||
snprintf(prog, sizeof prog, "%s/p", td);
|
||||
snprintf(scratch, sizeof scratch, "%s/p.sepwork", td);
|
||||
|
||||
if (write_file(rootww, root_src) || write_file(leafww, leaf_src)) {
|
||||
fail++; goto out;
|
||||
}
|
||||
|
||||
/* Which cached artifact(s) the row poisons. */
|
||||
struct { const char *tag; int wwi, obj; } rows[] = {
|
||||
{ "P.wwi", 1, 0 },
|
||||
{ "P.o", 0, 1 },
|
||||
{ "both", 1, 1 },
|
||||
};
|
||||
struct { const char *drv; } stg[] = { { "ww" }, { "ww_ww" } };
|
||||
|
||||
for (int s = 0; s < 2; s++) {
|
||||
const char *drv = stg[s].drv;
|
||||
char cache[1024], cwwi[1100], cobj[1100];
|
||||
snprintf(cache, sizeof cache, "%s/cache.%d", td, s);
|
||||
snprintf(cwwi, sizeof cwwi, "%s/leaf/P.wwi", cache);
|
||||
snprintf(cobj, sizeof cobj, "%s/leaf/P.o", cache);
|
||||
|
||||
/* The cstage driver rm -rf's the scratch each build; the wwstage
|
||||
* driver only mkdir's it (#58(a) asymmetry), so a stale leaf.s could
|
||||
* persist and defeat the MISS/HIT sentinel. The gate owns its scratch:
|
||||
* wipe it before every build so a fresh leaf.s reflects the last build. */
|
||||
#define BUILD() do { \
|
||||
snprintf(cmd, sizeof cmd, \
|
||||
"rm -rf %s; WW_PKGCACHE='%s' timeout 240 %s/%s build --sep " \
|
||||
"-o %s %s >/dev/null 2>&1", scratch, cache, bin, drv, prog, \
|
||||
rootww); \
|
||||
} while (0)
|
||||
|
||||
/* fresh cache for this stage, cold-seed it (MISS) */
|
||||
snprintf(cmd, sizeof cmd, "rm -rf %s", cache);
|
||||
runwait(cmd);
|
||||
BUILD();
|
||||
if (runwait(cmd) != 0) {
|
||||
fprintf(stderr, "poison FAIL[%s]: cold seed build failed\n", drv);
|
||||
fail++; continue;
|
||||
}
|
||||
if (!sdot(scratch, "leaf")) {
|
||||
fprintf(stderr, "poison FAIL[%s]: cold seed — leaf did not "
|
||||
"compile (MISS expected)\n", drv);
|
||||
fail++;
|
||||
}
|
||||
if (runwait(prog) != EXPECT_EXIT) {
|
||||
fprintf(stderr, "poison FAIL[%s]: cold seed prog exit != %d\n",
|
||||
drv, EXPECT_EXIT);
|
||||
fail++;
|
||||
}
|
||||
|
||||
for (size_t r = 0; r < sizeof rows / sizeof rows[0]; r++) {
|
||||
/* warm baseline — the cache is warm (heals re-store it), so an
|
||||
* unchanged rebuild must HIT; proves we poison a LIVE entry. */
|
||||
BUILD(); runwait(cmd);
|
||||
if (sdot(scratch, "leaf")) {
|
||||
fprintf(stderr, "poison FAIL[%s/%s]: warm baseline — leaf "
|
||||
"recompiled (cache not warm before poison)\n",
|
||||
drv, rows[r].tag);
|
||||
fail++;
|
||||
}
|
||||
|
||||
/* poison the row's artifact(s) to 0 bytes, key stays valid */
|
||||
if (rows[r].wwi && truncate0(cwwi) != 0) {
|
||||
fprintf(stderr, "poison FAIL[%s/%s]: cannot truncate P.wwi\n",
|
||||
drv, rows[r].tag);
|
||||
fail++;
|
||||
}
|
||||
if (rows[r].obj && truncate0(cobj) != 0) {
|
||||
fprintf(stderr, "poison FAIL[%s/%s]: cannot truncate P.o\n",
|
||||
drv, rows[r].tag);
|
||||
fail++;
|
||||
}
|
||||
|
||||
/* heal rebuild — a 0-byte artifact under a valid key must be
|
||||
* treated as MISS (re-derived), NOT served. */
|
||||
BUILD(); runwait(cmd);
|
||||
if (!sdot(scratch, "leaf")) {
|
||||
fprintf(stderr, "poison FAIL[%s/%s]: served poison — leaf did "
|
||||
"NOT re-derive after 0-byte artifact (silent serve-wrong)\n",
|
||||
drv, rows[r].tag);
|
||||
fail++;
|
||||
}
|
||||
if (runwait(prog) != EXPECT_EXIT) {
|
||||
fprintf(stderr, "poison FAIL[%s/%s]: heal prog exit != %d "
|
||||
"(empty/link-failed binary)\n", drv, rows[r].tag, EXPECT_EXIT);
|
||||
fail++;
|
||||
}
|
||||
/* re-store must leave both artifacts non-empty (write guard let a
|
||||
* good store through, AND a torn store never commits a 0-byte). */
|
||||
if (fsize(cwwi) <= 0 || fsize(cobj) <= 0) {
|
||||
fprintf(stderr, "poison FAIL[%s/%s]: cached artifact still "
|
||||
"0-byte after heal (re-store did not repair)\n",
|
||||
drv, rows[r].tag);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
#undef BUILD
|
||||
}
|
||||
|
||||
out:
|
||||
snprintf(cmd, sizeof cmd, "rm -rf %s", td);
|
||||
runwait(cmd);
|
||||
if (fail) {
|
||||
fprintf(stderr, "poison: %d check(s) failed\n", fail);
|
||||
return 1;
|
||||
}
|
||||
printf("poison: root->leaf via build_one_sep — a 0-byte cached P.wwi/P.o/both "
|
||||
"under a valid key self-heals (treated as MISS, re-derived to correct "
|
||||
"value, re-stored non-empty) — both stages\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user