diff options
context:
space:
mode:
-rw-r--r--cgitrc.5.txt25
-rw-r--r--source/cgit.c5
-rw-r--r--source/cgit.h2
-rw-r--r--source/shared.c1
-rw-r--r--source/ui-shared.c250
-rwxr-xr-xtests/t0112-submodule-links.sh109
-rwxr-xr-xtests/t0200-security.sh41
7 files changed, 422 insertions, 11 deletions
diff --git a/cgitrc.5.txt b/cgitrc.5.txt
index ac33d6d..84dcdb6 100644
--- a/cgitrc.5.txt
+++ b/cgitrc.5.txt
@@ -194,6 +194,19 @@ enable-git-config::
with "cgit." will be mapped to the corresponding "repo." key in cgit.
Default value: "0". See also: scan-path, section-from-path.
+enable-gitmodules-links::
+ Flag which, when set to "1", makes submodule listings derive a link
+ from the url recorded in the .gitmodules file at the shown revision,
+ for submodules that no module-link template covers. The url is first
+ matched, by its trailing path components, against the repositories
+ served by this cgit instance, so ssh, file and relative urls still
+ get a link when their target is hosted here, and the commit hash then
+ links to the target repository's commit page. Otherwise a plain http
+ or https url is linked as it is, an ssh url to a well-known public
+ host is rewritten to its https form, and any other url is left
+ unlinked and shown as a tooltip instead. Default value: "0". See
+ also: "repo.enable-gitmodules-links".
+
enable-http-clone::
If set to "1", cgit will act as a dumb HTTP endpoint for git clones.
You can add "http://$HTTP_HOST$SCRIPT_NAME/$CGIT_REPO_URL" to clone-url
@@ -375,8 +388,8 @@ mimetype-file::
module-link::
Text which will be used as the formatstring for a hyperlink when a
submodule is printed in a directory listing. The arguments for the
- formatstring are the path and SHA1 of the submodule commit. Default
- value: none.
+ formatstring are the name of the submodule directory and the SHA1 of
+ the submodule commit. Default value: none.
noplainemail::
If set to "1" showing full author email addresses will be disabled.
@@ -570,6 +583,10 @@ repo.enable-follow-links::
A flag which can be used to disable the global setting
`enable-follow-links'. Default value: none.
+repo.enable-gitmodules-links::
+ A flag which can be used to override the global setting
+ `enable-gitmodules-links'. Default value: none.
+
repo.enable-html-serving::
A flag which can be used to override the global setting
`enable-html-serving`. Default value: none.
@@ -620,8 +637,8 @@ repo.logo-link::
repo.module-link::
Text which will be used as the formatstring for a hyperlink when a
submodule is printed in a directory listing. The arguments for the
- formatstring are the path and SHA1 of the submodule commit. Default
- value: <module-link>
+ formatstring are the name of the submodule directory and the SHA1 of
+ the submodule commit. Default value: <module-link>
repo.module-link.<path>::
Text which will be used as the formatstring for a hyperlink when a
diff --git a/source/cgit.c b/source/cgit.c
index 37b07d7..90ee702 100644
--- a/source/cgit.c
+++ b/source/cgit.c
@@ -237,6 +237,7 @@ static void print_repo(FILE *f, struct cgit_repo *repo)
fprintf(f, "repo.enable-blame=%d\n", repo->enable_blame);
fprintf(f, "repo.enable-commit-graph=%d\n", repo->enable_commit_graph);
fprintf(f, "repo.enable-follow-links=%d\n", repo->enable_follow_links);
+ fprintf(f, "repo.enable-gitmodules-links=%d\n", repo->enable_gitmodules_links);
fprintf(f, "repo.enable-log-filecount=%d\n", repo->enable_log_filecount);
fprintf(f, "repo.enable-log-linecount=%d\n", repo->enable_log_linecount);
if (repo->about_filter && repo->about_filter != ctx.cfg.about_filter)
@@ -560,6 +561,8 @@ static void apply_config(const char *name, const char *value)
ctx.cfg.enable_blame = atoi(value);
else if (!strcmp(name, "enable-commit-graph"))
ctx.cfg.enable_commit_graph = atoi(value);
+ else if (!strcmp(name, "enable-gitmodules-links"))
+ ctx.cfg.enable_gitmodules_links = atoi(value);
else if (!strcmp(name, "enable-log-filecount"))
ctx.cfg.enable_log_filecount = atoi(value);
else if (!strcmp(name, "enable-log-linecount"))
@@ -1176,6 +1179,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu
repo->enable_commit_graph = atoi(value);
else if (!strcmp(name, "enable-follow-links"))
repo->enable_follow_links = atoi(value);
+ else if (!strcmp(name, "enable-gitmodules-links"))
+ repo->enable_gitmodules_links = atoi(value);
else if (!strcmp(name, "enable-log-filecount"))
repo->enable_log_filecount = atoi(value);
else if (!strcmp(name, "enable-log-linecount"))
diff --git a/source/cgit.h b/source/cgit.h
index edae762..bab41c5 100644
--- a/source/cgit.h
+++ b/source/cgit.h
@@ -86,6 +86,7 @@ struct cgit_repo {
int enable_blame;
int enable_commit_graph;
int enable_follow_links;
+ int enable_gitmodules_links;
int enable_log_filecount;
int enable_log_linecount;
int enable_remote_branches;
@@ -216,6 +217,7 @@ struct cgit_config {
int embedded;
int enable_filter_overrides;
int enable_follow_links;
+ int enable_gitmodules_links;
int enable_stats;
int enable_http_clone;
int enable_index_links;
diff --git a/source/shared.c b/source/shared.c
index 48d587d..37c21ae 100644
--- a/source/shared.c
+++ b/source/shared.c
@@ -218,6 +218,7 @@ struct cgit_repo *cgit_add_repo(const char *url)
repo->enable_blame = ctx.cfg.enable_blame;
repo->enable_commit_graph = ctx.cfg.enable_commit_graph;
repo->enable_follow_links = ctx.cfg.enable_follow_links;
+ repo->enable_gitmodules_links = ctx.cfg.enable_gitmodules_links;
repo->enable_log_filecount = ctx.cfg.enable_log_filecount;
repo->enable_log_linecount = ctx.cfg.enable_log_linecount;
repo->enable_remote_branches = ctx.cfg.enable_remote_branches;
diff --git a/source/ui-shared.c b/source/ui-shared.c
index 02352bf..317bff2 100644
--- a/source/ui-shared.c
+++ b/source/ui-shared.c
@@ -14,6 +14,7 @@
#include "cgit.h"
#include "html.h"
#include "shared.h"
+#include "submodule-config.h"
#include "ui-shared.h"
#include "ui-snapshot.h"
@@ -1028,12 +1029,229 @@ void cgit_object_link(struct object *obj)
reporevlink(page, name, NULL, NULL, ctx.qry.head, fullrev, NULL);
}
+/*
+ * The tree being listed decides which .gitmodules applies, so the revision
+ * the query names is resolved once and reused for every row.
+ */
+static const struct object_id *page_treeish(void)
+{
+ static struct object_id oid;
+ static int state;
+
+ if (!state) {
+ const char *rev = ctx.qry.oid ? ctx.qry.oid : ctx.qry.head;
+
+ state = rev && !repo_get_oid(the_repository, rev, &oid) ? 1 : -1;
+ }
+ return state > 0 ? &oid : NULL;
+}
+
+static void strip_git_suffix(struct strbuf *sb)
+{
+ while (sb->len && sb->buf[sb->len - 1] == '/')
+ strbuf_setlen(sb, sb->len - 1);
+ strbuf_strip_suffix(sb, ".git");
+}
+
+/*
+ * Reduces a submodule url to the path it names on its host, so the tail can
+ * be compared against the urls this instance serves. A relative url points
+ * at a sibling of the repository's own clone, which maps onto a sibling of
+ * its cgit url.
+ */
+static char *submodule_url_path(const char *url)
+{
+ struct strbuf sb = STRBUF_INIT;
+ const char *rest, *colon;
+
+ if (starts_with(url, "./") || starts_with(url, "../")) {
+ strbuf_addf(&sb, "%s/%s", ctx.repo->url, url);
+ if (strbuf_normalize_path(&sb)) {
+ strbuf_release(&sb);
+ return NULL;
+ }
+ } else if (skip_prefix(url, "http://", &rest) ||
+ skip_prefix(url, "https://", &rest) ||
+ skip_prefix(url, "git://", &rest) ||
+ skip_prefix(url, "ssh://", &rest)) {
+ rest = strchr(rest, '/');
+ if (!rest)
+ return NULL;
+ strbuf_addstr(&sb, rest);
+ } else if (skip_prefix(url, "file://", &rest)) {
+ strbuf_addstr(&sb, rest);
+ } else if ((colon = strchr(url, ':')) &&
+ !memchr(url, '/', colon - url)) {
+ // scp syntax, user@host:path
+ strbuf_addstr(&sb, colon + 1);
+ } else {
+ strbuf_addstr(&sb, url);
+ }
+
+ strip_git_suffix(&sb);
+ while (sb.len && sb.buf[0] == '/')
+ strbuf_remove(&sb, 0, 1);
+ if (!sb.len) {
+ strbuf_release(&sb);
+ return NULL;
+ }
+ return strbuf_detach(&sb, NULL);
+}
+
+/*
+ * Finds the served repository whose url matches the most trailing components
+ * of the submodule url, whatever scheme carried it.
+ */
+static struct cgit_repo *repo_serving_url(const char *url)
+{
+ struct cgit_repo *repo, *best = NULL;
+ char *path = submodule_url_path(url);
+ size_t plen, rlen, blen = 0;
+ int i;
+
+ if (!path)
+ return NULL;
+ plen = strlen(path);
+ for (i = 0; i < cgit_repolist.count; i++) {
+ repo = &cgit_repolist.repos[i];
+ if (repo->ignore)
+ continue;
+ rlen = strlen(repo->url);
+ if (!rlen || rlen > plen)
+ continue;
+ if (strcmp(path + plen - rlen, repo->url))
+ continue;
+ // Only whole trailing components count, so x/foo cannot
+ // claim a repository named oo.
+ if (rlen < plen && path[plen - rlen - 1] != '/')
+ continue;
+ if (rlen > blen) {
+ best = repo;
+ blen = rlen;
+ }
+ }
+ free(path);
+ return best;
+}
+
+/*
+ * Hosts whose repository and commit pages follow a known form, so an ssh
+ * url can still be offered as a browser link. kernel.org runs cgit, which
+ * keeps the .git suffix in its own page urls.
+ */
+static const struct forge {
+ const char *host;
+ const char *commit_seg;
+ int keep_dot_git;
+} forges[] = {
+ { "github.com", "/commit/", 0 },
+ { "gitlab.com", "/-/commit/", 0 },
+ { "bitbucket.org", "/commits/", 0 },
+ { "codeberg.org", "/commit/", 0 },
+ { "gitea.com", "/commit/", 0 },
+ { "git.sr.ht", "/commit/", 0 },
+ { "git.kernel.org", "/commit/?id=", 1 },
+};
+
+static const struct forge *forge_for_host(const char *host, size_t len)
+{
+ size_t i;
+
+ for (i = 0; i < ARRAY_SIZE(forges); i++)
+ if (strlen(forges[i].host) == len &&
+ !strncasecmp(forges[i].host, host, len))
+ return &forges[i];
+ return NULL;
+}
+
+/*
+ * Derives a submodule row's links from its .gitmodules entry. The url is
+ * matched against this instance's own repositories first, so ssh, file and
+ * relative urls still land on an internal page when their target is served
+ * here, and the pinned commit gets a page of its own. A plain web url is
+ * linked as it is, an ssh url to a known host is rewritten to its web form,
+ * and anything else is left unlinked with the url as a tooltip, since a
+ * scheme cgit cannot vouch for has no place in an href.
+ */
+static void gitmodules_link(const char *path, const char *rev,
+ char **module, char **commit,
+ const char **tooltip)
+{
+ const struct object_id *treeish = page_treeish();
+ const struct submodule *sub;
+ const struct forge *forge;
+ struct cgit_repo *target;
+ struct strbuf sb = STRBUF_INIT;
+ const char *url, *rest, *colon;
+
+ if (!treeish)
+ return;
+ sub = submodule_from_path(the_repository, treeish, path);
+ if (!sub || !sub->url)
+ return;
+ url = sub->url;
+
+ target = repo_serving_url(url);
+ if (target) {
+ char *query = cgit_fmtalloc("id=%s", rev);
+
+ *module = cgit_pageurl(target->url, "tree", query);
+ *commit = cgit_pageurl(target->url, "commit", query);
+ free(query);
+ return;
+ }
+
+ if (skip_prefix(url, "http://", &rest) ||
+ skip_prefix(url, "https://", &rest)) {
+ *module = xstrdup(url);
+ forge = forge_for_host(rest, strcspn(rest, "/"));
+ if (forge) {
+ strbuf_addstr(&sb, url);
+ while (sb.len && sb.buf[sb.len - 1] == '/')
+ strbuf_setlen(&sb, sb.len - 1);
+ if (!forge->keep_dot_git)
+ strbuf_strip_suffix(&sb, ".git");
+ strbuf_addstr(&sb, forge->commit_seg);
+ strbuf_addstr(&sb, rev);
+ *commit = strbuf_detach(&sb, NULL);
+ }
+ return;
+ }
+
+ if ((colon = strchr(url, ':')) && !memchr(url, '/', colon - url)) {
+ const char *host = url;
+ const char *at = memchr(url, '@', colon - url);
+
+ if (at)
+ host = at + 1;
+ forge = forge_for_host(host, colon - host);
+ if (forge) {
+ rest = colon + 1;
+ while (*rest == '/')
+ rest++;
+ strbuf_addf(&sb, "https://%.*s/%s",
+ (int)(colon - host), host, rest);
+ while (sb.len && sb.buf[sb.len - 1] == '/')
+ strbuf_setlen(&sb, sb.len - 1);
+ if (!forge->keep_dot_git)
+ strbuf_strip_suffix(&sb, ".git");
+ *module = xstrdup(sb.buf);
+ strbuf_addstr(&sb, forge->commit_seg);
+ strbuf_addstr(&sb, rev);
+ *commit = strbuf_detach(&sb, NULL);
+ return;
+ }
+ }
+
+ *tooltip = url;
+}
+
void cgit_submodule_link(const char *class, char *path, const char *rev)
{
struct string_list *list;
struct string_list_item *item;
- char tail;
- const char *name;
+ char tail, *module = NULL, *commit = NULL;
+ const char *name, *tooltip = NULL;
size_t len;
len = 0;
@@ -1049,12 +1267,16 @@ void cgit_submodule_link(const char *class, char *path, const char *rev)
}
}
+ if (!item && !ctx.repo->module_link &&
+ ctx.repo->enable_gitmodules_links)
+ gitmodules_link(path, rev, &module, &commit, &tooltip);
+
// The row shows the entry's own name, like every other row, while
// the lookups above want the full path.
name = strrchr(path, '/');
name = name ? name + 1 : path;
- if (item || ctx.repo->module_link) {
+ if (item || ctx.repo->module_link || module) {
html("<a ");
if (class)
htmlf("class='%s' ", class);
@@ -1062,9 +1284,11 @@ void cgit_submodule_link(const char *class, char *path, const char *rev)
if (item) {
const char *args[] = { rev };
emit_module_link(item->util, args, 1);
- } else {
+ } else if (ctx.repo->module_link) {
const char *args[] = { name, rev };
emit_module_link(ctx.repo->module_link, args, 2);
+ } else {
+ html_attr(module);
}
html("'>");
html_txt(name);
@@ -1073,15 +1297,29 @@ void cgit_submodule_link(const char *class, char *path, const char *rev)
html("<span");
if (class)
htmlf(" class='%s'", class);
+ if (tooltip) {
+ html(" title='");
+ html_attr(tooltip);
+ html("'");
+ }
html(">");
html_txt(name);
html("</span>");
}
- html(" @ <span class='ls-mod-hash' title='");
+ html(" @ ");
+ if (commit) {
+ html("<a class='ls-mod-hash' href='");
+ html_attr(commit);
+ html("' title='");
+ } else {
+ html("<span class='ls-mod-hash' title='");
+ }
html_attr(rev);
html("'>");
html_txtf("%.7s", rev);
- html("</span>");
+ html(commit ? "</a>" : "</span>");
+ free(module);
+ free(commit);
if (tail == '/')
path[len - 1] = tail;
}
diff --git a/tests/t0112-submodule-links.sh b/tests/t0112-submodule-links.sh
new file mode 100755
index 0000000..bbd99b4
--- /dev/null
+++ b/tests/t0112-submodule-links.sh
@@ -0,0 +1,109 @@
+#!/bin/sh
+
+# Submodule rows can derive their links from the .gitmodules entry at the
+# shown revision once enable-gitmodules-links is set. The url is matched
+# against the repositories this instance serves first, so ssh and relative
+# urls still land on an internal page, a plain web url is linked directly,
+# an ssh url to a known host is rewritten to its web form, and anything
+# else stays unlinked with the url offered as a tooltip.
+
+test_description='Check submodule links derived from .gitmodules'
+. ./setup.sh
+
+test_expect_success 'set up a parent repo with submodules' '
+ mkrepo repos/subtarget 1 &&
+ mkrepo repos/subparent 1 &&
+ (
+ cd repos/subparent &&
+ sub=$(git rev-parse HEAD) &&
+ git update-index --add --cacheinfo 160000,$sub,local-ssh &&
+ git update-index --add --cacheinfo 160000,$sub,sibling &&
+ git update-index --add --cacheinfo 160000,$sub,forge-https &&
+ git update-index --add --cacheinfo 160000,$sub,forge-ssh &&
+ git update-index --add --cacheinfo 160000,$sub,opaque &&
+ git update-index --add --cacheinfo 160000,$sub,cgit-host &&
+ cat >.gitmodules <<-\EOF &&
+ [submodule "local-ssh"]
+ path = local-ssh
+ url = git@example.com:mirrors/subtarget.git
+ [submodule "sibling"]
+ path = sibling
+ url = ../subtarget.git
+ [submodule "forge-https"]
+ path = forge-https
+ url = https://github.com/example/project.git
+ [submodule "forge-ssh"]
+ path = forge-ssh
+ url = git@github.com:example/other.git
+ [submodule "opaque"]
+ path = opaque
+ url = git@private.example.com:closed/thing.git
+ [submodule "cgit-host"]
+ path = cgit-host
+ url = https://git.kernel.org/pub/scm/git/git.git
+ EOF
+ git add .gitmodules &&
+ git commit -m submodules
+ ) &&
+ {
+ echo "virtual-root=/" &&
+ echo "cache-size=0" &&
+ echo "enable-gitmodules-links=1" &&
+ echo "repo.url=subparent" &&
+ echo "repo.path=$PWD/repos/subparent/.git" &&
+ echo "repo.url=subtarget" &&
+ echo "repo.path=$PWD/repos/subtarget/.git"
+ } >subrc
+'
+
+test_expect_success 'generate subparent/tree' '
+ CGIT_CONFIG="$PWD/subrc" QUERY_STRING="url=subparent/tree/" cgit >tmp
+'
+
+test_expect_success 'an ssh url served here links to the local repo' '
+ sub=$(git -C repos/subparent rev-parse HEAD~1) &&
+ grep "href=./subtarget/tree/?id=$sub.>local-ssh</a>" tmp
+'
+
+test_expect_success 'its hash links to the local commit page' '
+ sub=$(git -C repos/subparent rev-parse HEAD~1) &&
+ grep "href=./subtarget/commit/?id=$sub." tmp
+'
+
+test_expect_success 'a relative url resolves against this repository' '
+ sub=$(git -C repos/subparent rev-parse HEAD~1) &&
+ grep "href=./subtarget/tree/?id=$sub.>sibling</a>" tmp
+'
+
+test_expect_success 'a web url is linked as it is' '
+ grep "href=.https://github.com/example/project.git.>forge-https</a>" tmp
+'
+
+test_expect_success 'a known forge hash links to its commit page' '
+ sub=$(git -C repos/subparent rev-parse HEAD~1) &&
+ grep "href=.https://github.com/example/project/commit/$sub." tmp
+'
+
+test_expect_success 'an ssh url to a known forge is rewritten' '
+ sub=$(git -C repos/subparent rev-parse HEAD~1) &&
+ grep "href=.https://github.com/example/other.>forge-ssh</a>" tmp &&
+ grep "href=.https://github.com/example/other/commit/$sub." tmp
+'
+
+test_expect_success 'a cgit host keeps its .git suffix in the commit link' '
+ sub=$(git -C repos/subparent rev-parse HEAD~1) &&
+ grep "href=.https://git.kernel.org/pub/scm/git/git.git/commit/?id=$sub." tmp
+'
+
+test_expect_success 'an unresolvable url stays unlinked with a tooltip' '
+ grep "class=.ls-mod. title=.git@private.example.com:closed/thing.git.>opaque</span>" tmp
+'
+
+test_expect_success 'the links stay off without the flag' '
+ sed "/enable-gitmodules-links/d" subrc >subrcoff &&
+ CGIT_CONFIG="$PWD/subrcoff" QUERY_STRING="url=subparent/tree/" cgit >tmp &&
+ ! grep "subtarget/tree" tmp &&
+ grep "class=.ls-mod.>local-ssh</span>" tmp
+'
+
+test_done
diff --git a/tests/t0200-security.sh b/tests/t0200-security.sh
index f475301..8fce5f4 100755
--- a/tests/t0200-security.sh
+++ b/tests/t0200-security.sh
@@ -277,7 +277,7 @@ test_expect_success 'a surplus conversion is shown literally, not filled' '
grep "href=./m/submod/[0-9a-f]*/%s/%s/" tmp
'
-test_expect_success 'a well-formed module-link still takes path and sha1' '
+test_expect_success 'a well-formed module-link still takes name and sha1' '
{
echo "virtual-root=/" &&
echo "cache-size=0" &&
@@ -316,4 +316,43 @@ test_expect_success 'a doubled percent in a module-link renders as one' '
grep "href=./m/submod/%/$sub." tmp
'
+# The .gitmodules file is commit-controlled content, so a derived link may
+# carry any scheme and any byte an author can commit. Only http and https
+# may reach an href, and everything lands attribute-escaped.
+test_expect_success 'set up a hostile .gitmodules' '
+ (
+ cd repos/modlink &&
+ sub=$(git rev-parse HEAD) &&
+ git update-index --add --cacheinfo 160000,$sub,quoted &&
+ cat >.gitmodules <<-EOF &&
+ [submodule "submod"]
+ path = submod
+ url = javascript:alert(1)
+ [submodule "quoted"]
+ path = quoted
+ url = https://example.com/x'\''><script>alert(1)</script>
+ EOF
+ git add .gitmodules &&
+ git commit -m hostile
+ ) &&
+ {
+ echo "virtual-root=/" &&
+ echo "cache-size=0" &&
+ echo "enable-gitmodules-links=1" &&
+ echo "repo.url=modlink" &&
+ echo "repo.path=$PWD/repos/modlink/.git"
+ } >modlinkgmrc
+'
+
+test_expect_success 'a javascript url never reaches an href' '
+ CGIT_CONFIG="$PWD/modlinkgmrc" QUERY_STRING="url=modlink/tree/" cgit >tmp &&
+ ! grep "href=.javascript:" tmp &&
+ grep "class=.ls-mod. title=.javascript:alert(1).>submod</span>" tmp
+'
+
+test_expect_success 'a quote in a web url cannot break out of the href' '
+ ! grep "<script>alert" tmp &&
+ grep "href=.https://example.com/x&#x27;&gt;&lt;script&gt;" tmp
+'
+
test_done