Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / assets / solaris-11.svg
Author[]Andy Green <andy@warmcat.com> 2026-09-10 07:23 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-09-10 07:23 UTC
Tree1d8b9a8aa1a56833fccad0eb8f5a29e3aeaf0093   Raw Patch
 
web: relay ad-hoc build requests, answer cloneinfo locally
web: relay ad-hoc build requests, answer cloneinfo locally

 - com.warmcat.sai.cloneinfo (admin only) is answered from the shared
   databases with what the dialog needs: the seed task's build dimension,
   platform and expanded script, plus the project's "_" scratch branches
   as most recently pushed, newest first, so the dialog can default to the
   latest one.

 - com.warmcat.sai.taskclone (admin only) is sanity-checked and forwarded
   to sai-server, which validates again and resolves the ref itself.

 - browser -> sai-web messages are reassembled when they arrive in more
   than one fragment; the rx handler parses one-shot and the forward to
   sai-server wants the whole message.

 - the events table gets the adhoc column added at startup here too, and
   the project head status badge skips ad-hoc events.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/src/web/w-comms.c b/src/web/w-comms.c index e9739f4..3daf952 100644 --- a/src/web/w-comms.c +++ b/src/web/w-comms.c @@ -72,6 +72,12 @@ static const char * const well_known[] = { */ #define SAIW_BROWSER_MAX_CONNS 100 +/* + * Cap on a reassembled browser -> sai-web message. The largest legitimate + * one is a taskclone: a 4KiB build script JSON-escaped, plus small fields. + */ +#define SAIW_BROWSER_RX_REASM_MAX 32768 + int sai_get_head_status(struct vhd *vhd, const char *projname) { @@ -80,8 +86,12 @@ sai_get_head_status(struct vhd *vhd, const char *projname) sai_event_t *e; int state; - if (lws_struct_sq3_deserialize(vhd->pdb, NULL, "created ", - lsm_schema_sq3_map_event, + /* + * Ad-hoc events are scratch builds seeded by an admin; they don't + * say anything about the state of the branch, so skip them + */ + if (lws_struct_sq3_deserialize(vhd->pdb, " and ifnull(adhoc,0)=0", + "created ", lsm_schema_sq3_map_event, &o, &ac, 0, -1)) return -1; @@ -288,6 +298,13 @@ w_callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user, NULL, NULL, &err); if (err) sqlite3_free(err); + + err = NULL; + sqlite3_exec(vhd->pdb, + "ALTER TABLE events ADD COLUMN adhoc integer;", + NULL, NULL, &err); + if (err) + sqlite3_free(err); } sai_sqlite3_statement(vhd->pdb, "CREATE UNIQUE INDEX IF NOT EXISTS idx_event_uuid ON events(uuid);", "create event index"); @@ -795,6 +812,7 @@ http_resp: lwsl_wsi_info(wsi, "CLOSED browse conn"); lws_buflist2_destroy_all_segments(&pss->raw_tx); + lws_buflist_destroy_all_segments(&pss->rx_reasm); saiw_browser_state_changed(pss, 0); lws_dll2_remove(&pss->subs_list); lws_sul_cancel(&pss->sul_logcache); @@ -818,13 +836,69 @@ http_resp: // lwsl_user("SWT_BROWSE RX: %d\n", (int)len); /* - * Browser UI sent us something on websockets + * Browser UI sent us something on websockets. + * + * saiw_ws_json_rx_browser() parses one-shot and forwards to + * sai-server as a unit, so a message that arrives in several + * fragments (eg, a taskclone with an edited build script) is + * reassembled here first. The reassembly buffer keeps LWS_PRE + * headroom because the forwarding path needs it. */ - if (saiw_ws_json_rx_browser(vhd, pss, in, len, (lws_is_first_fragment(wsi) ? LWSSS_FLAG_SOM : 0) | - (lws_is_final_fragment(wsi) ? LWSSS_FLAG_EOM : 0))) { - lwsl_wsi_err(wsi, "Closing because saiw_ws_json_rx_browser returned it"); + if (lws_is_first_fragment(wsi) && lws_is_final_fragment(wsi) && + !pss->rx_reasm) { + if (saiw_ws_json_rx_browser(vhd, pss, in, len, + LWSSS_FLAG_SOM | + LWSSS_FLAG_EOM)) { + lwsl_wsi_err(wsi, "Closing because saiw_ws_json_rx_browser returned it"); - return -1; + return -1; + } + break; + } + + if (lws_is_first_fragment(wsi)) + /* new message while holding fragments: drop the old */ + lws_buflist_destroy_all_segments(&pss->rx_reasm); + + if (lws_buflist_total_len(&pss->rx_reasm) + len > + SAIW_BROWSER_RX_REASM_MAX) { + lwsl_wsi_notice(wsi, "rx reassembly over size, dropping"); + lws_buflist_destroy_all_segments(&pss->rx_reasm); + break; + } + + if (len && lws_buflist_append_segment(&pss->rx_reasm, in, + len) < 0) { + lwsl_wsi_notice(wsi, "rx reassembly oom, dropping"); + lws_buflist_destroy_all_segments(&pss->rx_reasm); + break; + } + + if (!lws_is_final_fragment(wsi)) + break; + + { + size_t rl = lws_buflist_total_len(&pss->rx_reasm); + uint8_t *reasm = malloc(LWS_PRE + rl); + + if (!reasm) { + lws_buflist_destroy_all_segments(&pss->rx_reasm); + break; + } + + lws_buflist_linear_use(&pss->rx_reasm, reasm + LWS_PRE, + rl); + lws_buflist_destroy_all_segments(&pss->rx_reasm); + + n = saiw_ws_json_rx_browser(vhd, pss, reasm + LWS_PRE, + rl, LWSSS_FLAG_SOM | + LWSSS_FLAG_EOM); + free(reasm); + if (n) { + lwsl_wsi_err(wsi, "Closing because saiw_ws_json_rx_browser returned it"); + + return -1; + } } break; diff --git a/src/web/w-private.h b/src/web/w-private.h index fd95d26..47a7590 100644 --- a/src/web/w-private.h +++ b/src/web/w-private.h @@ -88,6 +88,11 @@ struct pss { struct vhd *vhd; * many events' worth of task data). */ struct lws_buflist2_owner raw_tx; + /* + * Reassembly of a fragmented browser -> sai-web message (eg, a + * taskclone carrying an edited build script), see LWS_CALLBACK_RECEIVE + */ + struct lws_buflist *rx_reasm; struct lws_dll2 same; /* owner: vhd.browsers */ struct lws_dll2 subs_list; diff --git a/src/web/w-ws-browser.c b/src/web/w-ws-browser.c index 12fa11e..074aed0 100644 --- a/src/web/w-ws-browser.c +++ b/src/web/w-ws-browser.c @@ -131,6 +131,14 @@ static const lws_struct_map_t lsm_schema_json_map_bwsrx[] = { "com.warmcat.sai.projlist"), LSM_SCHEMA (sai_browse_rx_branchlist_t, NULL, lsm_browser_branchlist, "com.warmcat.sai.branchlist"), + /* + * Ad-hoc builds (admin only): cloneinfo is answered locally with what + * the dialog needs to prefill, taskclone is forwarded to sai-server + */ + LSM_SCHEMA (sai_browse_rx_evinfo_t, NULL, lsm_browser_taskreset, + /* shares struct */ "com.warmcat.sai.cloneinfo"), + LSM_SCHEMA (sai_browse_rx_taskclone_t, NULL, lsm_taskclone, + "com.warmcat.sai.taskclone"), }; enum { @@ -155,8 +163,153 @@ enum { SAIM_WS_BROWSER_RX_BUILDER_VISIBILITY, SAIM_WS_BROWSER_RX_PROJLIST, SAIM_WS_BROWSER_RX_BRANCHLIST, + SAIM_WS_BROWSER_RX_CLONEINFO, + SAIM_WS_BROWSER_RX_TASKCLONE, }; +/* nonzero if s is exactly len hex chars, as task / event uuids are */ +static int +saiw_id_ok(const char *s, size_t len) +{ + return strlen(s) == len && sai_is_git_hash(s); +} + +/* + * Answer com.warmcat.sai.cloneinfo: everything the ad-hoc build dialog needs + * to prefill from the seed task, plus the project's scratch ("_"-prefixed) + * branches as most recently pushed, newest first, so the dialog can default + * to the latest one. + * + * Everything here comes from the databases sai-server maintains, which we + * read directly, so no round trip to the server is needed. + */ +static int +saiw_browser_send_cloneinfo(struct vhd *vhd, struct pss *pss, + const char *seed_uuid) +{ + struct lwsac *ac_t = NULL, *ac_e = NULL; + char event_uuid[33], esc[96], filt[160], *ebuf = NULL, *p, *end; + lws_dll2_owner_t o_t, o_e; + sqlite3_stmt *stmt = NULL; + uint8_t *rbuf = NULL; + sqlite3 *pdb = NULL; + int ret = 1, first = 1, n; + sai_event_t *e; + sai_task_t *t; + + sai_task_uuid_to_event_uuid(event_uuid, seed_uuid); + + /* the seed task's event, for the repo name and its current ref */ + + lws_sql_purify(esc, event_uuid, sizeof(esc)); + lws_snprintf(filt, sizeof(filt), " and uuid='%s'", esc); + n = lws_struct_sq3_deserialize(vhd->pdb, filt, NULL, + lsm_schema_sq3_map_event, &o_e, &ac_e, + 0, 1); + if (n < 0 || !o_e.head) { + lwsl_notice("%s: no event %s\n", __func__, event_uuid); + goto bail; + } + e = lws_container_of(o_e.head, sai_event_t, list); + + /* the seed task itself, latest run */ + + if (sai_event_db_ensure_open(vhd->context, &vhd->sqlite3_cache, + vhd->sqlite3_path_lhs, event_uuid, 0, + &pdb)) { + lwsl_notice("%s: no event db %s\n", __func__, event_uuid); + goto bail; + } + + lws_sql_purify(esc, seed_uuid, sizeof(esc)); + lws_snprintf(filt, sizeof(filt), " and uuid='%s'", esc); + n = lws_struct_sq3_deserialize(pdb, filt, "run desc", + lsm_schema_sq3_map_task, &o_t, &ac_t, + 0, 1); + sai_event_db_close(&vhd->sqlite3_cache, &pdb); + if (n < 0 || !o_t.head) { + lwsl_notice("%s: no task %s\n", __func__, seed_uuid); + goto bail; + } + t = lws_container_of(o_t.head, sai_task_t, list); + + /* + * The build script is up to 4KiB and JSON escaping can grow each + * byte to 6, so both the escape buffer and the reply are heap + */ + ebuf = malloc((sizeof(t->build) * 6) + 8); + rbuf = malloc(LWS_PRE + 32768); + if (!ebuf || !rbuf) + goto bail; + p = (char *)rbuf + LWS_PRE; + end = p + 32768; + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "{\"schema\":\"com.warmcat.sai.cloneinfo\"," + "\"seed_uuid\":\"%s\",", + lws_json_purify(esc, seed_uuid, sizeof(esc) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"repo_name\":\"%s\",", + lws_json_purify(esc, e->repo_name, sizeof(esc) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"ref\":\"%s\",", + lws_json_purify(esc, e->ref, sizeof(esc) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"taskname\":\"%s\",", + lws_json_purify(esc, t->taskname, sizeof(esc) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"platform\":\"%s\",", + lws_json_purify(esc, t->platform, sizeof(esc) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"build\":\"%s\",", + lws_json_purify(ebuf, t->build, + (int)(sizeof(t->build) * 6) + 7, NULL)); + + /* + * Scratch branches pushed for this project, newest first. The + * pushes table is created by sai-server; if it isn't there yet the + * prepare fails and the list is simply empty. + */ + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"refs\":["); + + if (sqlite3_prepare_v2(vhd->pdb, + "SELECT ref, hash FROM pushes WHERE repo_name = ? " + "AND ref LIKE 'refs/heads/\\_%' ESCAPE '\\' " + "ORDER BY created DESC LIMIT 20", + -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, e->repo_name, -1, SQLITE_STATIC); + + while (sqlite3_step(stmt) == SQLITE_ROW && + lws_ptr_diff_size_t(end, p) > 256) { + const char *rn = (const char *)sqlite3_column_text(stmt, 0), + *h = (const char *)sqlite3_column_text(stmt, 1); + + if (!rn || !h) + continue; + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "%s{\"ref\":\"%s\",", first ? "" : ",", + lws_json_purify(esc, rn, sizeof(esc) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"hash\":\"%s\"}", + lws_json_purify(esc, h, sizeof(esc) - 1, NULL)); + first = 0; + } + sqlite3_finalize(stmt); + } else + lwsl_info("%s: no pushes table\n", __func__); + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "]}"); + + saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss, rbuf + LWS_PRE, + lws_ptr_diff_size_t(p, (char *)rbuf + LWS_PRE), + LWS_WRITE_TEXT); + ret = 0; + +bail: + free(rbuf); + free(ebuf); + lwsac_free(&ac_t); + lwsac_free(&ac_e); + + return ret; +} + /* * For issuing combined task and event data back to browser @@ -724,7 +877,9 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf, a.top_schema_index == SAIM_WS_BROWSER_RX_BUILDERDELETE || a.top_schema_index == SAIM_WS_BROWSER_RX_OPENSHELL || a.top_schema_index == SAIM_WS_BROWSER_RX_CLOSESHELL || - a.top_schema_index == SAIM_WS_BROWSER_RX_PTYDATA)) { + a.top_schema_index == SAIM_WS_BROWSER_RX_PTYDATA || + a.top_schema_index == SAIM_WS_BROWSER_RX_CLONEINFO || + a.top_schema_index == SAIM_WS_BROWSER_RX_TASKCLONE)) { uint8_t unauth_buf[LWS_PRE + 128]; int n1 = lws_snprintf((char *)unauth_buf + LWS_PRE, sizeof(unauth_buf) - LWS_PRE, "{\"schema\":\"com.warmcat.sai.unauthorized\"}"); @@ -1031,6 +1186,46 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf, goto ok; } + case SAIM_WS_BROWSER_RX_CLONEINFO: + /* + * Admin wants to seed an ad-hoc build from this task; answer + * locally with what the dialog needs, nothing goes to the + * server until he submits the taskclone + */ + ei = (sai_browse_rx_evinfo_t *)a.dest; + if (!saiw_id_ok(ei->event_hash, 64)) { + lwsl_notice("%s: bad cloneinfo uuid\n", __func__); + goto soft_error; + } + saiw_browser_send_cloneinfo(vhd, pss, ei->event_hash); + goto ok; + + case SAIM_WS_BROWSER_RX_TASKCLONE: + { + sai_browse_rx_taskclone_t *tc = + (sai_browse_rx_taskclone_t *)a.dest; + + /* + * Sanity-check before forwarding; sai-server validates again + * and resolves the ref to a hash itself. A string that fills + * its array exactly was truncated by lws_struct on the way in + * and can't be what the user meant. + */ + if (!saiw_id_ok(tc->seed_uuid, 64) || + strncmp(tc->ref, "refs/", 5) || !sai_is_safe_ref(tc->ref) || + strlen(tc->ref) >= sizeof(tc->ref) - 1 || + !tc->build[0] || + strlen(tc->build) >= sizeof(tc->build) - 1) { + lwsl_notice("%s: dropping malformed taskclone\n", + __func__); + goto soft_error; + } + + lwsl_notice("%s: forwarding taskclone: seed %s, ref %s\n", + __func__, tc->seed_uuid, tc->ref); + break; /* forward it to sai-server with the rest */ + } + case SAIM_WS_BROWSER_RX_TASKREMOVEALLTRIES: case SAIM_WS_BROWSER_RX_TASKRESET:
Page fetched 0s ago, creation time: 5ms (vhost etag hits: 0%, cache hits: 0%)