git-trace

romele / git-trace public

62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 11m ago

src/main.c

#include "auth.h"
#include "cgi.h"
#include "ci.h"
#include "collab.h"
#include "crypto.h"
#include "db.h"
#include "export.h"
#include "gitx.h"
#include "hooks.h"
#include "mirror.h"
#include "ops.h"
#include "httpd.h"
#include "patchset.h"
#include "ratelimit.h"
#include "repo.h"
#include "ssh.h"
#include "util.h"
#include "web.h"
#include "webhook.h"

#include <dirent.h>
#include <errno.h>
#include <ftw.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

/* ---------------- git smart/dumb HTTP routing ---------------- */

/*
 * Basic auth with per-IP throttling.  Returns uid, 0 when no
 * credentials were sent, or -1 when a 429 was already emitted.
 */
static int basic_auth_throttled(struct app_ctx *c, struct request *r)
{
    if (!req_header(r, "authorization"))
        return 0;
    int d = rl_check(r->remote_ip);
    if (d < 0) {
        http_error(r, 429, "too many failed attempts");
        return -1;
    }
    if (d > 0)
        usleep((useconds_t)d * 1000);
    int uid = auth_basic(c->db, r);
    if (uid < 0) {
        /* credentials were sent but rejected: answer 401 rather than
           silently degrading to anonymous (a typo'd password must not
           look like a successful auth) */
        rl_fail(r->remote_ip);
        http_unauthorized(r, "invalid credentials");
        return -1;
    }
    if (uid > 0)
        rl_ok(r->remote_ip);
    else
        rl_fail(r->remote_ip);
    return uid;
}

static void git_route(struct app_ctx *c, struct request *r,
                      const char *owner, const char *name,
                      const char *rest)
{
    if (!owner_ok(owner) || !name_ok(name)) {
        http_error(r, 404, "bad repository name");
        return;
    }
    if (!repo_exists(c->repos_root, owner, name)) {
        http_error(r, 404, "no such repository");
        return;
    }
    if (!git_rest_ok(rest)) {
        http_error(r, 400, "bad git path");
        return;
    }

    int is_get  = strcmp(r->method, "GET") == 0;
    int is_post = strcmp(r->method, "POST") == 0;

    /* is this request part of a push? */
    int wants_push = 0;
    if (is_post && strcmp(rest, "/git-receive-pack") == 0)
        wants_push = 1;
    if (is_get && strcmp(rest, "/info/refs") == 0) {
        char service[64] = "";
        req_query(r, "service", service, sizeof service);
        if (strcmp(service, "git-receive-pack") == 0)
            wants_push = 1;
    }

    /* authenticate via HTTP Basic; uid 0 = anonymous */
    int uid = basic_auth_throttled(c, r);
    if (uid < 0)
        return;
    int perm = perm_rank(repo_perm(c->db, owner, name, uid));
    int need = wants_push ? 2 : 1;          /* 'w' or 'r' */
    if (perm < need) {
        if (uid > 0)
            http_error(r, 403, wants_push ? "no write permission"
                                          : "no read permission");
        else
            http_unauthorized(r, wants_push
                ? "authentication required to push"
                : "authentication required");
        return;
    }

    if (is_get) {
        /* info/refs?service=git-upload-pack or dumb-protocol object GETs */
        cgi_git_backend(r, c->repos_root, r->path,
                        uid > 0 ? r->user_name : NULL);
        return;
    }
    if (is_post) {
        if (strcmp(rest, "/git-upload-pack") == 0 ||
            strcmp(rest, "/git-receive-pack") == 0) {
            if (r->content_length < 0) {
                http_error(r, 411, "no request body framing");
                return;
            }
            cgi_git_backend(r, c->repos_root, r->path,
                            uid > 0 ? r->user_name : NULL);
            return;
        }
        http_error(r, 404, "unknown git endpoint");
        return;
    }
    http_error(r, 405, "method not allowed");
}

/* ---------------- top-level dispatch ---------------- */

static void route(void *vctx, struct request *r)
{
    struct app_ctx *c = vctx;

    auth_identify(c->db, r);          /* session cookie -> r->user_* */
    r->open_reg = c->open_reg;
    if (r->user_id == 0 &&
        basic_auth_throttled(c, r) < 0)   /* curl -u on web pages too */
        return;

    if (strcmp(r->path, "/login") == 0) {
        web_login(c, r);
        return;
    }
    if (strcmp(r->path, "/logout") == 0) {
        web_logout(c, r);
        return;
    }
    if (strcmp(r->path, "/register") == 0) {
        web_register(c, r);
        return;
    }
    if (strcmp(r->path, "/audit") == 0) {
        web_audit(c, r);
        return;
    }
    if (strcmp(r->path, "/new") == 0) {
        web_new(c, r);
        return;
    }
    if (strcmp(r->path, "/account") == 0) {
        web_account(c, r);
        return;
    }
    if (strcmp(r->path, "/feed.xsl") == 0) {
        web_feed_xsl(c, r);
        return;
    }

    if (strcmp(r->path, "/") == 0) {
        if (strcmp(r->method, "GET") != 0) {
            http_error(r, 405, "method not allowed");
            return;
        }
        web_index(c, r);
        return;
    }

    /* split "/owner/repo[.git][/rest]" */
    const char *s1 = r->path + 1;
    const char *s2 = strchr(s1, '/');
    if (!s2) {
        http_error(r, 404, "not found");
        return;
    }
    char owner[64];
    size_t ol = (size_t)(s2 - s1);
    if (ol == 0 || ol >= sizeof owner) {
        http_error(r, 404, "not found");
        return;
    }
    memcpy(owner, s1, ol);
    owner[ol] = '\0';

    const char *s3 = s2 + 1;
    const char *s4 = strchr(s3, '/');
    char repo[64];
    size_t rl = s4 ? (size_t)(s4 - s3) : strlen(s3);
    if (rl == 0 || rl >= sizeof repo) {
        http_error(r, 404, "not found");
        return;
    }
    memcpy(repo, s3, rl);
    repo[rl] = '\0';

    const char *rest = s4 ? s4 : "";      /* includes leading '/' */

    if (ends_with(repo, ".git")) {
        repo[rl - 4] = '\0';              /* strip ".git" */
        if (rest[0] == '\0') {
            http_error(r, 404, "not found");
            return;
        }
        git_route(c, r, owner, repo, rest);
        return;
    }

    web_repo(c, r, owner, repo, rest);
}

/* ---------------- mkrepo ---------------- */

static int cmd_mkrepo(struct app_ctx *c, const char *owner, const char *name,
                      const char *desc, int is_private)
{
    /* owner "_" = ownerless/public repo */
    if (!owner_ok(owner)) {
        fprintf(stderr, "git-trace: invalid owner name\n");
        fprintf(stderr, "allowed: [A-Za-z0-9][A-Za-z0-9._-]{0,62}\n");
        return 1;
    }
    if (!name_ok(name)) {
        fprintf(stderr, "git-trace: invalid repo name\n");
        fprintf(stderr, "allowed: [A-Za-z0-9][A-Za-z0-9._-]{0,62}\n");
        return 1;
    }

    int owner_uid = 0;
    if (strcmp(owner, "_") != 0) {
        owner_uid = auth_user_id(c->db, owner);
        if (owner_uid < 0) {
            fprintf(stderr, "git-trace: unknown user '%s'"
                    " (create it with useradd, or use '_' for ownerless)\n",
                    owner);
            return 1;
        }
    }

    if (repo_create(c, owner_uid, owner, name, desc, is_private) != 0) {
        fprintf(stderr, "git-trace: cannot create %s/%s.git\n", owner, name);
        return 1;
    }

    sqlite3_stmt *st;
    int rid = 0;
    if (sqlite3_prepare_v2(c->db,
            "SELECT id FROM repos WHERE name=? AND"
            " (owner_id=? OR (owner_id IS NULL AND ?=0))",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
        sqlite3_bind_int(st, 2, owner_uid);
        sqlite3_bind_int(st, 3, owner_uid);
        if (sqlite3_step(st) == SQLITE_ROW)
            rid = sqlite3_column_int(st, 0);
        sqlite3_finalize(st);
    }
    db_audit(c->db, 0, getenv("USER") ? getenv("USER") : "cli", NULL,
             rid, "repo_create", "%s/%s.git%s", owner, name,
             is_private ? " (private)" : "");

    printf("created %s/%s.git (%s)\n", owner, name,
           is_private ? "private" : "public");
    return 0;
}

static int read_stdin_secret(char *out, size_t n)
{
    fprintf(stderr, "password: ");
    if (!fgets(out, (int)n, stdin)) {
        fprintf(stderr, "\ngit-trace: no password on stdin\n");
        return -1;
    }
    out[strcspn(out, "\r\n")] = '\0';
    if (!out[0]) {
        fprintf(stderr, "git-trace: empty password\n");
        return -1;
    }
    return 0;
}

static int cmd_useradd(struct app_ctx *c, const char *name,
                       const char *email, int is_admin)
{
    if (!name_ok(name)) {
        fprintf(stderr, "git-trace: invalid user name\n");
        return 1;
    }
    char pw[256];
    if (read_stdin_secret(pw, sizeof pw) != 0)
        return 1;
    if (auth_user_add(c->db, name, pw, email, is_admin) != 0) {
        memset(pw, 0, sizeof pw);
        fprintf(stderr, "git-trace: cannot create user '%s'"
                " (already exists?)\n", name);
        return 1;
    }
    memset(pw, 0, sizeof pw);
    printf("user '%s' created\n", name);
    return 0;
}

static int cmd_passwd(struct app_ctx *c, const char *name)
{
    char pw[256];
    if (read_stdin_secret(pw, sizeof pw) != 0)
        return 1;
    int rc = auth_user_passwd(c->db, name, pw);
    memset(pw, 0, sizeof pw);
    if (rc != 0) {
        fprintf(stderr, "git-trace: no such user '%s'\n", name);
        return 1;
    }
    printf("password updated\n");
    return 0;
}

static int cmd_mktoken(struct app_ctx *c, const char *name, const char *label)
{
    char tok[80];
    if (auth_mktoken(c->db, name, label, tok, sizeof tok) != 0) {
        fprintf(stderr, "git-trace: no such user '%s'\n", name);
        return 1;
    }
    printf("%s\n", tok);
    fprintf(stderr, "store it now: it is shown once.\n");
    return 0;
}

/* ---------------- storage quota ---------------- */

static long long du_acc;
static int du_cb(const char *p, const struct stat *sb, int tf,
                 struct FTW *w)
{
    (void)p; (void)tf; (void)w;
    du_acc += sb->st_blocks;
    return 0;
}

/* disk usage in KiB, du -sk semantics via st_blocks (512 B units) */
static long long du_kb(const char *path)
{
    du_acc = 0;
    if (nftw(path, du_cb, 16, FTW_PHYS) < 0)
        return -1;
    return du_acc / 2;
}

/*
 * cwd must be <repos_root>/<owner>/<name>.git — the layout hooks run
 * in.  Fills owner/name; -1 for anything outside the managed tree.
 */
static int repo_parts_from_cwd(struct app_ctx *c,
                               char owner[64], char name[80])
{
    char rdir[PATH_MAX];
    if (!realpath(".", rdir))
        return -1;
    size_t rootlen = strlen(c->repos_root);
    if (strncmp(rdir, c->repos_root, rootlen) != 0 ||
        rdir[rootlen] != '/')
        return -1;
    const char *rel = rdir + rootlen + 1;        /* "owner/name.git" */
    const char *sl = strchr(rel, '/');
    if (!sl)
        return -1;
    size_t ol = (size_t)(sl - rel);
    if (ol == 0 || ol >= 64)
        return -1;
    memcpy(owner, rel, ol);
    owner[ol] = '\0';
    size_t nl = strlen(sl + 1);
    if (nl <= 4 || strcmp(sl + 1 + nl - 4, ".git") != 0 || nl - 4 >= 80)
        return -1;
    memcpy(name, sl + 1, nl - 4);
    name[nl - 4] = '\0';
    return 0;
}

/*
 * Invoked by hooks/update before quota-check (cwd = the bare repo).
 * Enforces branch_rules: a push to a branch matching a rule requires
 * repo rank >= min_role, plus optional fast-forward-only update and
 * no-delete constraints.  Rules only tighten access — no matching
 * rule falls through to the built-in policy.  Local pushes (empty
 * REMOTE_USER) bypass: filesystem access already trumps any policy.
 */
static int cmd_branch_check(struct app_ctx *c, const char *ref,
                            const char *old_sha, const char *new_sha)
{
    char owner[64], name[80];
    if (!ref || repo_parts_from_cwd(c, owner, name) != 0)
        return 0;
    if (!starts_with(ref, "refs/heads/"))
        return 0;                          /* branches only, no tags */
    const char *branch = ref + 11;
    const char *actor = getenv("REMOTE_USER");
    if (!actor || !*actor)
        return 0;                          /* local push bypass */

    int repo_id = gt_repo_rowid(c->db, owner, name);
    if (repo_id < 0)
        return 0;

    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(c->db,
            "SELECT pattern,min_role,ff_only,no_delete"
            " FROM branch_rules WHERE repo_id=?",
            -1, &st, NULL) != SQLITE_OK)
        return 0;
    sqlite3_bind_int(st, 1, repo_id);
    int need = 0, ff_only = 0, no_delete = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        const char *pat = (const char *)sqlite3_column_text(st, 0);
        if (!pat || !gt_glob(pat, branch))
            continue;
        const char *mr = (const char *)sqlite3_column_text(st, 1);
        int r = mr ? perm_rank(mr[0]) : 0;
        if (r > need)
            need = r;
        ff_only   |= sqlite3_column_int(st, 2);
        no_delete |= sqlite3_column_int(st, 3);
    }
    sqlite3_finalize(st);
    if (!need)
        return 0;                          /* no matching rules */

    const char *zero = "0000000000000000000000000000000000000000";
    int del = !new_sha || strcmp(new_sha, zero) == 0;
    int uid = auth_user_id(c->db, actor);
    int rank = perm_rank(repo_perm(c->db, owner, name, uid));
    int deny = 0;
    const char *why = NULL;

    if (del) {
        deny = no_delete;
        why = "protected branch: deletion denied";
    } else if (rank < need) {
        deny = 1;
        why = need >= 3 ? "protected branch: push requires admin"
                        : "protected branch: push requires write";
    } else if (ff_only && old_sha && strcmp(old_sha, zero) != 0) {
        struct buf out;
        buf_init(&out);
        deny = git_capture(".", (const char *const[]){
                "git", "merge-base", "--is-ancestor",
                old_sha, new_sha, NULL}, &out) != 0;
        buf_free(&out);
        why = "protected branch: non-fast-forward push denied";
    }
    if (!deny)
        return 0;

    fprintf(stderr, "git-trace: %s\n", why);
    if (!del && rank < need)
        fprintf(stderr, "git-trace: submit a merge request or a"
                " patch set instead\n");
    db_audit(c->db, uid > 0 ? uid : 0, actor, NULL, repo_id,
             "push_denied", "%s: %s", ref, why);
    return 1;
}

/*
 * post-receive: stdin carries "old new ref" per accepted ref update.
 * Queue a 'push' delivery per ref, then hand off to the detached
 * dispatcher — the push itself must never wait on outbound HTTP.
 */
static int cmd_hook_post(struct app_ctx *c)
{
    char owner[64], name[80];
    if (repo_parts_from_cwd(c, owner, name) != 0)
        return 0;
    long repo_id = gt_repo_rowid(c->db, owner, name);
    if (repo_id < 0)
        return 0;
    const char *actor = getenv("REMOTE_USER");
    if (!actor)
        actor = "";
    char on[200];
    snprintf(on, sizeof on, "%s/%s", owner, name);

    /* CI opt-in lives on the repos row */
    int ci_on = 0;
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(c->db,
            "SELECT ci_enabled FROM repos WHERE id=?",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int64(st, 1, repo_id);
        if (sqlite3_step(st) == SQLITE_ROW)
            ci_on = sqlite3_column_int(st, 0);
        sqlite3_finalize(st);
    }

    char line[512];
    int queued = 0, ciq = 0, heads = 0;
    while (fgets(line, sizeof line, stdin)) {
        char old[80], new[80], ref[200];
        if (sscanf(line, "%79s %79s %199s", old, new, ref) != 3)
            continue;
        if (starts_with(ref, "refs/heads/") &&
            strcmp(old, new) != 0)
            heads++;
        struct buf p;
        buf_init(&p);
        buf_puts(&p, "{\"event\":\"push\",\"repo\":\"");
        json_put_esc(&p, on, strlen(on));
        buf_puts(&p, "\",\"ref\":\"");
        json_put_esc(&p, ref, strlen(ref));
        buf_puts(&p, "\",\"old\":\"");
        json_put_esc(&p, old, strlen(old));
        buf_puts(&p, "\",\"new\":\"");
        json_put_esc(&p, new, strlen(new));
        buf_puts(&p, "\",\"actor\":\"");
        json_put_esc(&p, actor, strlen(actor));
        buf_puts(&p, "\"}");
        if (p.data &&
            wh_enqueue(c->db, repo_id, "push", p.data) > 0)
            queued++;
        buf_free(&p);

        /* branch pushes on a CI-enabled repo queue a build; the
         * pipeline script still comes from the default branch */
        if (ci_on && starts_with(ref, "refs/heads/") &&
            strcmp(new, "0000000000000000000000000000000000000000")) {
            if (sqlite3_prepare_v2(c->db,
                    "INSERT INTO ci_runs(repo_id,sha,branch,actor)"
                    " VALUES(?,?,?,?)", -1, &st, NULL) == SQLITE_OK) {
                sqlite3_bind_int64(st, 1, repo_id);
                sqlite3_bind_text(st, 2, new, -1, SQLITE_TRANSIENT);
                sqlite3_bind_text(st, 3, ref + 11, -1,
                                  SQLITE_TRANSIENT);
                sqlite3_bind_text(st, 4, actor, -1, SQLITE_TRANSIENT);
                if (sqlite3_step(st) == SQLITE_DONE)
                    ciq++;
                sqlite3_finalize(st);
            }
        }
    }
    if (queued > 0)
        wh_dispatch_async(c);
    if (ciq > 0)
        gt_spawn_task("ci-run");
    /* push mirrors: a real heads/ update fans out to configured
     * remotes.  convergence is structural — an up-to-date push
     * runs no remote hooks, so A<->B cycles die in one hop. */
    if (heads > 0) {
        int np = 0;
        if (sqlite3_prepare_v2(c->db,
                "SELECT count(*) FROM repo_mirrors WHERE repo_id=?"
                " AND enabled=1 AND direction='push'",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int64(st, 1, repo_id);
            if (sqlite3_step(st) == SQLITE_ROW)
                np = sqlite3_column_int(st, 0);
            sqlite3_finalize(st);
        }
        if (np > 0)
            gt_spawn_task_arg("mirror-push", on);
    }
    return 0;
}

/*
 * Rewrite hooks/update in every managed bare repo — needed
 * after upgrading git-trace so existing repos pick up new policy
 * (e.g. branch_rules enforcement) baked into the hook script.
 */
static int cmd_rehook(struct app_ctx *c)
{
    DIR *od = opendir(c->repos_root);
    if (!od) {
        perror("rehook");
        return 1;
    }
    int n = 0, bad = 0;
    struct dirent *oe;
    while ((oe = readdir(od))) {
        if (oe->d_name[0] == '.')
            continue;
        char odir[PATH_MAX];
        if (snprintf(odir, sizeof odir, "%s/%s",
                     c->repos_root, oe->d_name) >= (int)sizeof odir)
            continue;
        DIR *rd = opendir(odir);
        if (!rd)
            continue;
        struct dirent *re;
        while ((re = readdir(rd))) {
            size_t l = strlen(re->d_name);
            if (l <= 4 || strcmp(re->d_name + l - 4, ".git") != 0)
                continue;
            char rdir[PATH_MAX];
            if (snprintf(rdir, sizeof rdir, "%s/%s",
                         odir, re->d_name) >= (int)sizeof rdir)
                continue;
            if (repo_install_hooks(c, rdir) == 0) {
                printf("%s/%s\n", oe->d_name, re->d_name);
                n++;
            } else {
                bad++;
            }
        }
        closedir(rd);
    }
    closedir(od);
    fprintf(stderr, "rehook: %d updated, %d failed\n", n, bad);
    return bad ? 1 : 0;
}

/*
 * Invoked by hooks/update during a push (cwd = the bare repo).
 * Fails open for anything outside the managed tree — same posture as
 * repo_perm for unregistered repos — but enforces the owner's
 * account-wide quota on every registered, owned repository.
 */
static int cmd_quota_check(struct app_ctx *c,
                           const char *ref, const char *new_sha)
{
    char owner[64], name[80];
    if (repo_parts_from_cwd(c, owner, name) != 0)
        return 0;

    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(c->db,
            "SELECT r.id, r.owner_id, u.quota_kb FROM repos r"
            " LEFT JOIN users u ON u.id=r.owner_id"
            " WHERE r.name=? AND (u.name=? OR (r.owner_id IS NULL AND ?='_'))",
            -1, &st, NULL) != SQLITE_OK)
        return 0;
    sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 2, owner, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 3, owner, -1, SQLITE_TRANSIENT);
    if (sqlite3_step(st) != SQLITE_ROW) {
        sqlite3_finalize(st);
        return 0;                            /* unregistered repo */
    }
    int repo_id = sqlite3_column_int(st, 0);
    int owner_id = sqlite3_column_type(st, 1) == SQLITE_NULL
                   ? 0 : sqlite3_column_int(st, 1);
    long long quota = sqlite3_column_type(st, 2) == SQLITE_NULL
                      ? 0 : sqlite3_column_int64(st, 2);
    sqlite3_finalize(st);

    /* journal the push: this hook runs last, so reaching here means
     * every earlier policy check has already passed */
    const char *actor = getenv("REMOTE_USER");
    int auid = actor ? auth_user_id(c->db, actor) : 0;
    db_audit(c->db, auid > 0 ? auid : 0, actor ? actor : "local", NULL,
             repo_id, "push", "%s -> %.12s",
             ref ? ref : "?", new_sha ? new_sha : "?");

    if (!owner_id || quota <= 0)
        return 0;                     /* ownerless or unlimited */

    long long incoming = 0;
    const char *qp = getenv("GIT_QUARANTINE_PATH");
    if (qp && *qp) {
        char qr[PATH_MAX];
        if (realpath(qp, qr)) {
            long long q = du_kb(qr);
            if (q > 0)
                incoming = q;
        }
    }
    long long total = du_kb(".");
    if (total < 0)
        return 0;
    long long current = total - incoming;    /* pre-push size */
    if (current < 0)
        current = 0;

    /* refresh this repo's cached size, then sum the account */
    if (sqlite3_prepare_v2(c->db,
            "UPDATE repos SET size_kb=? WHERE id=?",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int64(st, 1, current);
        sqlite3_bind_int(st, 2, repo_id);
        sqlite3_step(st);
        sqlite3_finalize(st);
    }
    long long used = 0;
    if (sqlite3_prepare_v2(c->db,
            "SELECT COALESCE(SUM(size_kb),0) FROM repos WHERE owner_id=?",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int(st, 1, owner_id);
        if (sqlite3_step(st) == SQLITE_ROW)
            used = sqlite3_column_int64(st, 0);
        sqlite3_finalize(st);
    }

    if (used + incoming > quota) {
        db_audit(c->db, auid > 0 ? auid : 0, actor ? actor : "local",
                 NULL, repo_id, "push_denied",
                 "%s: %lld KiB used + %lld KiB incoming > %lld KiB",
                 ref ? ref : "?", used, incoming, quota);
        fprintf(stderr,
            "git-trace: storage quota exceeded for this account\n"
            "git-trace:   used %lld KiB + incoming %lld KiB"
            " > quota %lld KiB\n",
            used, incoming, quota);
        return 1;
    }
    return 0;
}

static int cmd_quota(struct app_ctx *c, const char *user, const char *mb)
{
    int uid = auth_user_id(c->db, user);
    if (uid < 0) {
        fprintf(stderr, "git-trace: no such user '%s'\n", user);
        return 1;
    }
    if (mb) {
        char *end = NULL;
        long long v = strtoll(mb, &end, 10);
        if (!end || *end || v < 0) {
            fprintf(stderr, "git-trace: bad quota '%s' (MiB, 0=unlimited)\n",
                    mb);
            return 1;
        }
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(c->db,
                "UPDATE users SET quota_kb=? WHERE id=?",
                -1, &st, NULL) != SQLITE_OK)
            return 1;
        sqlite3_bind_int64(st, 1, v * 1024);
        sqlite3_bind_int(st, 2, uid);
        sqlite3_step(st);
        sqlite3_finalize(st);
        printf("quota for %s: %lld MiB\n", user, v);
        return 0;
    }
    long long quota = 0, used = 0;
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(c->db,
            "SELECT quota_kb FROM users WHERE id=?",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int(st, 1, uid);
        if (sqlite3_step(st) == SQLITE_ROW)
            quota = sqlite3_column_int64(st, 0);
        sqlite3_finalize(st);
    }
    if (sqlite3_prepare_v2(c->db,
            "SELECT COALESCE(SUM(size_kb),0) FROM repos WHERE owner_id=?",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int(st, 1, uid);
        if (sqlite3_step(st) == SQLITE_ROW)
            used = sqlite3_column_int64(st, 0);
        sqlite3_finalize(st);
    }
    printf("%s: %lld KiB used of %lld KiB quota\n", user, used, quota);
    return 0;
}

/* `git-trace tier <user>` shows the entitlement;
 * `git-trace tier <user> <tier> [until]` sets it, where until is
 * "+30" (days), "YYYY-MM-DD" or "never" — payment itself stays
 * an operator workflow outside the system, the audit log records
 * every change */
static int cmd_tier(struct app_ctx *c, const char *user,
                    const char *tier, const char *until)
{
    int uid = auth_user_id(c->db, user);
    if (uid < 0) {
        fprintf(stderr, "git-trace: no such user '%s'\n", user);
        return 1;
    }
    if (!tier) {
        long exp = 0;
        const char *t = auth_user_tier(c->db, uid, &exp);
        if (exp) {
            char d[32];
            struct tm tm;
            time_t tt = exp;
            gmtime_r(&tt, &tm);
            strftime(d, sizeof d, "%Y-%m-%d", &tm);
            printf("%s: tier=%s (expires %s)\n", user, t, d);
        } else {
            printf("%s: tier=%s\n", user, t);
        }
        return 0;
    }
    if (!auth_tier_ok(tier)) {
        fprintf(stderr, "git-trace: tier must be free|pro|self|enterprise\n");
        return 1;
    }
    long exp = 0;
    if (until && until[0] == '+') {
        long days = strtol(until + 1, NULL, 10);
        if (days < 1 || days > 36500) {
            fprintf(stderr, "git-trace: bad validity '+days'\n");
            return 1;
        }
        exp = (long)time(NULL) + days * 86400;
    } else if (until && strchr(until, '-')) {
        int y = 0, mo = 0, d = 0;
        if (sscanf(until, "%d-%d-%d", &y, &mo, &d) != 3 ||
            y < 2020 || mo < 1 || mo > 12 || d < 1 || d > 31) {
            fprintf(stderr, "git-trace: bad date '%s' (YYYY-MM-DD)\n",
                    until);
            return 1;
        }
        struct tm tm = { .tm_year = y - 1900, .tm_mon = mo - 1,
                         .tm_mday = d, .tm_hour = 23, .tm_min = 59,
                         .tm_sec = 59 };
        exp = (long)timegm(&tm);
    } else if (until && strcmp(until, "never") != 0) {
        fprintf(stderr, "git-trace: until must be +days|YYYY-MM-DD|never\n");
        return 1;
    }
    /* current value for the audit trail */
    char old[16] = "free";
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(c->db,
            "SELECT tier FROM users WHERE id=?", -1, &st, NULL)
        == SQLITE_OK) {
        sqlite3_bind_int(st, 1, uid);
        if (sqlite3_step(st) == SQLITE_ROW) {
            const char *s = (const char *)sqlite3_column_text(st, 0);
            if (s)
                snprintf(old, sizeof old, "%s", s);
        }
        sqlite3_finalize(st);
    }
    if (auth_tier_set(c->db, user, tier, exp) != 0) {
        fprintf(stderr, "git-trace: tier update failed\n");
        return 1;
    }
    char w[64] = "";
    if (exp) {
        struct tm tm;
        time_t tt = exp;
        gmtime_r(&tt, &tm);
        strftime(w, sizeof w, " until %Y-%m-%d", &tm);
    }
    db_audit(c->db, uid, getenv("USER") ? getenv("USER") : "cli", NULL,
             0, "tier_change", "%s: %s -> %s%s", user, old, tier, w);
    printf("%s: tier=%s%s\n", user, tier, w);
    return 0;
}

/* RFC 7914 test vectors for the in-tree scrypt */
static int cmd_kdf_selftest(void)
{
    static const struct {
        const char *pw, *salt;
        uint64_t N;
        unsigned r, p;
        const char *hex;
    } tv[] = {
        { "", "", 16, 1, 1,
          "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede214"
          "42fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d1"
          "8906" },
        { "password", "NaCl", 1024, 8, 16,
          "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b37"
          "31622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdf"
          "a2cc0640" },
        { "pleaseletmein", "SodiumChloride", 16384, 8, 1,
          "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1"
          "f2d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45"
          "575887" },
    };
    int fail = 0;
    for (size_t i = 0; i < sizeof tv / sizeof tv[0]; i++) {
        uint8_t dk[64], want[64];
        int rc = from_hex(tv[i].hex, want, 64);
        if (rc != 0) {
            printf("scrypt RFC7914 vector %d: bad test data\n",
                   (int)i + 1);
            return 1;
        }
        rc = scrypt_kdf((const uint8_t *)tv[i].pw, strlen(tv[i].pw),
                            (const uint8_t *)tv[i].salt,
                            strlen(tv[i].salt),
                            tv[i].N, tv[i].r, tv[i].p, dk, 64);
        int ok = rc == 0 && consteq(dk, want, 64);
        printf("scrypt RFC7914 vector %d: %s\n", (int)i + 1,
               ok ? "ok" : "FAIL");
        if (!ok)
            fail = 1;
        gt_bzero(dk, sizeof dk);
        gt_bzero(want, sizeof want);
    }
    return fail;
}

/* ---------------- entry ---------------- */

static void usage(FILE *f)
{
    fprintf(f,
        "git-trace — a zero-dependency git forge\n"
        "\n"
        "usage:\n"
        "  git-trace serve [-p port] [-R reposdir] [-d dbfile]\n"
        "  git-trace mkrepo [--private] <owner> <name> [description]\n"
        "  git-trace useradd [--admin] [--email=E] <name>   (pw on stdin)\n"
        "  git-trace passwd <name>                        (pw on stdin)\n"
        "  git-trace mktoken <name> <label>               (prints token)\n"
        "  git-trace sshkey add <user> [key|--name=L]     (key via stdin)\n"
        "  git-trace sshkey list [user] | rm <id>\n"
        "  git-trace quota <user> [MiB]                   (0 = unlimited)\n"
        "  git-trace tier <user> [free|pro|self|enterprise\n"
        "        [+days|YYYY-MM-DD|never]]               (billing tier)\n"
        "  git-trace keyfile [--write]                    (authorized_keys)\n"
        "  git-trace shell <keyid>                        (forced command)\n"
        "  git-trace mailrecv <owner> <repo>              (MTA pipe alias)\n"
        "  git-trace kdf-selftest | pwhash-status <user>\n"
        "  git-trace export [-o dir] [-n commits] [--private]\n"
        "        [owner/repo ...]                     (stagit-style)\n"
        "  git-trace webhook-run                            (drain queue)\n"
        "  git-trace doctor            health check (db,repos,ssh,disk)\n"
        "  git-trace backup <dir> | restore <dir>           (snapshot)\n"
        "\n"
        "owner '_' means an ownerless (public) repository.\n"
        "env: GIT_TRACE_REPOS, GIT_TRACE_DB, GIT_TRACE_PORT override\n"
        "defaults — used by the generated authorized_keys lines.\n");
}

#ifndef GT_VERSION
#define GT_VERSION "v0.0-dev"
#endif
#ifndef GT_SHA
#define GT_SHA "unknown"
#endif

/* is an executable on PATH? (feature probe for --version) */
static int on_path(const char *bin)
{
    const char *p = getenv("PATH");
    if (!p)
        return 0;
    char probe[PATH_MAX];
    for (const char *d = p; ; ) {
        const char *colon = strchr(d, ':');
        size_t dl = colon ? (size_t)(colon - d) : strlen(d);
        if (dl && dl + strlen(bin) + 2 <= sizeof probe) {
            memcpy(probe, d, dl);
            snprintf(probe + dl, sizeof probe - dl, "/%s", bin);
            if (access(probe, X_OK) == 0)
                return 1;
        }
        if (!colon)
            break;
        d = colon + 1;
    }
    return 0;
}

static void version(void)
{
    printf("git-trace %s (%s)\n"
           "  build:     c11, -O2 -Wall -Wextra -Werror -D_GNU_SOURCE\n"
           "  libraries: libc, sqlite3, pthread\n"
           "  tools:     git tar sh   [curl: %s]\n"
           "  surface:   smart-http ssh-forced-cmd acl issues mr patchset\n"
           "             webhooks ci static-export atom search mirroring\n",
           GT_VERSION, GT_SHA, on_path("curl") ? "found" : "absent");
}

int main(int argc, char **argv)
{
    struct app_ctx c;
    memset(&c, 0, sizeof c);
    const char *ev;
    snprintf(c.repos_root, sizeof c.repos_root, "%s",
             (ev = getenv("GIT_TRACE_REPOS")) ? ev : "repos");
    snprintf(c.db_path, sizeof c.db_path, "%s",
             (ev = getenv("GIT_TRACE_DB")) ? ev : "var/git-trace.db");
    c.port = (ev = getenv("GIT_TRACE_PORT")) ? atoi(ev) : 8080;
    c.open_reg = (ev = getenv("GIT_TRACE_OPEN_REG"))
                 ? atoi(ev) != 0 : 0;
    snprintf(c.ssh_url, sizeof c.ssh_url, "%s",
             (ev = getenv("GIT_TRACE_SSH_URL")) ? ev : "");
    snprintf(c.site_name, sizeof c.site_name, "%s",
             (ev = getenv("GIT_TRACE_NAME")) ? ev : "git-trace");
    /* GIT_TRACE_BASE_URL: public URL when behind a reverse proxy or
     * browser-preview tunnel — its host[:port] is accepted as an
     * additional valid Origin for POSTs */
    if ((ev = getenv("GIT_TRACE_BASE_URL"))) {
        const char *h = strstr(ev, "://");
        h = h ? h + 3 : ev;
        size_t hl = strcspn(h, "/");
        if (hl >= sizeof c.base_host)
            hl = sizeof c.base_host - 1;
        memcpy(c.base_host, h, hl);
        c.base_host[hl] = '\0';
    }

    const char *cmd = argc > 1 ? argv[1] : "serve";
    if (strcmp(cmd, "--version") == 0 || strcmp(cmd, "-V") == 0 ||
        strcmp(cmd, "version") == 0) {
        version();
        return 0;
    }
    int i = 2;
    for (; i < argc && argv[i][0] == '-'; i++) {
        if (strcmp(argv[i], "-p") == 0 && i + 1 < argc)
            c.port = atoi(argv[++i]);
        else if (strcmp(argv[i], "-R") == 0 && i + 1 < argc)
            snprintf(c.repos_root, sizeof c.repos_root, "%s", argv[++i]);
        else if (strcmp(argv[i], "-d") == 0 && i + 1 < argc)
            snprintf(c.db_path, sizeof c.db_path, "%s", argv[++i]);
        else
            break;              /* subcommand-specific flag: hands off */
    }

    if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 ||
        strcmp(cmd, "-h") == 0) {
        usage(stdout);
        return 0;
    }

    /* shell/keyfile are invoked by sshd: don't litter the OS user's
     * home with stray dirs */
    int quiet = strcmp(cmd, "shell") == 0 || strcmp(cmd, "keyfile") == 0 ||
                strcmp(cmd, "quota-check") == 0 ||
                strcmp(cmd, "branch-check") == 0 ||
                strcmp(cmd, "hook-post") == 0 ||
                strcmp(cmd, "webhook-run") == 0 ||
                strcmp(cmd, "ci-run") == 0 ||
                strcmp(cmd, "mirror-sync") == 0 ||
                strcmp(cmd, "mirror-push") == 0 ||
                strcmp(cmd, "mailrecv") == 0;
    if (!quiet) {
        mkdir_p("repos");
        mkdir_p("var");
    }

    char real[PATH_MAX];
    if (!realpath(c.repos_root, real)) {
        fprintf(stderr, "git-trace: cannot resolve %s\n", c.repos_root);
        return 1;
    }
    snprintf(c.repos_root, sizeof c.repos_root, "%s", real);

    /* db path must be absolute: authorized_keys lines embed it and the
     * forced command does not share our cwd */
    if (c.db_path[0] != '/') {
        char cwd[PATH_MAX];
        int n = getcwd(cwd, sizeof cwd)
            ? snprintf(real, sizeof real, "%s/%s", cwd, c.db_path) : -1;
        if (n < 0 || n >= (int)sizeof real)
            snprintf(real, sizeof real, "%s", c.db_path);
        snprintf(c.db_path, sizeof c.db_path, "%s", real);
    }

    c.db = db_open(c.db_path);

    if (strcmp(cmd, "shell") == 0) {
        if (i >= argc) { usage(stderr); return 2; }
        return cmd_shell(&c, argv[i]);
    }
    if (strcmp(cmd, "keyfile") == 0) {
        int wf = i < argc && strcmp(argv[i], "--write") == 0;
        return cmd_keyfile(&c, wf);
    }
    if (strcmp(cmd, "sshkey") == 0)
        return cmd_sshkey(&c, argc - i, argv + i);
    if (strcmp(cmd, "mailrecv") == 0) {
        if (i + 1 >= argc) { usage(stderr); return 2; }
        return gt_mailrecv(&c, argv[i], argv[i + 1]);
    }
    if (strcmp(cmd, "branch-check") == 0)
        return cmd_branch_check(&c, i < argc ? argv[i] : NULL,
                                i + 1 < argc ? argv[i + 1] : NULL,
                                i + 2 < argc ? argv[i + 2] : NULL);
    if (strcmp(cmd, "quota-check") == 0)
        return cmd_quota_check(&c, i < argc ? argv[i] : NULL,
                               i + 1 < argc ? argv[i + 1] : NULL);
    if (strcmp(cmd, "rehook") == 0)
        return cmd_rehook(&c);
    if (strcmp(cmd, "export") == 0)
        return gt_export(&c, argc - i, argv + i);
    if (strcmp(cmd, "webhook-run") == 0)
        return wh_run(&c) < 0 ? 1 : 0;
    if (strcmp(cmd, "ci-run") == 0)
        return ci_run_all(&c) < 0 ? 1 : 0;
    if (strcmp(cmd, "mirror-sync") == 0) {
        if (i >= argc)
            return gt_mirror_sync(&c, NULL, NULL) ? 1 : 0;
        char orname[256];
        snprintf(orname, sizeof orname, "%s", argv[i]);
        char *sl = strchr(orname, '/');
        if (!sl) { usage(stderr); return 2; }
        *sl = '\0';
        return gt_mirror_sync(&c, orname, sl + 1) ? 1 : 0;
    }
    if (strcmp(cmd, "mirror-push") == 0) {
        if (i >= argc)
            return gt_mirror_push(&c, NULL, NULL) ? 1 : 0;
        char orname[256];
        snprintf(orname, sizeof orname, "%s", argv[i]);
        char *sl = strchr(orname, '/');
        if (!sl) { usage(stderr); return 2; }
        *sl = '\0';
        return gt_mirror_push(&c, orname, sl + 1) ? 1 : 0;
    }
    if (strcmp(cmd, "doctor") == 0)
        return gt_doctor(&c);
    if (strcmp(cmd, "backup") == 0) {
        if (i >= argc) { usage(stderr); return 2; }
        return gt_backup(&c, argv[i]);
    }
    if (strcmp(cmd, "restore") == 0) {
        if (i >= argc) { usage(stderr); return 2; }
        return gt_restore(&c, argv[i]);
    }
    if (strcmp(cmd, "hook-post") == 0)
        return cmd_hook_post(&c);
    if (strcmp(cmd, "kdf-selftest") == 0)
        return cmd_kdf_selftest();
    if (strcmp(cmd, "pwhash-status") == 0) {
        if (i >= argc) { usage(stderr); return 2; }
        const char *s = auth_pw_scheme(c.db, argv[i]);
        if (!s) { fprintf(stderr, "no such user\n"); return 1; }
        printf("%s\n", s);
        return 0;
    }
    if (strcmp(cmd, "tier") == 0) {
        if (i >= argc) { usage(stderr); return 2; }
        return cmd_tier(&c, argv[i],
                        i + 1 < argc ? argv[i + 1] : NULL,
                        i + 2 < argc ? argv[i + 2] : NULL);
    }
    if (strcmp(cmd, "quota") == 0) {
        if (i >= argc) { usage(stderr); return 2; }
        return cmd_quota(&c, argv[i], i + 1 < argc ? argv[i + 1] : NULL);
    }

    if (strcmp(cmd, "mkrepo") == 0 || strcmp(cmd, "useradd") == 0 ||
        strcmp(cmd, "passwd") == 0 || strcmp(cmd, "mktoken") == 0) {
        const char *pos[4] = {0};
        int np = 0, priv = 0, adm = 0;
        const char *email = NULL;
        for (; i < argc; i++) {
            if (strcmp(argv[i], "--private") == 0)      priv = 1;
            else if (strcmp(argv[i], "--admin") == 0)   adm = 1;
            else if (starts_with(argv[i], "--email="))  email = argv[i] + 8;
            else if (np < 4)                            pos[np++] = argv[i];
            else { usage(stderr); return 2; }
        }
        if (strcmp(cmd, "mkrepo") == 0) {
            if (np < 2) { usage(stderr); return 2; }
            return cmd_mkrepo(&c, pos[0], pos[1], np > 2 ? pos[2] : NULL,
                              priv);
        }
        if (strcmp(cmd, "useradd") == 0) {
            if (np < 1) { usage(stderr); return 2; }
            return cmd_useradd(&c, pos[0], email, adm);
        }
        if (strcmp(cmd, "passwd") == 0) {
            if (np < 1) { usage(stderr); return 2; }
            return cmd_passwd(&c, pos[0]);
        }
        /* mktoken */
        if (np < 2) { usage(stderr); return 2; }
        return cmd_mktoken(&c, pos[0], pos[1]);
    }

    if (strcmp(cmd, "serve") != 0) {
        usage(stderr);
        return 2;
    }

    if (sqlite3_threadsafe() == 0) {
        fprintf(stderr, "git-trace: sqlite3 built without thread"
                " support — refusing to serve\n");
        return 1;
    }
    fprintf(stderr, "git-trace: repos at %s\n", c.repos_root);
    httpd_run(c.port, &c, route,
              c.base_host[0] ? c.base_host : NULL);
    return 0;
}