From f4a80bb998c901f1e3f33cfce41f6591dcb246c5 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Fri, 28 Aug 2026 23:42:41 +0100 Subject: Migrate to rust typed router --- Cargo.lock | 72 ++ Cargo.toml | 3 +- README.md | 14 +- cgit/cgit.c | 217 ++----- cgit/cgit.h | 7 +- cgit/cmd.c | 52 +- cgit/html.c | 15 - cgit/html.h | 1 - cgit/parsing.c | 50 -- cgit/ui-atom.c | 6 +- cgit/ui-repolist.c | 79 +-- cgit/ui-shared.c | 409 +++++++----- cgit/ui-shared.h | 3 + cgit/ui-snapshot.c | 4 + cgit/ui-ssdiff.c | 10 +- cgit/ui-summary.c | 2 +- cgit/ui-tag.c | 16 +- charts/gilti/README.md | 8 +- charts/gilti/templates/deployment.yaml | 8 +- charts/gilti/values.schema.json | 6 + charts/gilti/values.yaml | 4 + crates/gilti/Cargo.toml | 1 + crates/gilti/src/cgi.rs | 19 +- crates/gilti/src/lfs.rs | 389 +++++++++++ crates/gilti/src/main.rs | 575 ++++++++++++++-- crates/gilti/src/router.rs | 867 +++++++++++++++++++++++++ crates/gilti/src/ui.rs | 10 +- tests/smoke.sh | 39 +- 28 files changed, 2337 insertions(+), 549 deletions(-) create mode 100644 crates/gilti/src/lfs.rs create mode 100644 crates/gilti/src/router.rs diff --git a/Cargo.lock b/Cargo.lock index ba66bad..542fa41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,12 +54,56 @@ dependencies = [ "tower-service", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "errno" version = "0.3.14" @@ -103,6 +147,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gilti" version = "0.1.0" @@ -112,6 +166,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", + "sha2", "tokio", "tower", ] @@ -351,6 +406,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -465,6 +531,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 6fb0143..14294b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,12 @@ repository = "https://github.com/dimidiumlabs/gilti" axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] } libc = "0.2.172" percent-encoding = "2.3.2" -tokio = { version = "1.44.2", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal"] } +tokio = { version = "1.44.2", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal"] } tower = { version = "0.5.2", features = ["util"] } maud = "0.27.0" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" +sha2 = "0.10.9" [profile.release] codegen-units = 1 diff --git a/README.md b/README.md index 862664d..5e09b9b 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,15 @@ platforms but aren't ready to host complex services like Forgejo. ## Security boundary -- Git fetch and push use SSH public-key authentication through `gilti-ssh`. +- Git fetch is available anonymously over smart HTTP; authenticated fetch and + push use SSH public-key authentication through `gilti-ssh`. - Every configured key has read/write access to every repository and may create a repository by pushing to its name for the first time. -- cgit is anonymous and read-only; every repository is publicly visible. -- Smart HTTP, password authentication, shells, forwarding, and tunnels are - disabled; cgit filters are not supported. +- Repository browsing, archives, LFS downloads, and smart HTTP fetches are + anonymous and read-only; every repository is publicly visible. +- Password authentication, shells, forwarding, and tunnels are disabled; cgit + filters are not supported. Optional unauthenticated HTTP writes must be + enabled explicitly. - Gilti is a single-replica service backed by one POSIX persistent volume. It is not an HA system. @@ -44,7 +47,8 @@ The HTTP configuration is read from the environment at startup: - `GILTI_CGIT_ROOT_TITLE` (default: `Gilti`); - `GILTI_CGIT_ROOT_DESCRIPTION` (default: `A tiny Git server`); - `GILTI_CGIT_CLONE_PREFIX` (empty by default); -- `GILTI_CGIT_CACHE` (default: `5`, maximum: `3600` seconds). +- `GILTI_CGIT_CACHE` (default: `5`, maximum: `3600` seconds); +- `GILTI_HTTP_WRITE` (`0` by default; `1` enables unauthenticated HTTP pushes and LFS uploads). Gilti snapshots the authorized keys file at process startup; changing it takes effect after a restart. Repositories and the persistent SSH host key live on diff --git a/cgit/cgit.c b/cgit/cgit.c index f04a962..dbf8a0f 100644 --- a/cgit/cgit.c +++ b/cgit/cgit.c @@ -63,61 +63,29 @@ static int config_integer(const char *name) return result; } -static void querystring_cb(const char *name, const char *value) +static char *request_optional_string(const char *name) { - if (!value) - value = ""; - - if (!strcmp(name,"r")) { - ctx.qry.repo = xstrdup(value); - ctx.repo = cgit_get_repoinfo(value); - } else if (!strcmp(name, "p")) { - ctx.qry.page = xstrdup(value); - } else if (!strcmp(name, "url")) { - if (*value == '/') - value++; - ctx.qry.url = xstrdup(value); - cgit_parse_url(value); - } else if (!strcmp(name, "qt")) { - ctx.qry.grep = xstrdup(value); - } else if (!strcmp(name, "q")) { - ctx.qry.search = xstrdup(value); - } else if (!strcmp(name, "h")) { - ctx.qry.head = xstrdup(value); - } else if (!strcmp(name, "id")) { - ctx.qry.oid = xstrdup(value); - ctx.qry.has_oid = 1; - } else if (!strcmp(name, "id2")) { - ctx.qry.oid2 = xstrdup(value); - ctx.qry.has_oid = 1; - } else if (!strcmp(name, "ofs")) { - ctx.qry.ofs = atoi(value); - } else if (!strcmp(name, "path")) { - ctx.qry.path = trim_end(value, '/'); - } else if (!strcmp(name, "name")) { - ctx.qry.name = xstrdup(value); - } else if (!strcmp(name, "s")) { - ctx.qry.sort = xstrdup(value); - } else if (!strcmp(name, "showmsg")) { - ctx.qry.showmsg = atoi(value); - } else if (!strcmp(name, "period")) { - ctx.qry.period = xstrdup(value); - } else if (!strcmp(name, "dt")) { - ctx.qry.difftype = atoi(value); - ctx.qry.has_difftype = 1; - } else if (!strcmp(name, "ss")) { - /* No longer generated, but there may be links out there. */ - ctx.qry.difftype = atoi(value) ? DIFF_SSDIFF : DIFF_UNIFIED; - ctx.qry.has_difftype = 1; - } else if (!strcmp(name, "all")) { - ctx.qry.show_all = atoi(value); - } else if (!strcmp(name, "context")) { - ctx.qry.context = atoi(value); - } else if (!strcmp(name, "ignorews")) { - ctx.qry.ignorews = atoi(value); - } else if (!strcmp(name, "follow")) { - ctx.qry.follow = atoi(value); + const char *value = getenv(name); + + return value && *value ? xstrdup(value) : NULL; +} + +static int request_optional_integer(const char *name) +{ + const char *value = getenv(name); + char *end; + long result; + + if (!value || !*value) + return 0; + errno = 0; + result = strtol(value, &end, 10); + if (errno || end == value || *end || result < INT_MIN || result > INT_MAX) { + fprintf(stderr, "gilti-cgit: environment variable %s must be an integer\n", + name); + exit(1); } + return result; } static void prepare_context(void) @@ -209,65 +177,49 @@ static void prepare_context(void) ctx.page.mimetype = "text/html"; ctx.page.charset = PAGE_ENCODING; ctx.page.modified = time(NULL); - if (ctx.env.query_string) - ctx.qry.raw = xstrdup(ctx.env.query_string); -} - -struct refmatch { - char *req_ref; - char *first_ref; - int match; -}; - -static int find_current_ref(const struct reference *ref, void *cb_data) -{ - struct refmatch *info; - - info = (struct refmatch *)cb_data; - if (!strcmp(ref->name, info->req_ref)) - info->match = 1; - if (!info->first_ref) - info->first_ref = xstrdup(ref->name); - return info->match; } -static void free_refmatch_inner(struct refmatch *info) +static void prepare_request(void) { - if (info->first_ref) - free(info->first_ref); -} - -static char *find_default_branch(struct cgit_repo *repo) -{ - struct refmatch info; - char *ref; - - info.req_ref = repo->defbranch; - info.first_ref = NULL; - info.match = 0; - refs_for_each_branch_ref(get_main_ref_store(the_repository), - find_current_ref, &info); - if (info.match) - ref = info.req_ref; - else - ref = info.first_ref; - if (ref) - ref = xstrdup(ref); - free_refmatch_inner(&info); - - return ref; + ctx.qry.repo = request_optional_string("GILTI_REPOSITORY"); + ctx.qry.page = config_string("GILTI_PAGE"); + ctx.qry.url = request_optional_string("GILTI_CURRENT_URL"); + ctx.qry.head = request_optional_string("GILTI_REVISION"); + if (ctx.qry.head) { + ctx.qry.oid = xstrdup(ctx.qry.head); + ctx.qry.has_oid = 1; + } + ctx.qry.oid2 = request_optional_string("GILTI_OLD_REVISION"); + ctx.qry.path = request_optional_string("GILTI_PATH"); + ctx.qry.format = request_optional_string("GILTI_FORMAT"); + ctx.qry.signature = request_optional_integer("GILTI_SIGNATURE"); + ctx.qry.search = request_optional_string("GILTI_QUERY_SEARCH"); + ctx.qry.grep = request_optional_string("GILTI_QUERY_GREP"); + ctx.qry.sort = request_optional_string("GILTI_QUERY_SORT"); + ctx.qry.period = request_optional_string("GILTI_QUERY_PERIOD"); + ctx.qry.ofs = request_optional_integer("GILTI_QUERY_OFFSET"); + ctx.qry.showmsg = request_optional_integer("GILTI_QUERY_SHOWMSG"); + ctx.qry.context = request_optional_integer("GILTI_QUERY_CONTEXT"); + ctx.qry.ignorews = request_optional_integer("GILTI_QUERY_IGNOREWS"); + ctx.qry.follow = request_optional_integer("GILTI_QUERY_FOLLOW"); + if (getenv("GILTI_QUERY_DIFFTYPE")) { + ctx.qry.difftype = request_optional_integer("GILTI_QUERY_DIFFTYPE"); + ctx.qry.has_difftype = 1; + } + if (ctx.qry.repo) + ctx.repo = cgit_get_repoinfo(ctx.qry.repo); } static char *guess_defbranch(void) { - const char *ref, *refname; + const char *ref; struct object_id oid; ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository), "HEAD", 0, &oid, NULL); - if (!ref || !skip_prefix(ref, "refs/heads/", &refname)) - return "master"; - return xstrdup(refname); + if (!ref) + return xstrdup("HEAD"); + return xstrdup(ref); } /* The caller must free filename and ref after calling this. */ @@ -341,7 +293,7 @@ static void choose_readme(struct cgit_repo *repo) static void print_no_repo_clone_urls(const char *url) { html(""); @@ -385,34 +337,26 @@ static int prepare_repo_cmd(int nongit) if (!ctx.repo->defbranch) ctx.repo->defbranch = guess_defbranch(); - if (!ctx.qry.head) { - ctx.qry.nohead = 1; - ctx.qry.head = find_default_branch(ctx.repo); - } + if (!ctx.qry.head) + ctx.qry.head = xstrdup("HEAD"); - if (!ctx.qry.head) { + if (repo_get_oid(the_repository, ctx.qry.head, &oid)) { + if (strcmp(ctx.qry.page, "summary")) { + cgit_print_error_page(404, "Not found", + "Invalid revision: %s", ctx.qry.head); + return 1; + } cgit_print_http_headers(); cgit_print_docstart(); cgit_print_pageheader(); cgit_print_error("Repository seems to be empty"); - if (!strcmp(ctx.qry.page, "summary")) { - html("\n"); - cgit_prepare_repo_env(ctx.repo); - cgit_add_clone_urls(print_no_repo_clone_urls); - html("
 
Clone
\n"); - } + html("\n"); + cgit_prepare_repo_env(ctx.repo); + cgit_add_clone_urls(print_no_repo_clone_urls); + html("
 
Clone
\n"); cgit_print_docend(); return 1; } - - if (repo_get_oid(the_repository, ctx.qry.head, &oid)) { - char *old_head = ctx.qry.head; - ctx.qry.head = xstrdup(ctx.repo->defbranch); - cgit_print_error_page(404, "Not found", - "Invalid branch: %s", old_head); - free(old_head); - return 1; - } string_list_sort(&ctx.repo->submodules); cgit_prepare_repo_env(ctx.repo); choose_readme(ctx.repo); @@ -424,6 +368,11 @@ static void process_request(void) struct cgit_cmd *cmd; int nongit = 0; + if (ctx.qry.repo && !ctx.repo) { + cgit_print_error_page(404, "Not found", "Repository not found"); + return; + } + if (ctx.repo) prepare_repo_env(&nongit); @@ -466,8 +415,6 @@ static NORETURN void cgit_die_routine(const char *msg, va_list params) int cmd_main(int argc UNUSED, const char **argv UNUSED) { - const char *path; - set_die_routine(cgit_die_routine); prepare_context(); @@ -476,27 +423,7 @@ int cmd_main(int argc UNUSED, const char **argv UNUSED) cgit_repolist.repos = NULL; scan_tree(config_value("CGIT_SCAN_PATH")); - ctx.repo = NULL; - http_parse_querystring(ctx.qry.raw, querystring_cb); - - /* If no url parameter is specified on the querystring, use PATH_INFO - * as url. This allows cgit to work with virtual urls without the need - * for rewriterules in the webserver. - */ - path = ctx.env.path_info; - if (!ctx.qry.url && path) { - if (path[0] == '/') - path++; - ctx.qry.url = xstrdup(path); - if (ctx.qry.raw) { - char *newqry = fmtalloc("%s?%s", path, ctx.qry.raw); - free(ctx.qry.raw); - ctx.qry.raw = newqry; - } else - ctx.qry.raw = xstrdup(ctx.qry.url); - cgit_parse_url(ctx.qry.url); - } - + prepare_request(); process_request(); return 0; } diff --git a/cgit/cgit.h b/cgit/cgit.h index 1102c87..818970f 100644 --- a/cgit/cgit.h +++ b/cgit/cgit.h @@ -154,7 +154,6 @@ struct reflist { struct cgit_query { int has_oid; int has_difftype; - char *raw; char *repo; char *page; char *search; @@ -163,11 +162,11 @@ struct cgit_query { char *oid; char *oid2; char *path; - char *name; char *url; char *period; + char *format; + int signature; int ofs; - int nohead; char *sort; int showmsg; diff_type difftype; @@ -329,8 +328,6 @@ extern char *fmtalloc(const char *format,...); extern struct commitinfo *cgit_parse_commit(struct commit *commit); extern struct taginfo *cgit_parse_tag(struct tag *tag); -extern void cgit_parse_url(const char *url); - extern const char *cgit_repobasename(const char *reponame); extern int cgit_parse_snapshots_mask(const char *str); diff --git a/cgit/cmd.c b/cgit/cmd.c index 13892a9..43a9036 100644 --- a/cgit/cmd.c +++ b/cgit/cmd.c @@ -43,29 +43,16 @@ static void atom_fn(void) static void about_fn(void) { - if (ctx.repo) { - size_t path_info_len = ctx.env.path_info ? strlen(ctx.env.path_info) : 0; - if (!ctx.qry.path && - ctx.qry.url[strlen(ctx.qry.url) - 1] != '/' && - (!path_info_len || ctx.env.path_info[path_info_len - 1] != '/')) { - char *currenturl = cgit_currenturl(); - char *redirect = fmtalloc("%s/", currenturl); - cgit_redirect(redirect, true); - free(currenturl); - free(redirect); - } else if (ctx.repo->readme.nr) - cgit_print_repo_readme(ctx.qry.path); - else if (ctx.repo->homepage) - cgit_redirect(ctx.repo->homepage, false); - else { - char *currenturl = cgit_currenturl(); - char *redirect = fmtalloc("%s../", currenturl); - cgit_redirect(redirect, false); - free(currenturl); - free(redirect); - } - } else + if (!ctx.repo) { cgit_print_site_readme(); + return; + } + if (ctx.repo->readme.nr) + cgit_print_repo_readme(NULL); + else if (ctx.repo->homepage) + cgit_redirect(ctx.repo->homepage, false); + else + cgit_print_error_page(404, "Not found", "No repository documentation"); } static void blame_fn(void) @@ -86,6 +73,14 @@ static void commit_fn(void) cgit_print_commit(ctx.qry.oid, ctx.qry.path); } +static void revision_fn(void) +{ + if (ctx.qry.oid && starts_with(ctx.qry.oid, "refs/tags/")) + cgit_print_tag(ctx.qry.oid); + else + cgit_print_commit(ctx.qry.oid, NULL); +} + static void diff_fn(void) { cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 0); @@ -136,8 +131,16 @@ static void refs_fn(void) static void snapshot_fn(void) { - cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, ctx.qry.path, - ctx.qry.nohead); + char *filename; + + if (!ctx.qry.format) { + cgit_print_error_page(400, "Bad request", "Archive format is required"); + return; + } + filename = fmtalloc("%s.%s%s", cgit_snapshot_prefix(ctx.repo), + ctx.qry.format, ctx.qry.signature ? ".asc" : ""); + cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, filename, 0); + free(filename); } static void stats_fn(void) @@ -180,6 +183,7 @@ struct cgit_cmd *cgit_get_cmd(void) def_cmd(plain, 1, 0, 0), def_cmd(rawdiff, 1, 1, 0), def_cmd(refs, 1, 0, 0), + def_cmd(revision, 1, 0, 0), def_cmd(repolist, 0, 0, 0), def_cmd(snapshot, 1, 0, 0), def_cmd(stats, 1, 1, 0), diff --git a/cgit/html.c b/cgit/html.c index bef58df..37f05a0 100644 --- a/cgit/html.c +++ b/cgit/html.c @@ -348,18 +348,3 @@ int html_include_text(const char *filename) fclose(f); return 0; } - -void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)) -{ - const char *t = txt; - - while (t && *t) { - char *name = url_decode_parameter_name(&t); - if (*name) { - char *value = url_decode_parameter_value(&t); - fn(name, value); - free(value); - } - free(name); - } -} diff --git a/cgit/html.h b/cgit/html.h index 7cedb45..03c4efb 100644 --- a/cgit/html.h +++ b/cgit/html.h @@ -37,6 +37,5 @@ extern void html_fileperm(unsigned short mode); extern int html_include(const char *filename); extern int html_include_text(const char *filename); -extern void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)); #endif /* HTML_H */ diff --git a/cgit/parsing.c b/cgit/parsing.c index fc4215f..686ef5c 100644 --- a/cgit/parsing.c +++ b/cgit/parsing.c @@ -14,56 +14,6 @@ #include "cgit.h" -/* - * url syntax: [repo ['/' cmd [ '/' path]]] - * repo: any valid repo url, may contain '/' - * cmd: log | commit | diff | tree | view | blob | snapshot - * path: any valid path, may contain '/' - * - */ -void cgit_parse_url(const char *url) -{ - char *c, *cmd, *p, *buf; - struct cgit_repo *repo; - - if (!url || url[0] == '\0') - return; - - ctx.qry.page = NULL; - ctx.repo = cgit_get_repoinfo(url); - if (ctx.repo) { - ctx.qry.repo = ctx.repo->url; - return; - } - - buf = xstrdup(url); - cmd = NULL; - c = strchr(buf, '/'); - while (c) { - c[0] = '\0'; - repo = cgit_get_repoinfo(buf); - if (repo) { - ctx.repo = repo; - cmd = c; - } - c[0] = '/'; - c = strchr(c + 1, '/'); - } - - if (ctx.repo) { - ctx.qry.repo = ctx.repo->url; - p = strchr(cmd + 1, '/'); - if (p) { - p[0] = '\0'; - if (p[1]) - ctx.qry.path = trim_end(p + 1, '/'); - } - if (cmd[1]) - ctx.qry.page = xstrdup(cmd + 1); - } - free(buf); -} - static char *substr(const char *head, const char *tail) { char *buf; diff --git a/cgit/ui-atom.c b/cgit/ui-atom.c index 5da047a..5371319 100644 --- a/cgit/ui-atom.c +++ b/cgit/ui-atom.c @@ -19,7 +19,6 @@ static void add_entry(struct commit *commit, const char *host) { - char delim = '&'; char *hex; char *mail, *t, *t2; struct commitinfo *info; @@ -65,11 +64,8 @@ static void add_entry(struct commit *commit, const char *host) html("\n"); free(pageurl); } diff --git a/cgit/ui-repolist.c b/cgit/ui-repolist.c index 5f2da3d..f6f2e99 100644 --- a/cgit/ui-repolist.c +++ b/cgit/ui-repolist.c @@ -41,12 +41,14 @@ static time_t read_agefile(const char *path) static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime) { struct strbuf path = STRBUF_INIT; + struct strbuf head = STRBUF_INIT; struct stat s; struct cgit_repo *r = (struct cgit_repo *)repo; + const char *ref; if (repo->mtime != -1) { *mtime = repo->mtime; - return 1; + return repo->mtime != 0; } strbuf_addf(&path, "%s/%s", repo->path, ctx.cfg.agefile); if (stat(path.buf, &s) == 0) { @@ -58,12 +60,22 @@ static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime) } strbuf_reset(&path); - strbuf_addf(&path, "%s/refs/heads/%s", repo->path, - repo->defbranch ? repo->defbranch : "master"); - if (stat(path.buf, &s) == 0) { - *mtime = s.st_mtime; - r->mtime = *mtime; - goto end; + strbuf_addf(&path, "%s/HEAD", repo->path); + if (strbuf_read_file(&head, path.buf, 64) >= 0) { + strbuf_trim(&head); + if (skip_prefix(head.buf, "ref: ", &ref)) { + strbuf_reset(&path); + strbuf_addf(&path, "%s/%s", repo->path, ref); + if (stat(path.buf, &s) == 0) { + *mtime = s.st_mtime; + r->mtime = *mtime; + goto end; + } + } else if (head.len && stat(path.buf, &s) == 0) { + *mtime = s.st_mtime; + r->mtime = *mtime; + goto end; + } } strbuf_reset(&path); @@ -77,6 +89,7 @@ static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime) *mtime = 0; r->mtime = *mtime; end: + strbuf_release(&head); strbuf_release(&path); return (r->mtime != 0); } @@ -96,22 +109,11 @@ static int is_match(struct cgit_repo *repo) return 0; } -static int is_in_url(struct cgit_repo *repo) -{ - if (!ctx.qry.url) - return 1; - if (repo->url && starts_with(repo->url, ctx.qry.url)) - return 1; - return 0; -} - static int is_visible(struct cgit_repo *repo) { if (repo->hide || repo->ignore) return 0; - if (!(is_match(repo) && is_in_url(repo))) - return 0; - return 1; + return is_match(repo); } static int any_repos_visible(void) @@ -266,7 +268,7 @@ static void url_path(struct strbuf *url, const char *value) const unsigned char *p = (const unsigned char *)(value ? value : ""); for (; *p; p++) { - if (isalnum(*p) || strchr("!$()*,./:;@-[]_~+&", *p)) + if (isalnum(*p) || *p == '/' || *p == '_') strbuf_addch(url, *p); else strbuf_addf(url, "%%%02x", *p); @@ -312,31 +314,15 @@ static char *repo_url(const struct cgit_repo *repo, const char *page, const char *query) { struct strbuf url = STRBUF_INIT; - const char *delimiter = "?"; - if (ctx.cfg.virtual_root) { - url_path(&url, ctx.cfg.virtual_root); - url_path(&url, repo->url); - if (repo->url[strlen(repo->url) - 1] != '/') - strbuf_addch(&url, '/'); - if (page) { - url_path(&url, page); - strbuf_addch(&url, '/'); - } - } else { - url_path(&url, ctx.cfg.script_name); - strbuf_addstr(&url, "?url="); - url_arg(&url, repo->url); - if (repo->url[strlen(repo->url) - 1] != '/') - strbuf_addch(&url, '/'); - if (page) { - url_arg(&url, page); - strbuf_addch(&url, '/'); - } - delimiter = "&"; + strbuf_addch(&url, '/'); + url_path(&url, repo->url); + if (page) { + strbuf_addstr(&url, "/+/HEAD/+/"); + url_path(&url, page); } if (query) - strbuf_addf(&url, "%s%s", delimiter, query); + strbuf_addf(&url, "?%s", query); return strbuf_detach(&url, NULL); } @@ -428,7 +414,7 @@ void cgit_print_repolist(void) json_key("title"); json_value(ctx.cfg.root_title); json(","); json_key("root_desc"); json_value(ctx.cfg.root_desc); json(","); json_key("root_url"); json_value(cgit_rooturl()); json(","); - json_key("about_url"); { struct strbuf about = STRBUF_INIT; strbuf_addf(&about, "%s?p=about", cgit_rooturl()); json_value(about.buf); strbuf_release(&about); } json(","); + json_key("about_url"); json_value("/-/about"); json(","); json_key("noheader"); json_bool(ctx.cfg.noheader); json(","); json_key("search"); json_value(ctx.qry.search); json(","); json_key("current_url"); { char *url = cgit_currenturl(); json_value(url); free(url); } json(","); @@ -443,6 +429,8 @@ void cgit_print_repolist(void) json_key("rows"); json("["); for (i = 0; i < cgit_repolist.count; i++) { struct cgit_repo *repo = &cgit_repolist.repos[i]; + time_t mtime; + int populated; char *section, *url; if (!is_visible(repo)) continue; hits++; @@ -469,8 +457,9 @@ void cgit_print_repolist(void) json_key("owner"); json_value(repo->owner); json(","); json_key("owner_url"); { char *current = cgit_currenturl(); struct strbuf owner = STRBUF_INIT; strbuf_addf(&owner, "%s?q=", current); url_arg(&owner, repo->owner); json_value(owner.buf); strbuf_release(&owner); free(current); } json(","); json_key("idle"); json_age(repo); json(","); - json_key("log_url"); url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); json(","); - json_key("tree_url"); url = repo_url(repo, "tree", NULL); json_value(url); free(url); + populated = get_repo_modtime(repo, &mtime); + json_key("log_url"); if (populated) { url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); } else json("null"); json(","); + json_key("tree_url"); if (populated) { url = repo_url(repo, "tree", NULL); json_value(url); free(url); } else json("null"); json("}"); first = 0; } json("],"); diff --git a/cgit/ui-shared.c b/cgit/ui-shared.c index 96a8c3c..49e9c00 100644 --- a/cgit/ui-shared.c +++ b/cgit/ui-shared.c @@ -75,47 +75,15 @@ char *cgit_hosturl(void) char *cgit_currenturl(void) { - const char *root = cgit_rooturl(); - - if (!ctx.qry.url) - return xstrdup(root); - if (root[0] && root[strlen(root) - 1] == '/') - return fmtalloc("%s%s", root, ctx.qry.url); - return fmtalloc("%s/%s", root, ctx.qry.url); + return xstrdup(ctx.qry.url ? ctx.qry.url : cgit_rooturl()); } char *cgit_currentfullurl(void) { - const char *root = cgit_rooturl(); - const char *orig_query = ctx.env.query_string ? ctx.env.query_string : ""; - size_t len = strlen(orig_query); - char *query = xmalloc(len + 2), *start_url, *ret; - - /* Remove all url=... parts from query string */ - memcpy(query + 1, orig_query, len + 1); - query[0] = '?'; - start_url = query; - while ((start_url = strstr(start_url, "url=")) != NULL) { - if (start_url[-1] == '?' || start_url[-1] == '&') { - const char *end_url = strchr(start_url, '&'); - if (end_url) - memmove(start_url, end_url + 1, strlen(end_url)); - else - start_url[0] = '\0'; - } else - ++start_url; - } - if (!query[1]) - query[0] = '\0'; + const char *url = ctx.qry.url ? ctx.qry.url : cgit_rooturl(); + const char *query = ctx.env.query_string; - if (!ctx.qry.url) - ret = fmtalloc("%s%s", root, query); - else if (root[0] && root[strlen(root) - 1] == '/') - ret = fmtalloc("%s%s%s", root, ctx.qry.url, query); - else - ret = fmtalloc("%s/%s%s", root, ctx.qry.url, query); - free(query); - return ret; + return query && *query ? fmtalloc("%s?%s", url, query) : xstrdup(url); } const char *cgit_rooturl(void) @@ -126,32 +94,86 @@ const char *cgit_rooturl(void) return ctx.cfg.script_name; } +static void add_url_path(struct strbuf *url, const char *value) +{ + const unsigned char *p = (const unsigned char *)(value ? value : ""); + + for (; *p; p++) { + if (isalnum(*p) || *p == '/' || *p == '_') + strbuf_addch(url, *p); + else + strbuf_addf(url, "%%%02x", *p); + } +} + +static void add_repo_url(struct strbuf *url, const char *reponame) +{ + strbuf_addch(url, '/'); + add_url_path(url, reponame); +} + +static const char *link_revision(const char *head, const char *rev) +{ + return rev ? rev : head ? head : "HEAD"; +} + +static void add_revision(struct strbuf *url, const char *revision) +{ + struct object_id oid; + + if (!strcmp(revision, "HEAD") || starts_with(revision, "refs/") || + !get_oid_hex(revision, &oid)) + add_url_path(url, revision); + else { + strbuf_addstr(url, "refs/heads/"); + add_url_path(url, revision); + } +} + +static char *repo_view_url(const char *reponame, const char *page, + const char *revision, const char *path) +{ + struct strbuf url = STRBUF_INIT; + + add_repo_url(&url, reponame); + if (!page || !strcmp(page, "summary")) + return strbuf_detach(&url, NULL); + strbuf_addstr(&url, "/+/"); + if (!strcmp(page, "about") || !strcmp(page, "stats") || + !strcmp(page, "refs")) { + strbuf_addstr(&url, page); + return strbuf_detach(&url, NULL); + } + add_revision(&url, revision ? revision : "HEAD"); + if (strcmp(page, "commit")) { + strbuf_addstr(&url, "/+/"); + strbuf_addstr(&url, !strcmp(page, "plain") ? "tree" : page); + if (path) { + strbuf_addch(&url, '/'); + add_url_path(&url, path); + } + } + return strbuf_detach(&url, NULL); +} + char *cgit_repourl(const char *reponame) { - if (ctx.cfg.virtual_root) - return fmtalloc("%s%s/", ctx.cfg.virtual_root, reponame); - else - return fmtalloc("?r=%s", reponame); + return repo_view_url(reponame, NULL, NULL, NULL); } char *cgit_fileurl(const char *reponame, const char *pagename, const char *filename, const char *query) { - struct strbuf sb = STRBUF_INIT; - char *delim; - - if (ctx.cfg.virtual_root) { - strbuf_addf(&sb, "%s%s/%s/%s", ctx.cfg.virtual_root, reponame, - pagename, (filename ? filename:"")); - delim = "?"; - } else { - strbuf_addf(&sb, "?url=%s/%s/%s", reponame, pagename, - (filename ? filename : "")); - delim = "&"; - } - if (query) - strbuf_addf(&sb, "%s%s", delim, query); - return strbuf_detach(&sb, NULL); + char *url = repo_view_url(reponame, + !strcmp(pagename, "atom") ? "feed/atom" : pagename, + ctx.qry.head, filename); + char *result; + + if (!query) + return url; + result = fmtalloc("%s?%s", url, query); + free(url); + return result; } char *cgit_pageurl(const char *reponame, const char *pagename, @@ -160,6 +182,24 @@ char *cgit_pageurl(const char *reponame, const char *pagename, return cgit_fileurl(reponame, pagename, NULL, query); } +char *cgit_revurl(const char *reponame, const char *revision) +{ + return repo_view_url(reponame, "commit", revision, NULL); +} + +char *cgit_treeurl(const char *reponame, const char *revision, + const char *path, const char *fragment) +{ + char *url = repo_view_url(reponame, "tree", revision, path); + char *result; + + if (!fragment) + return url; + result = fmtalloc("%s#%s", url, fragment); + free(url); + return result; +} + const char *cgit_repobasename(const char *reponame) { /* I assume we don't need to store more than one repo basename */ @@ -202,7 +242,9 @@ static void site_url(const char *page, const char *search, const char *sort, int { char *delim = "?"; - if (always_root || page) + if (page) + htmlf("/-/%s", page); + else if (always_root) html_attr(cgit_rooturl()); else { char *currenturl = cgit_currenturl(); @@ -210,10 +252,6 @@ static void site_url(const char *page, const char *search, const char *sort, int free(currenturl); } - if (page) { - htmlf("?p=%s", page); - delim = "&"; - } if (search) { html(delim); html("q="); @@ -259,11 +297,8 @@ void cgit_index_link(const char *name, const char *title, const char *class, site_link(NULL, name, title, class, pattern, sort, ofs, always_root); } -static char *repolink(const char *title, const char *class, const char *page, - const char *head, const char *path) +static void link_start(const char *url, const char *title, const char *class) { - char *delim = "?"; - html("defbranch && strcmp(head, ctx.repo->defbranch)) { - html(delim); - html("h="); - html_url_arg(head); - delim = "&"; - } - return fmt("%s", delim); + html_attr(url); +} + +static char *repolink(const char *title, const char *class, const char *page, + const char *head, const char *path) +{ + char *url = repo_view_url(ctx.repo->url, page, head, path); + + link_start(url, title, class); + free(url); + return "?"; } static void reporevlink(const char *page, const char *name, const char *title, const char *class, const char *head, const char *rev, const char *path) { - char *delim; + char *url = repo_view_url(ctx.repo->url, page, + link_revision(head, rev), path); - delim = repolink(title, class, page, head, path); - if (rev && ctx.qry.head != NULL && strcmp(rev, ctx.qry.head)) { - html(delim); - html("id="); - html_url_arg(rev); + if (!strcmp(page ? page : "", "plain")) { + char *raw = fmtalloc("%s?format=raw", url); + free(url); + url = raw; } + link_start(url, title, class); + free(url); html("'>"); html_txt(name); html("
"); @@ -336,7 +352,11 @@ void cgit_summary_link(const char *name, const char *title, const char *class, void cgit_tag_link(const char *name, const char *title, const char *class, const char *tag) { - reporevlink("tag", name, title, class, tag, NULL, NULL); + char *ref = starts_with(tag, "refs/tags/") ? xstrdup(tag) : + fmtalloc("refs/tags/%s", tag); + + reporevlink("commit", name, title, class, ref, NULL, NULL); + free(ref); } void cgit_tree_link(const char *name, const char *title, const char *class, @@ -364,13 +384,7 @@ void cgit_log_link(const char *name, const char *title, const char *class, { char *delim; - delim = repolink(title, class, "log", head, path); - if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) { - html(delim); - html("id="); - html_url_arg(rev); - delim = "&"; - } + delim = repolink(title, class, "log", link_revision(head, rev), path); if (grep && pattern) { html(delim); html("qt="); @@ -405,13 +419,7 @@ void cgit_commit_link(const char *name, const char *title, const char *class, { char *delim; - delim = repolink(title, class, "commit", head, path); - if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) { - html(delim); - html("id="); - html_url_arg(rev); - delim = "&"; - } + delim = repolink(title, class, "commit", link_revision(head, rev), NULL); if (ctx.qry.difftype) { html(delim); htmlf("dt=%d", ctx.qry.difftype); @@ -454,28 +462,48 @@ void cgit_snapshot_link(const char *name, const char *title, const char *class, const char *head, const char *rev, const char *archivename) { - reporevlink("snapshot", name, title, class, head, rev, archivename); + const char *format = NULL; + const struct cgit_snapshot_format *f; + char *url, *full; + int signature = ends_with(archivename, ".asc"); + + for (f = cgit_snapshot_formats; f->suffix; f++) + if (strstr(archivename, f->suffix)) { + format = f->suffix + 1; + break; + } + if (!format) + return; + url = repo_view_url(ctx.repo->url, + signature ? "archive-signature" : "archive", + link_revision(head, rev), NULL); + full = fmtalloc("%s?format=%s", url, format); + free(url); + link_start(full, title, class); + free(full); + html("'>"); + html_txt(name); + html(""); } void cgit_diff_link(const char *name, const char *title, const char *class, const char *head, const char *new_rev, const char *old_rev, const char *path) { - char *delim; + struct strbuf url = STRBUF_INIT; + char *delim = "?"; - delim = repolink(title, class, "diff", head, path); - if (new_rev && ctx.qry.head != NULL && strcmp(new_rev, ctx.qry.head)) { - html(delim); - html("id="); - html_url_arg(new_rev); - delim = "&"; - } - if (old_rev) { - html(delim); - html("id2="); - html_url_arg(old_rev); - delim = "&"; + add_repo_url(&url, ctx.repo->url); + strbuf_addstr(&url, "/+/diff/"); + add_revision(&url, old_rev ? old_rev : "HEAD"); + strbuf_addstr(&url, ".."); + add_revision(&url, link_revision(head, new_rev)); + if (path) { + strbuf_addstr(&url, "/+/"); + add_url_path(&url, path); } + link_start(url.buf, title, class); + strbuf_release(&url); if (ctx.qry.difftype) { html(delim); htmlf("dt=%d", ctx.qry.difftype); @@ -504,7 +532,33 @@ void cgit_diff_link(const char *name, const char *title, const char *class, void cgit_patch_link(const char *name, const char *title, const char *class, const char *head, const char *rev, const char *path) { - reporevlink("patch", name, title, class, head, rev, path); + struct strbuf url = STRBUF_INIT; + struct object_id oid; + struct commit *commit; + const char *new_rev = link_revision(head, rev); + char *old_rev = xstrdup("HEAD"); + + if (!repo_get_oid(the_repository, new_rev, &oid) && + (commit = lookup_commit_reference(the_repository, &oid)) && + commit->parents) { + free(old_rev); + old_rev = xstrdup(oid_to_hex(&commit->parents->item->object.oid)); + } + add_repo_url(&url, ctx.repo->url); + strbuf_addstr(&url, "/+/patch/"); + add_revision(&url, old_rev); + strbuf_addstr(&url, ".."); + add_revision(&url, new_rev); + if (path) { + strbuf_addstr(&url, "/+/"); + add_url_path(&url, path); + } + link_start(url.buf, title, class); + strbuf_release(&url); + free(old_rev); + html("'>"); + html_txt(name); + html(""); } void cgit_stats_link(const char *name, const char *title, const char *class, @@ -541,7 +595,7 @@ static void cgit_self_link(char *name, const char *title, const char *class) ctx.qry.path, ctx.qry.ofs, ctx.qry.grep, ctx.qry.search, ctx.qry.showmsg, ctx.qry.follow); - else if (!strcmp(ctx.qry.page, "commit")) + else if (!strcmp(ctx.qry.page, "commit") || !strcmp(ctx.qry.page, "revision")) cgit_commit_link(name, title, class, ctx.qry.head, ctx.qry.has_oid ? ctx.qry.oid : NULL, ctx.qry.path); @@ -816,31 +870,27 @@ void cgit_print_docstart(void) if (ctx.cfg.css.items) for_each_string_list(&ctx.cfg.css, emit_css_link, NULL); else - emit_css_link(NULL, "/cgit.css"); + emit_css_link(NULL, "/-/assets/cgit.css"); if (ctx.cfg.js.items) for_each_string_list(&ctx.cfg.js, emit_js_link, NULL); else - emit_js_link(NULL, "/cgit.js"); + emit_js_link(NULL, "/-/assets/cgit.js"); if (ctx.cfg.favicon && *ctx.cfg.favicon) { html("\n"); } - if (host && ctx.repo && ctx.qry.head) { + if (host && ctx.repo && ctx.qry.head && starts_with(ctx.qry.head, "refs/")) { char *fileurl; - struct strbuf sb = STRBUF_INIT; - strbuf_addf(&sb, "h=%s", ctx.qry.head); html("\n"); - strbuf_release(&sb); free(fileurl); } if (ctx.repo) @@ -927,38 +977,39 @@ void cgit_add_clone_urls(void (*fn)(const char *)) { if (ctx.repo->clone_url) add_clone_urls(fn, expand_macros(ctx.repo->clone_url), NULL); - else if (ctx.cfg.clone_prefix) - add_clone_urls(fn, ctx.cfg.clone_prefix, ctx.repo->url); + else if (ctx.cfg.clone_prefix) { + char *suffix = fmtalloc("%s.git", ctx.repo->url); + add_clone_urls(fn, ctx.cfg.clone_prefix, suffix); + free(suffix); + } else { + char *host = cgit_hosturl(); + struct strbuf url = STRBUF_INIT; + + if (!host) + return; + strbuf_addf(&url, "%s%s", cgit_httpscheme(), host); + add_repo_url(&url, ctx.repo->url); + strbuf_addstr(&url, ".git"); + fn(url.buf); + strbuf_release(&url); + free(host); + } } -static int print_branch_option(const struct reference *ref, void *cb_data) +static int print_branch_option(const struct reference *ref, void *cb_data UNUSED) { - char *name = (char *)ref->name; - html_option(name, name, ctx.qry.head); + char *url = repo_view_url(ctx.repo->url, "tree", ref->name, + ctx.qry.vpath); + + html_option(url, ref->name, + ctx.qry.head && !strcmp(ref->name, ctx.qry.head) ? url : NULL); + free(url); return 0; } -void cgit_add_hidden_formfields(int incl_head, int incl_search, - const char *page) +void cgit_add_hidden_formfields(int incl_head UNUSED, int incl_search, + const char *page UNUSED) { - if (!ctx.cfg.virtual_root) { - struct strbuf url = STRBUF_INIT; - - strbuf_addf(&url, "%s/%s", ctx.qry.repo, page); - if (ctx.qry.vpath) - strbuf_addf(&url, "/%s", ctx.qry.vpath); - html_hidden("url", url.buf); - strbuf_release(&url); - } - - if (incl_head && ctx.qry.head && ctx.repo->defbranch && - strcmp(ctx.qry.head, ctx.repo->defbranch)) - html_hidden("h", ctx.qry.head); - - if (ctx.qry.oid) - html_hidden("id", ctx.qry.oid); - if (ctx.qry.oid2) - html_hidden("id2", ctx.qry.oid2); if (ctx.qry.showmsg) html_hidden("showmsg", "1"); @@ -982,17 +1033,26 @@ static void cgit_print_path_crumbs(char *path) { char *old_path = ctx.qry.path; char *p = path, *q, *end = path + strlen(path); + int blame = !strcmp(ctx.qry.page, "blame"); int levels = 0; ctx.qry.path = NULL; - cgit_self_link("root", NULL, NULL); + if (blame) + cgit_tree_link("root", NULL, NULL, ctx.qry.head, + ctx.qry.oid, NULL); + else + cgit_self_link("root", NULL, NULL); ctx.qry.path = p = path; while (p < end) { if (!(q = strchr(p, '/')) || levels > 15) q = end; *q = '\0'; html_txt("/"); - cgit_self_link(p, NULL, NULL); + if (blame && q < end) + cgit_tree_link(p, NULL, NULL, ctx.qry.head, + ctx.qry.oid, ctx.qry.path); + else + cgit_self_link(p, NULL, NULL); if (q < end) *q = '/'; p = q + 1; @@ -1033,17 +1093,13 @@ static void print_header(void) html(" : "); cgit_summary_link(ctx.repo->name, NULL, NULL, NULL); html(""); - html("
\n"); - cgit_add_hidden_formfields(0, 1, ctx.qry.page); - html("\n"); refs_for_each_branch_ref(get_main_ref_store(the_repository), print_branch_option, ctx.qry.head); if (ctx.repo->enable_remote_branches) refs_for_each_remote_ref(get_main_ref_store(the_repository), print_branch_option, ctx.qry.head); - html(" "); - html(""); - html("
"); + html(""); } else html_txt(ctx.cfg.root_title); html("\n"); @@ -1085,7 +1141,8 @@ void cgit_print_pageheader(void) else cgit_tree_link("tree", NULL, hc("tree"), ctx.qry.head, ctx.qry.oid, ctx.qry.vpath); - cgit_commit_link("commit", NULL, hc("commit"), + cgit_commit_link("commit", NULL, + ctx.qry.page && !strcmp(ctx.qry.page, "revision") ? "active" : hc("commit"), ctx.qry.head, ctx.qry.oid, ctx.qry.vpath); cgit_diff_link("diff", NULL, hc("diff"), ctx.qry.head, ctx.qry.oid, ctx.qry.oid2, ctx.qry.vpath); @@ -1102,7 +1159,7 @@ void cgit_print_pageheader(void) if (ctx.cfg.virtual_root) { char *fileurl = cgit_fileurl(ctx.qry.repo, "log", ctx.qry.vpath, NULL); - html_url_path(fileurl); + html_attr(fileurl); free(fileurl); } html("'>\n"); diff --git a/cgit/ui-shared.h b/cgit/ui-shared.h index 9e0027c..c1d055b 100644 --- a/cgit/ui-shared.h +++ b/cgit/ui-shared.h @@ -15,6 +15,9 @@ extern char *cgit_fileurl(const char *reponame, const char *pagename, const char *filename, const char *query); extern char *cgit_pageurl(const char *reponame, const char *pagename, const char *query); +extern char *cgit_revurl(const char *reponame, const char *revision); +extern char *cgit_treeurl(const char *reponame, const char *revision, + const char *path, const char *fragment); extern void cgit_add_clone_urls(void (*fn)(const char *)); diff --git a/cgit/ui-snapshot.c b/cgit/ui-snapshot.c index 99fae75..d2cc488 100644 --- a/cgit/ui-snapshot.c +++ b/cgit/ui-snapshot.c @@ -33,6 +33,10 @@ static int write_archive_type(const char *format, const char *hex, const char *p strbuf_release(&buf); } strvec_push(&argv, hex); + if (ctx.qry.path) { + strvec_push(&argv, "--"); + strvec_push(&argv, ctx.qry.path); + } /* * Now we need to copy the pointers to arguments into a new * structure because write_archive will rearrange its arguments diff --git a/cgit/ui-ssdiff.c b/cgit/ui-ssdiff.c index f523559..f310ec1 100644 --- a/cgit/ui-ssdiff.c +++ b/cgit/ui-ssdiff.c @@ -240,8 +240,9 @@ static void print_ssdiff_line(char *class, if (old_line_no > 0) { struct diff_filespec *old_file = cgit_get_current_old_file(); char *lineno_str = fmt("n%d", old_line_no); - char *id_str = fmt("id=%s#%s", is_null_oid(&old_file->oid)?"HEAD":oid_to_hex(old_rev_oid), lineno_str); - char *fileurl = cgit_fileurl(ctx.repo->url, "tree", old_file->path, id_str); + char *fileurl = cgit_treeurl(ctx.repo->url, + is_null_oid(&old_file->oid) ? "HEAD" : oid_to_hex(old_rev_oid), + old_file->path, lineno_str); html("%s", lineno_str + 1); @@ -263,8 +264,9 @@ static void print_ssdiff_line(char *class, if (new_line_no > 0) { struct diff_filespec *new_file = cgit_get_current_new_file(); char *lineno_str = fmt("n%d", new_line_no); - char *id_str = fmt("id=%s#%s", is_null_oid(&new_file->oid)?"HEAD":oid_to_hex(new_rev_oid), lineno_str); - char *fileurl = cgit_fileurl(ctx.repo->url, "tree", new_file->path, id_str); + char *fileurl = cgit_treeurl(ctx.repo->url, + is_null_oid(&new_file->oid) ? "HEAD" : oid_to_hex(new_rev_oid), + new_file->path, lineno_str); html("%s", lineno_str + 1); diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c index 7a81a35..dc62ea9 100644 --- a/cgit/ui-summary.c +++ b/cgit/ui-summary.c @@ -36,7 +36,7 @@ static void print_url(const char *url) } htmlf(""); diff --git a/cgit/ui-tag.c b/cgit/ui-tag.c index 86b81bd..e053f05 100644 --- a/cgit/ui-tag.c +++ b/cgit/ui-tag.c @@ -49,14 +49,20 @@ void cgit_print_tag(char *revname) struct strbuf fullref = STRBUF_INIT; struct object_id oid; struct object *obj; + const char *displayname; if (!revname) revname = ctx.qry.head; - strbuf_addf(&fullref, "refs/tags/%s", revname); + if (skip_prefix(revname, "refs/tags/", &displayname)) + strbuf_addstr(&fullref, revname); + else { + displayname = revname; + strbuf_addf(&fullref, "refs/tags/%s", revname); + } if (repo_get_oid(the_repository, fullref.buf, &oid)) { cgit_print_error_page(404, "Not found", - "Bad tag reference: %s", revname); + "Bad tag reference: %s", displayname); goto cleanup; } obj = parse_object(the_repository, &oid); @@ -72,13 +78,13 @@ void cgit_print_tag(char *revname) tag = lookup_tag(the_repository, &oid); if (!tag || parse_tag(the_repository, tag) || !(info = cgit_parse_tag(tag))) { cgit_print_error_page(500, "Internal server error", - "Bad tag object: %s", revname); + "Bad tag object: %s", displayname); goto cleanup; } cgit_print_layout_start(); html("\n"); html("\n", oid_to_hex(&oid)); if (info->tagger_date > 0) { html("
tag name"); - html_txt(revname); + html_txt(displayname); htmlf(" (%s)
tag date"); @@ -108,7 +114,7 @@ void cgit_print_tag(char *revname) cgit_print_layout_start(); html("\n"); html("\n"); html("
tag name"); - html_txt(revname); + html_txt(displayname); html("
tagged object"); cgit_object_link(obj); diff --git a/charts/gilti/README.md b/charts/gilti/README.md index 14a31f4..f8e5476 100644 --- a/charts/gilti/README.md +++ b/charts/gilti/README.md @@ -31,9 +31,11 @@ git push -u origin main ## Repository visibility -cgit is anonymous and read-only and scans the complete repository directory. -Consequently every repository available over SSH is also publicly visible over -HTTP. Web cloning and snapshots are disabled in the default policy. +Repository browsing, smart HTTP cloning, archives, and LFS downloads are +anonymous and read-only. Consequently every repository available over SSH is +also publicly visible over HTTP. HTTP pushes and LFS uploads remain disabled by +default; setting `http.write: true` enables them without authentication and is +appropriate only behind a trusted access-control proxy. ## Networking diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml index 12a39ce..58bf5d0 100644 --- a/charts/gilti/templates/deployment.yaml +++ b/charts/gilti/templates/deployment.yaml @@ -56,6 +56,8 @@ spec: value: {{ .Values.cgit.clonePrefix | quote }} - name: GILTI_CGIT_CACHE value: {{ .Values.cgit.cache | quote }} + - name: GILTI_HTTP_WRITE + value: {{ ternary "1" "0" .Values.http.write | quote }} ports: - name: http containerPort: 8080 @@ -65,18 +67,18 @@ spec: protocol: TCP startupProbe: httpGet: - path: /healthz + path: /-/health port: http failureThreshold: 60 periodSeconds: 5 readinessProbe: httpGet: - path: /healthz + path: /-/health port: http periodSeconds: 10 livenessProbe: httpGet: - path: /healthz + path: /-/health port: http periodSeconds: 20 resources: diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json index c15674a..a22ed4e 100644 --- a/charts/gilti/values.schema.json +++ b/charts/gilti/values.schema.json @@ -31,6 +31,12 @@ "cache": { "type": "integer", "minimum": 0, "maximum": 3600 } } }, + "http": { + "type": "object", + "properties": { + "write": { "type": "boolean" } + } + }, "persistence": { "type": "object", "properties": { diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml index 03c480b..60e492e 100644 --- a/charts/gilti/values.yaml +++ b/charts/gilti/values.yaml @@ -24,6 +24,10 @@ cgit: # In-memory CGI response lifetime in seconds; zero disables the cache. cache: 5 +http: + # Allow unauthenticated Git receive-pack and LFS uploads. + write: false + persistence: enabled: true existingClaim: "" diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml index 5752260..fee4d7c 100644 --- a/crates/gilti/Cargo.toml +++ b/crates/gilti/Cargo.toml @@ -21,3 +21,4 @@ tower.workspace = true maud.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs index ffcc081..38d0693 100644 --- a/crates/gilti/src/cgi.rs +++ b/crates/gilti/src/cgi.rs @@ -185,6 +185,12 @@ impl ResponseCache { } } +#[derive(Clone, Default)] +pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>); + +#[derive(Clone, Copy)] +pub struct NoCache; + #[derive(Clone, Copy)] pub struct RemoteAddr(pub std::net::SocketAddr); @@ -237,10 +243,15 @@ impl Cgi { |value| value.0, ); let (parts, body) = request.into_parts(); - let body = axum::body::to_bytes(body, 1024 * 1024) + let body = axum::body::to_bytes(body, 1024 * 1024 * 1024) .await .map_err(std::io::Error::other)?; - let cache_key = CacheKey::from_request(&parts, &body); + let cache_key = parts + .extensions + .get::() + .is_none() + .then(|| CacheKey::from_request(&parts, &body)) + .flatten(); if let Some(response) = self .cache .as_ref() @@ -313,6 +324,10 @@ impl Cgi { } } + if let Some(environment) = parts.extensions.get::() { + command.envs(environment.0.iter().cloned()); + } + let mut child = command.spawn()?; let mut stdin = child .stdin diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs new file mode 100644 index 0000000..7f883fb --- /dev/null +++ b/crates/gilti/src/lfs.rs @@ -0,0 +1,389 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +const MAX_OBJECT_SIZE: usize = 1024 * 1024 * 1024; + +#[derive(serde::Deserialize)] +struct BatchRequest { + operation: String, + objects: Vec, +} + +#[derive(serde::Deserialize)] +struct ObjectRequest { + oid: String, + size: u64, +} + +#[derive(serde::Deserialize)] +struct VerifyRequest { + oid: String, + size: u64, +} + +#[derive(serde::Serialize)] +struct BatchResponse { + transfer: &'static str, + objects: Vec, +} + +#[derive(serde::Serialize)] +struct ObjectResponse { + oid: String, + size: u64, + #[serde(skip_serializing_if = "Option::is_none")] + actions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(serde::Serialize)] +struct Actions { + #[serde(skip_serializing_if = "Option::is_none")] + download: Option, + #[serde(skip_serializing_if = "Option::is_none")] + upload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + verify: Option, +} + +#[derive(serde::Serialize)] +struct Action { + href: String, +} + +#[derive(serde::Serialize)] +struct ObjectError { + code: u16, + message: &'static str, +} + +pub async fn serve( + repositories: &std::path::Path, + repo: &str, + path: &str, + write_enabled: bool, + request: axum::extract::Request, +) -> axum::response::Response { + let Some(repository) = repository(repositories, repo) else { + return plain(axum::http::StatusCode::NOT_FOUND, "repository not found\n"); + }; + let objects = repository.join("lfs/objects"); + let method = request.method().clone(); + + if path == "objects/batch" && method == axum::http::Method::POST { + return batch(objects, write_enabled, request).await; + } + if let Some(oid) = path + .strip_prefix("objects/") + .and_then(|value| value.strip_suffix("/verify")) + && method == axum::http::Method::POST + { + return verify(&objects, oid, request).await; + } + if let Some(oid) = path.strip_prefix("objects/") { + if !valid_oid(oid) { + return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n"); + } + return match method { + axum::http::Method::GET | axum::http::Method::HEAD => { + download(&objects, oid, method == axum::http::Method::HEAD).await + } + axum::http::Method::PUT if write_enabled => upload(&objects, oid, request).await, + axum::http::Method::PUT => plain( + axum::http::StatusCode::FORBIDDEN, + "LFS uploads are disabled\n", + ), + _ => plain( + axum::http::StatusCode::METHOD_NOT_ALLOWED, + "method not allowed\n", + ), + }; + } + plain(axum::http::StatusCode::NOT_FOUND, "not found\n") +} + +async fn batch( + objects: std::path::PathBuf, + write_enabled: bool, + request: axum::extract::Request, +) -> axum::response::Response { + let base = request + .uri() + .path() + .strip_suffix("/objects/batch") + .unwrap_or(request.uri().path()); + let scheme = request + .headers() + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .filter(|value| matches!(*value, "http" | "https")) + .unwrap_or("http"); + let host = request + .headers() + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or("localhost"); + let prefix = format!("{scheme}://{host}{base}/objects"); + let body = match axum::body::to_bytes(request.into_body(), 1024 * 1024).await { + Ok(body) => body, + Err(_) => { + return plain( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "request too large\n", + ); + } + }; + let request: BatchRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"), + }; + if !matches!(request.operation.as_str(), "download" | "upload") { + return plain( + axum::http::StatusCode::BAD_REQUEST, + "invalid LFS operation\n", + ); + } + + let objects = request + .objects + .into_iter() + .map(|object| { + if !valid_oid(&object.oid) { + return error(object, 422, "invalid object id"); + } + let stored = object_path(&objects, &object.oid); + let present = std::fs::metadata(&stored) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() == object.size); + match request.operation.as_str() { + "download" if present => response( + object, + Actions { + download: Some(Action { + href: format!( + "{prefix}/{}", + stored.file_name().unwrap().to_string_lossy() + ), + }), + upload: None, + verify: None, + }, + ), + "download" => error(object, 404, "object not found"), + "upload" if present => response( + object, + Actions { + download: Some(Action { + href: format!( + "{prefix}/{}", + stored.file_name().unwrap().to_string_lossy() + ), + }), + upload: None, + verify: None, + }, + ), + "upload" if !write_enabled => error(object, 403, "LFS uploads are disabled"), + "upload" => response( + object, + Actions { + download: None, + upload: Some(Action { + href: format!( + "{prefix}/{}", + stored.file_name().unwrap().to_string_lossy() + ), + }), + verify: Some(Action { + href: format!( + "{prefix}/{}/verify", + stored.file_name().unwrap().to_string_lossy() + ), + }), + }, + ), + _ => unreachable!(), + } + }) + .collect(); + json( + axum::http::StatusCode::OK, + &BatchResponse { + transfer: "basic", + objects, + }, + ) +} + +async fn download(objects: &std::path::Path, oid: &str, head: bool) -> axum::response::Response { + let path = object_path(objects, oid); + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => metadata, + _ => return plain(axum::http::StatusCode::NOT_FOUND, "object not found\n"), + }; + let body = if head { + axum::body::Body::empty() + } else { + match tokio::fs::read(path).await { + Ok(body) => axum::body::Body::from(body), + Err(_) => { + return plain( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "read failed\n", + ); + } + } + }; + axum::http::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, "application/octet-stream") + .header(axum::http::header::CONTENT_LENGTH, metadata.len()) + .body(body) + .expect("valid LFS response") +} + +async fn upload( + objects: &std::path::Path, + oid: &str, + request: axum::extract::Request, +) -> axum::response::Response { + use sha2::Digest; + + let body = match axum::body::to_bytes(request.into_body(), MAX_OBJECT_SIZE).await { + Ok(body) => body, + Err(_) => { + return plain( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "object too large\n", + ); + } + }; + let actual = format!("{:x}", sha2::Sha256::digest(&body)); + if actual != oid { + return plain( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + "object hash mismatch\n", + ); + } + let path = object_path(objects, oid); + let Some(parent) = path.parent() else { + return plain( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "invalid storage path\n", + ); + }; + if tokio::fs::create_dir_all(parent).await.is_err() { + return plain( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "storage failed\n", + ); + } + let temporary = parent.join(format!(".{oid}.{}.tmp", std::process::id())); + if tokio::fs::write(&temporary, &body).await.is_err() + || tokio::fs::rename(&temporary, &path).await.is_err() + { + let _ = tokio::fs::remove_file(temporary).await; + return plain( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "storage failed\n", + ); + } + plain(axum::http::StatusCode::OK, "") +} + +async fn verify( + objects: &std::path::Path, + oid: &str, + request: axum::extract::Request, +) -> axum::response::Response { + if !valid_oid(oid) { + return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n"); + } + let body = match axum::body::to_bytes(request.into_body(), 64 * 1024).await { + Ok(body) => body, + Err(_) => { + return plain( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "request too large\n", + ); + } + }; + let request: VerifyRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"), + }; + let valid = request.oid == oid + && std::fs::metadata(object_path(objects, oid)) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() == request.size); + if valid { + plain(axum::http::StatusCode::OK, "") + } else { + plain(axum::http::StatusCode::NOT_FOUND, "object not found\n") + } +} + +fn repository(root: &std::path::Path, repo: &str) -> Option { + let root = std::fs::canonicalize(root).ok()?; + let repository = std::fs::canonicalize(root.join(format!("{repo}.git"))).ok()?; + repository + .is_dir() + .then_some(repository) + .filter(|repository| repository.starts_with(root)) +} + +fn object_path(root: &std::path::Path, oid: &str) -> std::path::PathBuf { + root.join(&oid[..2]).join(&oid[2..4]).join(oid) +} + +fn valid_oid(oid: &str) -> bool { + oid.len() == 64 + && oid + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn response(object: ObjectRequest, actions: Actions) -> ObjectResponse { + ObjectResponse { + oid: object.oid, + size: object.size, + actions: Some(actions), + error: None, + } +} + +fn error(object: ObjectRequest, code: u16, message: &'static str) -> ObjectResponse { + ObjectResponse { + oid: object.oid, + size: object.size, + actions: None, + error: Some(ObjectError { code, message }), + } +} + +fn json( + status: axum::http::StatusCode, + value: &T, +) -> axum::response::Response { + match serde_json::to_vec(value) { + Ok(body) => axum::http::Response::builder() + .status(status) + .header( + axum::http::header::CONTENT_TYPE, + "application/vnd.git-lfs+json", + ) + .body(axum::body::Body::from(body)) + .expect("valid LFS response"), + Err(_) => plain( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "encoding failed\n", + ), + } +} + +fn plain(status: axum::http::StatusCode, message: &'static str) -> axum::response::Response { + axum::http::Response::builder() + .status(status) + .header(axum::http::header::CONTENT_TYPE, "text/plain") + .body(axum::body::Body::from(message)) + .expect("valid LFS response") +} diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs index 67a3de9..f7e4cd0 100644 --- a/crates/gilti/src/main.rs +++ b/crates/gilti/src/main.rs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later mod cgi; +mod lfs; +pub mod router; mod ui; const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; @@ -11,7 +13,12 @@ const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server"; const MAX_CACHE_SECONDS: u64 = 3600; const CGIT: &str = "/usr/local/bin/gilti-cgit"; +const GIT_HTTP_BACKENDS: &[&str] = &[ + "/usr/libexec/git-core/git-http-backend", + "/usr/lib/git-core/git-http-backend", +]; const GIT_HOME: &str = "/var/lib/gilti/git"; +const REPOSITORIES: &str = "/var/lib/gilti/git/repositories"; const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css"; const CGIT_JS: &str = "/usr/share/webapps/cgit/cgit.js"; @@ -24,14 +31,14 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ("CGIT_CASE_SENSITIVE_SORT", "1"), ("CGIT_CLONE_URL", ""), ("CGIT_COMMIT_SORT", "0"), - ("CGIT_CSS", "/cgit.css"), + ("CGIT_CSS", "/-/assets/cgit.css"), ("CGIT_DIFFTYPE", "0"), ("CGIT_EMBEDDED", "0"), - ("CGIT_ENABLE_BLAME", "0"), + ("CGIT_ENABLE_BLAME", "1"), ("CGIT_ENABLE_COMMIT_GRAPH", "1"), ("CGIT_ENABLE_FOLLOW_LINKS", "0"), - ("CGIT_ENABLE_HTML_SERVING", "0"), - ("CGIT_ENABLE_HTTP_CLONE", "0"), + ("CGIT_ENABLE_HTML_SERVING", "1"), + ("CGIT_ENABLE_HTTP_CLONE", "1"), ("CGIT_ENABLE_INDEX_LINKS", "1"), ("CGIT_ENABLE_INDEX_OWNER", "1"), ("CGIT_ENABLE_LOG_FILECOUNT", "1"), @@ -39,13 +46,13 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ("CGIT_ENABLE_REMOTE_BRANCHES", "0"), ("CGIT_ENABLE_SUBJECT_LINKS", "0"), ("CGIT_ENABLE_TREE_LINENUMBERS", "1"), - ("CGIT_FAVICON", "/favicon.ico"), + ("CGIT_FAVICON", "/-/assets/favicon.ico"), ("CGIT_FOOTER", ""), ("CGIT_HEADER", ""), ("CGIT_HEAD_INCLUDE", ""), - ("CGIT_JS", ""), + ("CGIT_JS", "/-/assets/cgit.js"), ("CGIT_LOCAL_TIME", "0"), - ("CGIT_LOGO", "/cgit.png"), + ("CGIT_LOGO", "/-/assets/cgit.png"), ("CGIT_LOGO_LINK", ""), ("CGIT_MAX_ATOM_ITEMS", "10"), ("CGIT_MAX_BLOB_SIZE", "0"), @@ -53,7 +60,7 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ("CGIT_MAX_MESSAGE_LENGTH", "80"), ("CGIT_MAX_REPO_COUNT", "50"), ("CGIT_MAX_REPODESC_LENGTH", "80"), - ("CGIT_MAX_STATS", "0"), + ("CGIT_MAX_STATS", "4"), ("CGIT_MIMETYPE_FILE", ""), ("CGIT_MODULE_LINK", ""), ("CGIT_NOHEADER", "0"), @@ -71,7 +78,7 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ("CGIT_SECTION", ""), ("CGIT_SECTION_FROM_PATH", "0"), ("CGIT_SECTION_SORT", "1"), - ("CGIT_SNAPSHOTS", "0"), + ("CGIT_SNAPSHOTS", "2147483647"), ("CGIT_STRICT_EXPORT", ""), ("CGIT_SUMMARY_BRANCHES", "10"), ("CGIT_SUMMARY_LOG", "10"), @@ -82,8 +89,10 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ]; #[derive(Clone)] -struct AppState { +struct RepositoryService { cgit: cgi::Cgi, + git: cgi::Cgi, + write_enabled: bool, } struct Config { @@ -92,6 +101,7 @@ struct Config { root_title: String, root_description: String, clone_prefix: String, + http_write: bool, } impl Config { @@ -106,6 +116,7 @@ impl Config { root_title: environment("GILTI_CGIT_ROOT_TITLE", DEFAULT_ROOT_TITLE)?, root_description: environment("GILTI_CGIT_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?, clone_prefix: environment("GILTI_CGIT_CLONE_PREFIX", "")?, + http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?, }) } } @@ -120,6 +131,14 @@ fn environment(name: &str, default: &str) -> std::io::Result { } } +fn parse_bool(name: &str, value: &str) -> std::io::Result { + match value { + "0" => Ok(false), + "1" => Ok(true), + _ => Err(invalid_config(format!("{name} must be 0 or 1"))), + } +} + fn parse_cache(value: &str) -> std::io::Result { let seconds = value .parse::() @@ -140,7 +159,8 @@ fn invalid_config(message: impl Into) -> std::io::Error { async fn main() -> Result<(), Box> { let config = Config::from_environment()?; let listen_addr = config.listen_addr; - check_files()?; + let git_http_backend = git_http_backend()?; + check_files(&git_http_backend)?; if std::env::args().nth(1).as_deref() == Some("--check") { return Ok(()); @@ -155,30 +175,60 @@ async fn main() -> Result<(), Box> { for (name, value) in CGIT_ENVIRONMENT { cgit = cgit.env(*name, *value); } - let state = AppState { cgit }; + let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr) + .env("GIT_PROJECT_ROOT", REPOSITORIES) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("HOME", GIT_HOME) + .env("USER", "git") + .env("LOGNAME", "git") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("PATH", "/usr/bin:/bin"); + let repositories = RepositoryService { + cgit, + git, + write_enabled: config.http_write, + }; let app = axum::Router::new() .route( - "/healthz", - axum::routing::get(async || plain_response(axum::http::StatusCode::OK, "ok\n")), + "/-/health", + axum::routing::get(async || { + response( + axum::http::StatusCode::OK, + "application/json", + b"{\"status\":\"ok\"}\n".to_vec(), + ) + }), ) .route( - "/cgit.css", + "/-/about", + axum::routing::get(async || { + plain_response(axum::http::StatusCode::OK, "Gilti Git server\n") + }), + ) + .route( + "/-/terms", + axum::routing::get(async || { + plain_response(axum::http::StatusCode::OK, "No additional terms of use.\n") + }), + ) + .route( + "/-/assets/cgit.css", axum::routing::get(async || static_file(CGIT_CSS, "text/css")), ) .route( - "/cgit.js", + "/-/assets/cgit.js", axum::routing::get(async || static_file(CGIT_JS, "text/javascript")), ) .route( - "/cgit.png", + "/-/assets/cgit.png", axum::routing::get(async || static_file(CGIT_LOGO, "image/png")), ) .route( - "/favicon.ico", + "/-/assets/favicon.ico", axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")), ) - .fallback(proxy_to_cgit) - .with_state(state); + .fallback_service(repositories); let listener = tokio::net::TcpListener::bind(listen_addr).await?; eprintln!("gilti: listening on {listen_addr}"); @@ -193,12 +243,27 @@ async fn main() -> Result<(), Box> { Ok(()) } -fn check_files() -> std::io::Result<()> { - let metadata = std::fs::metadata(CGIT)?; - if !metadata.is_file() - || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 - { - return Err(std::io::Error::other(format!("{CGIT} is not executable"))); +fn git_http_backend() -> std::io::Result { + GIT_HTTP_BACKENDS + .iter() + .map(std::path::PathBuf::from) + .find(|path| path.is_file()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "git-http-backend not found") + }) +} + +fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> { + for path in [std::path::Path::new(CGIT), git_http_backend] { + let metadata = std::fs::metadata(path)?; + if !metadata.is_file() + || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 + { + return Err(std::io::Error::other(format!( + "{} is not executable", + path.display() + ))); + } } for path in [CGIT_CSS, CGIT_JS, CGIT_LOGO, CGIT_FAVICON] { if !std::fs::metadata(path)?.is_file() { @@ -231,26 +296,402 @@ fn static_file(path: &str, content_type: &'static str) -> axum::response::Respon } } -async fn proxy_to_cgit( - axum::extract::State(state): axum::extract::State, - axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo, - mut request: axum::extract::Request, -) -> axum::response::Response { - if request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD { - return plain_response(axum::http::StatusCode::FORBIDDEN, "forbidden\n"); +impl tower::Service for RepositoryService { + type Response = axum::response::Response; + type Error = std::convert::Infallible; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + _context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) } - request.extensions_mut().insert(cgi::RemoteAddr(remote)); - let method = request.method().clone(); - match tower::ServiceExt::oneshot(state.cgit.clone(), request).await { - Ok(response) => render_private_page(method, response).await, - Err(error) => { - eprintln!("gilti: cgit request failed: {error}"); - plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n") + fn call(&mut self, request: axum::extract::Request) -> Self::Future { + let service = self.clone(); + Box::pin(async move { Ok(service.execute(request).await) }) + } +} + +impl RepositoryService { + async fn execute(&self, mut request: axum::extract::Request) -> axum::response::Response { + let route = match router::parse(request.uri().path()) { + Ok(route) => route, + Err(_) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), + }; + if let Some(axum::extract::ConnectInfo(remote)) = request + .extensions() + .get::>() + .copied() + { + request.extensions_mut().insert(cgi::RemoteAddr(remote)); + } + + match route { + router::Route::Summary(route) => redirect(&route.repo), + router::Route::GitClone(route) => redirect(&route.repo), + router::Route::GitLfs(route) => { + lfs::serve( + std::path::Path::new(REPOSITORIES), + &route.repo, + &route.params, + self.write_enabled, + request, + ) + .await + } + router::Route::GitInfoRefs(route) => { + self.git(request, route.repo, "info/refs", self.write_enabled) + .await + } + router::Route::GitUploadPack(route) => { + self.git(request, route.repo, "git-upload-pack", false) + .await + } + router::Route::GitReceivePack(route) if self.write_enabled => { + self.git(request, route.repo, "git-receive-pack", true) + .await + } + router::Route::GitReceivePack(_) => { + plain_response(axum::http::StatusCode::FORBIDDEN, "HTTP push is disabled\n") + } + router::Route::GitHead(route) => self.git(request, route.repo, "HEAD", false).await, + router::Route::GitObjects(route) => { + self.git( + request, + route.repo, + &format!("objects/{}", route.params), + false, + ) + .await + } + route => self.cgit(request, route).await, + } + } + + async fn git( + &self, + mut request: axum::extract::Request, + repo: String, + endpoint: &str, + authenticated: bool, + ) -> axum::response::Response { + if !safe_repository(&repo) { + return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); + } + let mut environment = vec![ + ("PATH_INFO".into(), format!("/{repo}.git/{endpoint}").into()), + ("GIT_PROJECT_ROOT".into(), REPOSITORIES.into()), + ]; + if authenticated { + environment.push(("REMOTE_USER".into(), "gilti".into())); + } + request + .extensions_mut() + .insert(cgi::Environment(environment)); + request.extensions_mut().insert(cgi::NoCache); + match tower::ServiceExt::oneshot(self.git.clone(), request).await { + Ok(response) => response, + Err(error) => internal_error("git-http-backend", error), + } + } + + async fn cgit( + &self, + mut request: axum::extract::Request, + route: router::Route, + ) -> axum::response::Response { + if request.method() != axum::http::Method::GET + && request.method() != axum::http::Method::HEAD + { + return plain_response( + axum::http::StatusCode::METHOD_NOT_ALLOWED, + "method not allowed\n", + ); + } + let query = match request_query(request.uri().query()) { + Ok(query) => query, + Err(()) => return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n"), + }; + if !valid_format(&route, query.format.as_deref()) { + return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); + } + let environment = match cgit_environment(route, query, request.uri().path()) { + Ok(environment) => environment, + Err(()) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), + }; + request + .extensions_mut() + .insert(cgi::Environment(environment)); + let method = request.method().clone(); + match tower::ServiceExt::oneshot(self.cgit.clone(), request).await { + Ok(response) => render_private_page(method, response).await, + Err(error) => internal_error("cgit", error), + } + } +} + +#[derive(Default)] +struct RequestQuery { + format: Option, + environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, +} + +fn request_query(query: Option<&str>) -> Result { + let mut result = RequestQuery::default(); + for pair in query + .unwrap_or("") + .split('&') + .filter(|pair| !pair.is_empty()) + { + let (name, value) = pair.split_once('=').unwrap_or((pair, "")); + let name = decode_query(name)?; + let value = decode_query(value)?; + if name == "format" { + if result.format.replace(value).is_some() { + return Err(()); + } + continue; + } + let environment = match name.as_str() { + "q" => "GILTI_QUERY_SEARCH", + "qt" => "GILTI_QUERY_GREP", + "ofs" => "GILTI_QUERY_OFFSET", + "s" => "GILTI_QUERY_SORT", + "showmsg" => "GILTI_QUERY_SHOWMSG", + "period" => "GILTI_QUERY_PERIOD", + "dt" => "GILTI_QUERY_DIFFTYPE", + "context" => "GILTI_QUERY_CONTEXT", + "ignorews" => "GILTI_QUERY_IGNOREWS", + "follow" => "GILTI_QUERY_FOLLOW", + "view" => "GILTI_QUERY_VIEW", + _ => return Err(()), + }; + if result + .environment + .iter() + .any(|(existing, _)| existing == std::ffi::OsStr::new(environment)) + { + return Err(()); } + result.environment.push((environment.into(), value.into())); + } + Ok(result) +} + +fn decode_query(value: &str) -> Result { + let value = value.replace('+', " "); + let value = percent_encoding::percent_decode_str(&value) + .decode_utf8() + .map_err(|_| ())? + .into_owned(); + (!value.contains('\0')).then_some(value).ok_or(()) +} + +fn valid_format(route: &router::Route, format: Option<&str>) -> bool { + let Some(format) = format else { + return true; + }; + match route { + router::Route::Tree(_) | router::Route::Diff(_) => matches!(format, "html" | "raw"), + router::Route::Object(_) => format == "raw", + router::Route::Patch(_) => matches!(format, "patch" | "raw"), + router::Route::Archive(_) | router::Route::ArchiveSignature(_) => matches!( + format, + "tar" | "tar.gz" | "tar.bz2" | "tar.lz" | "tar.xz" | "tar.zst" | "zip" + ), + router::Route::AtomFeed(_) => format == "atom", + _ => format == "html", } } +fn cgit_environment( + route: router::Route, + query: RequestQuery, + current_url: &str, +) -> Result, ()> { + let mut environment = query.environment; + let format = query.format.as_deref(); + let mut set = |name: &str, value: String| environment.push((name.into(), value.into())); + set("GILTI_CURRENT_URL", current_url.to_owned()); + + match route { + router::Route::Repositories => set("GILTI_PAGE", "repolist".to_owned()), + router::Route::Overview(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "summary".to_owned()); + } + router::Route::About(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "about".to_owned()); + } + router::Route::Stats(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "stats".to_owned()); + } + router::Route::Object(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "blob".to_owned()); + set("GILTI_REVISION", route.params); + } + router::Route::Refs(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "refs".to_owned()); + } + router::Route::Revision(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "revision".to_owned()); + set("GILTI_REVISION", revision(route.params)); + } + router::Route::Log(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "log".to_owned()); + set("GILTI_REVISION", revision(route.params.rev)); + if let Some(path) = route.params.path { + set("GILTI_PATH", path); + } + } + router::Route::Tree(route) => { + set("GILTI_REPOSITORY", route.repo); + set( + "GILTI_PAGE", + if format == Some("raw") { + "plain" + } else { + "tree" + } + .to_owned(), + ); + set("GILTI_REVISION", revision(route.params.rev)); + if let Some(path) = route.params.path { + set("GILTI_PATH", path); + } + } + router::Route::Blame(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "blame".to_owned()); + set("GILTI_REVISION", revision(route.params.rev)); + set("GILTI_PATH", route.params.path); + } + router::Route::Archive(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "snapshot".to_owned()); + set("GILTI_REVISION", revision(route.params.rev)); + set("GILTI_FORMAT", format.unwrap_or("tar.gz").to_owned()); + if let Some(path) = route.params.path { + set("GILTI_PATH", path); + } + } + router::Route::ArchiveSignature(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "snapshot".to_owned()); + set("GILTI_REVISION", revision(route.params)); + set("GILTI_FORMAT", format.unwrap_or("tar.gz").to_owned()); + set("GILTI_SIGNATURE", "1".to_owned()); + } + router::Route::AtomFeed(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "atom".to_owned()); + set("GILTI_REVISION", route.params.reference); + if let Some(path) = route.params.path { + set("GILTI_PATH", path); + } + } + router::Route::Diff(route) => { + return comparison_environment(environment, route, format, false); + } + router::Route::Patch(route) => { + return comparison_environment(environment, route, format, true); + } + _ => return Err(()), + } + Ok(environment) +} + +fn comparison_environment( + mut environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, + route: router::RepoRoute, + format: Option<&str>, + patch: bool, +) -> Result, ()> { + environment.push(("GILTI_REPOSITORY".into(), route.repo.into())); + environment.push(( + "GILTI_PAGE".into(), + if patch { + "patch" + } else if format == Some("raw") { + "rawdiff" + } else { + "diff" + } + .into(), + )); + environment.push(( + "GILTI_OLD_REVISION".into(), + revision(route.params.old_rev).into(), + )); + environment.push(( + "GILTI_REVISION".into(), + revision(route.params.new_rev).into(), + )); + if let Some(path) = route.params.path { + environment.push(("GILTI_PATH".into(), path.into())); + } + Ok(environment) +} + +fn revision(revision: router::Revision) -> String { + match revision { + router::Revision::Head => "HEAD".to_owned(), + router::Revision::Ref(reference) | router::Revision::Commit(reference) => reference, + } +} + +fn safe_repository(repo: &str) -> bool { + !repo.is_empty() + && !repo.starts_with('/') + && !repo.chars().any(char::is_control) + && !repo.contains('\\') + && repo + .split('/') + .all(|component| !component.is_empty() && !matches!(component, "." | "..")) +} + +fn redirect(repo: &str) -> axum::response::Response { + axum::http::Response::builder() + .status(axum::http::StatusCode::PERMANENT_REDIRECT) + .header( + axum::http::header::LOCATION, + format!("/{}", encode_path(repo)), + ) + .body(axum::body::Body::empty()) + .expect("valid redirect") +} + +fn encode_path(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' { + encoded.push(char::from(byte)); + } else { + use std::fmt::Write; + write!(encoded, "%{byte:02X}").expect("writing to String cannot fail"); + } + } + encoded +} + +fn internal_error(context: &str, error: std::io::Error) -> axum::response::Response { + eprintln!("gilti: {context} request failed: {error}"); + plain_response( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "internal server error\n", + ) +} + async fn render_private_page( method: axum::http::Method, response: axum::response::Response, @@ -276,7 +717,10 @@ async fn render_private_page( Ok(body) => body, Err(error) => { eprintln!("gilti: cannot read private page response: {error}"); - return plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n"); + return plain_response( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "internal server error\n", + ); } }; match ui::render(&body) { @@ -286,7 +730,10 @@ async fn render_private_page( ), Err(error) => { eprintln!("gilti: invalid private page response: {error}"); - plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n") + plain_response( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "internal server error\n", + ) } } } @@ -312,6 +759,46 @@ fn plain_response( #[cfg(test)] mod tests { + fn environment<'a>( + values: &'a [(std::ffi::OsString, std::ffi::OsString)], + name: &str, + ) -> &'a str { + values + .iter() + .find(|(key, _)| key == std::ffi::OsStr::new(name)) + .and_then(|(_, value)| value.to_str()) + .unwrap() + } + + #[test] + fn route_parameters_become_trusted_cgit_environment() { + let route = super::router::parse( + "/group/repo/+/diff/refs/heads/main..0123456789abcdef0123456789abcdef01234567/+/src/lib.rs", + ) + .unwrap(); + let query = super::request_query(Some("format=raw&context=5")).unwrap(); + let values = super::cgit_environment(route, query, "/group/repo").unwrap(); + assert_eq!(environment(&values, "GILTI_REPOSITORY"), "group/repo"); + assert_eq!(environment(&values, "GILTI_PAGE"), "rawdiff"); + assert_eq!( + environment(&values, "GILTI_OLD_REVISION"), + "refs/heads/main" + ); + assert_eq!( + environment(&values, "GILTI_REVISION"), + "0123456789abcdef0123456789abcdef01234567" + ); + assert_eq!(environment(&values, "GILTI_PATH"), "src/lib.rs"); + assert_eq!(environment(&values, "GILTI_QUERY_CONTEXT"), "5"); + } + + #[test] + fn structural_query_parameters_are_rejected() { + assert!(super::request_query(Some("id=HEAD")).is_err()); + assert!(super::request_query(Some("path=README.md")).is_err()); + assert!(super::request_query(Some("format=raw&format=html")).is_err()); + } + #[test] fn cache_is_bounded() { assert_eq!( @@ -325,7 +812,7 @@ mod tests { #[tokio::test] async fn private_pages_become_html_and_head_keeps_headers() { - let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#; + let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/-/about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#; let response = axum::http::Response::builder() .status(axum::http::StatusCode::OK) .header( @@ -393,7 +880,7 @@ mod tests { super::render_private_page(axum::http::Method::GET, response) .await .status(), - axum::http::StatusCode::BAD_GATEWAY + axum::http::StatusCode::INTERNAL_SERVER_ERROR ); } } diff --git a/crates/gilti/src/router.rs b/crates/gilti/src/router.rs new file mode 100644 index 0000000..16289a1 --- /dev/null +++ b/crates/gilti/src/router.rs @@ -0,0 +1,867 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +/* +URL scheme + +The URL space is divided into disjoint Gilti-specific and repository-specific +namespaces. The reserved top-level segment "-" belongs to Gilti and cannot be +used as the first segment of a repository name. + +Git objects are content-addressed and immutable: a full object ID always +identifies the same object contents. Refs and repository metadata are mutable, +and unreachable objects may eventually be removed. + +Routes marked I use only immutable selectors. Routes marked M depend on the +current repository state. A composite selector inherits mutability from its +components: a tree path under a full commit OID is immutable, while the same +path under a branch or tag ref is mutable. A comparison is immutable only when +both of its revision selectors are immutable. + +Repository names, ref names, and tree paths may all contain "/". Their +boundaries are expressed using literal "/+/" segments and are never inferred +from repository state. Structural delimiters are recognized before percent +decoding. A literal data segment equal to "+" must be percent-encoded as +"%2B". + +Route parameters whose names end in "*" may contain "/". They are terminated +by the next structural "/+/" segment, a route-specific terminal marker, or the +end of the route. The terminal ".git" suffix separates a repository name from +Git HTTP transport paths. + + {repo*} canonical repository name + {ref*} ref name relative to "refs/", for example "heads/main" + {path*} path within a Git tree + {commit_id} full OID of a commit; abbreviated IDs are not accepted + {object_id} full OID of any Git object; abbreviated IDs are not accepted + {rev*} "{commit_id}", "refs/{ref*}", or "HEAD" + {asset_path*} path below the Gilti static asset root + {old_rev*} revision selector + {new_rev*} revision selector + {dumb_http_path*} path below the Git dumb HTTP "objects/" endpoint + {lfs_path*} path below the Git LFS endpoint + +Archive, diff, blob, and object formats are representation options, not route +parameters. They are selected by the "format" query parameter, then by the +Accept header, and finally by the server default. The query parameter takes +precedence when both are present. Archive signature routes use the same query +parameter to identify the signed archive format. Raw blob contents, raw Git +object contents, and raw unified diffs are representations of their respective +views rather than separate views. + +Rendered documents are an HTML view mode selected with the "view" query +parameter. Supported values are "source" and "rendered". Renderable documents +may default to "rendered"; other blobs default to "source". + +Gilti views and operational endpoints: + +/-/about <- Gilti about page +/-/terms <- Gilti terms of use +/-/assets/{asset_path*} <- Static asset +/-/health <- Health check returning {"status":"ok"} +*/ + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Route { + // Repository views: + /// M: Repository list + /// / + Repositories, + + /// M: Repository overview + /// /{repo*} + Overview(RepoRoute<()>), + + /// M: Repository documentation + /// /{repo*}/+/about + About(RepoRoute<()>), + + /// M: Repository activity statistics + /// /{repo*}/+/stats + Stats(RepoRoute<()>), + + /// M: Recent tags, commits, and summary data + /// /{repo*}/+/summary + Summary(RepoRoute<()>), + + // Immutable object views: + /// I: Git object + /// /{repo*}/+/object/{object_id} + Object(RepoRoute), + + // Refs and revisions: + /// M: Ref list + /// /{repo*}/+/refs + Refs(RepoRoute<()>), + + /// I/M: Revision overview and resolved target + /// /{repo*}/+/{rev*} + Revision(RepoRoute), + + /// I/M: Commit log, optionally restricted to a path + /// /{repo*}/+/{rev*}/+/log + /// /{repo*}/+/{rev*}/+/log/{path*} + Log(RepoRoute), + + /// I/M: Tree, blob, document, or submodule + /// /{repo*}/+/{rev*}/+/tree + /// /{repo*}/+/{rev*}/+/tree/{path*} + Tree(RepoRoute), + + /// I/M: File blame + /// /{repo*}/+/{rev*}/+/blame/{path*} + Blame(RepoRoute), + + /// I/M: Archive of the root tree or a subtree + /// /{repo*}/+/{rev*}/+/archive + /// /{repo*}/+/{rev*}/+/archive/{path*} + Archive(RepoRoute), + + /// I/M: Detached archive signature + /// /{repo*}/+/{rev*}/+/archive-signature + ArchiveSignature(RepoRoute), + + // Feed & updates: + /// M: Atom commit feed, optionally restricted to a path + /// /{repo*}/+/refs/{ref*}/+/feed/atom + /// /{repo*}/+/refs/{ref*}/+/feed/atom/{path*} + AtomFeed(RepoRoute), + + // Comparisons and patches: + /// I/M: Diff between two revisions, optionally restricted to a path + /// /{repo*}/+/diff/{old_rev*}..{new_rev*} + /// /{repo*}/+/diff/{old_rev*}..{new_rev*}/+/{path*} + Diff(RepoRoute), + + /// I/M: Mail-formatted patch or patch series, optionally restricted to a path + /// /{repo*}/+/patch/{old_rev*}..{new_rev*} + /// /{repo*}/+/patch/{old_rev*}..{new_rev*}/+/{path*} + Patch(RepoRoute), + + // Git HTTP transport endpoints: + /// M: Public clone URL + /// /{repo*}.git + GitClone(RepoRoute<()>), + + /// M: Service discovery and ref advertisement + /// /{repo*}.git/info/refs + GitInfoRefs(RepoRoute<()>), + + /// M: Fetch protocol endpoint + /// /{repo*}.git/git-upload-pack + GitUploadPack(RepoRoute<()>), + + /// M: Push protocol endpoint, when enabled + /// /{repo*}.git/git-receive-pack + GitReceivePack(RepoRoute<()>), + + /// M: Dumb HTTP HEAD advertisement, when enabled + /// /{repo*}.git/HEAD + GitHead(RepoRoute<()>), + + /// Git dumb HTTP object endpoint, when enabled + /// /{repo*}.git/objects/{dumb_http_path*} + GitObjects(RepoRoute), + + /// Git LFS endpoint, when enabled + /// /{repo*}.git/info/lfs/{lfs_path*} + GitLfs(RepoRoute), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepoRoute { + pub repo: String, + pub params: T, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevisionPath { + pub rev: Revision, + pub path: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevisionFile { + pub rev: Revision, + pub path: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RefPath { + pub reference: String, + pub path: Option, +} + +/// A revision range separated by `..` and an optional tree path separated by `/+/`. +/// It is immutable only when both revision selectors are full commit OIDs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Comparison { + pub old_rev: Revision, + pub new_rev: Revision, + pub path: Option, +} + +/// A revision route is immutable for a full commit OID and mutable for a ref or +/// `HEAD`. An annotated tag displays its object chain and peeled target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Revision { + Head, + Ref(String), + Commit(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParseError; + +impl std::fmt::Display for ParseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("invalid repository route") + } +} + +impl std::error::Error for ParseError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TokenKind { + Text, + Slash, + Boundary, + Range, + GitSuffix, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Token<'a> { + kind: TokenKind, + raw: &'a str, +} + +/// Parses a raw, percent-encoded URI path. Query parameters are handled separately. +pub fn parse(path: &str) -> Result { + let tokens = lex(path)?; + Parser { + tokens: &tokens, + cursor: 0, + } + .parse() +} + +fn lex(path: &str) -> Result>, ParseError> { + let bytes = path.as_bytes(); + let mut tokens = Vec::new(); + let (mut cursor, mut start) = (0, 0); + while cursor < bytes.len() { + if bytes[cursor] == b'%' { + if cursor + 2 >= bytes.len() + || !bytes[cursor + 1].is_ascii_hexdigit() + || !bytes[cursor + 2].is_ascii_hexdigit() + { + return Err(ParseError); + } + cursor += 3; + continue; + } + if matches!(bytes[cursor], b'?' | b'#') { + return Err(ParseError); + } + let delimiter = if bytes[cursor..].starts_with(b"/+/") { + Some((TokenKind::Boundary, 3)) + } else if bytes[cursor] == b'/' { + Some((TokenKind::Slash, 1)) + } else if bytes[cursor..].starts_with(b"..") { + Some((TokenKind::Range, 2)) + } else if bytes[cursor..].starts_with(b".git") + && (cursor + 4 == bytes.len() || bytes[cursor + 4] == b'/') + { + Some((TokenKind::GitSuffix, 4)) + } else { + None + }; + let Some((kind, width)) = delimiter else { + cursor += 1; + continue; + }; + if start < cursor { + tokens.push(Token { + kind: TokenKind::Text, + raw: &path[start..cursor], + }); + } + tokens.push(Token { + kind, + raw: &path[cursor..cursor + width], + }); + cursor += width; + start = cursor; + } + if start < cursor { + tokens.push(Token { + kind: TokenKind::Text, + raw: &path[start..cursor], + }); + } + Ok(tokens) +} + +struct Parser<'tokens, 'input> { + tokens: &'tokens [Token<'input>], + cursor: usize, +} + +impl<'tokens, 'input> Parser<'tokens, 'input> { + fn parse(mut self) -> Result { + self.expect(TokenKind::Slash)?; + if self.end() { + return Ok(Route::Repositories); + } + let repo = decode_repo(self.take_until(&[TokenKind::Boundary, TokenKind::GitSuffix]))?; + match self.peek() { + None => Ok(Route::Overview(route(repo, ()))), + Some(TokenKind::Boundary) => { + self.cursor += 1; + self.view(repo) + } + Some(TokenKind::GitSuffix) => { + self.cursor += 1; + self.git(repo) + } + _ => Err(ParseError), + } + } + + fn view(&mut self, repo: String) -> Result { + Ok(match self.peek_text() { + Some("about") => { + self.cursor += 1; + self.finish()?; + Route::About(route(repo, ())) + } + Some("stats") => { + self.cursor += 1; + self.finish()?; + Route::Stats(route(repo, ())) + } + Some("summary") => { + self.cursor += 1; + self.finish()?; + Route::Summary(route(repo, ())) + } + Some("refs") if self.cursor + 1 == self.tokens.len() => { + self.cursor += 1; + Route::Refs(route(repo, ())) + } + Some("object") => { + self.cursor += 1; + self.expect(TokenKind::Slash)?; + let object_id = parse_oid(self.take_text()?)?; + self.finish()?; + Route::Object(route(repo, object_id)) + } + Some("diff") | Some("patch") => { + let patch = self.take_text()? == "patch"; + self.expect(TokenKind::Slash)?; + return self.comparison(repo, patch); + } + _ => { + let rev = self.revision()?; + if self.end() { + Route::Revision(route(repo, rev)) + } else { + self.expect(TokenKind::Boundary)?; + return self.revision_view(repo, rev); + } + } + }) + } + + fn revision_view(&mut self, repo: String, rev: Revision) -> Result { + Ok(match self.take_text()? { + "log" => Route::Log(route( + repo, + RevisionPath { + rev, + path: self.optional_path(TokenKind::Slash)?, + }, + )), + "tree" => Route::Tree(route( + repo, + RevisionPath { + rev, + path: self.optional_path(TokenKind::Slash)?, + }, + )), + "blame" => { + self.expect(TokenKind::Slash)?; + Route::Blame(route( + repo, + RevisionFile { + rev, + path: decode_path(self.rest())?, + }, + )) + } + "archive" => Route::Archive(route( + repo, + RevisionPath { + rev, + path: self.optional_path(TokenKind::Slash)?, + }, + )), + "archive-signature" => { + self.finish()?; + Route::ArchiveSignature(route(repo, rev)) + } + "feed" => { + self.expect(TokenKind::Slash)?; + self.expect_text("atom")?; + Route::AtomFeed(route( + repo, + RefPath { + reference: reference(rev)?, + path: self.optional_path(TokenKind::Slash)?, + }, + )) + } + _ => return Err(ParseError), + }) + } + + fn comparison(&mut self, repo: String, patch: bool) -> Result { + let old_rev = self.revision()?; + self.expect(TokenKind::Range)?; + let new_rev = self.revision()?; + let comparison = route( + repo, + Comparison { + old_rev, + new_rev, + path: self.optional_path(TokenKind::Boundary)?, + }, + ); + Ok(if patch { + Route::Patch(comparison) + } else { + Route::Diff(comparison) + }) + } + + fn git(&mut self, repo: String) -> Result { + if self.end() { + return Ok(Route::GitClone(route(repo, ()))); + } + self.expect(TokenKind::Slash)?; + Ok(match self.take_text()? { + "info" => { + self.expect(TokenKind::Slash)?; + match self.take_text()? { + "refs" => { + self.finish()?; + Route::GitInfoRefs(route(repo, ())) + } + "lfs" => { + self.expect(TokenKind::Slash)?; + Route::GitLfs(route(repo, decode_path(self.rest())?)) + } + _ => return Err(ParseError), + } + } + "git-upload-pack" => { + self.finish()?; + Route::GitUploadPack(route(repo, ())) + } + "git-receive-pack" => { + self.finish()?; + Route::GitReceivePack(route(repo, ())) + } + "HEAD" => { + self.finish()?; + Route::GitHead(route(repo, ())) + } + "objects" => { + self.expect(TokenKind::Slash)?; + Route::GitObjects(route(repo, decode_path(self.rest())?)) + } + _ => return Err(ParseError), + }) + } + + fn revision(&mut self) -> Result { + parse_revision(self.take_until(&[TokenKind::Boundary, TokenKind::Range])) + } + + fn optional_path(&mut self, separator: TokenKind) -> Result, ParseError> { + if self.end() { + Ok(None) + } else { + self.expect(separator)?; + decode_path(self.rest()).map(Some) + } + } + + fn take_until(&mut self, delimiters: &[TokenKind]) -> &'tokens [Token<'input>] { + let start = self.cursor; + while self.peek().is_some_and(|kind| !delimiters.contains(&kind)) { + self.cursor += 1; + } + &self.tokens[start..self.cursor] + } + + fn rest(&mut self) -> &'tokens [Token<'input>] { + let rest = &self.tokens[self.cursor..]; + self.cursor = self.tokens.len(); + rest + } + + fn peek(&self) -> Option { + self.tokens.get(self.cursor).map(|token| token.kind) + } + + fn peek_text(&self) -> Option<&'input str> { + self.tokens + .get(self.cursor) + .filter(|token| token.kind == TokenKind::Text) + .map(|token| token.raw) + } + + fn take_text(&mut self) -> Result<&'input str, ParseError> { + let text = self.peek_text().ok_or(ParseError)?; + self.cursor += 1; + Ok(text) + } + + fn expect_text(&mut self, expected: &str) -> Result<(), ParseError> { + (self.take_text()? == expected) + .then_some(()) + .ok_or(ParseError) + } + + fn expect(&mut self, expected: TokenKind) -> Result<(), ParseError> { + (self.peek() == Some(expected)) + .then(|| self.cursor += 1) + .ok_or(ParseError) + } + + fn finish(&self) -> Result<(), ParseError> { + self.end().then_some(()).ok_or(ParseError) + } + + fn end(&self) -> bool { + self.cursor == self.tokens.len() + } +} + +fn parse_revision(value: &[Token<'_>]) -> Result { + if value.len() == 1 && value[0].kind == TokenKind::Text { + return match value[0].raw { + "HEAD" => Ok(Revision::Head), + oid => parse_oid(oid).map(Revision::Commit), + }; + } + if value.len() >= 2 && value[0].raw == "refs" && value[1].kind == TokenKind::Slash { + let reference = format!("refs/{}", decode_path(&value[2..])?); + return valid_ref(&reference) + .then_some(Revision::Ref(reference)) + .ok_or(ParseError); + } + Err(ParseError) +} + +fn parse_oid(value: &str) -> Result { + (matches!(value.len(), 40 | 64) + && value + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))) + .then(|| value.to_owned()) + .ok_or(ParseError) +} + +fn valid_ref(value: &str) -> bool { + !value.ends_with('.') + && !value.contains("..") + && !value.contains("@{") + && value.split('/').all(|part| { + !part.is_empty() + && !part.starts_with('.') + && !part.ends_with(".lock") + && !part + .bytes() + .any(|byte| byte < b' ' || byte == 0x7f || b" ~^:?*[\\".contains(&byte)) + }) +} + +fn decode_repo(value: &[Token<'_>]) -> Result { + decode_name(value, true) +} + +fn decode_path(value: &[Token<'_>]) -> Result { + decode_name(value, false) +} + +fn decode_name(value: &[Token<'_>], repo: bool) -> Result { + if value.is_empty() { + return Err(ParseError); + } + for (index, component) in value + .split(|token| token.kind == TokenKind::Slash) + .enumerate() + { + let literal = |expected| component.len() == 1 && component[0].raw == expected; + if component.is_empty() + || component.iter().any(|token| { + !matches!( + token.kind, + TokenKind::Text | TokenKind::Range | TokenKind::GitSuffix + ) + }) + || literal(".") + || literal("..") + || literal("+") + || repo + && (index == 0 && literal("-") + || component + .last() + .is_some_and(|token| token.kind == TokenKind::GitSuffix)) + { + return Err(ParseError); + } + } + let mut decoded = String::new(); + for token in value { + decoded.push_str( + &percent_encoding::percent_decode_str(token.raw) + .decode_utf8() + .map_err(|_| ParseError)?, + ); + } + (!decoded.contains('\0')) + .then_some(decoded) + .ok_or(ParseError) +} + +fn reference(rev: Revision) -> Result { + match rev { + Revision::Ref(reference) => Ok(reference), + _ => Err(ParseError), + } +} + +fn route(repo: String, params: T) -> RepoRoute { + RepoRoute { repo, params } +} + +#[cfg(test)] +mod tests { + use super::{ + Comparison, RefPath, RepoRoute, Revision, RevisionFile, RevisionPath, Route, TokenKind, + lex, parse, + }; + + const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + const OTHER: &str = "89abcdef0123456789abcdef0123456789abcdef"; + + fn repository(params: T) -> RepoRoute { + repo("group/проект", params) + } + + fn repo(name: &str, params: T) -> RepoRoute { + RepoRoute { + repo: name.to_owned(), + params, + } + } + + #[test] + fn lexer_preserves_raw_tokens() { + use TokenKind::{Boundary, GitSuffix, Range, Slash, Text}; + + let tokens = lex("/repo%2Egit.git/diff/HEAD..refs/heads/main/+/src").unwrap(); + assert_eq!( + tokens + .iter() + .map(|token| (token.kind, token.raw)) + .collect::>(), + [ + (Slash, "/"), + (Text, "repo%2Egit"), + (GitSuffix, ".git"), + (Slash, "/"), + (Text, "diff"), + (Slash, "/"), + (Text, "HEAD"), + (Range, ".."), + (Text, "refs"), + (Slash, "/"), + (Text, "heads"), + (Slash, "/"), + (Text, "main"), + (Boundary, "/+/"), + (Text, "src"), + ] + ); + } + + #[test] + fn parses_repository_views() { + assert_eq!(parse("/").unwrap(), Route::Repositories); + assert_eq!( + parse("/group/%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82").unwrap(), + Route::Overview(repository(())) + ); + assert_eq!( + parse("/foo%2Egit/+/summary").unwrap(), + Route::Summary(repo("foo.git", ())) + ); + assert_eq!( + parse("/odd..repo").unwrap(), + Route::Overview(repo("odd..repo", ())) + ); + for (path, expected) in [ + ("about", Route::About(repository(()))), + ("stats", Route::Stats(repository(()))), + ("summary", Route::Summary(repository(()))), + ("refs", Route::Refs(repository(()))), + ] { + assert_eq!(parse(&format!("/group/проект/+/{path}")).unwrap(), expected); + } + assert_eq!( + parse(&format!("/group/проект/+/object/{COMMIT}")).unwrap(), + Route::Object(repository(COMMIT.to_owned())) + ); + } + + #[test] + fn parses_revision_views_and_escaped_paths() { + let reference = Revision::Ref("refs/heads/feature/+".to_owned()); + assert_eq!( + parse("/group/проект/+/refs/heads/feature/%2B").unwrap(), + Route::Revision(repository(reference.clone())) + ); + assert_eq!( + parse("/group/проект/+/refs/heads/feature/%2B/+/tree").unwrap(), + Route::Tree(repository(RevisionPath { + rev: reference.clone(), + path: None, + })) + ); + assert_eq!( + parse("/group/проект/+/refs/heads/feature/%2B/+/tree/src/%2B/lib.rs").unwrap(), + Route::Tree(repository(RevisionPath { + rev: reference.clone(), + path: Some("src/+/lib.rs".to_owned()), + })) + ); + assert_eq!( + parse("/group/проект/+/HEAD/+/tree/src/a..b/foo.git").unwrap(), + Route::Tree(repository(RevisionPath { + rev: Revision::Head, + path: Some("src/a..b/foo.git".to_owned()), + })) + ); + assert_eq!( + parse(&format!("/group/проект/+/{COMMIT}/+/log/src/lib.rs")).unwrap(), + Route::Log(repository(RevisionPath { + rev: Revision::Commit(COMMIT.to_owned()), + path: Some("src/lib.rs".to_owned()), + })) + ); + assert_eq!( + parse("/group/проект/+/HEAD/+/archive").unwrap(), + Route::Archive(repository(RevisionPath { + rev: Revision::Head, + path: None, + })) + ); + assert_eq!( + parse("/group/проект/+/refs/heads/main/+/blame/src/lib.rs").unwrap(), + Route::Blame(repository(RevisionFile { + rev: Revision::Ref("refs/heads/main".to_owned()), + path: "src/lib.rs".to_owned(), + })) + ); + } + + #[test] + fn parses_feeds_comparisons_and_patches() { + assert_eq!( + parse("/group/проект/+/refs/heads/main/+/feed/atom/src/lib.rs").unwrap(), + Route::AtomFeed(repository(RefPath { + reference: "refs/heads/main".to_owned(), + path: Some("src/lib.rs".to_owned()), + })) + ); + assert_eq!( + parse(&format!( + "/group/проект/+/diff/refs/heads/main..{COMMIT}/+/src/lib.rs" + )) + .unwrap(), + Route::Diff(repository(Comparison { + old_rev: Revision::Ref("refs/heads/main".to_owned()), + new_rev: Revision::Commit(COMMIT.to_owned()), + path: Some("src/lib.rs".to_owned()), + })) + ); + assert_eq!( + parse(&format!("/group/проект/+/patch/{COMMIT}..{OTHER}")).unwrap(), + Route::Patch(repository(Comparison { + old_rev: Revision::Commit(COMMIT.to_owned()), + new_rev: Revision::Commit(OTHER.to_owned()), + path: None, + })) + ); + } + + #[test] + fn parses_git_transport_routes() { + for (path, expected) in [ + ("/group/проект.git", Route::GitClone(repository(()))), + ("/foo%2Egit.git", Route::GitClone(repo("foo.git", ()))), + ( + "/group/проект.git/info/refs", + Route::GitInfoRefs(repository(())), + ), + ( + "/group/проект.git/git-upload-pack", + Route::GitUploadPack(repository(())), + ), + ( + "/group/проект.git/git-receive-pack", + Route::GitReceivePack(repository(())), + ), + ("/group/проект.git/HEAD", Route::GitHead(repository(()))), + ( + "/group/проект.git/objects/ab/cdef", + Route::GitObjects(repository("ab/cdef".to_owned())), + ), + ( + "/group/проект.git/info/lfs/objects/batch", + Route::GitLfs(repository("objects/batch".to_owned())), + ), + ] { + assert_eq!(parse(path).unwrap(), expected); + } + } + + #[test] + fn rejects_ambiguous_or_noncanonical_routes() { + for path in [ + "", + "/-/about", + "/repo/", + "/repo/+/", + "/repo/+/main", + "/repo/+/deadbeef", + "/repo/+/refs/heads/bad..ref", + "/repo/+/HEAD/+/blame", + "/repo/+/HEAD/+/feed/atom", + "/repo/+/diff/HEAD...HEAD", + "/repo/+/diff/HEAD..", + "/repo/+/tree", + "/repo.git/unknown", + "/repo/%zz", + "/repo?format=raw", + ] { + assert!(parse(path).is_err(), "accepted {path}"); + } + } +} diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs index 33942a9..796baa5 100644 --- a/crates/gilti/src/ui.rs +++ b/crates/gilti/src/ui.rs @@ -74,8 +74,8 @@ struct Repo { owner: String, owner_url: String, idle: Option, - log_url: String, - tree_url: String, + log_url: Option, + tree_url: Option, } #[derive(Deserialize)] @@ -130,7 +130,7 @@ fn render_repolist(page: RepoList) -> Markup { } @if shell.css.is_empty() { - link rel="stylesheet" type="text/css" href="/cgit.css"; + link rel="stylesheet" type="text/css" href="/-/assets/cgit.css"; } @for css in &shell.css { @@ -140,7 +140,7 @@ fn render_repolist(page: RepoList) -> Markup { } @if shell.js.is_empty() { - script type="text/javascript" src="/cgit.js" {} + script type="text/javascript" src="/-/assets/cgit.js" {} } @for js in &shell.js { @@ -219,7 +219,7 @@ fn page_content(page: &RepoList, footer_inside: bool) -> Markup { td { a href=(&repo.url) { (&repo.description.text) @if repo.description.truncated { "..." } } } @if page.owner_enabled { td { a href=(&repo.owner_url) { (&repo.owner) } } } td { @if let Some(age) = &repo.idle { span class=(if age.unit == "minutes" { "age-mins".to_owned() } else { format!("age-{}", age.unit) }) data-ut=(age.timestamp) title=(&age.title) { (format!("{:.0}", age.amount)) " " (if age.unit == "minutes" { "min." } else { &age.unit }) } } } - @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } a class="button" href=(&repo.log_url) { "log" } a class="button" href=(&repo.tree_url) { "tree" } } } + @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } @if let Some(url) = &repo.log_url { a class="button" href=(url) { "log" } } @if let Some(url) = &repo.tree_url { a class="button" href=(url) { "tree" } } } } }, } } diff --git a/tests/smoke.sh b/tests/smoke.sh index 0ed91da..460d79a 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -56,7 +56,7 @@ start() { "$image" >/dev/null i=0 - until curl -fsS "http://127.0.0.1:$http_port/healthz" >/dev/null 2>&1; do + until curl -fsS "http://127.0.0.1:$http_port/-/health" >/dev/null 2>&1; do i=$((i + 1)) if [ "$i" -ge 60 ]; then "$engine" logs "$name" >&2 @@ -109,30 +109,30 @@ for expected in \ exit 1 } done -[ "$(curl -fsS "http://127.0.0.1:$http_port/healthz")" = ok ] || { +[ "$(curl -fsS "http://127.0.0.1:$http_port/-/health")" = '{"status":"ok"}' ] || { echo 'unexpected health response' >&2 exit 1 } status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http_port/") -[ "$status" = 403 ] || { - echo "POST to cgit returned HTTP $status instead of 403" >&2 +[ "$status" = 405 ] || { + echo "POST to repository browser returned HTTP $status instead of 405" >&2 exit 1 } -curl -fsS "http://127.0.0.1:$http_port/cgit.css" | grep -q 'cgit' -content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.css" | +curl -fsS "http://127.0.0.1:$http_port/-/assets/cgit.css" | grep -q 'cgit' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/cgit.css" | awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') [ "$content_type" = text/css ] || { echo "unexpected cgit.css content type: $content_type" >&2 exit 1 } -curl -fsS "http://127.0.0.1:$http_port/cgit.js" | grep -q 'function' -content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.js" | +curl -fsS "http://127.0.0.1:$http_port/-/assets/cgit.js" | grep -q 'function' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/cgit.js" | awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') [ "$content_type" = text/javascript ] || { echo "unexpected cgit.js content type: $content_type" >&2 exit 1 } -curl -fsSI "http://127.0.0.1:$http_port/healthz" >/dev/null +curl -fsSI "http://127.0.0.1:$http_port/-/health" >/dev/null # shellcheck disable=SC2016 # Expanded by the shell inside the container. httpd_uid=$("$engine" exec "$name" sh -c ' @@ -245,7 +245,26 @@ until curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'; do [ "$i" -lt 30 ] || { "$engine" logs "$name" >&2; exit 1; } sleep 1 done -cache_url="http://127.0.0.1:$http_port/testing/" +curl -fsS "http://127.0.0.1:$http_port/testing" | grep -q 'Initial commit' +curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep -q 'Testing' +summary_status=$(curl -sS -o /dev/null -w '%{http_code}:%{redirect_url}' \ + "http://127.0.0.1:$http_port/testing/+/summary") +[ "$summary_status" = "308:http://127.0.0.1:$http_port/testing" ] || { + echo "unexpected summary redirect: $summary_status" >&2 + exit 1 +} +GIT_CONFIG_GLOBAL=/dev/null git clone -q \ + "http://127.0.0.1:$http_port/testing.git" "$work/testing-http-clone" +[ -f "$work/testing-http-clone/README.md" ] +lfs_oid=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881 +lfs_response=$(printf '{"operation":"download","objects":[{"oid":"%s","size":1}]}' "$lfs_oid" | + curl -fsS -H 'Content-Type: application/vnd.git-lfs+json' --data-binary @- \ + "http://127.0.0.1:$http_port/testing.git/info/lfs/objects/batch") +printf '%s' "$lfs_response" | grep -q '"code":404' || { + echo 'unexpected LFS batch response' >&2 + exit 1 +} +cache_url="http://127.0.0.1:$http_port/testing" curl -fsS -D "$work/cache-1.headers" -o /dev/null "$cache_url" sleep 2 curl -fsS -D "$work/cache-2.headers" -o /dev/null "$cache_url" -- Gilti