// Pinned acceptance for P3.4b — failure paths are loud and machine-readable. // // Drives the BUILT `build/dist` binary (via `process.run`, like // publish_persist.sx) through every publish failure class the slice plan // names — a malformed manifest, a missing artifact file, an unknown // platform id, a declared-size mismatch, and a declared-sha256 mismatch — // and asserts the P3.4b contract for each: // // * exit code 1 (command failed; NOT the parser's EX_USAGE 64), // * stdout under `--json` is a SINGLE JSON object // `{"status":"error","error":{"code":,"message":...}}`, // * nothing is persisted: a fresh store gains no dist.db. // // The no-partial-state crux is then asserted against a NON-EMPTY store: // publish version A successfully, fail version B on a digest mismatch into // the SAME store, and require the store state unchanged — one release, // channel still pointing at A (no partially-published release, no moved // channel pointer). Store state is queried from `/dist.db` via the // SQLite bindings. #import "modules/std.sx"; #import "modules/std/json.sx"; process :: #import "modules/std/process.sx"; fs :: #import "modules/std/fs.sx"; sq :: #import "vendors/sqlite/sqlite.sx"; STORE :: ".sx-tmp/publish_fail"; MDIR :: ".sx-tmp/publish_fail_m"; // Manifests share the committed 5-byte fixtures; paths resolve relative to // the manifest's own directory (MDIR), so ../../examples/fixtures reaches // them. GOOD_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; BAD_DIGEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\"}]}"; BAD_SIZE :: "{\"app\":\"acme-app\",\"version\":\"1.2.5\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"size\":9999}]}"; BAD_PLATFORM:: "{\"app\":\"acme-app\",\"version\":\"1.2.6\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"playstation\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; NO_ARTIFACT :: "{\"app\":\"acme-app\",\"version\":\"1.2.7\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"no-such-file.apk\"}]}"; NOT_JSON :: "this is not a manifest"; get :: (o: Object, key: string) -> Value { i := 0; while i < o.len { if o.items[i].key == key { return o.items[i].val; } i += 1; } process.assert(false, concat("missing json key: ", key)); dummy : Value = .null_; return dummy; } get_str :: (o: Object, key: string) -> string { return get(o, key).str; } get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; } get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; } publish_cmd :: (mpath: string, store: string) -> string { c := concat("build/dist ci publish --manifest ", mpath); c = concat(c, concat(" --local-store ", store)); return concat(c, " --json 2>/dev/null"); } // One-row scalar queries over `/dist.db` ("" = unbound binding). db_open_ro :: () -> sq.Sqlite { c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY); process.assert(!oe, "dist.db must open as a SQLite database"); c.busy_timeout(2000); return c; } q_text :: (sql: string, p1: string) -> string { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_text(0); st.finalize(); c.close(); return out; } q_int :: (sql: string, p1: string) -> i64 { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_int64(0); st.finalize(); c.close(); return out; } // Write `body` to `path` via the shell (single-quoted, so the JSON's double // quotes pass through literally). write_file :: (path: string, body: string) { cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); process.run(cmd); } // Run one failing publish and assert the full failure contract: exit 1, // stdout is exactly one JSON error object, and its error.code equals // `want_code`. `store` distinguishes the per-case fresh stores. assert_fails :: (label: string, mpath: string, store: string, want_code: string, scratch: Allocator) { r := process.run(publish_cmd(mpath, store)); process.assert(r != null, concat("spawn failed: ", label)); res := r!; process.assert(res.exit_code == 1, concat("must exit 1 (command failed): ", label)); v, e := parse(res.stdout, scratch); if e { process.assert(false, concat("stdout must be one JSON object: ", label)); return; } o := v.object; process.assert(get_str(o, "status") == "error", concat("status must be \"error\": ", label)); eo := get_obj(o, "error"); process.assert(get_str(eo, "code") == want_code, concat("error.code mismatch: ", label)); process.assert(get_str(eo, "message").len > 0, concat("error.message must be non-empty: ", label)); out(concat(concat(" ", label), ": exit 1 + JSON error ok\n")); } main :: () -> i32 { gpa := GPA.init(); arena := Arena.init(xx gpa, 1 << 20); defer arena.deinit(); process.run(concat("rm -rf ", STORE)); process.run(concat("rm -rf ", MDIR)); process.run(concat("mkdir -p ", MDIR)); write_file(path_join(MDIR, "good_a.json"), GOOD_A); write_file(path_join(MDIR, "bad_digest.json"), BAD_DIGEST); write_file(path_join(MDIR, "bad_size.json"), BAD_SIZE); write_file(path_join(MDIR, "bad_platform.json"), BAD_PLATFORM); write_file(path_join(MDIR, "no_artifact.json"), NO_ARTIFACT); write_file(path_join(MDIR, "not_json.json"), NOT_JSON); // ── each failure class: exit 1 + the precise dotted code, into a // fresh per-case store that must gain NO database ──────────────── assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena); assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena); assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena); assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena); assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena); process.assert(!fs.exists(concat(STORE, "-digest/dist.db")), "failed publish into a fresh store must not create dist.db"); // ── no-partial-state against a NON-EMPTY store ──────────────────── // Publish A successfully, then fail B on a digest mismatch into the // SAME store: the db must be unchanged (one release, channel → A). ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE)); process.assert(ra != null, "spawn publish A failed"); process.assert(ra!.exit_code == 0, "publish A must exit 0"); rb := process.run(publish_cmd(path_join(MDIR, "bad_digest.json"), STORE)); process.assert(rb != null, "spawn failing publish B failed"); process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)"); process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after failed B: db still has ONE release (A)"); process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after failed B: one channel"); process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-1.2.3", "after failed B: channel still points at A (no moved pointer)"); print(" non-empty store: failed publish left the db unchanged\n"); process.run(concat("rm -rf ", concat(STORE, "-digest"))); process.run(concat("rm -rf ", concat(STORE, "-size"))); process.run(concat("rm -rf ", concat(STORE, "-platform"))); process.run(concat("rm -rf ", concat(STORE, "-missing"))); process.run(concat("rm -rf ", concat(STORE, "-badjson"))); process.run(concat("rm -rf ", STORE)); process.run(concat("rm -rf ", MDIR)); print("publish_fail: ALL CASES PASS\n"); return 0; }