From git at linskey.org Fri Aug 7 15:09:46 2026 From: git at linskey.org (Benjamin Linskey) Date: Fri, 7 Aug 2026 11:09:46 -0400 Subject: [PATCH] cgitrc.5: Clarify operation of sorting options Message-ID: <20260807150946.18649-1-git@linskey.org> This updates the descriptions of the repository-sort and section-sort options to clarify that no sorting whatsoever will be performed if section-sort is set to 0. Prior to this change, the descriptions could have been read as implying that the repository-sort criterion is always applied within a section and that the section-sort option only controls whether the sections themselves are sorted by name. Signed-off-by: Benjamin Linskey --- A careful reading of the man page when I was initially setting up cgit led me to the erroneous interpretation described above, so this change may be helpful for other newcomers. I've noticed that contributors typically sign-off on their commits, so I did the same, but I haven't found an explanation of what that's intended to signify in this project. I assume, of course, that it's the usual matter of having the right to license the code and so forth, but I don't see that spelled out anywhere. - Ben cgitrc.5.txt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cgitrc.5.txt b/cgitrc.5.txt index 7c39bf9..dd1f1d2 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -350,10 +350,11 @@ renamelimit:: `man git-diff`). Default value: "-1". repository-sort:: - The way in which repositories in each section are sorted. Valid values - are "name" for sorting by the repo name or "age" for sorting by the - most recently updated repository. Default value: "name". See also: - section, case-sensitive-sort, section-sort. + The way in which repositories in each section are sorted when + section-sort is set to "1". Valid values are "name" for sorting by the + repo name or "age" for sorting by the most recently updated repository. + Default value: "name". See also: section, case-sensitive-sort, + section-sort. robots:: Text used as content for the "robots" meta-tag. Default value: @@ -397,9 +398,10 @@ section:: section-sort:: Flag which, when set to "1", will sort the sections on the repository - listing by name. Set this flag to "0" if the order in the cgitrc file should - be preserved. Default value: "1". See also: section, - case-sensitive-sort, repository-sort. + listing by name and sort their contents by the criterion specified by + repository-sort. Set this flag to "0" if the order of sections and + repositories in the cgitrc file should be preserved. Default value: + "1". See also: section, case-sensitive-sort, repository-sort. section-from-path:: A number which, if defined prior to scan-path, specifies how many -- 2.55.0 From git at linskey.org Tue Aug 11 03:14:13 2026 From: git at linskey.org (Benjamin Linskey) Date: Mon, 10 Aug 2026 23:14:13 -0400 Subject: [PATCH] css: Switch to a more widely supported arrow char Message-ID: <20260811031413.45222-1-git@linskey.org> The previously used character is not included in the default font on macOS. Signed-off-by: Benjamin Linskey --- This is the simple "rightwards black arrow" character. There are quite a few other arrow characters with broader cross-platform support that could be used as well. cgit.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cgit.css b/cgit.css index 669862c..af29ffd 100644 --- a/cgit.css +++ b/cgit.css @@ -332,7 +332,7 @@ div#cgit table.ssdiff td.lineno a:hover { div#cgit table.blob td.linenumbers a:target:before { color: red; - content: "\2BA9"; + content: "\2B95"; } div#cgit table.blame td.hashes, -- 2.55.0 From sgothel at jausoft.com Mon Aug 17 23:38:01 2026 From: sgothel at jausoft.com (=?UTF-8?Q?Sven_G=C3=B6thel?=) Date: Tue, 18 Aug 2026 01:38:01 +0200 Subject: Patch & Discussion: Use 64-bit FNV-1a hash + proper hash-round concat + clipping In-Reply-To: References: Message-ID: <15abacec-d4b2-4a7c-b95d-c8b849deff54@jausoft.com> I have added the cgit-reaper here - repo - backup and well described it in its README. Main objective for this file reaper is to allow cgit to use the full range of 64-bit FNV-1a value to reduce collisions but limiting the maximum number of cache files to a considerably lower number. This works w/ my cgit patches using the 64-bit FNV-1a value and a bit more - my branch - backup cgitrc config example: # new cgit-reaper config (defaults) pid-parent-dir=/var/run cache-max-files=1048576 cache-min-ttl=1 cache-max-ttl=525600 # shared cgit/cgit-reaper config (best) cache-size=18446744073709551615 +++ Merging welcome, as well as to take over the cgit-reaper to your cgit repo of course. However, it is coded in C++20 and uses my jaulib submodule . +++ This whole analysis started when the server I maintain started to be 'under attack' (DoS) a while ago. Today, every now and then they are run over still, but I managed to fine tune resources including cgit with my patches. Cheers, ~Sven On 7/23/26 4:27 PM, Sven G?thel wrote: > As described: > > > Or find it in , > if I must revise it (bugs). > > Will post the patch if interested. > > I get good collision-free hashes w/ > e.g. 16M cache-size (16777215), see table below > with added used hex-nibbles. > > Maximum would be full 64-bit values, > however .. this is surely not desired :) > (On a ZFS fs this would not be an issue though) > > The FNV-1a 64bit hash is the clipped > to the desired size w/ shift-xor and masking. > > The project hash is also better concatenated, > passing the previous round to the hash-algo. > > When updating or changing the cache-size, > one better temporarily removes the binary > and re-creates the cache/cgit folder. > See > > Comments and ideas are welcome. > > Cheers, > > ~Sven > > # FFFFFFFF 4294M (full 32-bit) > # cache-size=4294967295 > # FFFFFFF 268M > # cache-size=268435455 > # FFFFFF 16M > cache-size=16777215 > # FFFFF 1M > # cache-size=1048575 > > -------------- next part -------------- A non-text attachment was scrubbed... Name: OpenPGP_signature.asc Type: application/pgp-signature Size: 833 bytes Desc: OpenPGP digital signature URL: From konstantin at linuxfoundation.org Tue Aug 25 03:34:42 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 03:34:42 +0000 Subject: [PATCH 1/2] cache: don't fill a slot we no longer own In-Reply-To: <20260825-fix-cache-slot-race-v1-0-a70fa1b3ba20@linuxfoundation.org> References: <20260825-fix-cache-slot-race-v1-0-a70fa1b3ba20@linuxfoundation.org> Message-ID: <20260825-fix-cache-slot-race-v1-1-a70fa1b3ba20@linuxfoundation.org> lock_slot() opens the lock file and only then acquires the fcntl lock. Those two steps are not atomic, so between them the process that held the lock can rename that same file over the cache slot and exit. The lock we subsequently acquire is then held on the live cache file rather than on a lock file, and lock_name no longer refers to it at all. Filling that descriptor rewrites a cache slot that other processes are streaming, and the closing rename() fails with ENOENT because lock_name is gone -- a failure no caller checks. Until "cache: truncate lock file before filling" the slot was not truncated first, so a short response written over a long one left the tail of the previous occupant in place; fstat() in fill_slot() reported the combined size and print_slot() sent all of it. With snapshot tarballs sharing the slot space, that tail can be hundreds of megabytes of binary data appended to an HTML page, and it persists until the slot expires. Verify after taking the lock that our descriptor still refers to the file lock_name points at, and give up with EAGAIN if it does not. The caller then serves stale content or generates uncached, both of which are safe. While here, stop print_slot() from spinning forever when sendfile() reports EOF before cache_st.st_size -- reachable once a slot can be truncated underneath a reader -- report a failed publish instead of discarding it, and release the lock file on the ftruncate()/xwrite() error paths instead of leaking it. Add a test that plants a stale, oversized slot and checks that neither the served page nor the slot left behind inherits any of it. Assisted-by: LLM [analysis, codegen, tests] Signed-off-by: Konstantin Ryabitsev --- cache.c | 47 +++++++++++++++++++++++++++++++++++++---- tests/t0021-cache-slot-reuse.sh | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/cache.c b/cache.c index e70af13..ebc7de2 100644 --- a/cache.c +++ b/cache.c @@ -108,6 +108,13 @@ static int print_slot(struct cache_slot *slot) } if (off == size) return 0; + if (ret == 0) { + /* EOF before cache_st.st_size: the slot was truncated + * while we were streaming it. Stop instead of asking + * for the same missing bytes forever. + */ + return EIO; + } } while (1); #endif @@ -174,6 +181,8 @@ static int lock_slot(struct cache_slot *slot) .l_start = 0, .l_len = 0, }; + struct stat st_fd, st_name; + int err; slot->lock_fd = open(slot->lock_name, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); @@ -185,11 +194,36 @@ static int lock_slot(struct cache_slot *slot) slot->lock_fd = -1; return saved_errno; } + + /* Between our open() and our F_SETLK, the process that held the lock + * may have renamed this very file over the cache slot and exited. In + * that case the lock we just took is on the *live cache file*, not on + * a lock file, and filling it would rewrite a slot that concurrent + * readers are streaming. Only proceed if our descriptor still refers + * to the file that lock_name points at. + */ + if (fstat(slot->lock_fd, &st_fd) || stat(slot->lock_name, &st_name) || + st_fd.st_dev != st_name.st_dev || st_fd.st_ino != st_name.st_ino) { + close(slot->lock_fd); + slot->lock_fd = -1; + return EAGAIN; + } + if (ftruncate(slot->lock_fd, 0) < 0) - return errno; + goto out_err; if (xwrite(slot->lock_fd, slot->key, slot->keylen + 1) < 0) - return errno; + goto out_err; return 0; + +out_err: + /* We own lock_name, so don't leave a half-written lock file (and a + * leaked descriptor) behind for the next process to trip over. + */ + err = errno; + unlink(slot->lock_name); + close(slot->lock_fd); + slot->lock_fd = -1; + return err; } /* Release the current lockfile. If `replace_old_slot` is set the @@ -297,7 +331,10 @@ static int process_slot(struct cache_slot *slot) close_lock(slot); } else { close_slot(slot); - unlock_slot(slot, 1); + if ((err = unlock_slot(slot, 1)) != 0) + cache_log("[cgit] Unable to publish slot %s: %s (%d)\n", + slot->lock_name, + strerror(err), err); slot->cache_fd = slot->lock_fd; } } @@ -343,7 +380,9 @@ static int process_slot(struct cache_slot *slot) // Lets avoid such a race by just printing the content of // the lock file. slot->cache_fd = slot->lock_fd; - unlock_slot(slot, 1); + if ((err = unlock_slot(slot, 1)) != 0) + cache_log("[cgit] Unable to publish slot %s: %s (%d)\n", + slot->lock_name, strerror(err), err); if ((err = print_slot(slot)) != 0) { cache_log("[cgit] error printing cache %s: %s (%d)\n", slot->cache_name, diff --git a/tests/t0021-cache-slot-reuse.sh b/tests/t0021-cache-slot-reuse.sh new file mode 100755 index 0000000..e6dd374 --- /dev/null +++ b/tests/t0021-cache-slot-reuse.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +test_description='Verify that a cache slot never serves stale content' +. ./setup.sh + +# With cache-size=1 every key hashes onto the same slot, which makes the +# slot filename deterministic and lets us plant content in it. +slot=cache/00000000 +lock=$slot.lock +junk=JUNKJUNKJUNKJUNKJUNKJUNKJUNK + +# Simulate a slot left behind by a process that was killed while filling +# it: a key that does not match, followed by far more content than the +# page we are about to generate. +plant_junk() { + printf 'stale-key\0' >"$1" && + for i in $(test_seq 1 2000) + do + echo "$junk" + done >>"$1" +} + +test_expect_success 'setup' ' + rm -f cache/* && + sed -e "s/^cache-size=.*/cache-size=1/" cgitrc >cgitrc.tmp && + mv -f cgitrc.tmp cgitrc && + cgit_url "foo/refs" >/dev/null && + test -f "$slot" +' + +test_expect_success 'a stale lock file is not inherited by the next page' ' + rm -f cache/* && + plant_junk "$lock" && + cgit_url "foo/refs" >output && + ! grep -q "$junk" output && + test "$(tail -n 1 output)" = "" +' + +test_expect_success 'no stale content is left behind in the slot' ' + ! grep -q "$junk" "$slot" && + cgit_url "foo/refs" >output.cached && + ! grep -q "$junk" output.cached && + test "$(tail -n 1 output.cached)" = "" +' + +test_done -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 03:34:43 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 03:34:43 +0000 Subject: [PATCH 2/2] tests: exercise the lock_slot() rename race directly In-Reply-To: <20260825-fix-cache-slot-race-v1-0-a70fa1b3ba20@linuxfoundation.org> References: <20260825-fix-cache-slot-race-v1-0-a70fa1b3ba20@linuxfoundation.org> Message-ID: <20260825-fix-cache-slot-race-v1-2-a70fa1b3ba20@linuxfoundation.org> The preceding commit is covered only indirectly: the test plants a stale slot rather than reproducing the interleaving that produces one. The race needs a rename() to land between cgit's own open() and its F_SETLK, which a test cannot arrange from the outside. Add a CGIT_TEST_LOCK_DELAY hook that sleeps in that window, in the spirit of git's own GIT_TEST_* variables, and a test that uses it to rename the lock file over the cache slot at exactly the wrong moment. The test asserts that the slot still holds what the previous occupant put there, which fails without the inode check and passes with it. The test costs two seconds of wall clock and is the only timing dependent test in the suite. The margin is large relative to the work cgit does in that window, but it is a wall clock assumption rather than a guarantee. Assisted-by: LLM [analysis, codegen, tests] Signed-off-by: Konstantin Ryabitsev --- cache.c | 6 ++++++ tests/t0021-cache-slot-reuse.sh | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/cache.c b/cache.c index ebc7de2..ea28023 100644 --- a/cache.c +++ b/cache.c @@ -182,12 +182,18 @@ static int lock_slot(struct cache_slot *slot) .l_len = 0, }; struct stat st_fd, st_name; + const char *delay; int err; slot->lock_fd = open(slot->lock_name, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); if (slot->lock_fd == -1) return errno; + /* Test hook: widen the window between open() and F_SETLK so that the + * test suite can rename the lock file away underneath us on purpose. + */ + if ((delay = getenv("CGIT_TEST_LOCK_DELAY"))) + sleep(atoi(delay)); if (fcntl(slot->lock_fd, F_SETLK, &lock) < 0) { int saved_errno = errno; close(slot->lock_fd); diff --git a/tests/t0021-cache-slot-reuse.sh b/tests/t0021-cache-slot-reuse.sh index e6dd374..ed25197 100755 --- a/tests/t0021-cache-slot-reuse.sh +++ b/tests/t0021-cache-slot-reuse.sh @@ -43,4 +43,24 @@ test_expect_success 'no stale content is left behind in the slot' ' test "$(tail -n 1 output.cached)" = "" ' +# The lock file can be renamed over the cache slot by the process that +# holds it in the window between our open() and our F_SETLK. The lock we +# then acquire is on the live cache file, and filling it clobbers a slot +# other processes are streaming. CGIT_TEST_LOCK_DELAY widens that window +# so we can perform the rename at exactly the wrong moment. +rename_lock_during_fill() { + CGIT_TEST_LOCK_DELAY=2 cgit_url "foo/refs" >output.race & + cgit_pid=$! + sleep 1 + mv "$lock" "$slot" + wait $cgit_pid +} + +test_expect_success 'a slot renamed away mid-lock is left alone' ' + rm -f cache/* && + plant_junk "$lock" && + rename_lock_during_fill && + grep -q "$junk" "$slot" +' + test_done -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 03:34:41 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 03:34:41 +0000 Subject: [PATCH 0/2] cache: fix corrupted pages served from the on-disk cache Message-ID: <20260825-fix-cache-slot-race-v1-0-a70fa1b3ba20@linuxfoundation.org> Every so often cgit serves a page from its file cache with unrelated data appended after the closing -- anywhere from a few stray links and a footer of somebody else's source code to hundreds of megabytes of binary junk that pins the browser at 100% CPU. It clears up on its own once the slot expires, which is a large part of why it has been hard to catch in the act. Reports of this go back years. The cause is a TOCTOU in lock_slot(). It opens the lock file and only then takes the fcntl lock, and those two steps are not atomic. In between, the process holding the lock can rename that same file over the cache slot and exit. The lock subsequently acquired is then held on the live cache file, lock_name no longer refers to it, and filling it rewrites a slot that other processes are streaming. The closing rename() fails with ENOENT, which no caller checks. Patch 1 has lock_slot() confirm, after taking the lock, that its descriptor still refers to the file lock_name points at, and give up with EAGAIN if it does not. The caller then serves stale content or generates uncached, both of which are safe. It also fixes three smaller things noticed along the way: print_slot() spins forever if sendfile() reports EOF before the size fstat() promised, a failed publish is discarded silently, and the ftruncate()/xwrite() error paths leak the lock file and its descriptor. Patch 2 adds a CGIT_TEST_LOCK_DELAY hook so the suite can drive the race directly -- the interleaving happens inside cgit's own execution and cannot be arranged from the outside. It is split out so the fix can go in on its own if a test hook in lock_slot() is unwelcome, and its commit message notes the two seconds of wall clock it costs. On what is and is not established here: the race is real, it reproduces standalone in C, and the fix demonstrably closes it. That this particular race is what produced the reports is inference rather than confirmation -- no corrupted slot was ever captured while it was still live. The circumstantial fit is good: the sizes involved, the binary tail, snapshot tarballs sharing the slot space as the obvious donor, and the self-healing after roughly a TTL. But if it recurs after this lands, the thing to grab is the slot file itself before it expires; the key stored at the front of it will say immediately whether it is this or something else. Note, this was largely written by an LLM because I'm not great at writing C, but I've carefully reviewed the results and they look sane to me. I'm not sure the solution in the 2nd commit is that great, so feel free to drop it if you think carrying an env-triggered delay in the built binary is a no-go. Signed-off-by: Konstantin Ryabitsev --- Konstantin Ryabitsev (2): cache: don't fill a slot we no longer own tests: exercise the lock_slot() rename race directly cache.c | 53 ++++++++++++++++++++++++++++++--- tests/t0021-cache-slot-reuse.sh | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) --- base-commit: 044821677c774cd24f25f1818ea51d09cc64b006 change-id: 20260825-fix-cache-slot-race-e9dc27e02f20 Best regards, -- Konstantin Ryabitsev From konstantin at linuxfoundation.org Tue Aug 25 18:05:16 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:05:16 +0000 Subject: [PATCH 3/3] cgit: zero-pad the cached repolist filename In-Reply-To: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> References: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> Message-ID: <20260825-fix-invalid-repo-crash-v1-3-8c3b52d192a9@linuxfoundation.org> The name of the cached repolist is built with "%8lx", which pads to eight characters with spaces rather than with zeroes, so a hash below 0x10000000 produces a filename like "rc- 1234". Use "%08lx", which is what the fixed width was evidently meant to be. This is cosmetic and optional. hash_str() returns an unsigned long, so on a 64-bit host the hash is essentially always wider than eight characters and neither conversion pads at all; the two differ only for the rare small hash that would otherwise get spaces in its filename. Nothing misbehaves either way, and because the change is confined to those cases it does not rename any cache file that a running instance is likely to have. Assisted-by: LLM [analysis, codegen] Signed-off-by: Konstantin Ryabitsev --- cgit.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cgit.c b/cgit.c index 2dfba9e..ffd8896 100644 --- a/cgit.c +++ b/cgit.c @@ -953,7 +953,7 @@ static void process_cached_repolist(const char *path) hash = hash_str(path); if (ctx.cfg.project_list) hash += hash_str(ctx.cfg.project_list); - strbuf_addf(&cached_rc, "%s/rc-%8lx", ctx.cfg.cache_root, hash); + strbuf_addf(&cached_rc, "%s/rc-%08lx", ctx.cfg.cache_root, hash); if (stat(cached_rc.buf, &st)) { /* Nothing is cached, we need to scan without forking. And -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 18:05:14 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:05:14 +0000 Subject: [PATCH 1/3] cgit: skip the notes system when the repository failed to open In-Reply-To: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> References: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> Message-ID: <20260825-fix-invalid-repo-crash-v1-1-8c3b52d192a9@linuxfoundation.org> prepare_repo_env() calls setup_git_directory_gently() and then loads the display notes unconditionally. When the setup fails, because repo.path is not a valid git directory, the notes loader still resolves refs/notes/commits, which reads the_repository->hash_algo. Git leaves that pointer NULL outside a repository since v2.46, so cgit dereferences NULL and dies before prepare_repo_cmd() gets a chance to report the failure. The gap is easy to reach through scan-path, because cgit's own is_git_dir() only stats objects/ and HEAD while git additionally requires refs/ and a HEAD it can validate. A repository that is being created or replicated therefore appears in the index and crashes every page that touches it. Skip the notes system when the setup did not find a repository, and let prepare_repo_cmd() print the config error page it already has. Cover the three shapes a broken repo.path can take with a test. Fixes: 8a92df0 ("Do not load user or system gitconfig and gitattributes") Assisted-by: LLM [analysis, codegen, tests] Signed-off-by: Konstantin Ryabitsev --- cgit.c | 7 ++++-- tests/t0023-invalid-repo-path.sh | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/cgit.c b/cgit.c index ca318e8..47471b0 100644 --- a/cgit.c +++ b/cgit.c @@ -587,9 +587,12 @@ static void prepare_repo_env(int *nongit) /* Setup the git directory and initialize the notes system. Both of these * load local configuration from the git repository, so we do them both while - * the HOME variables are unset. */ + * the HOME variables are unset. The notes system resolves a ref, which + * needs a repository: skip it when the setup above failed, and let + * prepare_repo_cmd() report the failure instead. */ setup_git_directory_gently(nongit); - load_display_notes(NULL); + if (!*nongit) + load_display_notes(NULL); } static int prepare_repo_cmd(int nongit) diff --git a/tests/t0023-invalid-repo-path.sh b/tests/t0023-invalid-repo-path.sh new file mode 100755 index 0000000..891c6c0 --- /dev/null +++ b/tests/t0023-invalid-repo-path.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +test_description='Check handling of a repo.path that is not a git directory' +. ./setup.sh + +# A repo.path can stop being a valid git directory at any time: the directory +# is removed, a symlink goes dangling, or the repository format is one this +# build refuses. cgit is supposed to answer with its "config error" page, so +# every page has to survive the failed setup rather than dereference a +# repository that was never opened. + +setup_broken_repos() { + rm -rf broken && mkdir -p broken/empty-dir && + git init -q --bare broken/badformat && + git -C broken/badformat config core.repositoryformatversion 1 && + git -C broken/badformat config extensions.frobnicate true && + cat >>cgitrc <<-EOF + repo.url=gone + repo.path=$PWD/broken/does-not-exist + repo.desc=vanished repo + + repo.url=empty-dir + repo.path=$PWD/broken/empty-dir + repo.desc=not a git directory + + repo.url=badformat + repo.path=$PWD/broken/badformat + repo.desc=unsupported repository format + EOF +} + +test_expect_success 'setup' 'setup_broken_repos' + +for repo in gone empty-dir badformat +do + for page in summary log tree commit diff refs + do + test_expect_success "$page of a $repo repo reports a config error" ' + cgit_url "'"$repo"'/'"$page"'" >output && + grep -q "config error" output && + grep -q "Failed to open" output + ' + done +done + +test_expect_success 'a bogus repo does not crash with an object id either' ' + cgit_url "gone/commit/&id=HEAD" >output && + grep -q "config error" output +' + +test_done -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 18:05:15 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:05:15 +0000 Subject: [PATCH 2/3] cgit: do not let a stale lockfile pin the cached repolist In-Reply-To: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> References: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> Message-ID: <20260825-fix-invalid-repo-crash-v1-2-8c3b52d192a9@linuxfoundation.org> When scan-path is used, the discovered repolist is cached in an "rc-" file below cache-root and refreshed from a forked child once it is older than cache-scanrc-ttl. The refresh serializes on a lockfile which it creates with O_EXCL and renames into place when it is done, so a process which dies before that rename leaves the lockfile behind. Since nothing ever removes it, every later refresh fails with EEXIST and quietly gives up, and the expired repolist is served unchanged from then on. The stale repolist keeps handing out a repo.path for a repository that has since been removed, which is exactly the input that the previous patch stops crashing on. A crash is also one way to leave the lockfile behind in the first place, so the two faults sustain each other: cgit dies while the refresh child is torn down with it, and the lockfile it leaves guarantees the next request reads the same bad path again. Serialize on an advisory lock instead. The kernel releases it when the holder dies, so a concurrent refresh is still skipped while a lockfile whose owner is gone is simply taken over and renamed into place. This also cleans up any lockfile left behind by an older cgit. Fixes: d746827 ("cgit.c: add support for caching autodetected repositories") Assisted-by: LLM [analysis, codegen, tests] Signed-off-by: Konstantin Ryabitsev --- cgit.c | 45 +++++++++++++++++++++---- tests/t0024-repolist-cache.sh | 78 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/cgit.c b/cgit.c index 47471b0..2dfba9e 100644 --- a/cgit.c +++ b/cgit.c @@ -881,20 +881,51 @@ static void print_repolist(FILE *f, struct cgit_repolist *list, int start) static int generate_cached_repolist(const char *path, const char *cached_rc) { struct strbuf locked_rc = STRBUF_INIT; + struct flock lock = { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + }; int result = 0; int idx; + int fd; FILE *f; strbuf_addf(&locked_rc, "%s.lock", cached_rc); - f = fopen(locked_rc.buf, "wx"); - if (!f) { - /* Inform about the error unless the lockfile already existed, - * since that only means we've got concurrent requests. + fd = open(locked_rc.buf, O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR); + if (fd == -1) { + result = errno; + fprintf(stderr, "[cgit] Error opening %s: %s (%d)\n", + locked_rc.buf, strerror(result), result); + goto out; + } + /* Serialize on an advisory lock rather than on the mere existence of + * the lockfile: a cgit process which dies before it can rename the + * lockfile into place must not keep every later process from + * refreshing the cached repolist. + */ + if (fcntl(fd, F_SETLK, &lock) == -1) { + /* Another process is generating the repolist, so leave it to + * that one. This is not an error worth reporting. */ result = errno; - if (result != EEXIST) - fprintf(stderr, "[cgit] Error opening %s: %s (%d)\n", - locked_rc.buf, strerror(result), result); + close(fd); + goto out; + } + if (ftruncate(fd, 0) == -1) { + result = errno; + fprintf(stderr, "[cgit] Error truncating %s: %s (%d)\n", + locked_rc.buf, strerror(result), result); + close(fd); + goto out; + } + f = fdopen(fd, "w"); + if (!f) { + result = errno; + fprintf(stderr, "[cgit] Error opening %s: %s (%d)\n", + locked_rc.buf, strerror(result), result); + close(fd); goto out; } idx = cgit_repolist.count; diff --git a/tests/t0024-repolist-cache.sh b/tests/t0024-repolist-cache.sh new file mode 100755 index 0000000..dadc37e --- /dev/null +++ b/tests/t0024-repolist-cache.sh @@ -0,0 +1,78 @@ +#!/bin/sh + +test_description='Check that the cached repolist recovers from a stale lockfile' +. ./setup.sh + +# With scan-path, cgit caches the discovered repolist in an "rc-" file below +# cache-root and refreshes it from a forked child once it is older than +# cache-scanrc-ttl. That refresh used to serialize on the mere existence of a +# lockfile, so a cgit process which died before it could rename the lockfile +# into place left the lockfile behind for good: every later refresh quietly +# gave up on it, and a repository which had since been removed stayed in the +# repolist forever. + +scan_url() { + CGIT_CONFIG="$PWD/cgitrc.scan" QUERY_STRING="url=$1" cgit +} + +# The refresh runs in a forked child, so poll for its result rather than +# assuming it has already finished. +wait_for_rescan() { + i=0 + while test $i -lt 10 + do + grep -q "^repo.url=doomed" cache-scan/rc-* || return 0 + sleep 1 + i=$((i + 1)) + done + return 1 +} + +warm_repolist() { + rm -rf scan cache-scan && + mkdir -p cache-scan && + git init -q --bare scan/keep.git && + git init -q --bare scan/doomed.git && + scan_url "" >/dev/null && + grep -q "^repo.url=doomed" cache-scan/rc-* +} + +test_expect_success 'setup' ' + cat >cgitrc.scan <<-EOF + virtual-root=/ + cache-root=$PWD/cache-scan + cache-size=1021 + cache-scanrc-ttl=0 + scan-path=$PWD/scan + EOF +' + +test_expect_success 'a removed repository leaves an expired repolist' ' + warm_repolist && + rm -rf scan/doomed.git && + sleep 1 && + scan_url "" >/dev/null && + wait_for_rescan +' + +test_expect_success 'a stale lockfile does not pin the repolist' ' + warm_repolist && + rm -rf scan/doomed.git && + for rc in cache-scan/rc-* + do + : >"$rc.lock" || return 1 + done && + sleep 1 && + scan_url "" >/dev/null && + wait_for_rescan +' + +test_expect_success 'the stale lockfile is not left behind' ' + test -z "$(ls cache-scan/rc-*.lock 2>/dev/null)" +' + +test_expect_success 'the surviving repository is still listed' ' + grep -q "^repo.url=keep" cache-scan/rc-* +' + +test_done -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 18:05:13 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:05:13 +0000 Subject: [PATCH 0/3] cgit: fix a segfault on a removed repository Message-ID: <20260825-fix-invalid-repo-crash-v1-0-8c3b52d192a9@linuxfoundation.org> A repository whose path is not a valid git directory crashes cgit on every page instead of producing the "config error" page that prepare_repo_cmd() already has for exactly this case. The crash is a NULL dereference of the_repository->hash_algo from the notes system, which prepare_repo_env() invokes without checking whether the repository was opened at all. The call has been unguarded since 2013, but it only became fatal with git v2.46, which stopped defaulting the hash algorithm outside a repository so that unconverted callers would be found. This is one of them. Reaching it does not take a hand-written cgitrc. With scan-path, cgit caches the discovered repolist in an "rc-" file below cache-root, and that cache is parsed before its age is even looked at, so a repository that has been removed keeps handing out its repo.path until the cache is refreshed. The refresh in turn serializes on a lockfile created with O_EXCL, which a dying cgit leaves behind for good, and from then on the expired repolist is never replaced. The two faults sustain each other: the crash tears down the refresh child along with it, and the lockfile that child leaves guarantees the next request reads the same removed path again. On git.kernel.org this kept one deleted repository segfaulting cgit on every request for three days, until the cache was cleared by hand. The first patch stops the crash; the second lets the repolist cache recover on its own, including from a lockfile left behind by an older cgit. The third is an unrelated cosmetic fix to the cache filename, noticed while reading the same function, and can be dropped without affecting the other two. --- Konstantin Ryabitsev (3): cgit: skip the notes system when the repository failed to open cgit: do not let a stale lockfile pin the cached repolist cgit: zero-pad the cached repolist filename cgit.c | 54 ++++++++++++++++++++++------ tests/t0023-invalid-repo-path.sh | 51 ++++++++++++++++++++++++++ tests/t0024-repolist-cache.sh | 78 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 10 deletions(-) --- base-commit: 044821677c774cd24f25f1818ea51d09cc64b006 change-id: 20260825-fix-invalid-repo-crash-7530f413c5bd Best regards, -- Konstantin Ryabitsev From konstantin at linuxfoundation.org Tue Aug 25 18:15:59 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:15:59 +0000 Subject: [PATCH] ui-shared: fix the Expires header on error pages Message-ID: <20260825-fix-error-page-expires-v1-1-388dcc7e7f07@linuxfoundation.org> page.expires is an absolute time: the normal path seeds it from page.modified and adds the relevant TTL in seconds. The error page instead assigns cache-dynamic-ttl directly, which is a count of minutes and carries no base, so every error page cgit has produced since "ui-shared: cache errors for dynamic TTL" reports Expires: Thu, 01 Jan 1970 00:00:05 GMT five seconds past the epoch, which is the value of the default TTL read as seconds. The intent of that change was to stop errors being cached forever; the effect is that they are never cached at all, since every downstream cache is told the response was already stale. Seed the value from page.modified and scale the TTL to seconds, matching what calc_ttl() does for ordinary pages. prepare_context() sets page.modified before anything can produce an error page, so the base is always available. Add a test. The header can be checked without any date arithmetic: a TTL of zero has to reproduce Last-Modified exactly, and a non-zero one has to differ from it without falling back to the epoch. Fixes: c5975ae ("ui-shared: cache errors for "dynamic TTL"") Assisted-by: LLM [analysis, codegen, tests] Signed-off-by: Konstantin Ryabitsev --- I found this while looking at something else, and it looks like it has been there since errors were first given the dynamic TTL, and the change that introduced it was trying to do the opposite of what it ended up doing. Its reasoning was that errors are potentially transient and so should not be cached forever; the implementation tells every downstream cache the response expired in 1970, so they are not cached at all. Nothing breaks as a result, which is presumably why it went unnoticed: a response that claims to be stale is refetched, so the failure mode is an immediate cache miss rather than wrong content. On a site where a crawler is walking a large namespace and collecting 404s, though, that is a lot of requests that a proxy/CDN could have served from cache. The test asserts the header without doing any date arithmetic, which would otherwise mean either parsing HTTP dates in shell or assuming a particular date(1). A zero TTL has to reproduce Last-Modified exactly, and a non-zero one has to differ from it without landing on the epoch. Both assertions fail before the fix and pass after it. --- tests/t0022-error-page-headers.sh | 37 +++++++++++++++++++++++++++++++++++++ ui-shared.c | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/t0022-error-page-headers.sh b/tests/t0022-error-page-headers.sh new file mode 100755 index 0000000..51fbad8 --- /dev/null +++ b/tests/t0022-error-page-headers.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +test_description='Check the Expires header on error pages' +. ./setup.sh + +# cgit_vprint_error_page() derives page.expires from cache-dynamic-ttl, which +# is a count of minutes, while page.expires is an absolute time. Checking the +# result needs no date arithmetic: a zero TTL has to reproduce Last-Modified +# exactly, and a non-zero one has to land somewhere else without falling back +# to the epoch. + +header_value() { + sed -n "s/^$1: //p" "$2" | tr -d '\r' +} + +test_expect_success 'a non-zero dynamic TTL expires the error page later' ' + cgit_url "bar/commit/&id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" >output && + grep -q "Status: 404 Not found" output && + modified=$(header_value Last-Modified output) && + expires=$(header_value Expires output) && + test -n "$modified" && + test -n "$expires" && + test "$modified" != "$expires" && + case "$expires" in *1970*) return 1 ;; esac +' + +test_expect_success 'a zero dynamic TTL expires the error page immediately' ' + rm -rf cache && mkdir -p cache && + printf "cache-dynamic-ttl=0\n" >>cgitrc && + cgit_url "bar/commit/&id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" >output && + modified=$(header_value Last-Modified output) && + expires=$(header_value Expires output) && + test -n "$modified" && + test "$modified" = "$expires" +' + +test_done diff --git a/ui-shared.c b/ui-shared.c index df52a9b..44794d8 100644 --- a/ui-shared.c +++ b/ui-shared.c @@ -896,7 +896,7 @@ void cgit_print_error_page(int code, const char *msg, const char *fmt, ...) void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap) { - ctx.page.expires = ctx.cfg.cache_dynamic_ttl; + ctx.page.expires = ctx.page.modified + ctx.cfg.cache_dynamic_ttl * 60; ctx.page.status = code; ctx.page.statusmsg = msg; cgit_print_layout_start(); --- base-commit: 044821677c774cd24f25f1818ea51d09cc64b006 change-id: 20260825-fix-error-page-expires-2c9f3970582e Best regards, -- Konstantin Ryabitsev From konstantin at linuxfoundation.org Tue Aug 25 18:28:11 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:28:11 +0000 Subject: [PATCH] ui: optionally refuse objects not reachable from any reference Message-ID: <20260825-object-reachability-check-v1-1-1b78d5c823c2@linuxfoundation.org> When several repositories share an object database -- through objects/info/alternates, or any other arrangement that deduplicates identical objects across forks -- every object becomes readable from every one of those repositories. cgit will render a commit page for a commit belonging to an unrelated fork just as readily as for its own, so a single commit has as many valid URLs as there are repositories sharing the database. Crawlers find all of them. Every one of those URLs generates a full commit page, diff included, and claims its own cache slot, so a site with hundreds of forks pays for hundreds of copies of the same page and evicts the pages visitors actually asked for. Add enable-object-reachability-check, off by default and overridable per repository, which answers such a request with "404 Not found" instead. The reference set mirrors the one shown on the refs page -- branches and tags always, remote branches only when enable-remote-branches is set -- so that an object is never refused here while remaining visible there. An object that is not commit-ish is answered differently. Whether a blob or a tree occurs somewhere in the history cannot be determined without walking every object in the repository, which no request can afford, so such an object is displayed only when a reference points straight at it. Those are exactly the objects cgit links to by raw id from its refs and tag pages, so its own links keep working, while a request naming a blob belonging to some other repository is refused. Reaching a blob by path through a commit is governed by that commit and is unaffected. A revision that does not resolve at all is left to the page handler, which reports it in its own words. Conflating the two would tell the visitor that an object is unknown when in fact it exists and simply does not belong here. The refusal names only the revision the visitor supplied and never reads the object, so it discloses nothing about the commit and no diff is ever generated. Snapshots take their revision from the filename rather than from the query string, so they are checked separately, at the point where the revision becomes known. Answering the question means walking history, which is bounded by generation numbers only when the repository has a commit-graph. Without one the walk falls back to commit dates and is orders of magnitude slower, so the documentation is explicit that this should not be enabled on a repository whose maintenance does not include commit-graph writes. That cost is one reason to leave the option off by default, and not the main one. Where no object database is shared there is nothing for the check to refuse, so most instances would gain nothing from it. It is also a behaviour change: an object that is present but unreferenced is served today and would stop being served, and such objects arise routinely, from a branch that was reset or force-pushed before gc ran, or from a fetch that has not been given a ref. Refusing them is the right answer only for a site that knows its object database is shared. Assisted-by: LLM [analysis, codegen, tests] Signed-off-by: Konstantin Ryabitsev --- On git.kernel.org forks share a single object database, so a commit that lives in one tree has as many valid URLs as there are forks of it -- hundreds, in the popular cases -- and crawlers find every one of them. We have been working around this with a commit filter that shells out to git-branch --contains and prints a notice, which does not really help: by the time a filter runs, the diff has already been generated, returned and cached. Doing the check before dispatch is both cheaper and actually effective. Some numbers from a fork of the kernel tree, measuring the underlying traversal: reachable, recent commit 0.017s reachable, old commit, with commit-graph 0.052s reachable, old commit, without commit-graph 18.268s unreachable commit 0.005s The case crawlers actually hit, and the only one that leads to a refusal, is the cheapest of the four: generation numbers reject it without a walk. The third row is the one the documentation warns about. Two choices that are not visible in the diff: - The reachability half fails open. An empty repository, or an error out of the traversal, counts as reachable, on the grounds that refusing to serve a real page is the more damaging way to be wrong. - Refusals are cached like any other id= page, which is to say with cache-static-ttl and therefore, by default, forever. A refusal outliving the condition that produced it is not ideal, but it is what cgit already does for a 404 on a genuinely missing object, and giving error responses their own lifetime is a wider change. I intend to propose that separately. And one question. A refused blob is reported with the same "not reachable from any reference" message as a refused commit, which is a stretch: such a blob may well be reachable in git's sense and merely cannot be shown to be. Splitting the message is one more format string if you would rather have it split. --- cgit.c | 10 +++ cgit.h | 5 ++ cgitrc.5.txt | 31 ++++++++ shared.c | 111 +++++++++++++++++++++++++++ tests/t0112-object-reachability.sh | 150 +++++++++++++++++++++++++++++++++++++ ui-shared.c | 26 +++++++ ui-shared.h | 1 + ui-snapshot.c | 9 +++ 8 files changed, 343 insertions(+) diff --git a/cgit.c b/cgit.c index ca318e8..ce98a43 100644 --- a/cgit.c +++ b/cgit.c @@ -72,6 +72,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu repo->enable_log_filecount = atoi(value); else if (!strcmp(name, "enable-log-linecount")) repo->enable_log_linecount = atoi(value); + else if (!strcmp(name, "enable-object-reachability-check")) + repo->enable_object_reachability_check = atoi(value); else if (!strcmp(name, "enable-remote-branches")) repo->enable_remote_branches = atoi(value); else if (!strcmp(name, "enable-subject-links")) @@ -191,6 +193,8 @@ static void config_cb(const char *name, const char *value) ctx.cfg.enable_log_filecount = atoi(value); else if (!strcmp(name, "enable-log-linecount")) ctx.cfg.enable_log_linecount = atoi(value); + else if (!strcmp(name, "enable-object-reachability-check")) + ctx.cfg.enable_object_reachability_check = atoi(value); else if (!strcmp(name, "enable-remote-branches")) ctx.cfg.enable_remote_branches = atoi(value); else if (!strcmp(name, "enable-subject-links")) @@ -762,6 +766,10 @@ static void process_request(void) if (ctx.repo && prepare_repo_cmd(nongit)) return; + if (cgit_reject_unreachable_object(ctx.qry.oid) || + cgit_reject_unreachable_object(ctx.qry.oid2)) + return; + cmd->fn(); } @@ -848,6 +856,8 @@ static void print_repo(FILE *f, struct cgit_repo *repo) fprintf(f, "repo.logo=%s\n", repo->logo); if (repo->logo_link) fprintf(f, "repo.logo-link=%s\n", repo->logo_link); + fprintf(f, "repo.enable-object-reachability-check=%d\n", + repo->enable_object_reachability_check); fprintf(f, "repo.enable-remote-branches=%d\n", repo->enable_remote_branches); fprintf(f, "repo.enable-subject-links=%d\n", repo->enable_subject_links); fprintf(f, "repo.enable-html-serving=%d\n", repo->enable_html_serving); diff --git a/cgit.h b/cgit.h index 7d7ece7..c7a62ce 100644 --- a/cgit.h +++ b/cgit.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -104,6 +105,7 @@ struct cgit_repo { int enable_follow_links; int enable_log_filecount; int enable_log_linecount; + int enable_object_reachability_check; int enable_remote_branches; int enable_subject_links; int enable_html_serving; @@ -238,6 +240,7 @@ struct cgit_config { int enable_commit_graph; int enable_log_filecount; int enable_log_linecount; + int enable_object_reachability_check; int enable_remote_branches; int enable_subject_links; int enable_html_serving; @@ -347,6 +350,8 @@ extern void cgit_add_ref(struct reflist *list, struct refinfo *ref); extern void cgit_free_reflist_inner(struct reflist *list); extern int cgit_refs_cb(const struct reference *ref, void *cb_data); +extern int cgit_oid_is_reachable(const struct object_id *oid); + extern void cgit_free_commitinfo(struct commitinfo *info); extern void cgit_free_taginfo(struct taginfo *info); diff --git a/cgitrc.5.txt b/cgitrc.5.txt index 7c39bf9..4111eb3 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -202,6 +202,33 @@ enable-log-linecount:: and removed lines for each commit on the repository log page. Default value: "0". +enable-object-reachability-check:: + Flag which, when set to "1", will make cgit refuse to display an + object that is not reachable from any reference in the repository, + answering with "404 Not found" instead. This is only useful where + several repositories share an object database, for example through + `objects/info/alternates`: every object is then readable from every + repository, so each object has as many valid URLs as there are + repositories sharing the database, and cgit will render and cache a + page for each one of them. ++ +The reference set used is the one shown on the refs page, so branches and +tags always, and remote branches only when "enable-remote-branches" is +set. ++ +Blobs and trees are treated differently, because whether one occurs +somewhere in the history cannot be determined without walking every object +in the repository. Such an object is displayed only when a reference points +straight at it, which is the case cgit itself links to by raw object id. +Requesting a blob by path through a commit is unaffected. ++ +Answering the question means walking history, which is affordable only +when the repository has a commit-graph; without one the walk is bounded by +commit dates rather than by generation numbers and takes orders of +magnitude longer. Do not enable this on repositories of any size unless +`git commit-graph write` is part of their maintenance. Default value: "0". +See also: "repo.enable-object-reachability-check". + enable-remote-branches:: Flag which, when set to "1", will make cgit display remote branches in the summary and refs views. Default value: "0". See also: @@ -519,6 +546,10 @@ repo.enable-log-linecount:: A flag which can be used to disable the global setting `enable-log-linecount'. Default value: none. +repo.enable-object-reachability-check:: + A flag which can be used to override the global setting + `enable-object-reachability-check'. Default value: none. + repo.enable-remote-branches:: Flag which, when set to "1", will make cgit display remote branches in the summary and refs views. Default value: . diff --git a/shared.c b/shared.c index a39394d..6d1891b 100644 --- a/shared.c +++ b/shared.c @@ -66,6 +66,7 @@ struct cgit_repo *cgit_add_repo(const char *url) ret->enable_follow_links = ctx.cfg.enable_follow_links; ret->enable_log_filecount = ctx.cfg.enable_log_filecount; ret->enable_log_linecount = ctx.cfg.enable_log_linecount; + ret->enable_object_reachability_check = ctx.cfg.enable_object_reachability_check; ret->enable_remote_branches = ctx.cfg.enable_remote_branches; ret->enable_subject_links = ctx.cfg.enable_subject_links; ret->enable_html_serving = ctx.cfg.enable_html_serving; @@ -224,6 +225,116 @@ int cgit_refs_cb(const struct reference *ref, void *cb_data) return 0; } +struct reachability_tips { + struct commit **commits; + size_t nr; + size_t alloc; +}; + +static int collect_reachability_tip(const struct reference *ref, void *cb_data) +{ + struct reachability_tips *tips = cb_data; + struct commit *commit; + + commit = lookup_commit_reference_gently(the_repository, ref->oid, 1); + if (!commit) + return 0; + ALLOC_GROW(tips->commits, tips->nr + 1, tips->alloc); + tips->commits[tips->nr++] = commit; + return 0; +} + +/* Walk the references this repository publishes. The set deliberately mirrors + * the one shown by the refs page, so that an object is never refused here + * while remaining visible there, or the other way around. + */ +static void for_each_published_ref(refs_for_each_cb fn, void *cb_data) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + + if (refs_for_each_branch_ref(refs, fn, cb_data)) + return; + if (refs_for_each_tag_ref(refs, fn, cb_data)) + return; + if (ctx.repo && ctx.repo->enable_remote_branches) + refs_for_each_remote_ref(refs, fn, cb_data); +} + +struct ref_target_match { + const struct object_id *oid; + int found; +}; + +static int match_ref_target(const struct reference *ref, void *cb_data) +{ + struct ref_target_match *match = cb_data; + struct object *obj; + + if (oideq(ref->oid, match->oid)) { + match->found = 1; + return 1; + } + if (odb_read_object_info(the_repository->objects, ref->oid, NULL) != OBJ_TAG) + return 0; + obj = parse_object(the_repository, ref->oid); + if (!obj) + return 0; + obj = deref_tag(the_repository, obj, NULL, 0); + if (obj && oideq(&obj->oid, match->oid)) { + match->found = 1; + return 1; + } + return 0; +} + +/* Determine whether 'oid' is published by this repository, meaning that a + * reference points directly at it or that it is reachable from one. + * + * For an object that is not commit-ish, only the first test is available. + * Asking whether a blob or a tree occurs somewhere in the history would mean + * walking every object in the repository, which no request can afford. The + * objects cgit links to by raw id are exactly the ones a reference points at, + * so the affordable test is also the one that matters: it keeps those links + * working and refuses everything else, which in a shared object database is + * every blob and tree belonging to some other repository. + * + * The reachability half reports the object as published when it cannot answer + * -- an empty repository has no tips to walk from, and an error out of the + * traversal cannot be interpreted -- because refusing to serve a page is the + * more damaging way to be wrong. + * + * Note that this half walks history, and is only affordable when the + * repository has a commit-graph; without one, the generation numbers that + * bound the walk are unavailable and the cost grows with the size of history. + */ +int cgit_oid_is_reachable(const struct object_id *oid) +{ + struct reachability_tips tips = { NULL, 0, 0 }; + struct ref_target_match match = { oid, 0 }; + struct commit *commit; + int reachable = 1; + + commit = lookup_commit_reference_gently(the_repository, oid, 1); + if (!commit) { + /* An object that is not in the database at all is a different + * question, and one the page handler answers in its own words. + */ + if (!odb_has_object(the_repository->objects, oid, 0)) + return 1; + for_each_published_ref(match_ref_target, &match); + return match.found; + } + + for_each_published_ref(collect_reachability_tip, &tips); + + if (tips.nr) + reachable = repo_in_merge_bases_many(the_repository, commit, + tips.nr, tips.commits, + 1) != 0; + free(tips.commits); + return reachable; +} + void cgit_diff_tree_cb(struct diff_queue_struct *q, struct diff_options *options, void *data) { diff --git a/tests/t0112-object-reachability.sh b/tests/t0112-object-reachability.sh new file mode 100755 index 0000000..30927fd --- /dev/null +++ b/tests/t0112-object-reachability.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +test_description='Verify enable-object-reachability-check' +. ./setup.sh + +# Build a commit that exists in the object database but is not reachable from +# any reference, which is what a repository sharing its object database with +# others looks like from the inside. The marker keeps each such commit +# distinct: the test dates are fixed, so two commits built the same way would +# otherwise be the same object. +mkunreachable() { + ( + cd "$1" && + echo "PAYLOAD$2" >leak.txt && + git add leak.txt && + tree=$(git write-tree) && + git commit-tree -p HEAD -m "SUBJECT$2" "$tree" + ) +} + +test_expect_success 'setup' ' + unreachable=$(mkunreachable repos/bar ONE) && + tagged=$(mkunreachable repos/bar TWO) && + nograph=$(mkunreachable repos/foo THREE) && + (cd repos/bar && git reset -q --hard HEAD) && + (cd repos/foo && git reset -q --hard HEAD) && + (cd repos/bar && git tag -a -m "orphan" orphan "$tagged") && + reachable=$(cd repos/bar && git rev-parse HEAD) && + test "$unreachable" != "$tagged" && + unreachable_blob=$(cd repos/bar && git rev-parse "$unreachable:leak.txt") && + lightweight_blob=$(cd repos/bar && + echo LIGHTWEIGHT | git hash-object -w --stdin) && + annotated_blob=$(cd repos/bar && + echo ANNOTATED | git hash-object -w --stdin) && + (cd repos/bar && git tag blobtag "$lightweight_blob") && + (cd repos/bar && git tag -a -m "blob" annblobtag "$annotated_blob") && + cat >>cgitrc <<-EOF + repo.url=strict + repo.path=$PWD/repos/bar/.git + repo.desc=reachability checked + repo.enable-object-reachability-check=1 + + repo.url=strict-nograph + repo.path=$PWD/repos/foo/.git + repo.desc=reachability checked without a commit-graph + repo.enable-object-reachability-check=1 + EOF +' + +test_expect_success 'unreachable commit is served when the check is disabled' ' + cgit_url "bar/commit/&id=$unreachable" >output && + grep -q "SUBJECTONE" output && + grep -q "PAYLOADONE" output +' + +test_expect_success 'unreachable commit is refused when the check is enabled' ' + cgit_url "strict/commit/&id=$unreachable" >output && + grep -q "Status: 404 Not found" output && + grep -q "is not reachable from any reference in this repository" output +' + +test_expect_success 'the refusal discloses nothing about the commit' ' + ! grep -q "SUBJECTONE" output && + ! grep -q "PAYLOADONE" output +' + +test_expect_success 'reachable commit is still served' ' + cgit_url "strict/commit/&id=$reachable" >output && + ! grep -q "Status: 404" output && + grep -q "$reachable" output +' + +test_expect_success 'a commit reachable only from a tag is served' ' + cgit_url "strict/commit/&id=$tagged" >output && + ! grep -q "Status: 404" output && + grep -q "SUBJECTTWO" output +' + +# Whether a blob occurs anywhere in the history cannot be answered without +# walking every object in the repository, so the check accepts a blob only when +# a reference points straight at it. Those are exactly the blobs cgit itself +# links to by raw id, from the refs and tag pages. +test_expect_success 'a blob is served by raw id when the check is disabled' ' + cgit_url "bar/blob/&id=$unreachable_blob" >output && + grep -q "PAYLOADONE" output +' + +test_expect_success 'a blob no reference points at is refused' ' + cgit_url "strict/blob/&id=$unreachable_blob" >output && + grep -q "Status: 404 Not found" output && + ! grep -q "PAYLOADONE" output +' + +test_expect_success 'a blob a lightweight tag points at is served' ' + cgit_url "strict/blob/&id=$lightweight_blob" >output && + ! grep -q "Status: 404" output && + grep -q "LIGHTWEIGHT" output +' + +test_expect_success 'a blob an annotated tag points at is served' ' + cgit_url "strict/blob/&id=$annotated_blob" >output && + ! grep -q "Status: 404" output && + grep -q "ANNOTATED" output +' + +test_expect_success 'ordinary views are unaffected' ' + cgit_url "strict/refs" >output && + ! grep -q "Status: 404" output && + cgit_url "strict/plain/file-50&id=$reachable" >output && + ! grep -q "Status: 404" output +' + +test_expect_success 'an object that does not resolve is still reported as such' ' + cgit_url "strict/commit/&id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" >output && + grep -q "Bad commit reference" output && + ! grep -q "is not reachable from any reference" output +' + +test_expect_success 'an unreachable id2 is refused by the diff view' ' + cgit_url "strict/diff/&id=$reachable&id2=$unreachable" >output && + grep -q "Status: 404 Not found" output && + grep -q "is not reachable from any reference in this repository" output && + ! grep -q "PAYLOADONE" output +' + +test_expect_success 'an unreachable snapshot is refused' ' + cgit_url "strict/snapshot/$unreachable.tar.gz" >output && + grep -q "Status: 404 Not found" output && + grep -q "is not reachable from any reference in this repository" output +' + +test_expect_success 'the check works without a commit-graph' ' + cgit_url "strict-nograph/commit/&id=$nograph" >output && + grep -q "Status: 404 Not found" output && + grep -q "is not reachable from any reference in this repository" output +' + +# Answering the reachability question walks history, so the refusal is worth +# caching: regenerating it for every crawler request would cost more than the +# slot it occupies. +test_expect_success 'a refusal is cached and replayed' ' + rm -f cache/* && + cgit_url "strict/commit/&id=$unreachable" >output && + grep -q "Status: 404 Not found" output && + test -n "$(ls cache)" && + cgit_url "strict/commit/&id=$unreachable" >output.cached && + test_cmp output output.cached +' + +test_done diff --git a/ui-shared.c b/ui-shared.c index df52a9b..16c0524 100644 --- a/ui-shared.c +++ b/ui-shared.c @@ -886,6 +886,32 @@ void cgit_print_docend(void) html("\n\n"); } +/* Reject a request for an object that this repository does not publish. This + * only matters where several repositories share an object database: an object + * is then readable from every one of them, and each has as many valid URLs for + * it as there are repositories. + * + * A revision that does not resolve at all is left to the page handler, which + * reports it in its own words. Conflating the two would tell the visitor that + * an object is unknown when in fact it exists and simply does not belong here. + */ +int cgit_reject_unreachable_object(const char *rev) +{ + struct object_id oid; + + if (!ctx.repo || !ctx.repo->enable_object_reachability_check) + return 0; + if (!rev || repo_get_oid(the_repository, rev, &oid)) + return 0; + if (cgit_oid_is_reachable(&oid)) + return 0; + + cgit_print_error_page(404, "Not found", + "Object %s is not reachable from any reference " + "in this repository", rev); + return 1; +} + void cgit_print_error_page(int code, const char *msg, const char *fmt, ...) { va_list ap; diff --git a/ui-shared.h b/ui-shared.h index 2a3a7f5..c581e43 100644 --- a/ui-shared.h +++ b/ui-shared.h @@ -71,6 +71,7 @@ extern void cgit_print_http_headers(void); extern void cgit_redirect(const char *url, bool permanent); extern void cgit_print_docstart(void); extern void cgit_print_docend(void); +extern int cgit_reject_unreachable_object(const char *rev); __attribute__((format (printf,3,4))) extern void cgit_print_error_page(int code, const char *msg, const char *fmt, ...); extern void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap); diff --git a/ui-snapshot.c b/ui-snapshot.c index d157222..a5007c9 100644 --- a/ui-snapshot.c +++ b/ui-snapshot.c @@ -305,6 +305,15 @@ void cgit_print_snapshot(const char *head, const char *hex, if (!hex) hex = head; + /* The revision may have come from the filename rather than from the + * query string, so it has not been checked by the dispatcher yet. + */ + if (cgit_reject_unreachable_object(hex)) { + free(prefix); + free(adj_filename); + return; + } + if (!prefix) prefix = xstrdup(cgit_snapshot_prefix(ctx.repo)); --- base-commit: 044821677c774cd24f25f1818ea51d09cc64b006 change-id: 20260825-object-reachability-check-b1db7249b02f Best regards, -- Konstantin Ryabitsev From konstantin at linuxfoundation.org Tue Aug 25 18:59:45 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:59:45 +0000 Subject: [PATCH RFC 0/3] ui-bugs: browse git-bug data from cgit Message-ID: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> git-bug (https://github.com/git-bug/git-bug) keeps issues inside the repository itself, as ordinary git objects under refs/bugs/. That makes it attractive for projects hosted on plain git infrastructure, which has no issue tracker to offer: the bugs travel with the clone, and no service has to be run alongside the repository. What has been missing is a way to read them without cloning and installing the git-bug binary. This series adds a read-only "bugs" page to cgit. It renders nothing itself; it invokes a filter and wraps the output. The filter is handed the path component -- empty for the bug list, or a bug id for a single bug -- and produces the HTML. Delegating rather than parsing is deliberate. git-bug's on-disk format is its own business and has changed before; a C implementation in cgit would pin cgit to whatever the format looked like on the day it was written, and every subsequent change would become a cgit bug. A filter also means a site can render bugs to its own taste, or point the page at something else entirely, without patching cgit. The cost is that the page does nothing useful until a filter is configured, which is why patch 2 supplies a working one. The three patches are: 1/3 the page handler, the config options, and the nav tab 2/3 filters/bugs-git-bug.lua, a working filter built on ezgb.lua and luagit2 3/3 tests for the C plumbing ezgb.lua is not in this tree. It lives under lua/ in https://git.kernel.org/pub/scm/utils/ezgb/ezgb.git alongside a rockspec, so `luarocks make lua-ezgb-scm-1.rockspec' from that checkout installs it. It is read-only and pulls in luagit2, luaossl and lua-cjson. Configuration is two lines: enable-bugs=1 bugs-filter=lua:/usr/share/cgit/filters/bugs-git-bug.lua The result can be seen running at https://git.kernel.org/pub/scm/utils/b4/b4.git/bugs/ which is b4's own bug tracker, rendered by patch 2's filter on a cgit carrying this series. The page is off by default, and returns 403 rather than 404 when it is not enabled, so a repository that already carries refs/bugs/ does not quietly grow a new page across an upgrade. Rendering is cached under its own cache-bugs-ttl, defaulting to the same 15 minutes as the about page, since invoking a filter that reads git objects is not something to repeat per request. A few choices worth arguing about: - The nav tab is gated on enable-bugs alone, not on the repository actually having refs/bugs/. Probing for the ref would hide the tab on repositories with no bugs, but it costs a ref lookup on every page of every repository, and it makes the tab flicker into existence when the first bug is filed. I opted for the explicit switch. - The page is registered with want_vpath=0. A bug id is meaningful only to this page, and treating it as the virtual path would append it to the log, tree and diff tab links, sending anyone who clicked them to a path that does not exist. There is a test for this. - The example filter needs ezgb.lua, luagit2, lua-cjson (or lua-json) and luaossl. That is a lot to ask of a packager, which is why it sits in filters/ as an example alongside the others rather than being wired up by the Makefile. Nothing in cgit depends on it. - The bug page styles go into cgit.css rather than being emitted inline by the filter. A filter is free to inline its own, but a deployment serving a strict Content-Security-Policy would then need style-src 'unsafe-inline' for this one page, which seemed like the wrong trade to bake into the example. Read-only is the whole scope. Filing and commenting stay with the git-bug CLI, which is where the Lamport clocks and the operation DAG are kept consistent; a web form writing into that from a CGI is not a thing I want to maintain. This shares cgit.c, cgit.h, shared.c, cgitrc.5.txt, ui-shared.c and ui-shared.h with the object reachability series I have out, but the two apply cleanly in either order and neither depends on the other. Signed-off-by: Konstantin Ryabitsev --- Konstantin Ryabitsev (3): ui-bugs: add git-bug browsing via a Lua filter filters: add an example git-bug filter tests: cover the bugs page access control and config cgit.c | 18 +++ cgit.css | 103 +++++++++++++ cgit.h | 7 +- cgit.mk | 1 + cgitrc.5.txt | 32 ++++ cmd.c | 10 ++ filter.c | 1 + filters/bugs-git-bug.lua | 384 +++++++++++++++++++++++++++++++++++++++++++++++ shared.c | 2 + tests/t0113-bugs.sh | 77 ++++++++++ ui-bugs.c | 30 ++++ ui-bugs.h | 6 + ui-shared.c | 12 ++ ui-shared.h | 2 + 14 files changed, 684 insertions(+), 1 deletion(-) --- base-commit: 044821677c774cd24f25f1818ea51d09cc64b006 change-id: 20260825-feature-git-bug-c8042452d25e Best regards, -- Konstantin Ryabitsev From konstantin at linuxfoundation.org Tue Aug 25 18:59:48 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:59:48 +0000 Subject: [PATCH RFC 3/3] tests: cover the bugs page access control and config In-Reply-To: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> References: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> Message-ID: <20260825-feature-git-bug-v1-3-cdc1378010af@linuxfoundation.org> Exercise the C plumbing for the bugs page. The tests need neither Lua nor git-bug, since everything they check happens before the filter would be invoked: - the bugs tab is absent by default and present with enable-bugs=1 - enable-bugs is honoured both globally and per-repo - the page returns 404 when no bugs-filter is configured - the page returns 403 when it is not enabled - a bug id in the path does not leak into the other nav tab URLs - cache-bugs-ttl parses without complaint Assisted-by: LLM [codegen, tests] Signed-off-by: Konstantin Ryabitsev --- tests/t0113-bugs.sh | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/t0113-bugs.sh b/tests/t0113-bugs.sh new file mode 100755 index 0000000..8088ed2 --- /dev/null +++ b/tests/t0113-bugs.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +test_description='Check bugs page access control, nav links, and config' +. ./setup.sh + +test_expect_success 'bugs tab not shown by default' ' + cgit_url "foo/summary" >tmp && + ! grep "bugs" tmp +' + +test_expect_success 'enable bugs for foo repo' ' + cat >>cgitrc <<-EOF + + repo.url=bugs-test + repo.path=$PWD/repos/foo/.git + repo.desc=bugs test repo + repo.enable-bugs=1 + EOF +' + +test_expect_success 'bugs tab shown when enabled' ' + cgit_url "bugs-test/summary" >tmp && + grep ">bugs<" tmp +' + +test_expect_success 'bugs page returns 404 when no filter configured' ' + cgit_url "bugs-test/bugs/" >tmp && + grep "404" tmp && + grep "No bugs-filter configured" tmp +' + +test_expect_success 'bugs page returns 403 when not enabled' ' + cgit_url "foo/bugs/" >tmp && + grep "403" tmp +' + +test_expect_success 'bugs tab not shown when not enabled' ' + cgit_url "foo/summary" >tmp && + ! grep ">bugs<" tmp +' + +test_expect_success 'other nav links do not include bug path' ' + cgit_url "bugs-test/bugs/deadbeef" >tmp && + ! grep "/log/deadbeef" tmp && + ! grep "/tree/deadbeef" tmp && + ! grep "/commit/deadbeef" tmp && + ! grep "/diff/deadbeef" tmp +' + +test_expect_success 'enable-bugs works in global config' ' + sed -i "/^repo.url=bugs-test/,\$d" cgitrc && + cat >>cgitrc <<-EOF + enable-bugs=1 + + repo.url=bugs-global + repo.path=$PWD/repos/foo/.git + repo.desc=global bugs test + EOF +' + +test_expect_success 'bugs tab shown with global enable-bugs' ' + cgit_url "bugs-global/summary" >tmp && + grep ">bugs<" tmp +' + +test_expect_success 'repos defined before global enable-bugs do not inherit it' ' + cgit_url "foo/summary" >tmp && + ! grep ">bugs<" tmp +' + +test_expect_success 'cache-bugs-ttl parsed without error' ' + sed -i "/^enable-bugs/a cache-bugs-ttl=30" cgitrc && + cgit_url "foo/summary" >tmp && + grep "foo" tmp +' + +test_done -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 18:59:46 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:59:46 +0000 Subject: [PATCH RFC 1/3] ui-bugs: add git-bug browsing via a Lua filter In-Reply-To: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> References: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> Message-ID: <20260825-feature-git-bug-v1-1-cdc1378010af@linuxfoundation.org> Add a "bugs" page that delegates rendering to a configurable Lua filter. The filter receives the path component as its argument -- empty for the bug list, or a bug id for the detail view -- and is responsible for reading git-bug data out of the repository and producing the HTML. Keeping the reading logic outside cgit means no git-bug object format knowledge enters the C code, and the format can move without cgit following it. git-bug stores its data as ordinary git objects under refs/bugs/, so a filter using luagit2 can read it without any external database. The page is off by default and returns 403 when enable-bugs is not set, so a repository that happens to carry refs/bugs/ does not start serving a new page after an upgrade. A per-repo bugs-filter, like every other filter override, requires enable-filter-overrides. The bugs page is registered with want_vpath=0, because a bug id in the path is meaningful only to this page; letting it become the virtual path would append it to the log, tree and diff tab links. New cgitrc options: enable-bugs=1 enable the bugs tab (default: 0) bugs-filter=lua:... filter for rendering (global or per-repo) cache-bugs-ttl=15 cache TTL in minutes (default: 15) Assisted-by: LLM [codegen, review] Signed-off-by: Konstantin Ryabitsev --- cgit.c | 18 +++++++++++ cgit.css | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ cgit.h | 7 +++- cgit.mk | 1 + cgitrc.5.txt | 32 +++++++++++++++++++ cmd.c | 10 ++++++ filter.c | 1 + shared.c | 2 ++ ui-bugs.c | 30 +++++++++++++++++ ui-bugs.h | 6 ++++ ui-shared.c | 12 +++++++ ui-shared.h | 2 ++ 12 files changed, 223 insertions(+), 1 deletion(-) diff --git a/cgit.c b/cgit.c index ca318e8..e64fa5f 100644 --- a/cgit.c +++ b/cgit.c @@ -64,6 +64,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu repo->snapshots = ctx.cfg.snapshots & cgit_parse_snapshots_mask(value); else if (!strcmp(name, "enable-blame")) repo->enable_blame = atoi(value); + else if (!strcmp(name, "enable-bugs")) + repo->enable_bugs = atoi(value); else if (!strcmp(name, "enable-commit-graph")) repo->enable_commit_graph = atoi(value); else if (!strcmp(name, "enable-follow-links")) @@ -114,6 +116,8 @@ void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *valu else if (ctx.cfg.enable_filter_overrides) { if (!strcmp(name, "about-filter")) repo->about_filter = cgit_new_filter(value, ABOUT); + else if (!strcmp(name, "bugs-filter")) + repo->bugs_filter = cgit_new_filter(value, BUGS); else if (!strcmp(name, "commit-filter")) repo->commit_filter = cgit_new_filter(value, COMMIT); else if (!strcmp(name, "source-filter")) @@ -185,6 +189,8 @@ static void config_cb(const char *name, const char *value) ctx.cfg.enable_index_owner = atoi(value); else if (!strcmp(name, "enable-blame")) ctx.cfg.enable_blame = atoi(value); + else if (!strcmp(name, "enable-bugs")) + ctx.cfg.enable_bugs = atoi(value); else if (!strcmp(name, "enable-commit-graph")) ctx.cfg.enable_commit_graph = atoi(value); else if (!strcmp(name, "enable-log-filecount")) @@ -219,12 +225,16 @@ static void config_cb(const char *name, const char *value) ctx.cfg.cache_dynamic_ttl = atoi(value); else if (!strcmp(name, "cache-about-ttl")) ctx.cfg.cache_about_ttl = atoi(value); + else if (!strcmp(name, "cache-bugs-ttl")) + ctx.cfg.cache_bugs_ttl = atoi(value); else if (!strcmp(name, "cache-snapshot-ttl")) ctx.cfg.cache_snapshot_ttl = atoi(value); else if (!strcmp(name, "case-sensitive-sort")) ctx.cfg.case_sensitive_sort = atoi(value); else if (!strcmp(name, "about-filter")) ctx.cfg.about_filter = cgit_new_filter(value, ABOUT); + else if (!strcmp(name, "bugs-filter")) + ctx.cfg.bugs_filter = cgit_new_filter(value, BUGS); else if (!strcmp(name, "commit-filter")) ctx.cfg.commit_filter = cgit_new_filter(value, COMMIT); else if (!strcmp(name, "email-filter")) @@ -375,6 +385,7 @@ static void prepare_context(void) ctx.cfg.cache_max_create_time = 5; ctx.cfg.cache_root = CGIT_CACHE_ROOT; ctx.cfg.cache_about_ttl = 15; + ctx.cfg.cache_bugs_ttl = 15; ctx.cfg.cache_snapshot_ttl = 5; ctx.cfg.cache_repo_ttl = 5; ctx.cfg.cache_root_ttl = 5; @@ -816,6 +827,8 @@ static void print_repo(FILE *f, struct cgit_repo *repo) fprintf(f, "repo.clone-url=%s\n", repo->clone_url); fprintf(f, "repo.enable-blame=%d\n", repo->enable_blame); + fprintf(f, "repo.enable-bugs=%d\n", + repo->enable_bugs); fprintf(f, "repo.enable-commit-graph=%d\n", repo->enable_commit_graph); fprintf(f, "repo.enable-follow-links=%d\n", @@ -826,6 +839,8 @@ static void print_repo(FILE *f, struct cgit_repo *repo) repo->enable_log_linecount); if (repo->about_filter && repo->about_filter != ctx.cfg.about_filter) cgit_fprintf_filter(repo->about_filter, f, "repo.about-filter="); + if (repo->bugs_filter && repo->bugs_filter != ctx.cfg.bugs_filter) + cgit_fprintf_filter(repo->bugs_filter, f, "repo.bugs-filter="); if (repo->commit_filter && repo->commit_filter != ctx.cfg.commit_filter) cgit_fprintf_filter(repo->commit_filter, f, "repo.commit-filter="); if (repo->source_filter && repo->source_filter != ctx.cfg.source_filter) @@ -1033,6 +1048,9 @@ static int calc_ttl(void) if (!strcmp(ctx.qry.page, "about")) return ctx.cfg.cache_about_ttl; + if (!strcmp(ctx.qry.page, "bugs")) + return ctx.cfg.cache_bugs_ttl; + if (!strcmp(ctx.qry.page, "snapshot")) return ctx.cfg.cache_snapshot_ttl; diff --git a/cgit.css b/cgit.css index d10a24d..13ee509 100644 --- a/cgit.css +++ b/cgit.css @@ -907,3 +907,106 @@ div#cgit table.ssdiff td.space { div#cgit table.ssdiff td.space div { min-height: 3em; } + +/* bugs page */ + +div#cgit div#bugs .bug-counts { + margin: 0.5em 0 1em 0; +} + +div#cgit div#bugs .bug-counts a { + padding: 3px 10px; + border: 1px solid #d1d5da; + border-radius: 3px; + margin-right: 4px; + color: #333; +} + +div#cgit div#bugs .bug-counts a.active { + background: #333; + color: #fff; + border-color: #333; +} + +div#cgit div#bugs table.list td.id { + font-family: monospace; + white-space: nowrap; +} + +div#cgit div#bugs table.list td.comments { + text-align: center; + color: #666; + white-space: nowrap; +} + +div#cgit div#bugs table.list td.age { + white-space: nowrap; +} + +div#cgit div#bugs .bug-labels span { + display: inline-block; + padding: 0 4px; + margin: 0 1px; + background: #e4e4e4; + border: 1px solid #d1d5da; + border-radius: 3px; + font-size: 90%; +} + +div#cgit div#bugs .bug-status-open { + color: #fff; + background: #2cbe4e; + padding: 1px 6px; + border-radius: 3px; + font-weight: bold; + font-size: 90%; +} + +div#cgit div#bugs .bug-status-closed { + color: #fff; + background: #cb2431; + padding: 1px 6px; + border-radius: 3px; + font-weight: bold; + font-size: 90%; +} + +div#cgit div#bugs .comment { + border: 1px solid #d1d5da; + border-radius: 3px; + margin: 8px 0; +} + +div#cgit div#bugs .comment-header { + background: #f1f8ff; + padding: 6px 10px; + border-bottom: 1px solid #d1d5da; + color: #555; + font-size: 95%; +} + +div#cgit div#bugs .comment-header a.date { + float: right; + font-weight: normal; + color: #666; +} + +div#cgit div#bugs .comment-body { + padding: 8px 12px; + white-space: pre-wrap; + font-family: monospace; + font-size: 95%; + line-height: 1.45; +} + +div#cgit div#bugs .pager { + margin: 1em 0; +} + +div#cgit div#bugs .pager a { + padding: 3px 10px; + border: 1px solid #d1d5da; + border-radius: 3px; + margin-right: 4px; + color: #333; +} diff --git a/cgit.h b/cgit.h index 7d7ece7..310ee04 100644 --- a/cgit.h +++ b/cgit.h @@ -63,7 +63,7 @@ typedef enum { } diff_type; typedef enum { - ABOUT, COMMIT, SOURCE, EMAIL, AUTH, OWNER + ABOUT, COMMIT, SOURCE, EMAIL, AUTH, OWNER, BUGS } filter_type; struct cgit_filter { @@ -100,6 +100,7 @@ struct cgit_repo { char *snapshot_prefix; int snapshots; int enable_blame; + int enable_bugs; int enable_commit_graph; int enable_follow_links; int enable_log_filecount; @@ -112,6 +113,7 @@ struct cgit_repo { int commit_sort; time_t mtime; struct cgit_filter *about_filter; + struct cgit_filter *bugs_filter; struct cgit_filter *commit_filter; struct cgit_filter *source_filter; struct cgit_filter *email_filter; @@ -226,6 +228,7 @@ struct cgit_config { int cache_scanrc_ttl; int cache_static_ttl; int cache_about_ttl; + int cache_bugs_ttl; int cache_snapshot_ttl; int case_sensitive_sort; int embedded; @@ -235,6 +238,7 @@ struct cgit_config { int enable_index_links; int enable_index_owner; int enable_blame; + int enable_bugs; int enable_commit_graph; int enable_log_filecount; int enable_log_linecount; @@ -269,6 +273,7 @@ struct cgit_config { struct string_list mimetypes; struct string_list js; struct cgit_filter *about_filter; + struct cgit_filter *bugs_filter; struct cgit_filter *commit_filter; struct cgit_filter *source_filter; struct cgit_filter *email_filter; diff --git a/cgit.mk b/cgit.mk index 3fcc1ca..aa8faf4 100644 --- a/cgit.mk +++ b/cgit.mk @@ -79,6 +79,7 @@ CGIT_OBJ_NAMES += shared.o CGIT_OBJ_NAMES += ui-atom.o CGIT_OBJ_NAMES += ui-blame.o CGIT_OBJ_NAMES += ui-blob.o +CGIT_OBJ_NAMES += ui-bugs.o CGIT_OBJ_NAMES += ui-clone.o CGIT_OBJ_NAMES += ui-commit.o CGIT_OBJ_NAMES += ui-diff.o diff --git a/cgitrc.5.txt b/cgitrc.5.txt index 7c39bf9..9d5cc17 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -34,6 +34,15 @@ about-filter:: included verbatim on the about page. Default value: none. See also: "FILTER API". +bugs-filter:: + Specifies a command which will be invoked to render the bugs page + for repositories that use git-bug (https://github.com/git-bug/git-bug). + The filter receives the path component as its first argument: an + empty string for the bug list, or a bug ID for the detail view. + The filter is responsible for reading git-bug data from the + repository and producing HTML output. Default value: none. See + also: "enable-bugs", "FILTER API". + agefile:: Specifies a path, relative to each repository path, which can be used to specify the date and time of the youngest commit in the repository. @@ -59,6 +68,11 @@ cache-about-ttl:: version of the repository about page. See also: "CACHE". Default value: "15". +cache-bugs-ttl:: + Number which specifies the time-to-live, in minutes, for the cached + version of the repository bugs page. See also: "CACHE". Default + value: "15". + cache-dynamic-ttl:: Number which specifies the time-to-live, in minutes, for the cached version of repository pages accessed without a fixed SHA1. See also: @@ -147,6 +161,11 @@ enable-blame:: for files, and will make it generate links to that page in appropriate places. Default value: "0". +enable-bugs:: + Flag which, when set to "1", will add a "bugs" tab to the repository + navigation for browsing git-bug issues. Requires a "bugs-filter" to + be configured. Default value: "0". + enable-commit-graph:: Flag which, when set to "1", will make cgit print an ASCII-art commit history graph to the left of the commit messages in the repository @@ -491,6 +510,10 @@ repo.defbranch:: repo.desc:: The value to show as repository description. Default value: none. +repo.bugs-filter:: + Override the default bugs-filter. Default value: none. See also: + "enable-filter-overrides". See also: "FILTER API". + repo.email-filter:: Override the default email-filter. Default value: none. See also: "enable-filter-overrides". See also: "FILTER API". @@ -499,6 +522,10 @@ repo.enable-blame:: A flag which can be used to disable the global setting `enable-blame'. Default value: none. +repo.enable-bugs:: + A flag which can be used to disable the global setting + `enable-bugs'. Default value: none. + repo.enable-commit-graph:: A flag which can be used to disable the global setting `enable-commit-graph'. Default value: none. @@ -844,6 +871,11 @@ enable-index-links=1 enable-blame=1 +# Enable git-bug browsing for repos that have it +enable-bugs=1 +bugs-filter=lua:/usr/share/cgit/filters/bugs-git-bug.lua + + # Enable ASCII art commit history graph on the log pages enable-commit-graph=1 diff --git a/cmd.c b/cmd.c index 0eb75b1..73c61d0 100644 --- a/cmd.c +++ b/cmd.c @@ -13,6 +13,7 @@ #include "ui-atom.h" #include "ui-blame.h" #include "ui-blob.h" +#include "ui-bugs.h" #include "ui-clone.h" #include "ui-commit.h" #include "ui-diff.h" @@ -72,6 +73,14 @@ static void blame_fn(void) cgit_print_error_page(403, "Forbidden", "Blame is disabled"); } +static void bugs_fn(void) +{ + if (ctx.repo->enable_bugs) + cgit_print_bugs(); + else + cgit_print_error_page(403, "Forbidden", "Bug tracker view is not enabled"); +} + static void blob_fn(void) { cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head, 0); @@ -175,6 +184,7 @@ struct cgit_cmd *cgit_get_cmd(void) def_cmd(about, 0, 0, 0), def_cmd(blame, 1, 1, 0), def_cmd(blob, 1, 0, 0), + def_cmd(bugs, 1, 0, 0), def_cmd(commit, 1, 1, 0), def_cmd(diff, 1, 1, 0), def_cmd(info, 1, 0, 1), diff --git a/filter.c b/filter.c index c778d05..9a7077f 100644 --- a/filter.c +++ b/filter.c @@ -432,6 +432,7 @@ struct cgit_filter *cgit_new_filter(const char *cmd, filter_type filtertype) argument_count = 0; break; + case BUGS: case SOURCE: case ABOUT: argument_count = 1; diff --git a/shared.c b/shared.c index a39394d..2632073 100644 --- a/shared.c +++ b/shared.c @@ -62,6 +62,7 @@ struct cgit_repo *cgit_add_repo(const char *url) ret->section = ctx.cfg.section; ret->snapshots = ctx.cfg.snapshots; ret->enable_blame = ctx.cfg.enable_blame; + ret->enable_bugs = ctx.cfg.enable_bugs; ret->enable_commit_graph = ctx.cfg.enable_commit_graph; ret->enable_follow_links = ctx.cfg.enable_follow_links; ret->enable_log_filecount = ctx.cfg.enable_log_filecount; @@ -76,6 +77,7 @@ struct cgit_repo *cgit_add_repo(const char *url) ret->readme = ctx.cfg.readme; ret->mtime = -1; ret->about_filter = ctx.cfg.about_filter; + ret->bugs_filter = ctx.cfg.bugs_filter; ret->commit_filter = ctx.cfg.commit_filter; ret->source_filter = ctx.cfg.source_filter; ret->email_filter = ctx.cfg.email_filter; diff --git a/ui-bugs.c b/ui-bugs.c new file mode 100644 index 0000000..268c04a --- /dev/null +++ b/ui-bugs.c @@ -0,0 +1,30 @@ +/* ui-bugs.c: git-bug browsing via Lua filter + * + * Copyright (C) 2026 by the Linux Foundation + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#include "cgit.h" +#include "ui-bugs.h" +#include "html.h" +#include "ui-shared.h" + +void cgit_print_bugs(void) +{ + struct cgit_filter *f = ctx.repo->bugs_filter; + + if (!f) { + cgit_print_error_page(404, "Not Found", + "No bugs-filter configured for this repository"); + return; + } + + cgit_print_layout_start(); + html("
"); + cgit_open_filter(f, ctx.qry.path ? ctx.qry.path : ""); + cgit_close_filter(f); + html("
"); + cgit_print_layout_end(); +} diff --git a/ui-bugs.h b/ui-bugs.h new file mode 100644 index 0000000..ec0fdd8 --- /dev/null +++ b/ui-bugs.h @@ -0,0 +1,6 @@ +#ifndef UI_BUGS_H +#define UI_BUGS_H + +extern void cgit_print_bugs(void); + +#endif /* UI_BUGS_H */ diff --git a/ui-shared.c b/ui-shared.c index df52a9b..086e413 100644 --- a/ui-shared.c +++ b/ui-shared.c @@ -516,6 +516,12 @@ void cgit_stats_link(const char *name, const char *title, const char *class, reporevlink("stats", name, title, class, head, NULL, path); } +void cgit_bugs_link(const char *name, const char *title, const char *class, + const char *path) +{ + reporevlink("bugs", name, title, class, NULL, NULL, path); +} + static void cgit_self_link(char *name, const char *title, const char *class) { if (!strcmp(ctx.qry.page, "repolist")) @@ -567,6 +573,8 @@ static void cgit_self_link(char *name, const char *title, const char *class) else if (!strcmp(ctx.qry.page, "stats")) cgit_stats_link(name, title, class, ctx.qry.head, ctx.qry.path); + else if (!strcmp(ctx.qry.page, "bugs")) + cgit_bugs_link(name, title, class, ctx.qry.path); else { /* Don't known how to make link for this page */ repolink(title, class, ctx.qry.page, ctx.qry.head, ctx.qry.path); @@ -1107,6 +1115,10 @@ void cgit_print_pageheader(void) 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); + if (ctx.repo->enable_bugs) + reporevlink("bugs", "bugs", NULL, + hc("bugs"), ctx.qry.head, NULL, + NULL); if (ctx.repo->max_stats) cgit_stats_link("stats", NULL, hc("stats"), ctx.qry.head, ctx.qry.vpath); diff --git a/ui-shared.h b/ui-shared.h index 2a3a7f5..9d98781 100644 --- a/ui-shared.h +++ b/ui-shared.h @@ -53,6 +53,8 @@ extern void cgit_diff_link(const char *name, const char *title, extern void cgit_stats_link(const char *name, const char *title, const char *class, const char *head, const char *path); +extern void cgit_bugs_link(const char *name, const char *title, + const char *class, const char *path); extern void cgit_object_link(struct object *obj); extern void cgit_submodule_link(const char *class, char *path, -- 2.55.0 From konstantin at linuxfoundation.org Tue Aug 25 18:59:47 2026 From: konstantin at linuxfoundation.org (Konstantin Ryabitsev) Date: Tue, 25 Aug 2026 18:59:47 +0000 Subject: [PATCH RFC 2/3] filters: add an example git-bug filter In-Reply-To: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> References: <20260825-feature-git-bug-v1-0-cdc1378010af@linuxfoundation.org> Message-ID: <20260825-feature-git-bug-v1-2-cdc1378010af@linuxfoundation.org> Add filters/bugs-git-bug.lua, a working filter for the bugs page built on ezgb.lua and luagit2. It renders a list view with open/closed filtering and pagination, and a detail view with bug metadata and comments. ezgb.lua is not part of cgit. It lives under lua/ in https://git.kernel.org/pub/scm/utils/ezgb/ezgb.git, which also carries a rockspec for it. The list view uses ezgb's build_bug_summary() rather than constructing each bug in full, so identity resolution, comment text and operation hashing are not paid for bugs that are not on the requested page. Identity resolution is deferred to render time and done only for the bugs actually shown. URLs in comment text are linkified, and every comment gets a permalink anchor derived from its combined id, so a single comment can be linked to directly. Links are built from REQUEST_URI rather than assembled from configuration, so the filter behaves correctly whatever virtual-root or URL rewriting is in front of it. The styles live in cgit.css instead of being injected inline, so a deployment serving a strict Content-Security-Policy does not have to allow style-src 'unsafe-inline' for this page. The filter needs ezgb.lua, luagit2, lua-cjson (or lua-json) and luaossl. It is an example rather than a dependency: nothing in cgit requires it, and the page works with any filter that speaks the same interface. Assisted-by: LLM [codegen, review] Signed-off-by: Konstantin Ryabitsev --- filters/bugs-git-bug.lua | 384 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/filters/bugs-git-bug.lua b/filters/bugs-git-bug.lua new file mode 100644 index 0000000..47fde29 --- /dev/null +++ b/filters/bugs-git-bug.lua @@ -0,0 +1,384 @@ +-- SPDX-License-Identifier: GPL-2.0-or-later +-- Copyright (C) 2026 by the Linux Foundation +-- +-- cgit bugs-filter: renders git-bug data using ezgb.lua +-- +-- Requires: ezgb.lua, luagit2, lua-cjson (or lua-json), luaossl +-- +-- ezgb.lua is not part of cgit. It lives under lua/ in +-- https://git.kernel.org/pub/scm/utils/ezgb/ezgb.git, which also carries a +-- rockspec: "luarocks make lua-ezgb-scm-1.rockspec" from that checkout +-- installs it. +-- +-- cgitrc configuration: +-- enable-bugs=1 +-- bugs-filter=lua:/path/to/filters/bugs-git-bug.lua +-- +-- Environment variables (set by cgit): +-- CGIT_REPO_PATH - filesystem path to the repository +-- CGIT_REPO_URL - repository URL in cgit +-- QUERY_STRING - CGI query string (for pagination) + +local ezgb = require("ezgb") + +-- --- Configuration --------------------------------------------------- + +local PAGE_SIZE = 50 + +-- --- Helpers --------------------------------------------------------- + +local function html_linkify(text) + local pos = 1 + while pos <= #text do + -- Find the next URL or Message-ID, whichever comes first + local us, ue = text:find("https?://[%w%.%-_~:/?#%[%]@!$&'()*+,;=%%]+", pos) + local ms, me, msgid = text:find("Message%-ID:%s*<([^>]+)>", pos) + + if not us and not ms then + html_txt(text:sub(pos)) + break + end + + -- Pick whichever match starts first + if ms and (not us or ms < us) then + -- Message-ID match -- link just the id, not the whole line + -- Output: Message-ID: <msgid> + local angle_start = text:find("<", ms) + if angle_start > pos then + html_txt(text:sub(pos, angle_start - 1)) + end + html("<") + local url = "https://msgid.link/" .. msgid + html("") + html_txt(msgid) + html("") + html(">") + pos = me + 1 + else + -- URL match -- strip trailing punctuation + local url = text:sub(us, ue) + while url:match("[.,;:>%)%]}'\"]+$") do + url = url:sub(1, -2) + ue = ue - 1 + end + if us > pos then + html_txt(text:sub(pos, us - 1)) + end + html("") + html_txt(url) + html("") + pos = ue + 1 + end + end +end + +local function parse_query_string(qs) + local params = {} + if not qs then return params end + for k, v in qs:gmatch("([^&=]+)=([^&]*)") do + params[k] = v + end + return params +end + +local function bugs_base_url() + -- Derive the bugs/ base from the current request so that links + -- work regardless of virtual-root / URL-rewriting setup. + local uri = os.getenv("REQUEST_URI") or "" + -- Strip query string + uri = uri:gsub("%?.*$", "") + -- Ensure the URI is an absolute path to prevent scheme injection + -- (e.g. javascript: or data:) from a misconfigured proxy. + if uri:sub(1, 1) ~= "/" then uri = "/" end + -- Find the /bugs/ segment and truncate there + local base = uri:match("^(.*/bugs/)") + if base then return base end + -- Fallback: append /bugs/ to whatever we have + return uri:gsub("/$", "") .. "/bugs/" +end + +local function bugs_url(path, query) + local url = bugs_base_url() + if path and path ~= "" then + url = url .. path + end + if query and query ~= "" then + url = url .. "?" .. query + end + return url +end + +local function reltime(ts) + local diff = os.time() - ts + if diff < 60 then return "now" + elseif diff < 3600 then return string.format("%d min. ago", math.floor(diff / 60)) + elseif diff < 86400 then return string.format("%d hours ago", math.floor(diff / 3600)) + elseif diff < 2592000 then return string.format("%d days ago", math.floor(diff / 86400)) + elseif diff < 31536000 then return string.format("%d months ago", math.floor(diff / 2592000)) + else return string.format("%d years ago", math.floor(diff / 31536000)) end +end + +-- --- List view ------------------------------------------------------- + +local function render_list(params) + local show_closed = params.status == "closed" + local offset = math.max(0, tonumber(params.ofs) or 0) + + -- Build lightweight summaries for all bugs to get accurate + -- open/closed counts. Summaries are cheap (no identity + -- resolution, no comment text, no op hashing). + local refs = ezgb.list_bug_refs() + local open_refs = {} + local closed_refs = {} + for _, ref in ipairs(refs) do + local s = ezgb.build_bug_summary(ref.id) + if s then + if s.status == ezgb.STATUS_OPEN then + open_refs[#open_refs + 1] = s + else + closed_refs[#closed_refs + 1] = s + end + end + end + + -- Sort newest first + local function by_date(a, b) return a.created_at > b.created_at end + table.sort(open_refs, by_date) + table.sort(closed_refs, by_date) + + local bugs = show_closed and closed_refs or open_refs + + -- Open/closed counts + html("
") + if show_closed then + html("" .. #open_refs .. " open ") + html("" .. #closed_refs .. " closed") + else + html("" .. #open_refs .. " open ") + html("" .. #closed_refs .. " closed") + end + html("
\n") + + -- Table + html("\n") + html("") + html("") + html("") + html("") + html("") + html("") + html("") + html("\n") + + local last = math.min(offset + PAGE_SIZE, #bugs) + for i = offset + 1, last do + local bug = bugs[i] + local lbls = {} + for l in pairs(bug.labels) do lbls[#lbls + 1] = l end + table.sort(lbls) + + html("") + + html("") + + html("") + + html("") + + html("") + + html("\n") + end + html("
IdTitleAuthorAgeCommentsLabels
") + html("") + html_txt(bug.id:sub(1, 8)) + html("") + html_txt(bug.title) + html("") + local creator = ezgb.resolve_identity(bug.creator_id) + html_txt(creator.name) + html("") + html_txt(reltime(bug.created_at)) + html("") + if bug.comment_count > 0 then + html_txt(tostring(bug.comment_count)) + end + html("") + for _, l in ipairs(lbls) do + html("") + html_txt(l) + html(" ") + end + html("
\n") + + -- Pager + if #bugs > PAGE_SIZE then + html("
") + local status_q = show_closed and "status=closed&" or "" + if offset > 0 then + local prev_ofs = math.max(0, offset - PAGE_SIZE) + html("← prev ") + end + if last < #bugs then + html("next →") + end + html(" (" .. last .. "/" .. #bugs .. ")") + html("
\n") + end +end + +-- --- Detail view ----------------------------------------------------- + +local function render_detail(bid) + local bug, err = ezgb.build_bug(bid) + if not bug then + html("

Bug not found: ") + html_txt(err or bid) + html("

\n") + return + end + + html("

← back to bug list

\n") + + -- Bug info table + html("\n") + html("\n") + + html("\n") + + html("\n") + + html("\n") + + local lbls = {} + for l in pairs(bug.labels) do lbls[#lbls + 1] = l end + if #lbls > 0 then + table.sort(lbls) + html("\n") + end + + html("\n") + + if next(bug.metadata) then + for k, v in pairs(bug.metadata) do + html("\n") + end + end + html("
title") + html_txt(bug.title) + html("
status") + if bug.status == ezgb.STATUS_OPEN then + html("open") + else + html("closed") + end + html("
author") + html_txt(bug.creator.name) + if bug.creator.email ~= "" and bug.creator.email ~= bug.creator.id then + html(" <") + html_txt(bug.creator.email) + html(">") + end + html("
created") + html_txt(os.date("%Y-%m-%d %H:%M:%S", bug.created_at)) + html(" (") + html_txt(reltime(bug.created_at)) + html(")
labels") + for _, l in ipairs(lbls) do + html("") + html_txt(l) + html(" ") + end + html("
id") + html_txt(bug.id) + html("
") + html_txt(k) + html("") + html_txt(v) + html("
\n") + + -- Comments + if #bug.comments > 0 then + html("

" .. #bug.comments .. " comment") + if #bug.comments ~= 1 then html("s") end + html("

\n") + + for _, c in ipairs(bug.comments) do + local anchor = c.id:sub(1, 12) + html("
\n") + html("
") + html("") + html_txt(os.date("%Y-%m-%d %H:%M", c.created_at)) + html(" ") + html("") + html_txt(c.author.name) + html(" commented") + html("
\n") + html("
")
+            html_linkify(c.text)
+            html("
\n") + html("
\n") + end + end +end + +-- --- Filter entry points --------------------------------------------- + +local saved_path = "" + +function filter_open(path) + saved_path = path or "" +end + +function filter_close() + local repo_path = os.getenv("CGIT_REPO_PATH") + if not repo_path then + html("

CGIT_REPO_PATH not set

") + return 0 + end + local ok, err = pcall(ezgb.open, repo_path) + if not ok then + html("

Failed to open bug database: ") + html_txt(tostring(err)) + html("

") + return 0 + end + + local params = parse_query_string(os.getenv("QUERY_STRING")) + + if saved_path == "" then + render_list(params) + else + render_detail(saved_path) + end + return 0 +end + +function filter_write(str) +end -- 2.55.0 From git at linskey.org Wed Aug 26 01:40:33 2026 From: git at linskey.org (Benjamin Linskey) Date: Tue, 25 Aug 2026 21:40:33 -0400 Subject: [PATCH] Add permalink hyperlinks on tree and blame pages Message-ID: <20260826014033.14167-1-git@linskey.org> This adds a "permalink" hyperlink that links to the current page with an explicit commit hash value in the URL. This is useful when, for example, you'd like to share a link to a file from the current tip of a branch that will not change when subsequent commits are made to that branch. Signed-off-by: Benjamin Linskey --- Thanks to Trevor Gross for suggesting this feature in an earlier message on the list and providing feedback on this patch. As he pointed out to me, the permalink will not preserve the URL fragment after a line number is clicked. This could be remedied, but doing so would turn this from a trivial diff into a substantially larger set of changes, so I've elected to share this simple but still useful version for now. ui-blame.c | 8 ++++++++ ui-tree.c | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/ui-blame.c b/ui-blame.c index 5c6f36e..6c7b4ce 100644 --- a/ui-blame.c +++ b/ui-blame.c @@ -157,10 +157,18 @@ static void print_object(const struct object_id *oid, const char *path, cgit_set_title_from_path(path); cgit_print_layout_start(); + + /* Get the commit hash for use in constructing a permalink. */ + struct object_id rev_oid; + repo_get_oid(the_repository, rev, &rev_oid); + htmlf("blob: %s (", oid_to_hex(oid)); cgit_plain_link("plain", NULL, NULL, ctx.qry.head, rev, path); html(") ("); cgit_tree_link("tree", NULL, NULL, ctx.qry.head, rev, path); + html(") ("); + cgit_blame_link("permalink", NULL, NULL, ctx.qry.head, + oid_to_hex(&rev_oid), path); html(")\n"); if (buffer_is_binary(buf, size)) { diff --git a/ui-tree.c b/ui-tree.c index 5ac8c9a..e9a46f4 100644 --- a/ui-tree.c +++ b/ui-tree.c @@ -111,6 +111,11 @@ static void print_object(const struct object_id *oid, const char *path, const ch cgit_set_title_from_path(path); cgit_print_layout_start(); + + /* Get the commit hash for use in constructing a permalink. */ + struct object_id rev_oid; + repo_get_oid(the_repository, rev, &rev_oid); + htmlf("blob: %s (", oid_to_hex(oid)); cgit_plain_link("plain", NULL, NULL, ctx.qry.head, rev, path); @@ -119,6 +124,9 @@ static void print_object(const struct object_id *oid, const char *path, const ch cgit_blame_link("blame", NULL, NULL, ctx.qry.head, rev, path); } + html(") ("); + cgit_tree_link("permalink", NULL, NULL, ctx.qry.head, + oid_to_hex(&rev_oid), path); html(")\n"); if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) { -- 2.55.0 From sgothel at jausoft.com Wed Aug 26 16:22:26 2026 From: sgothel at jausoft.com (=?UTF-8?Q?Sven_G=C3=B6thel?=) Date: Wed, 26 Aug 2026 18:22:26 +0200 Subject: Patch & Discussion: Use 64-bit FNV-1a hash + proper hash-round concat + clipping In-Reply-To: <15abacec-d4b2-4a7c-b95d-c8b849deff54@jausoft.com> References: <15abacec-d4b2-4a7c-b95d-c8b849deff54@jausoft.com> Message-ID: On 8/18/26 1:38 AM, Sven G?thel wrote: > I have added the cgit-reaper here > - repo > - backup > and well described it in its README. > > Main objective for this file reaper is to allow cgit to use the full range of 64-bit FNV-1a value > to reduce collisions but limiting the maximum number of cache files to a considerably lower number. > > This works w/ my cgit patches using the 64-bit FNV-1a value and a bit more > - my branch > - backup ... bump, no interest? I saw Konstantin's patches yesterday, which touch the locking area I also have changed (EAGAIN + timeouts). Review welcome. If interested I can post the commits via email here too, but so far no feedback since June. Cheers, ~Sven From hskim at dilluti0n.com Thu Aug 27 15:58:04 2026 From: hskim at dilluti0n.com (Hee-Suk Kim) Date: Fri, 28 Aug 2026 00:58:04 +0900 Subject: How do I render a manual page from CGit's about page? Message-ID: <87zey7o8s3.fsf@dilluti0n.com> Hi all, Recently, I set up an HTTP hosting for my Git server using the cgit 1.2.3 on Debian 13. I love that the About page can render Markdown, but while looking at the about page of password-store cgit [1], I noticed it renders a man page instead. Since there's nothing but the original man/pass.1 troff file in the tree - no .md or .html files at all - it looks like cgit is doing the troff rendering. I'm curious how I can set that up! Thanks, Hee-Suk Kim [1] https://git.zx2c4.com/password-store/about/ -------------- next part -------------- A non-text attachment was scrubbed... Name: signature.asc Type: application/pgp-signature Size: 337 bytes Desc: not available URL: From dakkar at thenautilus.net Thu Aug 27 16:10:18 2026 From: dakkar at thenautilus.net (Gianni Ceccarelli) Date: Thu, 27 Aug 2026 17:10:18 +0100 Subject: How do I render a manual page from CGit's about page? In-Reply-To: <87zey7o8s3.fsf@dilluti0n.com> References: <87zey7o8s3.fsf@dilluti0n.com> Message-ID: <20260827171006.37410187@exelion> On Fri, 28 Aug 2026 00:58:04 +0900 Hee-Suk Kim wrote: > Since there's nothing but the original man/pass.1 troff file in the > tree > - no .md or .html files at all - it looks like cgit is doing the troff > rendering. I'm curious how I can set that up! Guessing: in the repository's own configuration, ``readme`` is set to ``:man/pass.1`` ("that file, from whatever commit/branch we're currently rendering") see https://git.zx2c4.com/cgit/tree/cgitrc.5.txt#n588 and ``about-filter`` is set to the path of a script that can render manpages to HTML, see https://git.zx2c4.com/cgit/tree/cgitrc.5.txt#n462 -- Dakkar - GPG public key fingerprint = A071 E618 DD2C 5901 9574 6FE2 40EA 9883 7519 3F88 key id = 0x75193F88 From sgothel at jausoft.com Sat Aug 29 18:45:31 2026 From: sgothel at jausoft.com (=?UTF-8?Q?Sven_G=C3=B6thel?=) Date: Sat, 29 Aug 2026 20:45:31 +0200 Subject: Conclusion: Bot Mitigation (timeout) etc Message-ID: <47961fe4-80b1-40a4-b83a-0cb88eeffe7e@jausoft.com> I have concluded my DoS Bot Mitigation and added a cgit section here See the Haskell/Lightpanda incident for example . Notable: Most of these bots go straight to the cgit pages, millions of bots! W/o mitigation, I could have just ended my few server. It reflects my last cgit commits using SIGALRM to counter the lack of signal propagation from Apache2 to cgit under suEXEC. To block a bot-attack, I set Apache2 Timeout=10 and cgit timeout=8 (or roughly around these). - my branch - backup The SIGALRM AS-Safe log messages exposes either high git processing time under heavy bot-load or a 'slow-attack' bot/client (doubt anybody uses a dial-up modem today). I have further added an early out for max-blob-size, before reading the git data to save I/O load. The previous added mitigations/patches regarding lock-failure, io-idle-timeout, io-min-rate and the 64-bit RNV-1a hash (+ using cgit-reaper) also helps against (slow-)attacks. Find cgit-reaper here - repo - backup Main objective for this file reaper is to allow cgit to use the full range of 64-bit FNV-1a value to reduce collisions but limiting the maximum number of cache files to a considerably lower number. This works w/ my cgit patches using the 64-bit FNV-1a value and a bit more - my branch - backup Cheers, ~Sven From sgothel at jausoft.com Sat Aug 29 18:50:56 2026 From: sgothel at jausoft.com (=?UTF-8?Q?Sven_G=C3=B6thel?=) Date: Sat, 29 Aug 2026 20:50:56 +0200 Subject: Conclusion: Bot Mitigation (timeout) etc In-Reply-To: <47961fe4-80b1-40a4-b83a-0cb88eeffe7e@jausoft.com> References: <47961fe4-80b1-40a4-b83a-0cb88eeffe7e@jausoft.com> Message-ID: <4a721493-2b08-46b3-a9e7-9ff102bde8e1@jausoft.com> Sorry for the last duplicated paragraph, a copy/paste human error. I do not use AI in my work here! ~Sven -------------- next part -------------- A non-text attachment was scrubbed... Name: OpenPGP_signature.asc Type: application/pgp-signature Size: 833 bytes Desc: OpenPGP digital signature URL: From sgothel at jausoft.com Sat Aug 29 22:13:28 2026 From: sgothel at jausoft.com (=?UTF-8?Q?Sven_G=C3=B6thel?=) Date: Sun, 30 Aug 2026 00:13:28 +0200 Subject: How do I render a manual page from CGit's about page? In-Reply-To: <87zey7o8s3.fsf@dilluti0n.com> References: <87zey7o8s3.fsf@dilluti0n.com> Message-ID: <59403be5-9830-4cd6-aabf-a7928192722e@jausoft.com> I use pandoc, see my style-sheet and integration here backup Cheers ~Sven