Login
Documentation
Login
/*
 * StoneFossil — bridge implementation.
 *
 * Responsibilities, each isolated in its own helper:
 *   1. invoke_fossil()          run fossil_main() once, capturing stdout/stderr.
 *   2. stone_fossil_run()       public wrapper for one-shot commands.
 *   3. the loopback HTTP server reads one request, hands it to
 *      `fossil http <repo> --in REQ --out RESP --localauth`, returns the reply.
 *
 * The server mirrors exactly how Fossil's own `server`/`ui` commands dispatch
 * work (spawning `fossil http --in --out` per connection) — but in-process,
 * so no fork/exec is needed (and none is permitted on iOS).
 */

#include "StoneFossil.h"

#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>

/* Fossil's own bundled SQLite (extsrc/sqlite3.c, compiled into this same
 * archive -- see build-fossil-xcframework.sh/linux-crosslink-test.sh). Used
 * only by stone_fossil_query() below, which is the ONE place other than
 * Fossil's own command dispatch that touches a .fossil file's SQLite
 * directly -- deliberately the same library instance. */
#include "sqlite3.h"

/* Provided by the vendored Fossil core (src/main.c). */
extern int fossil_main(int argc, char **argv);

/* One lock guards every entry into Fossil (global state + single-threaded
 * SQLite). It is recursive only conceptually: we never re-enter under it. */
static pthread_mutex_t g_fossil_lock = PTHREAD_MUTEX_INITIALIZER;

/* ------------------------------------------------------------------ */
/* exit() interception                                                 */
/* ------------------------------------------------------------------ */
/*
 * Fossil is built around one-process-per-request: after serving an HTTP
 * request it funnels through fossil_exit() -> exit(), tearing down the whole
 * process. That is wrong for an in-process server, so the Fossil core is
 * compiled with -Dexit=stone_exit (see the build scripts). When a Fossil call
 * is "armed" (we are inside invoke_fossil), stone_exit longjmp()s back to the
 * shim instead of terminating; otherwise it behaves like the real _exit().
 *
 * This is safe because fossil_exit() already calls db_close() before exiting,
 * so the repository is flushed and closed before we unwind. Each fossil_main()
 * begins with memset(&g,0,...), so global state is reset for the next request.
 * The single g_fossil_lock guarantees only one armed call at a time, so a
 * single jmp_buf suffices.
 */
static jmp_buf g_exit_jmp;
static int g_exit_armed = 0;
static int g_exit_code = 0;

/* Replaces exit() inside the Fossil core (via -Dexit=stone_exit). */
__attribute__((noreturn)) void stone_exit(int rc) {
    if (g_exit_armed) {
        g_exit_code = rc;
        g_exit_armed = 0;
        longjmp(g_exit_jmp, 1);
    }
    _exit(rc);
}

/* ------------------------------------------------------------------ */
/* Temp-file helpers                                                   */
/* ------------------------------------------------------------------ */

/* Build a unique temp path and open it. Returns fd (>=0) or -1.
 * On success, *out_path is a malloc()'d path the caller must free+unlink. */
static int open_tempfile(const char *tag, char **out_path) {
    const char *dir = getenv("TMPDIR");
    if (dir == NULL || dir[0] == '\0') dir = "/tmp";

    size_t need = strlen(dir) + strlen(tag) + 32;
    char *path = (char *)malloc(need);
    if (path == NULL) return -1;
    snprintf(path, need, "%s/stone-%s-XXXXXX", dir, tag);

    int fd = mkstemp(path);
    if (fd < 0) {
        free(path);
        return -1;
    }
    *out_path = path;
    return fd;
}

/* Read an entire file into a NUL-terminated malloc()'d buffer.
 * Returns the byte length (excluding the terminator), or -1 on error. */
static long read_file_all(const char *path, char **out_buf) {
    FILE *f = fopen(path, "rb");
    if (f == NULL) return -1;
    if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return -1; }
    long n = ftell(f);
    if (n < 0) { fclose(f); return -1; }
    rewind(f);

    char *buf = (char *)malloc((size_t)n + 1);
    if (buf == NULL) { fclose(f); return -1; }
    size_t got = fread(buf, 1, (size_t)n, f);
    fclose(f);
    buf[got] = '\0';
    *out_buf = buf;
    return (long)got;
}

/* ------------------------------------------------------------------ */
/* Core: run fossil_main() with stdout/stderr captured to a buffer.    */
/* ------------------------------------------------------------------ */

/* Fossil needs a user identity for clone/commit. iOS sets no USER environment
 * variable and there is no global Fossil config, so seed a sensible default
 * once. setenv(..., 0) leaves any identity the host already provided intact. */
static pthread_once_t g_env_once = PTHREAD_ONCE_INIT;
static void stone_init_env(void) {
    setenv("USER", "stone", 0);
    setenv("FOSSIL_USER", "stone", 0);
}

void stone_fossil_set_user(const char *user) {
    if (user == NULL || user[0] == '\0') return;
    setenv("USER", user, 1);
    setenv("FOSSIL_USER", user, 1);
}

void stone_fossil_set_ca_file(const char *path) {
    if (path == NULL || path[0] == '\0') return;
    setenv("SSL_CERT_FILE", path, 1);
}

void stone_fossil_set_home(const char *path) {
    if (path == NULL || path[0] == '\0') return;
    setenv("FOSSIL_HOME", path, 1);
}

/* argv here is the FULL vector including "fossil" at index 0. */
static int invoke_fossil(int argc, char *argv[], char **out_text) {
    pthread_once(&g_env_once, stone_init_env);
    pthread_mutex_lock(&g_fossil_lock);

    char *cap_path = NULL;
    int cap_fd = -1;
    int saved_out = -1, saved_err = -1;

    if (out_text != NULL) {
        cap_fd = open_tempfile("out", &cap_path);
        if (cap_fd >= 0) {
            fflush(stdout);
            fflush(stderr);
            saved_out = dup(STDOUT_FILENO);
            saved_err = dup(STDERR_FILENO);
            dup2(cap_fd, STDOUT_FILENO);
            dup2(cap_fd, STDERR_FILENO);
        }
    }

    /* Arm exit interception so a request that ends in fossil_exit()/exit()
     * unwinds back here instead of terminating the process. */
    int rc;
    g_exit_code = 0;
    if (setjmp(g_exit_jmp) == 0) {
        g_exit_armed = 1;
        rc = fossil_main(argc, argv);
    } else {
        /* Returned via stone_exit()/longjmp after serving the request. */
        rc = g_exit_code;
    }
    g_exit_armed = 0;

    if (out_text != NULL && cap_fd >= 0) {
        fflush(stdout);
        fflush(stderr);
        dup2(saved_out, STDOUT_FILENO);
        dup2(saved_err, STDERR_FILENO);
        close(saved_out);
        close(saved_err);
        close(cap_fd);

        char *buf = NULL;
        if (read_file_all(cap_path, &buf) >= 0) {
            *out_text = buf;
        } else {
            *out_text = NULL;
        }
        unlink(cap_path);
        free(cap_path);
    }

    pthread_mutex_unlock(&g_fossil_lock);
    return rc;
}

int stone_fossil_run(int argc, const char *const argv[], char **out_text) {
    if (out_text) *out_text = NULL;

    /* Prepend "fossil" to form the full argument vector. */
    int full = argc + 1;
    char **vec = (char **)calloc((size_t)full + 1, sizeof(char *));
    if (vec == NULL) return -1;
    vec[0] = strdup("fossil");
    for (int i = 0; i < argc; i++) vec[i + 1] = strdup(argv[i]);

    /* Fossil edits the argv it is given in place: find_option() removes
     * each option it consumes by shifting the later entries left, so after
     * e.g. "bundle import B -R R --publish --force" vec[] holds some
     * pointers twice and others not at all. Freeing vec[] afterwards was a
     * double free (SIGABRT in stone_fossil_run on the phone, sending a
     * conversation reply). Free the strings from this untouched copy. */
    char **owned = (char **)malloc((size_t)full * sizeof(char *));
    if (owned == NULL) {
        for (int i = 0; i < full; i++) free(vec[i]);
        free(vec);
        return -1;
    }
    memcpy(owned, vec, (size_t)full * sizeof(char *));

    int rc = invoke_fossil(full, vec, out_text);

    for (int i = 0; i < full; i++) free(owned[i]);
    free(owned);
    free(vec);
    return rc;
}

/* ------------------------------------------------------------------ */
/* Loopback HTTP server                                                */
/* ------------------------------------------------------------------ */

typedef struct {
    int listen_fd;
    int port;
    char *repo_path;
    pthread_t thread;
    volatile int running;
} ServerState;

static ServerState g_server = {.listen_fd = -1, .port = 0, .repo_path = NULL,
                               .running = 0};

/* Guards g_server.repo_path so it can be swapped (retargeted) from another
 * thread while the accept loop is reading it between requests. */
static pthread_mutex_t g_repo_lock = PTHREAD_MUTEX_INITIALIZER;

/* Read exactly n bytes worth of one HTTP request from the socket into a temp
 * file: request line + headers (until CRLFCRLF) plus Content-Length body.
 * Returns a malloc()'d temp path on success (caller unlink+free), or NULL. */
static char *slurp_request(int fd) {
    size_t cap = 8192, len = 0;
    char *buf = (char *)malloc(cap);
    if (buf == NULL) return NULL;

    long header_end = -1;       /* index just past the CRLFCRLF */
    long content_length = 0;
    long total_needed = -1;

    for (;;) {
        if (len == cap) {
            cap *= 2;
            char *nb = (char *)realloc(buf, cap);
            if (nb == NULL) { free(buf); return NULL; }
            buf = nb;
        }
        ssize_t r = read(fd, buf + len, cap - len);
        if (r < 0) {
            if (errno == EINTR) continue;
            free(buf);
            return NULL;
        }
        if (r == 0) break; /* peer closed */
        len += (size_t)r;

        if (header_end < 0) {
            /* Look for end of headers. */
            for (size_t i = 3; i < len; i++) {
                if (buf[i - 3] == '\r' && buf[i - 2] == '\n' &&
                    buf[i - 1] == '\r' && buf[i] == '\n') {
                    header_end = (long)i + 1;
                    break;
                }
            }
            if (header_end >= 0) {
                /* Parse Content-Length (case-insensitive) within headers. */
                for (long i = 0; i + 15 < header_end; i++) {
                    if (strncasecmp(buf + i, "content-length:", 15) == 0) {
                        content_length = strtol(buf + i + 15, NULL, 10);
                        if (content_length < 0) content_length = 0;
                        break;
                    }
                }
                total_needed = header_end + content_length;
            }
        }
        if (total_needed >= 0 && (long)len >= total_needed) break;
    }

    if (header_end < 0) { free(buf); return NULL; }

    char *path = NULL;
    int tfd = open_tempfile("req", &path);
    if (tfd < 0) { free(buf); return NULL; }
    long want = (total_needed >= 0) ? total_needed : (long)len;
    if (want > (long)len) want = (long)len;
    ssize_t w = write(tfd, buf, (size_t)want);
    close(tfd);
    free(buf);
    if (w != want) { unlink(path); free(path); return NULL; }
    return path;
}

/* Write the whole response buffer to the socket, then close the write side. */
static void send_all(int fd, const char *data, long len) {
    long off = 0;
    while (off < len) {
        ssize_t w = write(fd, data + off, (size_t)(len - off));
        if (w < 0) {
            if (errno == EINTR) continue;
            break;
        }
        off += w;
    }
    shutdown(fd, SHUT_WR);
}

static void handle_connection(int cfd) {
    char *req_path = slurp_request(cfd);
    if (req_path == NULL) return;

    char *resp_path = NULL;
    int rfd = open_tempfile("resp", &resp_path);
    if (rfd < 0) { unlink(req_path); free(req_path); return; }
    close(rfd); /* fossil opens it by name for writing */

    char baseurl[64];
    /* The listening socket is deliberately IPv4-only (INADDR_LOOPBACK).
     * Use its literal address in Fossil's generated redirects too: WKWebView
     * may resolve localhost to ::1 first, where this server is not listening. */
    snprintf(baseurl, sizeof(baseurl), "http://127.0.0.1:%d", g_server.port);

    /* Snapshot the current repo path so a concurrent retarget can't free it
     * out from under this request. */
    pthread_mutex_lock(&g_repo_lock);
    char *repo = g_server.repo_path ? strdup(g_server.repo_path) : NULL;
    pthread_mutex_unlock(&g_repo_lock);
    if (repo == NULL) {
        unlink(req_path); free(req_path);
        unlink(resp_path); free(resp_path);
        return;
    }

    char *argv[] = {
        "fossil", "http", repo,
        "--in", req_path,
        "--out", resp_path,
        "--ipaddr", "127.0.0.1",
        "--baseurl", baseurl,
        "--localauth",   /* localhost gets full admin/read-write access */
        "--nossl",
        "--nocompress",
        NULL};
    int argc = (int)(sizeof(argv) / sizeof(argv[0])) - 1;

    invoke_fossil(argc, argv, NULL);
    free(repo);

    char *resp = NULL;
    long n = read_file_all(resp_path, &resp);
    if (n >= 0 && resp != NULL) {
        send_all(cfd, resp, n);
        free(resp);
    }

    unlink(req_path);
    free(req_path);
    unlink(resp_path);
    free(resp_path);
}

/* Serve one accepted connection, then close it. Runs on its own detached
 * thread (see accept_loop). The Fossil work it triggers is still serialized by
 * g_fossil_lock inside invoke_fossil; threading here only keeps a slow or idle
 * socket from blocking other connections. */
static void *connection_thread(void *arg) {
    int cfd = *(int *)arg;
    free(arg);
    handle_connection(cfd);
    close(cfd);
    return NULL;
}

static void *accept_loop(void *arg) {
    (void)arg;
    while (g_server.running) {
        int cfd = accept(g_server.listen_fd, NULL, NULL);
        if (cfd < 0) {
            if (errno == EINTR) continue;
            break; /* listen socket closed -> shut down */
        }
        /* Hand each connection to its own short-lived thread. A browser opens
         * several connections per page (and speculative/preconnect sockets that
         * may stay idle); serving them serially here would let one stalled
         * read() block the request the page is actually waiting on. */
        int *pfd = (int *)malloc(sizeof(int));
        if (pfd == NULL) { close(cfd); continue; }
        *pfd = cfd;
        pthread_t t;
        if (pthread_create(&t, NULL, connection_thread, pfd) != 0) {
            free(pfd);
            close(cfd);
            continue;
        }
        pthread_detach(t);
    }

    /* Falling out of the loop means the listening socket is no longer being
     * serviced (either a deliberate shutdown via stone_fossil_server_stop(),
     * which already closed listen_fd itself, or accept() failing for a
     * reason other than EINTR -- e.g. fd exhaustion under load from a large
     * repo's many concurrent WKWebView connections). Either way, reflect
     * that here so a future stone_fossil_server_start() call sees a truthful
     * g_server.running and can recover by starting a fresh listener, instead
     * of believing a silently-dead server is still healthy and handing back
     * a URL nothing is listening on -- which is what "Couldn't load the
     * local Fossil page: Could not connect to the server" looks like from
     * the WebView's side once this happens once, and never goes away for
     * the rest of the app session (see the fix note below).
     */
    g_server.running = 0;
    if (g_server.listen_fd >= 0) {
        close(g_server.listen_fd);
        g_server.listen_fd = -1;
    }
    return NULL;
}

int stone_fossil_server_start(const char *repo_path, int *out_port) {
    if (repo_path == NULL || out_port == NULL) return -1;
    if (g_server.running) {
        /* Already up -- report the real port. Previously this returned
         * success without ever touching *out_port, which left the caller's
         * `var port: Int32 = 0` at its default and produced a URL like
         * http://127.0.0.1:0/ -- an address nothing can ever connect to.
         * That silently-wrong URL is exactly the "Couldn't connect to the
         * server" failure reported for BQ2: whatever repo is opened right
         * after the accept loop dies (see the cleanup added above) hits
         * this branch, since g_server.running was stale-true. */
        *out_port = g_server.port;
        return 0;
    }

    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) return -1;

    int yes = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 127.0.0.1 only */
    addr.sin_port = 0;                              /* OS-assigned port */

    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        close(fd);
        return -1;
    }
    socklen_t alen = sizeof(addr);
    if (getsockname(fd, (struct sockaddr *)&addr, &alen) < 0) {
        close(fd);
        return -1;
    }
    if (listen(fd, 16) < 0) {
        close(fd);
        return -1;
    }

    g_server.listen_fd = fd;
    g_server.port = ntohs(addr.sin_port);
    g_server.repo_path = strdup(repo_path);
    g_server.running = 1;

    if (pthread_create(&g_server.thread, NULL, accept_loop, NULL) != 0) {
        g_server.running = 0;
        close(fd);
        g_server.listen_fd = -1;
        free(g_server.repo_path);
        g_server.repo_path = NULL;
        return -1;
    }

    *out_port = g_server.port;
    return 0;
}

int stone_fossil_server_set_repo(const char *repo_path) {
    if (repo_path == NULL) return -1;
    if (!g_server.running) return -1;

    char *copy = strdup(repo_path);
    if (copy == NULL) return -1;

    pthread_mutex_lock(&g_repo_lock);
    free(g_server.repo_path);
    g_server.repo_path = copy;
    pthread_mutex_unlock(&g_repo_lock);
    return 0;
}

void stone_fossil_server_stop(void) {
    if (!g_server.running) return;
    g_server.running = 0;
    if (g_server.listen_fd >= 0) {
        shutdown(g_server.listen_fd, SHUT_RDWR);
        close(g_server.listen_fd);
        g_server.listen_fd = -1;
    }
    pthread_join(g_server.thread, NULL);
    pthread_mutex_lock(&g_repo_lock);
    free(g_server.repo_path);
    g_server.repo_path = NULL;
    pthread_mutex_unlock(&g_repo_lock);
    g_server.port = 0;
}

/* ------------------------------------------------------------------ */
/* Read-only query (MaintainerRequestScanner)                          */
/* ------------------------------------------------------------------ */

/* Append `srclen` bytes at `src` to the growable buffer `*bufp`/`*lenp`
 * (capacity `*capp`), growing it as needed. Returns 0 on success, -1 on
 * allocation failure (buffer already freed in that case). */
static int query_buf_append(char **bufp, size_t *lenp, size_t *capp,
                             const char *src, size_t srclen) {
    size_t need = *lenp + srclen + 1;
    if (need > *capp) {
        size_t newcap = *capp;
        while (newcap < need) newcap *= 2;
        char *nb = (char *)realloc(*bufp, newcap);
        if (nb == NULL) {
            free(*bufp);
            *bufp = NULL;
            return -1;
        }
        *bufp = nb;
        *capp = newcap;
    }
    memcpy(*bufp + *lenp, src, srclen);
    *lenp += srclen;
    (*bufp)[*lenp] = '\0';
    return 0;
}

/* Provided by the patched Fossil core (src/main.c; scripts/fossil-inprocess.patch):
 * set on a thread whose SQLite errors must not go through Fossil's logger. */
extern __thread int stoneLogMuted;

/* How long a read waits for a sync's write to finish before giving up. A
 * write's COMMIT holds the file exclusively, briefly; with no wait, a read
 * landing in it failed at once and read as "nothing here". */
#define STONE_QUERY_BUSY_MS 2000

static int query_read(const char *repo_path, const char *sql, char **out_text) {
    sqlite3 *db = NULL;
    /* Read-only + no-mutex, same reasoning as the caller this replaces: this
     * takes no write lock of its own, and waits a bounded time for one the
     * embedded Fossil engine or an in-flight sync is holding. */
    int flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX;
    if (sqlite3_open_v2(repo_path, &db, flags, NULL) != SQLITE_OK || db == NULL) {
        if (db) sqlite3_close(db);
        return -1;
    }
    sqlite3_busy_timeout(db, STONE_QUERY_BUSY_MS);

    sqlite3_stmt *stmt = NULL;
    if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK || stmt == NULL) {
        sqlite3_close(db);
        return -1;
    }

    size_t cap = 4096, len = 0;
    char *buf = (char *)malloc(cap);
    if (buf == NULL) {
        sqlite3_finalize(stmt);
        sqlite3_close(db);
        return -1;
    }
    buf[0] = '\0';

    int rc;
    int failed = 0;
    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
        int n = sqlite3_column_count(stmt);
        for (int i = 0; i < n; i++) {
            if (i > 0 && query_buf_append(&buf, &len, &cap, "\x1F", 1) != 0) {
                failed = 1;
                break;
            }
            const unsigned char *text = sqlite3_column_text(stmt, i);
            if (text != NULL &&
                query_buf_append(&buf, &len, &cap, (const char *)text, strlen((const char *)text)) != 0) {
                failed = 1;
                break;
            }
        }
        if (failed) break;
        if (query_buf_append(&buf, &len, &cap, "\x1E", 1) != 0) {
            failed = 1;
            break;
        }
    }

    sqlite3_finalize(stmt);
    sqlite3_close(db);

    if (failed || rc != SQLITE_DONE) {
        free(buf);
        return -1;
    }

    *out_text = buf;
    return 0;
}

int stone_fossil_query(const char *repo_path, const char *sql, char **out_text) {
    if (repo_path == NULL || sql == NULL || out_text == NULL) return -1;
    *out_text = NULL;
    /* SQLite's log callback is process-wide: this read's errors would go into
     * a concurrent sync's output, from this thread, and read as the sync
     * failing (SQLITE_BUSY "in PRAGMA table_info(ticketchng)", "SQL: COMMIT"
     * on the phone, 2026-11-02). They come back to the caller as -1 instead. */
    stoneLogMuted = 1;
    int rc = query_read(repo_path, sql, out_text);
    stoneLogMuted = 0;
    return rc;
}

/* ------------------------------------------------------------------ */
/* Cards (the console's card/NAME definitions)                         */
/* ------------------------------------------------------------------ */
/*
 * The same meaning as OCX's src/ocx/fossil/cards.cr and
 * src/ocx/commands/cards.cr (ollama check-in 973aca3f4c), checked against
 * its shared examples by scripts/linux-cards-driver.c. All JSON is read and
 * written by SQLite's own JSON functions, on an in-memory connection; each
 * repository is read on its own read-only connection, as in
 * stone_fossil_query.
 */

#define CARD_WRITTEN "%Y-%m-%dT%H:%M:%SZ"
/* The same, inside a sqlite3_mprintf format. */
#define CARD_WRITTEN_F "%%Y-%%m-%%dT%%H:%%M:%%SZ"
#define CARD_MAX_KEYS 64

/* The first column of the first row of `sql`, or NULL (no row, a NULL
 * value, or an error). Free with sqlite3_free. */
static char *card_scalar(sqlite3 *db, const char *sql) {
    sqlite3_stmt *stmt = NULL;
    char *out = NULL;
    if (sql == NULL || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
    if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL)
        out = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(stmt, 0));
    sqlite3_finalize(stmt);
    return out;
}

/* `path` of the definition when its JSON type is `type` ("text",
 * "integer", "array", "object", or "boolean" for true/false), else NULL.
 * Objects and arrays come back as JSON text. Free with sqlite3_free. */
static char *card_def(sqlite3 *w, const char *def, const char *path, const char *type) {
    sqlite3_stmt *stmt = NULL;
    char *out = NULL;
    const char *sql = "SELECT CASE WHEN ?3 = 'boolean' THEN json_type(?1, ?2) IN ('true','false')"
                      " ELSE json_type(?1, ?2) = ?3 END, json_extract(?1, ?2)";
    if (sqlite3_prepare_v2(w, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
    sqlite3_bind_text(stmt, 1, def, -1, SQLITE_STATIC);
    sqlite3_bind_text(stmt, 2, path, -1, SQLITE_STATIC);
    sqlite3_bind_text(stmt, 3, type, -1, SQLITE_STATIC);
    if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_int(stmt, 0) == 1)
        out = sqlite3_mprintf("%s", sqlite3_column_type(stmt, 1) == SQLITE_NULL
                                        ? "" : (const char *)sqlite3_column_text(stmt, 1));
    sqlite3_finalize(stmt);
    return out;
}

static int card_is_word(const char *s) {
    if (s == NULL || *s == '\0') return 0;
    for (; *s; s++) if (!((*s >= 'a' && *s <= 'z') || *s == '_')) return 0;
    return 1;
}

/* A field name -> SQL expression map, in insertion order. */
typedef struct {
    int n;
    char *key[CARD_MAX_KEYS];
    char *expr[CARD_MAX_KEYS];
} card_map;

static void card_map_set(card_map *m, const char *key, char *expr) {
    for (int i = 0; i < m->n; i++) {
        if (strcmp(m->key[i], key) == 0) {
            sqlite3_free(m->expr[i]);
            m->expr[i] = expr;
            return;
        }
    }
    if (m->n == CARD_MAX_KEYS) { sqlite3_free(expr); return; }
    m->key[m->n] = sqlite3_mprintf("%s", key);
    m->expr[m->n++] = expr;
}

static const char *card_map_get(const card_map *m, const char *key) {
    for (int i = 0; i < m->n; i++) if (strcmp(m->key[i], key) == 0) return m->expr[i];
    return NULL;
}

static void card_map_free(card_map *m) {
    for (int i = 0; i < m->n; i++) { sqlite3_free(m->key[i]); sqlite3_free(m->expr[i]); }
    m->n = 0;
}

static int card_has(char *const list[], int n, const char *key) {
    for (int i = 0; i < n; i++) if (strcmp(list[i], key) == 0) return 1;
    return 0;
}

/* One repository's matching items as a JSON array (sqlite3_free), "[]" when
 * it has none or lacks a table or column; its newest dated event goes to
 * *as_of. NULL when the repository can't be read. */
static char *card_repo_rows(const char *repo_path, const char *kind, const char *tag,
                            const char *value, const char *login, sqlite3_int64 seconds,
                            const char *now, char *const fields[], int nfields, char **as_of) {
    sqlite3 *db = NULL;
    if (sqlite3_open_v2(repo_path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, NULL) != SQLITE_OK) {
        if (db) sqlite3_close(db);
        return NULL;
    }
    /* Wait out a sync's COMMIT, as query_read does. */
    sqlite3_busy_timeout(db, STONE_QUERY_BUSY_MS);
    char *out = NULL;
    card_map source = {0};
    char *columns[CARD_MAX_KEYS];
    int ncolumns = 0;
    const char *table = strcmp(kind, "tickets") == 0 ? "ticket"
                      : strcmp(kind, "checkins_tagged") == 0 ? "event" : "forumpost";

    *as_of = card_scalar(db, "SELECT strftime('" CARD_WRITTEN "', max(mtime)) FROM event WHERE type IN ('ci','f','t')");
    char *project = card_scalar(db, "SELECT value FROM config WHERE name='project-name'");
    if (project == NULL) project = sqlite3_mprintf("");

    sqlite3_stmt *stmt = NULL;
    char *sql = sqlite3_mprintf("SELECT name FROM pragma_table_info(%Q)", table);
    /* A missing table has no rows here; a locked file is an error, and must
     * not read as "no items". */
    int step = SQLITE_ERROR;
    if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
        while ((step = sqlite3_step(stmt)) == SQLITE_ROW && ncolumns < CARD_MAX_KEYS)
            columns[ncolumns++] = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(stmt, 0));
    }
    sqlite3_finalize(stmt);
    sqlite3_free(sql);
    if (step != SQLITE_DONE && step != SQLITE_ROW) {
        sqlite3_free(project);
        for (int i = 0; i < ncolumns; i++) sqlite3_free(columns[i]);
        sqlite3_close(db);
        sqlite3_free(*as_of);
        *as_of = NULL;
        return NULL;
    }

    int none = ncolumns == 0;
    if (!none && strcmp(kind, "tickets") == 0) {
        for (int i = 0; i < nfields && !none; i++) {
            const char *f = fields[i];
            if (!card_has(columns, ncolumns, f) && strcmp(f, "time") && strcmp(f, "hash")
                && strcmp(f, "project") && strcmp(f, "user")) none = 1;
        }
    }

    char *base = NULL, *where = NULL;
    if (!none) {
        card_map_set(&source, "project", sqlite3_mprintf("%Q", project));
        if (strcmp(kind, "posts_tagged") == 0 || strcmp(kind, "threads_active_within") == 0) {
            card_map_set(&source, "title", sqlite3_mprintf("substr(e.comment, instr(e.comment, ':') + 2)"));
            card_map_set(&source, "user", sqlite3_mprintf("e.user"));
            card_map_set(&source, "time", sqlite3_mprintf("strftime('" CARD_WRITTEN_F "', f.fmtime)"));
            card_map_set(&source, "hash", sqlite3_mprintf("b.uuid"));
            if (strcmp(kind, "posts_tagged") == 0) {
                base = sqlite3_mprintf("FROM forumpost f JOIN blob b ON b.rid=f.fpid JOIN event e ON e.objid=f.fpid "
                                       "JOIN tagxref x ON x.rid=f.fpid AND x.tagtype>0 JOIN tag t ON t.tagid=x.tagid ");
                where = sqlite3_mprintf("t.tagname=%Q AND coalesce(x.value,'')=%Q", tag, value ? value : "");
                if (login) {
                    char *more = sqlite3_mprintf("%s AND NOT EXISTS (SELECT 1 FROM forumpost later JOIN event le "
                        "ON le.objid=later.fpid WHERE later.froot=f.froot AND later.fmtime>f.fmtime AND le.user=%Q)",
                        where, login);
                    sqlite3_free(where);
                    where = more;
                }
            } else {
                base = sqlite3_mprintf("FROM forumpost f JOIN blob b ON b.rid=f.fpid JOIN event e ON e.objid=f.fpid ");
                where = sqlite3_mprintf("f.fmtime=(SELECT max(n.fmtime) FROM forumpost n WHERE n.froot=f.froot) "
                    "AND f.fmtime BETWEEN julianday(strftime('" CARD_WRITTEN_F "', %Q, '-%lld seconds')) "
                    "AND julianday(strftime('" CARD_WRITTEN_F "', %Q))", now, (long long)seconds, now);
            }
        } else if (strcmp(kind, "checkins_tagged") == 0) {
            base = sqlite3_mprintf("FROM event e JOIN blob b ON b.rid=e.objid JOIN tagxref x ON x.rid=e.objid "
                                   "AND x.tagtype>0 JOIN tag t ON t.tagid=x.tagid ");
            card_map_set(&source, "title", sqlite3_mprintf("coalesce(e.ecomment,e.comment)"));
            card_map_set(&source, "user", sqlite3_mprintf("e.user"));
            card_map_set(&source, "time", sqlite3_mprintf("strftime('" CARD_WRITTEN_F "', e.mtime)"));
            card_map_set(&source, "hash", sqlite3_mprintf("b.uuid"));
            where = sqlite3_mprintf("e.type='ci' AND t.tagname=%Q", tag);
        } else {
            base = sqlite3_mprintf("FROM ticket k ");
            card_map_set(&source, "title", sqlite3_mprintf("k.title"));
            card_map_set(&source, "user", sqlite3_mprintf("''"));
            card_map_set(&source, "time", sqlite3_mprintf("strftime('" CARD_WRITTEN_F "', k.tkt_mtime)"));
            card_map_set(&source, "hash", sqlite3_mprintf("k.tkt_uuid"));
            for (int i = 0; i < ncolumns; i++)
                if (card_is_word(columns[i])) card_map_set(&source, columns[i], sqlite3_mprintf("k.%s", columns[i]));
            where = sqlite3_mprintf("k.type=%Q AND k.status NOT IN ('Closed','Fixed','Resolved')", value ? value : "");
        }
        for (int i = 0; i < nfields; i++) if (card_map_get(&source, fields[i]) == NULL) none = 1;
    }

    if (none) {
        out = sqlite3_mprintf("[]");
    } else {
        /* shown: the fields, then project, title, time and hash, each once */
        static const char *const extra[] = {"project", "title", "time", "hash"};
        char *shown[CARD_MAX_KEYS + 4];
        int nshown = 0;
        for (int i = 0; i < nfields; i++)
            if (!card_has(shown, nshown, fields[i])) shown[nshown++] = fields[i];
        for (int i = 0; i < 4; i++)
            if (!card_has(shown, nshown, extra[i])) shown[nshown++] = (char *)extra[i];
        sqlite3_str *pairs = sqlite3_str_new(NULL);
        for (int i = 0; i < nshown; i++)
            sqlite3_str_appendf(pairs, "%s%Q, coalesce(CAST(%s AS TEXT),'')", i ? "," : "",
                                shown[i], card_map_get(&source, shown[i]));
        char *p = sqlite3_str_finish(pairs);
        char *q = sqlite3_mprintf("SELECT json_group_array(json(item)) FROM "
                                  "(SELECT json_object(%s) AS item %s WHERE %s)", p, base, where);
        out = card_scalar(db, q);
        sqlite3_free(p);
        sqlite3_free(q);
    }

    sqlite3_free(base);
    sqlite3_free(where);
    sqlite3_free(project);
    card_map_free(&source);
    for (int i = 0; i < ncolumns; i++) sqlite3_free(columns[i]);
    sqlite3_close(db);
    return out;
}

/* Run `sql` on w with up to four bound texts (NULL binds NULL). */
static int card_exec(sqlite3 *w, const char *sql, const char *a, const char *b, const char *c, const char *d) {
    sqlite3_stmt *stmt = NULL;
    if (sqlite3_prepare_v2(w, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
    const char *args[4] = {a, b, c, d};
    for (int i = 0; i < 4 && i < sqlite3_bind_parameter_count(stmt); i++)
        if (args[i]) sqlite3_bind_text(stmt, i + 1, args[i], -1, SQLITE_STATIC);
    int rc;
    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {}
    sqlite3_finalize(stmt);
    return rc == SQLITE_DONE ? 0 : -1;
}

/* Like card_exec, returning the first column of the first row. */
static char *card_value(sqlite3 *w, const char *sql, const char *a, const char *b) {
    sqlite3_stmt *stmt = NULL;
    char *out = NULL;
    if (sqlite3_prepare_v2(w, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
    if (a && sqlite3_bind_parameter_count(stmt) >= 1) sqlite3_bind_text(stmt, 1, a, -1, SQLITE_STATIC);
    if (b && sqlite3_bind_parameter_count(stmt) >= 2) sqlite3_bind_text(stmt, 2, b, -1, SQLITE_STATIC);
    if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL)
        out = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(stmt, 0));
    sqlite3_finalize(stmt);
    return out;
}

static int card_answer(const char *const repo_paths[], int repo_count, const char *project,
                       const char *name, const char *definition_json, const char *viewer,
                       const char *now, char **out_json);

int stone_fossil_card(const char *const repo_paths[], int repo_count, const char *project,
                      const char *name, const char *definition_json, const char *viewer,
                      const char *now, char **out_json) {
    /* As in stone_fossil_query: a card's read errors go to its caller, never
     * through Fossil's logger into a concurrent sync's output. */
    stoneLogMuted = 1;
    int rc = card_answer(repo_paths, repo_count, project, name, definition_json, viewer, now, out_json);
    stoneLogMuted = 0;
    return rc;
}

static int card_answer(const char *const repo_paths[], int repo_count, const char *project,
                       const char *name, const char *definition_json, const char *viewer,
                       const char *now, char **out_json) {
    if (repo_paths == NULL || repo_count < 0 || project == NULL || name == NULL
        || definition_json == NULL || viewer == NULL || now == NULL || out_json == NULL) return -1;
    *out_json = NULL;

    sqlite3 *w = NULL;
    if (sqlite3_open_v2(":memory:", &w, SQLITE_OPEN_READWRITE | SQLITE_OPEN_NOMUTEX, NULL) != SQLITE_OK) {
        if (w) sqlite3_close(w);
        return -1;
    }
    int rc = -2;
    const char *def = definition_json;
    char *fields[CARD_MAX_KEYS];
    int nfields = 0;
    char *title = card_def(w, def, "$.title", "text");
    char *fields_json = card_def(w, def, "$.fields", "array");
    char *order = card_def(w, def, "$.order", "text");
    char *limit = card_def(w, def, "$.limit", "integer");
    char *say = card_def(w, def, "$.say", "text");
    char *say_none = card_def(w, def, "$.say_none", "text");
    char *posts = card_def(w, def, "$.find.posts_tagged", "text");
    char *checkins = card_def(w, def, "$.find.checkins_tagged", "text");
    char *tickets = card_def(w, def, "$.find.tickets", "object");
    char *ttype = card_def(w, def, "$.find.tickets.type", "text");
    char *topen = card_def(w, def, "$.find.tickets.open", "boolean");
    char *within = card_def(w, def, "$.find.threads_active_within", "integer");
    char *value = card_def(w, def, "$.find.value", "text");
    char *unanswered = card_def(w, def, "$.find.unanswered_by", "text");
    char *as_of = NULL, *items = NULL, *sentence = NULL;

    int kinds = (posts != NULL) + (checkins != NULL) + (tickets != NULL) + (within != NULL);
    if (title == NULL || fields_json == NULL || order == NULL || limit == NULL || say == NULL
        || say_none == NULL || kinds != 1 || (tickets && (ttype == NULL || topen == NULL)))
        goto done;

    sqlite3_stmt *stmt = NULL;
    if (sqlite3_prepare_v2(w, "SELECT value, type FROM json_each(?1)", -1, &stmt, NULL) != SQLITE_OK) goto done;
    sqlite3_bind_text(stmt, 1, fields_json, -1, SQLITE_STATIC);
    int bad = 0;
    while (sqlite3_step(stmt) == SQLITE_ROW) {
        if (strcmp((const char *)sqlite3_column_text(stmt, 1), "text") != 0 || nfields == CARD_MAX_KEYS) { bad = 1; break; }
        fields[nfields++] = sqlite3_mprintf("%s", (const char *)sqlite3_column_text(stmt, 0));
    }
    sqlite3_finalize(stmt);
    if (bad) goto done;

    rc = -1;
    const char *kind = posts ? "posts_tagged" : checkins ? "checkins_tagged" : tickets ? "tickets" : "threads_active_within";
    const char *find_value = value ? (strcmp(value, "$viewer") == 0 ? viewer : value) : (tickets ? ttype : NULL);
    const char *login = unanswered ? (strcmp(unanswered, "$viewer") == 0 ? viewer : unanswered) : NULL;
    sqlite3_int64 seconds = within ? (sqlite3_int64)strtoll(within, NULL, 10) : 0;

    if (card_exec(w, "CREATE TEMP TABLE item(j TEXT)", NULL, NULL, NULL, NULL) != 0) goto done;
    for (int i = 0; i < repo_count; i++) {
        char *repo_as_of = NULL;
        char *rows = card_repo_rows(repo_paths[i], kind, posts ? posts : checkins, find_value, login,
                                    seconds, now, fields, nfields, &repo_as_of);
        if (rows == NULL) { sqlite3_free(repo_as_of); goto done; }
        int ok = card_exec(w, "INSERT INTO item SELECT value FROM json_each(?1)", rows, NULL, NULL, NULL);
        sqlite3_free(rows);
        if (repo_as_of && (as_of == NULL || strcmp(repo_as_of, as_of) > 0)) {
            sqlite3_free(as_of);
            as_of = repo_as_of;
        } else {
            sqlite3_free(repo_as_of);
        }
        if (ok != 0) goto done;
    }

    const char *dir = strcmp(order, "newest") == 0 ? "DESC" : "ASC";
    char *sel = sqlite3_mprintf("CREATE TEMP TABLE sel AS SELECT j FROM item ORDER BY "
        "json_extract(j,'$.time') %s, json_extract(j,'$.hash') %s, json_extract(j,'$.project') %s "
        "LIMIT max(%s, 0)", dir, dir, dir, limit);
    int ok = card_exec(w, sel, NULL, NULL, NULL, NULL);
    sqlite3_free(sel);
    if (ok != 0) goto done;
    char *count = card_value(w, "SELECT count(*) FROM item", NULL, NULL);
    items = card_value(w, "SELECT json_group_array(json(x)) FROM (SELECT (SELECT json_group_object(key, value) "
        "FROM json_each(s.j) WHERE key IN (SELECT value FROM json_each(?1)) OR key = 'project') AS x "
        "FROM sel s ORDER BY s.rowid)", fields_json, NULL);
    char *first = card_value(w, "SELECT j FROM sel ORDER BY rowid LIMIT 1", NULL, NULL);
    if (count == NULL || items == NULL) { sqlite3_free(count); sqlite3_free(first); goto done; }

    if (first == NULL) {
        sentence = sqlite3_mprintf("%s", say_none);
    } else {
        /* {count}, {titles} joined by "; ", and {FIELD} of the first item,
         * whose own keys win, as OCX merges them; anything else is left. */
        sqlite3_str *s = sqlite3_str_new(NULL);
        for (const char *p = say; *p; ) {
            const char *q = p + 1;
            if (*p == '{') while ((*q >= 'a' && *q <= 'z') || *q == '_') q++;
            if (*p == '{' && q > p + 1 && *q == '}') {
                char *key = sqlite3_mprintf("%.*s", (int)(q - p - 1), p + 1);
                char *path = sqlite3_mprintf("$.\"%s\"", key);
                char *v = card_value(w, "SELECT CASE WHEN json_type(?1, ?2) IS NOT NULL "
                                        "THEN CAST(json_extract(?1, ?2) AS TEXT) END", first, path);
                if (v == NULL && strcmp(key, "count") == 0) v = sqlite3_mprintf("%s", count);
                if (v == NULL && strcmp(key, "titles") == 0)
                    v = card_value(w, "SELECT coalesce(group_concat(t, '; '), '') FROM (SELECT "
                                      "coalesce(json_extract(j,'$.title'),'') AS t FROM sel ORDER BY rowid)", NULL, NULL);
                if (v) sqlite3_str_appendall(s, v);
                else sqlite3_str_append(s, p, (int)(q - p + 1));
                sqlite3_free(v);
                sqlite3_free(path);
                sqlite3_free(key);
                p = q + 1;
            } else {
                sqlite3_str_append(s, p, 1);
                p++;
            }
        }
        sentence = sqlite3_str_finish(s);
    }

    sqlite3_stmt *o = NULL;
    if (sentence && sqlite3_prepare_v2(w, "SELECT json_object('project', ?7, 'name', ?1, 'title', ?2, 'as_of', ?3, "
            "'count', CAST(?4 AS INTEGER), 'say', ?5, 'items', json(?6))", -1, &o, NULL) == SQLITE_OK) {
        sqlite3_bind_text(o, 1, name, -1, SQLITE_STATIC);
        sqlite3_bind_text(o, 2, title, -1, SQLITE_STATIC);
        if (as_of) sqlite3_bind_text(o, 3, as_of, -1, SQLITE_STATIC);
        sqlite3_bind_text(o, 4, count, -1, SQLITE_STATIC);
        sqlite3_bind_text(o, 5, sentence, -1, SQLITE_STATIC);
        sqlite3_bind_text(o, 6, items, -1, SQLITE_STATIC);
        sqlite3_bind_text(o, 7, project, -1, SQLITE_STATIC);
        if (sqlite3_step(o) == SQLITE_ROW) {
            const char *text = (const char *)sqlite3_column_text(o, 0);
            size_t len = strlen(text) + 1;
            *out_json = (char *)malloc(len);
            if (*out_json) { memcpy(*out_json, text, len); rc = 0; }
        }
    }
    sqlite3_finalize(o);
    sqlite3_free(count);
    sqlite3_free(first);

done:
    for (int i = 0; i < nfields; i++) sqlite3_free(fields[i]);
    sqlite3_free(title); sqlite3_free(fields_json); sqlite3_free(order); sqlite3_free(limit);
    sqlite3_free(say); sqlite3_free(say_none); sqlite3_free(posts); sqlite3_free(checkins);
    sqlite3_free(tickets); sqlite3_free(ttype); sqlite3_free(topen); sqlite3_free(within);
    sqlite3_free(value); sqlite3_free(unanswered); sqlite3_free(as_of); sqlite3_free(items);
    sqlite3_free(sentence);
    sqlite3_close(w);
    return rc;
}