"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Mark Jamsek <mark@jamsek.com>
Subject:
implement gotwebd test harness
To:
gameoftrees@openbsd.org
Date:
Sun, 24 Nov 2024 16:52:18 +1100

Download raw body.

Thread
The below diff implements a test harness for gotwebd. I've tried to keep
the test case interface consistent with the rest of our regress suite
and think I've succeeded for the most part. stsp convinced me on IRC to
keep the expected content in stable files and keep the markup, both of
which I think are good ideas.

I haven't written any documentation yet as I wanted to share it first,
but, like gotd, it needs to be invoked with privileges though it drops
to the GOTWEBD_TEST_USER (by default the doas user) where possible.

To test it out, make sure you have a gotwebd binary on your path,
and run `doas make webd-regress` from the root of your got work tree.

Please review the shell bits in particular, much of the setup was lifted
from gotd regress so should be good. And thanks to a misc@ post, I also
realised last night that I could lift a bunch of code from base, which
I'm much happier with than the initial implementation I wrote from the
fastcgi spec!


commit a5cc2235e2c075d4c460509c7965fce909d38316 (gwdth2)
from: Mark Jamsek <mark@jamsek.dev>
date: Sun Nov 24 05:14:25 2024 UTC

implement gotwebd test harness

M  Makefile                              |    3+  0-
A  regress/gotwebd/Makefile              |   90+  0-
A  regress/gotwebd/action_commit.html    |   32+  0-
A  regress/gotwebd/action_index.html     |    5+  0-
A  regress/gotwebd/common.sh             |  103+  0-
A  regress/gotwebd/gotwebd_test.c        |  455+  0-
A  regress/gotwebd/prepare_test_repo.sh  |   55+  0-
A  regress/gotwebd/test_gotwebd.sh       |   78+  0-

8 files changed, 821 insertions(+), 0 deletions(-)

commit - 6c3c9861fa886ed16cba03b9d9df4744979dc300
commit + a5cc2235e2c075d4c460509c7965fce909d38316
blob - 04e58218bd0bcb1c6545b97400a903fb87f7a310
blob + 787e6174012ddab20988c46ebabd7429009f0b92
--- Makefile
+++ Makefile
@@ -64,4 +64,7 @@ server-install:
 server-regress:
 	${MAKE} -C regress/gotd
 
+webd-regress:
+	${MAKE} -C regress/gotwebd
+
 .include <bsd.subdir.mk>
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


-- 
Mark Jamsek <https://bsdbox.org>
GPG: F2FF 13DE 6A06 C471 CA80  E6E2 2930 DC66 86EE CF68