diff options
context:
space:
mode:
-rw-r--r--custom/extensions/link-trailers.lua109
-rw-r--r--tests/extensions/test-link-trailers.lua66
-rwxr-xr-xtests/t0506-link-trailers.sh70
3 files changed, 245 insertions, 0 deletions
diff --git a/custom/extensions/link-trailers.lua b/custom/extensions/link-trailers.lua
new file mode 100644
index 0000000..645c517
--- /dev/null
+++ b/custom/extensions/link-trailers.lua
@@ -0,0 +1,109 @@
+-- cgit trailer-filter that turns the value of a commit trailer into a link,
+-- named by the trailer-filter or repo.trailer-filter setting in cgitrc. cgit
+-- opens it once per trailer with the trailer key and the page name as its
+-- arguments and hands over the value already HTML-escaped, so all this does
+-- is wrap the value, or the part of it a rule captures, in an anchor. Runs on
+-- Lua 5.1 through 5.4 and LuaJIT with nothing outside the standard library.
+--
+-- trailer-filter=lua:/path/to/link-trailers.lua
+--
+-- Trailers whose value is a person, such as Signed-off-by, never reach this
+-- filter. cgit writes those through the email filter instead.
+
+
+-- Rules are tried in order and the first whose key matches the trailer, case
+-- insensitively, and whose pattern matches the value wins. Each pattern is a
+-- Lua pattern with a single capture and a URL where %s is replaced by that
+-- capture, percent-encoded. The matched run is what gets wrapped and the
+-- capture is only what goes into the URL. A rule without a key applies to
+-- every trailer.
+--
+-- Lua patterns are not regular expressions. The reference is
+-- https://www.lua.org/manual/5.1/manual.html#5.4.1
+--
+-- Patterns run against the escaped value, so match the entity spellings
+-- '&', '<' and '>' rather than the bare characters, and keep a
+-- pattern from ending part way through one.
+local rules = {
+ -- Fixes: 1234567 ("subject") and Reverts: 1234567 link the object name
+ -- to its commit page. The relative form is resolved against the
+ -- current page and works for the common virtual-root layout.
+ { key = "Fixes", pattern = "^(%x%x%x%x%x%x%x+)", url = "./?id=%s" },
+ { key = "Reverts", pattern = "^(%x%x%x%x%x%x%x+)", url = "./?id=%s" },
+ -- Closes: #123 and Bug: 123 point at the tracker.
+ { key = "Closes", pattern = "^#?(%d+)$", url = "https://bugs.example.com/?bug=%s" },
+ { key = "Bug", pattern = "^#?(%d+)$", url = "https://bugs.example.com/?bug=%s" },
+ -- { key = "CVE", pattern = "^CVE%-(%d%d%d%d%-%d+)$",
+ -- url = "https://www.cve.org/CVERecord?id=CVE-%s" },
+}
+
+-- A value that is nothing but one http or https URL, under any key, links to
+-- itself. Set false to leave such values as text.
+local link_urls = true
+
+
+local key = ""
+local chunks = {}
+
+-- Percent-encode everything but the URL unreserved characters, so a captured
+-- value cannot break out of the href attribute or out of the URL itself.
+local function url_encode(s)
+ return (string.gsub(s, "[^%w._~-]", function(c)
+ return string.format("%%%02X", string.byte(c))
+ end))
+end
+
+-- Build one anchor from a URL template holding %s and the text to show. The
+-- replacement is a function so that a '%' in the encoded value is not taken
+-- for a gsub reference.
+local function make_link(url_template, capture, display)
+ local encoded = url_encode(capture)
+ local href = string.gsub(url_template, "%%s", function()
+ return encoded
+ end)
+ return "<a href='" .. href .. "'>" .. display .. "</a>"
+end
+
+-- The value is already escaped, which is also what an attribute wants, so a
+-- URL only has to be kept away from the quote that closes the attribute.
+local function link_url(text)
+ if not link_urls or not string.find(text, "^https?://[^%s'\"]+$") then
+ return nil
+ end
+ return "<a href='" .. text .. "'>" .. text .. "</a>"
+end
+
+local function link_rule(text)
+ local wanted = string.lower(key)
+ for _, rule in ipairs(rules) do
+ if rule.key == nil or string.lower(rule.key) == wanted then
+ -- A malformed pattern is an operator error, so skip
+ -- that rule rather than fail the whole page.
+ local ok, start, stop, capture = pcall(string.find, text, rule.pattern)
+ if ok and start then
+ if capture == nil then
+ capture = string.sub(text, start, stop)
+ end
+ return string.sub(text, 1, start - 1) ..
+ make_link(rule.url, capture, string.sub(text, start, stop)) ..
+ string.sub(text, stop + 1)
+ end
+ end
+ end
+ return nil
+end
+
+function filter_open(trailer_key, page)
+ key = trailer_key or ""
+ chunks = {}
+end
+
+function filter_write(str)
+ chunks[#chunks + 1] = str
+end
+
+function filter_close()
+ local text = table.concat(chunks)
+ html(link_url(text) or link_rule(text) or text)
+ return 0
+end
diff --git a/tests/extensions/test-link-trailers.lua b/tests/extensions/test-link-trailers.lua
new file mode 100644
index 0000000..c879340
--- /dev/null
+++ b/tests/extensions/test-link-trailers.lua
@@ -0,0 +1,66 @@
+-- Unit checks for custom/extensions/link-trailers.lua, run under a standalone
+-- Lua by t0506-link-trailers.sh with the script path as the argument. The
+-- shipped configuration links Fixes and Reverts object names to ./?id=,
+-- Closes and Bug marks to bugs.example.com and a bare URL to itself, and
+-- everything here checks that shape, the key matching and the values that
+-- must pass through untouched.
+
+local test_directory = arg[0]:match("^(.*)/") or "."
+local h = dofile(test_directory .. "/harness.lua")
+
+h.load(arg[1])
+
+local function render(key, text)
+ return h.run({ key, "commit" }, { text })
+end
+
+local out, ret = render("Fixes", "1234567 (\"plain commit\")")
+h.contains("a Fixes object name links to its commit", out,
+ "<a href='./?id=1234567'>1234567</a> (\"plain commit\")")
+h.equals("close answers zero", ret, 0)
+
+out = render("fixes", "1234567")
+h.contains("the key matches case insensitively", out, "<a href='./?id=1234567'>1234567</a>")
+
+out = render("Fixes", "123456")
+h.excludes("six hex characters stay text", out, "<a")
+
+out = render("Reverts", "cafebabe")
+h.contains("a Reverts object name links too", out, "<a href='./?id=cafebabe'>cafebabe</a>")
+
+out = render("Closes", "#45")
+h.equals("a Closes mark links to the tracker", out,
+ "<a href='https://bugs.example.com/?bug=45'>#45</a>")
+
+out = render("Bug", "45")
+h.equals("a bare Bug number links to the tracker", out,
+ "<a href='https://bugs.example.com/?bug=45'>45</a>")
+
+out = render("Closes", "not a number")
+h.equals("a Closes value that is no number stays text", out, "not a number")
+
+out = render("Link", "https://example.com/?a=1&amp;b=2")
+h.equals("a bare URL links to itself with its escaping kept", out,
+ "<a href='https://example.com/?a=1&amp;b=2'>https://example.com/?a=1&amp;b=2</a>")
+
+out = render("Whatever", "https://example.com/x")
+h.contains("a URL links under any key", out, "<a href='https://example.com/x'>")
+
+out = render("Link", "https://example.com/it's")
+h.equals("a URL holding a quote stays text", out, "https://example.com/it's")
+
+out = render("Link", "see https://example.com/x")
+h.equals("a URL inside other text stays text", out, "see https://example.com/x")
+
+out = render("Reported-by", "someone")
+h.equals("a key with no rule passes through", out, "someone")
+
+out = h.run({ "Fixes", "commit" }, { "12345", "67 tail" })
+h.contains("a value split across writes is seen whole", out,
+ "<a href='./?id=1234567'>1234567</a> tail")
+
+out, ret = render("Fixes", "")
+h.equals("an empty value stays empty", out, "")
+h.equals("an empty value still answers zero", ret, 0)
+
+h.finish()
diff --git a/tests/t0506-link-trailers.sh b/tests/t0506-link-trailers.sh
new file mode 100755
index 0000000..d397309
--- /dev/null
+++ b/tests/t0506-link-trailers.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+
+# Checks link-trailers.lua, the shipped trailer-filter, whose configuration as
+# shipped links Fixes and Reverts object names to ./?id=, Closes and Bug marks
+# to bugs.example.com and a bare URL to itself. The unit checks cover the rules
+# under every suitable standalone Lua, and a commit carrying one of each is
+# then rendered through cgit itself.
+
+test_description='Check the shipped link-trailers extension'
+CGIT_TEST_NO_CREATE_REPOS=YesPlease
+. ./setup.sh
+. "$TEST_OUTPUT_DIRECTORY/extensions/lib.sh"
+
+interpreters=$(ext_lua_interpreters 4)
+test -z "$interpreters" &&
+ say 'no standalone lua on the path, unit checks skipped'
+
+for lua in $interpreters
+do
+ test_expect_success "unit checks under $lua" "
+ '$lua' '$EXT_TEST_DIRECTORY/test-link-trailers.lua' '$EXTENSIONS_DIRECTORY/link-trailers.lua'
+ "
+done
+
+test "$CGIT_HAS_LUA" -eq 1 && test_set_prereq CGIT_LUA
+test "$CGIT_HAS_LUA" -eq 1 ||
+ say 'cgit built without lua, filter checks through cgit skipped'
+
+test_expect_success 'create a repository whose commit carries linkable trailers' '
+ test_create_repo repos/linky &&
+ (
+ cd repos/linky &&
+ echo content >file &&
+ git add file &&
+ git commit -F - <<-\EOF
+ fix it
+
+ Fixes: 1234567 ("earlier")
+ Link: https://example.com/?a=1&b=2
+ Closes: #45
+ EOF
+ )
+'
+
+test_expect_success 'point cgit at it through the trailer filter' '
+ cat >cgitrc <<-EOF
+ virtual-root=/
+ cache-size=0
+ enable-trailers=1
+ enable-filter-overrides=1
+ repo.url=linky
+ repo.path=$PWD/repos/linky/.git
+ repo.trailer-filter=lua:$EXTENSIONS_DIRECTORY/link-trailers.lua
+ EOF
+'
+
+test_expect_success CGIT_LUA 'the Fixes object name links to its commit page' '
+ cgit_url "linky/commit/" >tmp &&
+ grep "<th>Fixes</th><td><a href=....id=1234567.>1234567</a> (.earlier.)</td>" tmp
+'
+
+test_expect_success CGIT_LUA 'the bare URL links to itself' '
+ grep "<a href=.https://example.com/?a=1&amp;b=2.>https://example.com/?a=1&amp;b=2</a>" tmp
+'
+
+test_expect_success CGIT_LUA 'the Closes mark links out' '
+ grep "bugs.example.com/?bug=45.>#45</a>" tmp
+'
+
+test_done