Commit Diff


commit - 52ba27b372c79f398a374541cae9f306284131c7
commit + eaecb958183f6eb4fdb79b3258d465a4fd8cb72e
blob - /dev/null
blob + 91d341dc319c3e0475087f52ae0c6e121584398a (mode 644)
--- /dev/null
+++ regress/gotwebd/Makefile
@@ -0,0 +1,90 @@
+.PATH:${.CURDIR}/../../lib
+
+REGRESS_TARGETS=test_gotwebd
+
+PROG = gotwebd_test
+SRCS = gotwebd_test.c error.c hash.c pollfd.c
+
+CPPFLAGS = -I${.CURDIR}/../../include -I${.CURDIR}/../../lib
+
+NOMAN = yes
+
+NOOBJ=Yes
+
+.PHONY: ensure_root prepare_test_env prepare_test_repo start_gotwebd
+
+GOTWEBD_TEST_TMPDIR=/tmp
+GOTWEBD_TEST_ROOT?!!=mktemp -d "${GOTWEBD_TEST_TMPDIR}/gotwebd-test-XXXXXXXXXX"
+GOTWEBD_TEST_CHROOT=${GOTWEBD_TEST_ROOT}/var/www
+GOTWEBD_TEST_CONF=${GOTWEBD_TEST_ROOT}/gotwebd.conf
+GOTWEBD_TEST_SOCK=${GOTWEBD_TEST_CHROOT}/gotweb.sock
+GOTWEBD_TEST_FCGI=${.OBJDIR}/${PROG}
+
+GOTWEBD_TEST_USER?=${DOAS_USER}
+.if empty(GOTWEBD_TEST_USER)
+GOTWEBD_TEST_USER=${SUDO_USER}
+.endif
+.if empty(GOTWEBD_TEST_USER)
+GOTWEBD_TEST_USER=${USER}
+.endif
+
+GOTWEBD_TEST_USER_HOME!=getent passwd ${GOTWEBD_TEST_USER} | cut -d: -f6
+
+PREFIX ?= /usr/local
+BINDIR ?= ${PREFIX}/sbin
+
+GOTWEBD_START_CMD?=${BINDIR}/gotwebd -vvf ${GOTWEBD_TEST_CONF}
+GOTWEBD_STOP_CMD?=pkill -TERM -fx '${GOTWEBD_START_CMD}'
+GOTWEBD_TRAP=trap "${GOTWEBD_STOP_CMD}" HUP INT QUIT PIPE TERM
+
+GOTWEBD_TEST_ENV=GOTWEBD_TEST_SOCK=${GOTWEBD_TEST_SOCK} \
+	GOTWEBD_TEST_CHROOT=${GOTWEBD_TEST_CHROOT} \
+	GOTWEBD_TEST_ROOT=${GOTWEBD_TEST_ROOT} \
+	GOTWEBD_TEST_CONF=${GOTWEBD_TEST_CONF} \
+	GOTWEBD_TEST_USER=${GOTWEBD_TEST_USER} \
+	GOTWEBD_TEST_FCGI=${GOTWEBD_TEST_FCGI} \
+	GOTWEBD_TEST_FCGI=${GOTWEBD_TEST_FCGI} \
+	PATH=$(GOTWEBD_TEST_USER_HOME)/bin:${PATH} \
+	HOME=$(GOTWEBD_TEST_USER_HOME)
+
+ensure_root:
+	@if [[ `id -u` -ne 0 ]]; then \
+		echo gotwebd test suite must be started by root >&2; \
+		false; \
+	fi ; \
+	if [[ "${GOTWEBD_TEST_USER}" = "root" ]]; then \
+		echo GOTWEBD_TEST_USER must be a non-root user >&2; \
+		false; \
+	fi
+
+gotwebd_libexec:
+	@su -m ${GOTWEBD_TEST_USER} -c \
+	    '${MAKE} -C ${.CURDIR}/../../gotwebd/libexec' >/dev/null 2>&1
+
+prepare_test_env: gotwebd_libexec ensure_root
+	@mkdir -p "${GOTWEBD_TEST_CHROOT}"
+	@DESTDIR=${GOTWEBD_TEST_ROOT} \
+	    ${MAKE} -C ${.CURDIR}/../../gotwebd/libexec install >/dev/null 2>&1
+	@chown ${GOTWEBD_TEST_USER} "${GOTWEBD_TEST_ROOT}" \
+	    "${GOTWEBD_TEST_CHROOT}"
+
+prepare_test_repo: prepare_test_env
+	@su -m ${GOTWEBD_TEST_USER} -c 'env ${GOTWEBD_TEST_ENV} \
+	    sh ./prepare_test_repo.sh "${GOTWEBD_TEST_CHROOT}"'
+
+start_gotwebd: prepare_test_repo gotwebd_test
+	@echo 'user "${GOTWEBD_TEST_USER}"' > ${GOTWEBD_TEST_CONF}
+	@echo 'chroot "${GOTWEBD_TEST_CHROOT}"' >> ${GOTWEBD_TEST_CONF}
+	@echo 'listen on socket "${GOTWEBD_TEST_SOCK}"' >> ${GOTWEBD_TEST_CONF}
+	@echo 'server "localhost" {' >> ${GOTWEBD_TEST_CONF}
+	@echo '    show_repo_owner off' >> ${GOTWEBD_TEST_CONF}
+	@echo '}' >> ${GOTWEBD_TEST_CONF}
+	@${GOTWEBD_TRAP}; ${GOTWEBD_START_CMD}
+	@${GOTWEBD_TRAP}; sleep .5
+
+test_gotwebd: start_gotwebd
+	@-$(GOTWEBD_TRAP); su -m ${GOTWEBD_TEST_USER} -c \
+		'env $(GOTWEBD_TEST_ENV) sh ./test_gotwebd.sh'
+	@${GOTWEBD_STOP_CMD} 2>/dev/null
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 681cedc2e3bbeb0d09075359cb45e7be7e8a45df (mode 644)
--- /dev/null
+++ regress/gotwebd/action_commit.html
@@ -0,0 +1,32 @@
+Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none';

+Content-Type: text/html

+

+<!doctype html><html><head><meta charset="utf-8" /><title>Gotweb</title><meta name="viewport" content="initial-scale=1.0" /><meta name="msapplication-TileColor" content="#da532c" /><meta name="theme-color" content="#ffffff"/><link rel="apple-touch-icon" sizes="180x180" href="/gotwebd_test_harness/apple-touch-icon.png" /><link rel="icon" type="image/png" sizes="32x32" href="/gotwebd_test_harness/favicon-32x32.png" /><link rel="icon" type="image/png" sizes="16x16" href="/gotwebd_test_harness/favicon-16x16.png" /><link rel="manifest" href="/gotwebd_test_harness/site.webmanifest"/><link rel="mask-icon" href="/gotwebd_test_harness/safari-pinned-tab.svg" /><link rel="stylesheet" type="text/css" href="/gotwebd_test_harness/gotweb.css" /></head><body><header id="header"><div id="got_link"><a href="https://gameoftrees.org" target="_blank"><img src="/gotwebd_test_harness/got.png" /></a></div></header><nav id="site_path"><div id="site_link"><a href="?index_page=0">Repos</a> / <a href="?action=summary&path=repo.git">repo.git</a> / diff</div></nav><main class="action-diff"><header class="subtitle"><h2>Commit Diff</h2></header><div id="diff_content"><div class="page_header_wrapper"><dl><dt>Commit:</dt><dd><code class="commit-id">${COMMIT_ID}</code></dd><dt>From:</dt><dd>${COMMITTER} &lt;${COMMITTER_EMAIL}&gt;</dd><dt>Date:</dt><dd><time datetime="${COMMIT_YMDHMS}">${COMMIT_DATE}
+ UTC</time></dd><dt>Message:</dt><dd class="commit-msg">import the test tree
+</dd><dt>Actions:</dt><dd><a href="?action=patch&commit=${COMMIT_ID}&path=repo.git">Patch</a> | <a href="?action=tree&commit=${COMMIT_ID}&path=repo.git">Tree</a></dd></dl></div><hr /><pre id="diff"><span class="diff_line diff_meta">commit - /dev/null</span>
+<span class="diff_line diff_meta">commit + ${COMMIT_ID}</span>
+<span class="diff_line diff_meta">blob - /dev/null</span>
+<span class="diff_line diff_meta">blob + ${BLOB_ALPHA} (mode 644)</span>
+<span class="diff_line diff_minus">--- /dev/null</span>
+<span class="diff_line diff_plus">+++ alpha</span>
+<span class="diff_line diff_chunk_header">@@ -0,0 +1 @@</span>
+<span class="diff_line diff_plus">+alpha</span>
+<span class="diff_line diff_meta">blob - /dev/null</span>
+<span class="diff_line diff_meta">blob + ${BLOB_BETA} (mode 644)</span>
+<span class="diff_line diff_minus">--- /dev/null</span>
+<span class="diff_line diff_plus">+++ beta</span>
+<span class="diff_line diff_chunk_header">@@ -0,0 +1 @@</span>
+<span class="diff_line diff_plus">+beta</span>
+<span class="diff_line diff_meta">blob - /dev/null</span>
+<span class="diff_line diff_meta">blob + ${BLOB_ZETA} (mode 644)</span>
+<span class="diff_line diff_minus">--- /dev/null</span>
+<span class="diff_line diff_plus">+++ epsilon/zeta</span>
+<span class="diff_line diff_chunk_header">@@ -0,0 +1 @@</span>
+<span class="diff_line diff_plus">+zeta</span>
+<span class="diff_line diff_meta">blob - /dev/null</span>
+<span class="diff_line diff_meta">blob + ${BLOB_DELTA} (mode 644)</span>
+<span class="diff_line diff_minus">--- /dev/null</span>
+<span class="diff_line diff_plus">+++ gamma/delta</span>
+<span class="diff_line diff_chunk_header">@@ -0,0 +1 @@</span>
+<span class="diff_line diff_plus">+delta</span>
+</pre></div></main><footer id="site_owner_wrapper"><p id="site_owner">Got Owner</p></footer></body></html>

blob - /dev/null
blob + b976197617d10a91b220cac19df18a839c0c317b (mode 644)
--- /dev/null
+++ regress/gotwebd/action_index.html
@@ -0,0 +1,5 @@
+Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none';

+Content-Type: text/html

+

+<!doctype html><html><head><meta charset="utf-8" /><title>Gotweb</title><meta name="viewport" content="initial-scale=1.0" /><meta name="msapplication-TileColor" content="#da532c" /><meta name="theme-color" content="#ffffff"/><link rel="apple-touch-icon" sizes="180x180" href="/gotwebd_test_harness/apple-touch-icon.png" /><link rel="icon" type="image/png" sizes="32x32" href="/gotwebd_test_harness/favicon-32x32.png" /><link rel="icon" type="image/png" sizes="16x16" href="/gotwebd_test_harness/favicon-16x16.png" /><link rel="manifest" href="/gotwebd_test_harness/site.webmanifest"/><link rel="mask-icon" href="/gotwebd_test_harness/safari-pinned-tab.svg" /><link rel="stylesheet" type="text/css" href="/gotwebd_test_harness/gotweb.css" /></head><body><header id="header"><div id="got_link"><a href="https://gameoftrees.org" target="_blank"><img src="/gotwebd_test_harness/got.png" /></a></div></header><nav id="site_path"><div id="site_link"><a href="?index_page=0">Repos</a> / <a href="?action=summary&path=repo.git">repo.git</a> / summary</div></nav><main class="action-summary"><dl id="summary_wrapper" class="page_header_wrapper"><dt>Description:</dt><dd>Unnamed repository; edit this file &apos;description&apos; to name the repository.
+</dd><dt>Last Change:</dt><dd><time datetime="${COMMIT_YMDHMS}">right now</time></dd><dt>Clone URL:</dt><dd><pre class="clone-url"></pre></dd></dl><div class="summary-briefs"><header class='subtitle'><h2>Commit Briefs</h2></header><div id="briefs_content"><div class='brief'><p class='brief_meta'><span class='briefs_age'><time datetime="${COMMIT_YMDHMS}">right now</time></span> <span class='briefs_id'>${COMMIT_ID10}</span> <span class="briefs_author">Flan Hacker </span></p><p class="briefs_log"><a href="?action=diff&commit=${COMMIT_ID}&headref=HEAD&path=repo.git">import the test tree</a> <span class="refs_str">(main)</span></p></div><div class="navs_wrapper"><div class="navs"><a href="?action=diff&commit=${COMMIT_ID}&headref=HEAD&path=repo.git">diff</a> | <a href="?action=patch&commit=${COMMIT_ID}&headref=HEAD&path=repo.git">patch</a> | <a href="?action=tree&commit=${COMMIT_ID}&headref=HEAD&path=repo.git">tree</a></div></div><hr /></div></div><div class="summary-branches"><header class='subtitle'><h2>Branches</h2></header><div id="branches_content"><section class="branches_wrapper"><div class="branches_age"><time datetime="${COMMIT_YMDHMS}">right now</time></div><div class="branch"><a href="?action=summary&headref=main&path=repo.git">main</a></div><div class="navs_wrapper"><div class="navs"><a href="?action=summary&headref=main&path=repo.git">summary</a> | <a href="?action=briefs&headref=main&path=repo.git">commit briefs</a> | <a href="?action=commits&headref=main&path=repo.git">commits</a></div></div><hr /></section></div></div><div class="summary-tags"><header class='subtitle'><h2>Tags</h2></header><div id="tags_content"><div id="err_content">This repository contains no tags</div></div></div><div class="summary-tree"><header class='subtitle'><h2>Tree</h2></header><div id="tree_content"><table id="tree"><tr class="tree_wrapper"><td class="tree_line"><a href="?action=blob&commit=${COMMIT_ID}&file=alpha&folder=&path=repo.git">alpha</a></td><td class="tree_line_blank"><a href="?action=commits&commit=${COMMIT_ID}&file=alpha&folder=&path=repo.git">commits</a> | <a href="?action=blame&commit=${COMMIT_ID}&file=alpha&folder=&path=repo.git">blame</a></td></tr><tr class="tree_wrapper"><td class="tree_line"><a href="?action=blob&commit=${COMMIT_ID}&file=beta&folder=&path=repo.git">beta</a></td><td class="tree_line_blank"><a href="?action=commits&commit=${COMMIT_ID}&file=beta&folder=&path=repo.git">commits</a> | <a href="?action=blame&commit=${COMMIT_ID}&file=beta&folder=&path=repo.git">blame</a></td></tr><tr class="tree_wrapper"><td class="tree_line" colspan=2><a href="?action=tree&commit=${COMMIT_ID}&folder=%2Fepsilon&path=repo.git">epsilon/</a></td></tr><tr class="tree_wrapper"><td class="tree_line" colspan=2><a href="?action=tree&commit=${COMMIT_ID}&folder=%2Fgamma&path=repo.git">gamma/</a></td></tr></table></div></div></main><footer id="site_owner_wrapper"><p id="site_owner">Got Owner</p></footer></body></html>

blob - /dev/null
blob + d7ef5732c24f80f6d83250e1af12fa4f13c315c9 (mode 644)
--- /dev/null
+++ regress/gotwebd/common.sh
@@ -0,0 +1,103 @@
+#!/bin/sh
+#
+# Copyright (c) 2019, 2020 Stefan Sperling <stsp@openbsd.org>
+# Copyright (c) 2024 Mark Jamsek <mark@jamsek.dev>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+
+interpolate()
+{
+	perl -p -e \
+	    's/\$\{(\w+)\}/(exists $ENV{$1} ? $ENV{$1} : "UNDEFINED $1")/eg' \
+	    < "$1"
+}
+
+test_cleanup()
+{
+	local testroot="$1"
+	local repo="$2"
+
+	if [ -n "$repo" ]; then
+		git_fsck $testroot $repo
+		ret=$?
+		if [ $ret -ne 0 ]; then
+			return $ret
+		fi
+	fi
+
+	rm -rf "$testroot"
+}
+
+test_done()
+{
+	local testroot="$1"
+	local repo="$2"
+	local result="$3"
+
+	if [ "$result" = "0" ]; then
+		test_cleanup "$testroot" "$repo" || return 1
+		if [ -z "$GOT_TEST_QUIET" ]; then
+			echo "ok"
+		fi
+	elif echo "$result" | grep -q "^xfail"; then
+		# expected test failure; test reproduces an unfixed bug
+		echo "$result"
+		test_cleanup "$testroot" "$repo" || return 1
+	else
+		echo "test failed; leaving test data in $testroot"
+	fi
+}
+
+test_init()
+{
+	local testname="$1"
+	local no_repo="$2"
+
+	if [ -z "$testname" ]; then
+		echo "No test name provided" >&2
+		return 1
+	fi
+
+	local testroot=$(mktemp -d \
+	    "$GOTWEBD_TEST_ROOT/gotwebd-test-$testname-XXXXXXXXXX")
+
+	if [ -z "$no_repo" ]; then
+		mkdir $testroot/repo
+		git_init $testroot/repo
+		make_test_tree $testroot/repo
+		git -C $repo add .
+		git_commit $testroot/repo -m "adding the test tree"
+	fi
+
+	echo "$testroot"
+}
+
+run_test()
+{
+	testfunc="$1"
+
+	if [ -n "$regress_run_only" ]; then
+		case "$regress_run_only" in
+		*$testfunc*) ;;
+		*) return ;;
+		esac
+	fi
+
+	if [ -z "$GOT_TEST_QUIET" ]; then
+		echo -n "$testfunc "
+	fi
+
+	$testfunc
+}
blob - /dev/null
blob + c860ec205f79a0008dd231920f23f8fb1ba69716 (mode 644)
--- /dev/null
+++ regress/gotwebd/gotwebd_test.c
@@ -0,0 +1,455 @@
+/*
+ * Copyright (c) 2024 Mark Jamsek <mark@jamsek.dev>
+ * Copyright (c) 2014 Florian Obser <florian@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/socket.h>
+#include <sys/un.h>
+
+#include <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "got_error.h"
+#include "got_lib_poll.h"
+
+#ifndef nitems
+#define nitems(_a)	(sizeof((_a)) / sizeof((_a)[0]))
+#endif
+
+#define GOTWEBD_TEST_HARNESS		"gotwebd_test_harness"
+
+/*
+ * Socket path should be passed on the command line or set as an envvar.
+ * Query string and request method can be passed on the command line;
+ * if not provided, use the index summary page and GET request method.
+ */
+#define GOTWEBD_TEST_QUERYSTRING	"action=summary&path=repo.git"
+
+#define GOTWEBD_TEST_PATH_INFO		"/"GOTWEBD_TEST_HARNESS"/"
+#define GOTWEBD_TEST_REMOTE_ADDR	"::1"
+#define GOTWEBD_TEST_REMOTE_PORT	"32768"
+#define GOTWEBD_TEST_SERVER_ADDR	"::1"
+#define GOTWEBD_TEST_SERVER_PORT	"80"
+#define GOTWEBD_TEST_SERVER_NAME	"gotwebd"
+#define GOTWEBD_TEST_SCRIPT_NAME	GOTWEBD_TEST_HARNESS
+#define GOTWEBD_TEST_REQUEST_URI	"/"GOTWEBD_TEST_HARNESS"/"
+#define GOTWEBD_TEST_DOCUMENT_URI	"/"GOTWEBD_TEST_HARNESS"/"
+#define GOTWEBD_TEST_DOCUMENT_ROOT	"/cgi-bin/"GOTWEBD_TEST_HARNESS
+#define GOTWEBD_TEST_REQUEST_METHOD	"GET"
+#define GOTWEBD_TEST_SCRIPT_FILENAME	"/cgi-bin/"GOTWEBD_TEST_HARNESS
+#define GOTWEBD_TEST_SERVER_PROTOCOL	"HTTP/1.1"
+#define GOTWEBD_TEST_SERVER_SOFTWARE	GOTWEBD_TEST_HARNESS
+#define GOTWEBD_TEST_GATEWAY_INTERFACE	"CGI/1.1"
+
+#define PARAM(_p)	{ #_p, GOTWEBD_TEST_##_p }
+
+static const char *mock_params[][2] = {
+	PARAM(PATH_INFO),
+	PARAM(REMOTE_ADDR),
+	PARAM(REMOTE_PORT),
+	PARAM(SERVER_ADDR),
+	PARAM(SERVER_PORT),
+	PARAM(SERVER_NAME),
+	PARAM(SCRIPT_NAME),
+	PARAM(REQUEST_URI),
+	PARAM(DOCUMENT_URI),
+	PARAM(DOCUMENT_ROOT),
+	PARAM(REQUEST_METHOD),
+	PARAM(SCRIPT_FILENAME),
+	PARAM(SERVER_PROTOCOL),
+	PARAM(SERVER_SOFTWARE),
+	PARAM(GATEWAY_INTERFACE)
+};
+
+#undef PARAM
+
+#define FCGI_CONTENT_SIZE	65535
+#define FCGI_PADDING_SIZE	255
+#define FCGI_RECORD_SIZE	\
+    (sizeof(struct fcgi_record_header) + FCGI_CONTENT_SIZE + FCGI_PADDING_SIZE)
+
+#define FCGI_BEGIN_REQUEST	1
+#define FCGI_ABORT_REQUEST	2
+#define FCGI_END_REQUEST	3
+#define FCGI_PARAMS		4
+#define FCGI_STDIN		5
+#define FCGI_STDOUT		6
+#define FCGI_STDERR		7
+#define FCGI_DATA		8
+#define FCGI_GET_VALUES		9
+#define FCGI_GET_VALUES_RESULT	10
+#define FCGI_UNKNOWN_TYPE	11
+#define FCGI_MAXTYPE		(FCGI_UNKNOWN_TYPE)
+
+#define FCGI_RESPONDER		1
+
+struct fcgi_record_header {
+	uint8_t		version;
+	uint8_t		type;
+	uint16_t	id;
+	uint16_t	content_len;
+	uint8_t		padding_len;
+	uint8_t		reserved;
+}__attribute__((__packed__));
+
+struct fcgi_begin_request_body {
+	uint16_t	role;
+	uint8_t		flags;
+	uint8_t		reserved[5];
+}__attribute__((__packed__));
+
+struct server_fcgi_param {
+	int		total_len;
+	uint8_t		buf[FCGI_RECORD_SIZE];
+};
+
+enum fcgistate {
+	FCGI_READ_HEADER,
+	FCGI_READ_CONTENT,
+	FCGI_READ_PADDING
+};
+
+struct fcgi_data {
+	enum fcgistate		state;
+	int			toread;
+	int			padding_len;
+	int			type;
+	int			status;
+};
+
+__dead static void
+usage(void)
+{
+	fprintf(stderr, "usage: %s [-m method] [-q query] [-s socket]\n",
+	    getprogname());
+	exit(1);
+}
+
+static const struct got_error *
+fcgi_writechunk(int type, uint8_t *dat, size_t datlen)
+{
+	if (type == FCGI_END_REQUEST)
+		datlen = 0;
+
+	if (datlen > 0) {
+		if (write(STDOUT_FILENO, dat, datlen) == -1)
+			return got_error_from_errno("write");
+	} else if (fputs("\r\n", stdout) == EOF)
+		return got_error_from_errno("fputs");
+
+	return NULL;
+}
+
+static const struct got_error *
+fcgi_read(int fd, struct fcgi_data *fcgi)
+{
+	const struct got_error		*err;
+	struct fcgi_record_header	*h;
+	char				 buf[FCGI_RECORD_SIZE];
+	size_t				 len;
+
+	do {
+		if (fcgi->toread > sizeof(buf)) {
+			/* cannot happen with gotwebd response */
+			return got_error_msg(GOT_ERR_NO_SPACE,
+			    "bad fcgi response size");
+		}
+
+		err = got_poll_read_full(fd, &len, buf,
+		    fcgi->toread, fcgi->toread);
+		if (err != NULL) {
+			if (err->code != GOT_ERR_EOF)
+				return err;
+			err = NULL;
+			break;
+		}
+
+		fcgi->toread -= len;
+		if (fcgi->toread != 0)
+			return got_error_msg(GOT_ERR_BAD_PACKET,
+			    "short fcgi response");
+
+		switch (fcgi->state) {
+		case FCGI_READ_HEADER:
+			h = (struct fcgi_record_header *)buf;
+			fcgi->type = h->type;
+			fcgi->state = FCGI_READ_CONTENT;
+			fcgi->padding_len = h->padding_len;
+			fcgi->toread = ntohs(h->content_len);
+
+			if (fcgi->toread != 0)
+				break;
+
+			/* fallthrough if content_len == 0 */
+		case FCGI_READ_CONTENT:
+			switch (fcgi->type) {
+			case FCGI_STDERR:  /* gotwebd doesn't send STDERR */
+			case FCGI_STDOUT:
+			case FCGI_END_REQUEST:
+				err = fcgi_writechunk(fcgi->type, buf, len);
+				if (err != NULL)
+					return err;
+				break;
+			}
+			if (fcgi->padding_len == 0) {
+				fcgi->state = FCGI_READ_HEADER;
+				fcgi->toread = sizeof(*h);
+			} else {
+				fcgi->state = FCGI_READ_PADDING;
+				fcgi->toread = fcgi->padding_len;
+			}
+			break;
+		case FCGI_READ_PADDING:
+			fcgi->state = FCGI_READ_HEADER;
+			fcgi->toread = sizeof(*h);
+			break;
+		default:
+			/* should not happen with gotwebd */
+			return got_error_msg(GOT_ERR_RANGE, "bad fcgi state");
+		}
+	} while (len > 0);
+
+	return NULL;
+}
+
+static const struct got_error *
+fcgi_add_stdin(int fd)
+{
+	struct fcgi_record_header h;
+
+	memset(&h, 0, sizeof(h));
+	h.version = 1;
+	h.type = FCGI_STDIN;
+	h.id = htons(1);
+	h.padding_len = 0;
+	h.content_len = 0;
+
+	return got_poll_write_full(fd, &h, sizeof(h));
+}
+
+static const struct got_error *
+fcgi_add_param(int fd, struct server_fcgi_param *p,
+    const char *key, const char *val)
+{
+	struct fcgi_record_header	*h;
+	int				 len, key_len, val_len;
+	uint8_t				*param;
+
+	key_len = strlen(key);
+	val_len = strlen(val);
+	len = key_len + val_len;
+	len += key_len > 127 ? 4 : 1;
+	len += val_len > 127 ? 4 : 1;
+
+	if (len > FCGI_CONTENT_SIZE)
+		return got_error_msg(GOT_ERR_RANGE, "parameter too large");
+
+	if (p->total_len + len > FCGI_CONTENT_SIZE) {
+		const struct got_error *err;
+
+		err = got_poll_write_full(fd, p->buf,
+		    sizeof(*h) + p->total_len);
+		if (err != NULL)
+			return err;
+		p->total_len = 0;
+	}
+
+	h = (struct fcgi_record_header *)p->buf;
+	param = p->buf + sizeof(*h) + p->total_len;
+
+	if (key_len > 127) {
+		*param++ = ((key_len >> 24) & 0xff) | 0x80;
+		*param++ = ((key_len >> 16) & 0xff);
+		*param++ = ((key_len >> 8) & 0xff);
+		*param++ = (key_len & 0xff);
+	} else
+		*param++ = key_len;
+
+	if (val_len > 127) {
+		*param++ = ((val_len >> 24) & 0xff) | 0x80;
+		*param++ = ((val_len >> 16) & 0xff);
+		*param++ = ((val_len >> 8) & 0xff);
+		*param++ = (val_len & 0xff);
+	} else
+		*param++ = val_len;
+
+	memcpy(param, key, key_len);
+	param += key_len;
+	memcpy(param, val, val_len);
+
+	p->total_len += len;
+
+	h->content_len = htons(p->total_len);
+	return NULL;
+}
+
+static const struct got_error *
+fcgi_send_params(int fd, struct server_fcgi_param *param,
+    const char *meth, const char *qs)
+{
+	const struct got_error		*err;
+	struct fcgi_record_header	*h;
+	const char			*k, *v;
+	int				 i;
+
+	h = (struct fcgi_record_header *)&param->buf;
+	h->type = FCGI_PARAMS;
+	h->content_len = 0;
+
+	for (i = 0; i < nitems(mock_params); ++i) {
+		k = mock_params[i][0];
+		v = mock_params[i][1];
+		if ((err = fcgi_add_param(fd, param, k, v)) != NULL)
+			return err;
+	}
+	if (qs == NULL)
+		qs = GOTWEBD_TEST_QUERYSTRING;
+	if ((err = fcgi_add_param(fd, param, "QUERY_STRING", qs)) != NULL)
+		return err;
+	if (meth == NULL)
+		meth = GOTWEBD_TEST_REQUEST_METHOD;
+	if ((err = fcgi_add_param(fd, param, "REQUEST_METHOD", meth)) != NULL)
+		return err;
+
+	err = got_poll_write_full(fd, param->buf,
+	    sizeof(*h) + ntohs(h->content_len));
+	if (err != NULL)
+		return err;
+
+	/* send "no more params" message */
+	h->content_len = 0;
+	return got_poll_write_full(fd, param->buf, sizeof(*h));
+}
+
+static const struct got_error *
+fcgi(const char *sock, const char *meth, const char *qs)
+{
+	const struct got_error		*err;
+	struct server_fcgi_param	 param;
+	struct fcgi_record_header	*h;
+	struct fcgi_begin_request_body	*begin;
+	struct fcgi_data		 fcgi;
+	struct sockaddr_un		 sun;
+	int				 fd = -1;
+
+	if ((fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0)) == -1)
+		return got_error_from_errno("socket");
+
+	memset(&sun, 0, sizeof(sun));
+	sun.sun_family = AF_UNIX;
+
+	if (strlcpy(sun.sun_path, sock, sizeof(sun.sun_path))
+	    >= sizeof(sun.sun_path)) {
+		err = got_error_fmt(GOT_ERR_NO_SPACE,
+		    "socket path too long: %s", sock);
+		goto done;
+	}
+
+	if ((connect(fd, (struct sockaddr *)&sun, sizeof(sun))) == -1) {
+		err = got_error_from_errno_fmt("connect: %s", sock);
+		goto done;
+	}
+
+	if (pledge("stdio", NULL) == -1) {
+		err = got_error_from_errno("pledge");
+		goto done;
+	}
+
+	memset(&fcgi, 0, sizeof(fcgi));
+
+	fcgi.state = FCGI_READ_HEADER;
+	fcgi.toread = sizeof(*h);
+	fcgi.status = 200;
+
+	memset(&param, 0, sizeof(param));
+
+	h = (struct fcgi_record_header *)&param.buf;
+	h->version = 1;
+	h->type = FCGI_BEGIN_REQUEST;
+	h->id = htons(1);
+	h->content_len = htons(sizeof(*begin));
+	h->padding_len = 0;
+
+	begin = (struct fcgi_begin_request_body *)&param.buf[sizeof(*h)];
+	begin->role = htons(FCGI_RESPONDER);
+
+	err = got_poll_write_full(fd, param.buf, sizeof(*h) + sizeof(*begin));
+	if (err != NULL)
+		goto done;
+
+	if ((err = fcgi_send_params(fd, &param, meth, qs)) != NULL)
+		goto done;
+
+	if ((err = fcgi_add_stdin(fd)) != NULL)
+		goto done;
+
+	err = fcgi_read(fd, &fcgi);
+
+ done:
+	if (fd != -1 && close(fd) == EOF && err == NULL)
+		err = got_error_from_errno("close");
+	return err;
+}
+
+int
+main(int argc, char *argv[])
+{
+	const struct got_error	*error;
+	const char		*meth = NULL, *qs = NULL, *sock = NULL;
+	int			 ch;
+
+	while ((ch = getopt(argc, argv, "m:q:s:")) != -1) {
+		switch (ch) {
+		case 'm':
+			meth = optarg;
+			break;
+		case 'q':
+			qs = optarg;
+			break;
+		case 's':
+			sock = optarg;
+			break;
+		default:
+			usage();
+			/* NOTREACHED */
+		}
+	}
+
+	argc -= optind;
+	argv += optind;
+
+	if (argc != 0)
+		usage();
+
+	if (sock == NULL) {
+		sock = getenv("GOTWEBD_TEST_SOCK");
+		if (sock == NULL)
+			errx(1, "socket path not provided");
+	}
+
+	if (unveil(sock, "rw") != 0)
+		err(1, "unveil");
+	if (pledge("stdio unix", NULL) == -1)
+		err(1, "pledge");
+
+	error = fcgi(sock, meth, qs);
+	if (error != NULL)
+		errx(1, "%s", error->msg);
+
+	return 0;
+}
blob - /dev/null
blob + c69f369c8092cbd1911705cfd13c37bb20a570b1 (mode 644)
--- /dev/null
+++ regress/gotwebd/prepare_test_repo.sh
@@ -0,0 +1,55 @@
+#!/bin/sh
+#
+# Copyright (c) 2024 Mark Jamsek <mark@jamsek.dev>
+# Copyright (c) 2022 Stefan Sperling <stsp@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ./common.sh
+
+make_repo()
+{
+	local chroot="$1"
+	local no_tree="$2"
+	local repo_path="${chroot}/got/public/repo.git"
+
+	if [ -e "${chroot}/got" ]; then
+		rm -rf "${chroot}/got"
+	fi
+
+	mkdir -p "${chroot}/got/public"
+	if [ $? -ne 0 ]; then
+		echo "failed to make gotweb public repositories tree"
+		return 1
+	fi
+
+	gotadmin init -A "$GOT_TEST_ALGO" "${repo_path}"
+
+	if [ -n "$no_tree" ]; then
+		return
+	fi
+
+	test_tree=$(mktemp -d "${chroot}/gotwebd-test-tree-XXXXXXXXXX")
+	make_test_tree "$test_tree"
+
+	got import -m "import the test tree" -r "${repo_path}" "$test_tree" \
+	    > /dev/null
+	if [ $? -ne 0 ]; then
+		echo "failed to import test tree"
+		return 1
+	fi
+
+	rm -r "$test_tree" # TODO: trap
+}
+
+make_repo "$@"
blob - /dev/null
blob + 835f0a599f14f193c79d96780f45c2df410e6988 (mode 644)
--- /dev/null
+++ regress/gotwebd/test_gotwebd.sh
@@ -0,0 +1,78 @@
+#!/bin/sh
+#
+# Copyright (c) 2024 Mark Jamsek <mark@jamsek.dev>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ./common.sh
+
+test_gotwebd_action_index()
+{
+	local testroot=$(test_init gotwebd_action_index 1)
+	local repo="${GOTWEBD_TEST_CHROOT}/got/public/repo.git"
+	local author_time=$(git_show_author_time $repo)
+	local id=$(git_show_head $repo)
+
+	COMMIT_ID=$id \
+	COMMIT_ID10=$(printf '%.10s' $id) \
+	COMMIT_YMDHMS=$(date -u -r $author_time +"%FT%TZ") \
+	interpolate action_index.html > $testroot/content.expected
+
+	$GOTWEBD_TEST_FCGI > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "" "$ret"
+}
+
+test_gotwebd_action_commit()
+{
+	local testroot=$(test_init gotwebd_action_commit 1)
+	local repo="${GOTWEBD_TEST_CHROOT}/got/public/repo.git"
+	local id=$(git_show_head $repo)
+	local author_time=$(git_show_author_time $repo)
+	local qs="action=diff&commit=${id}&headref=HEAD&path=repo.git"
+
+	COMMIT_ID=$id \
+	BLOB_ALPHA=$(get_blob_id $repo "" alpha) \
+	BLOB_BETA=$(get_blob_id $repo "" beta) \
+	BLOB_ZETA=$(get_blob_id $repo epsilon zeta) \
+	BLOB_DELTA=$(get_blob_id $repo gamma delta) \
+	COMMITTER="Flan Hacker" \
+	COMMITTER_EMAIL="flan_hacker@openbsd.org" \
+	COMMIT_YMDHMS=$(date -u -r $author_time +"%FT%TZ") \
+	COMMIT_DATE=$(date -u -r $author_time +"%a %b %e %X %Y") \
+	interpolate action_commit.html > $testroot/content.expected
+
+	$GOTWEBD_TEST_FCGI -q "$qs" > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$repo" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$repo" "$ret"
+}
+
+test_parseargs "$@"
+run_test test_gotwebd_action_index
+run_test test_gotwebd_action_commit