commit 5d57069f73989777a26d483634dfbdd86089b532
Author: Fred Großkopf <fred@kuandu.systems>
Date: Wed, 16 Sep 2026 17:17:32 +0200
Init
Diffstat:
47 files changed, 12130 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,5 @@
+testroot
+build
+*.BAK
+simplefiles*
+TODO.md
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,13 @@
+Copyright (c) 2026 Fred Großkopf
+
+Permission to use, copy, modify, and/or 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.
diff --git a/Makefile b/Makefile
@@ -0,0 +1,374 @@
+#
+# SimpleFiles Makefile
+#
+
+CC= cc
+CFLAGS= -static -Wall -Wextra -O2 -fno-pie -nopie -Isrc
+
+#
+# Directories
+#
+
+BUILDDIR= build
+TESTROOT= testroot
+TEST_CSRF_ORIGIN= http://localhost:8000
+
+HTDOCS= ${TESTROOT}/htdocs
+CGIBIN= ${TESTROOT}/cgi-bin
+ETCDIR= ${TESTROOT}/etc
+RUN= ${TESTROOT}/run
+LOGS= ${TESTROOT}/logs
+STORAGEROOT= ${TESTROOT}/storage
+UPLOADDIR= ${TESTROOT}/run/simplefiles/uploads
+LOCKSDIR= ${TESTROOT}/run/simplefiles/lock
+TEMPLATE_ROOT= ${TESTROOT}/simplefiles
+GLOBAL_CONFIG= ${ETCDIR}/simplefiles.conf
+AUTHDIR= ${TESTROOT}/auth
+
+
+#
+# Build targets
+#
+
+UPLOAD_CGI= ${BUILDDIR}/upload.cgi
+LIST_CGI= ${BUILDDIR}/list.cgi
+DELETE_CGI= ${BUILDDIR}/delete.cgi
+DOWNLOAD_CGI= ${BUILDDIR}/download.cgi
+RENAME_CGI= ${BUILDDIR}/rename.cgi
+
+CGI_TARGETS= ${UPLOAD_CGI} \
+ ${LIST_CGI} \
+ ${DELETE_CGI} \
+ ${DOWNLOAD_CGI} \
+ ${RENAME_CGI}
+
+#
+# Common source files
+#
+
+COMMON_SRCS= src/common.c \
+ src/config.c \
+ src/http.c \
+ src/html.c \
+ src/url.c
+
+COMMON_HDRS= src/common.h \
+ src/config.h \
+ src/http.h \
+ src/html.h \
+ src/url.h
+
+REQUSET_HDRS= src/request.h
+REQUEST_SRCS= src/request.c
+
+#
+# Unit tests
+#
+
+VALID_FILENAME_TEST= ${BUILDDIR}/valid_filename
+URL_TEST= ${BUILDDIR}/url
+HTML_TEST= ${BUILDDIR}/html
+HTTP_TEST= ${BUILDDIR}/http
+COMMON_TEST= ${BUILDDIR}/common
+CONFIG_TEST= ${BUILDDIR}/config
+MULTIPART_TEST= ${BUILDDIR}/multipart
+
+UNIT_TESTS= ${VALID_FILENAME_TEST} \
+ ${URL_TEST} \
+ ${HTML_TEST} \
+ ${HTTP_TEST} \
+ ${COMMON_TEST} \
+ ${CONFIG_TEST} \
+ ${MULTIPART_TEST}
+#
+# Regression tests
+#
+
+UPLOAD_REGRESSION= tests/regression/upload.sh
+LIST_REGRESSION= tests/regression/list.sh
+DELETE_REGRESSION= tests/regression/delete.sh
+DOWNLOAD_REGRESSION= tests/regression/download.sh
+RENAME_REGRESSION= tests/regression/rename.sh
+
+#
+# Templates / static files
+#
+
+UPLOAD_TEMPLATE= html/upload.html.template
+DELETE_TEMPLATE= html/delete.html.template
+LIST_TEMPLATE= html/list.html.template
+LIST_READONLY_TEMPLATE= html/list-readonly.html.template
+RENAME_TEMPLATE= html/rename.html.template
+AUTH_FILE= conf/bob
+
+#
+# Deployment package
+#
+
+DIST_NAME= simplefiles
+DIST_DIR= ${DIST_NAME}
+DIST_ARCHIVE= ${DIST_NAME}.tar.gz
+
+#
+# Default targets
+#
+
+.PHONY: all build test test-unit test-regression
+.PHONY: testroot prepare-testroot
+.PHONY: clean cleantestroot dist clean-dist
+
+all: ${CGI_TARGETS}
+
+build: ${CGI_TARGETS}
+
+#
+# CGI programs
+#
+
+${UPLOAD_CGI}: src/upload.c ${COMMON_SRCS} ${COMMON_HDRS} src/multipart.h src/multipart.c
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ src/upload.c ${COMMON_SRCS} src/multipart.c
+
+${LIST_CGI}: src/list.c ${COMMON_SRCS} ${COMMON_HDRS}
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ src/list.c ${COMMON_SRCS}
+
+${DELETE_CGI}: src/delete.c ${COMMON_SRCS} ${COMMON_HDRS} ${REQUEST_HDRS}
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ src/delete.c ${COMMON_SRCS} ${REQUEST_SRCS}
+
+${DOWNLOAD_CGI}: src/download.c ${COMMON_SRCS} ${COMMON_HDRS} ${REQUEST_HDRS}
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ src/download.c ${COMMON_SRCS} ${REQUEST_SRCS}
+
+${RENAME_CGI}: src/rename.c ${COMMON_SRCS} ${COMMON_HDRS} ${REQUEST_HDRS}
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ src/rename.c ${COMMON_SRCS} ${REQUEST_SRCS}
+
+#
+# Unit tests
+#
+
+${VALID_FILENAME_TEST}: tests/unit/valid_filename.c \
+ src/common.c \
+ src/common.h \
+ src/config.h \
+ src/html.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/valid_filename.c \
+ src/common.c \
+ src/config.c \
+ src/html.c
+
+${URL_TEST}: tests/unit/url.c \
+ src/url.c \
+ src/url.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/url.c \
+ src/url.c
+
+${HTML_TEST}: tests/unit/html.c \
+ src/html.c \
+ src/html.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/html.c \
+ src/html.c
+
+${HTTP_TEST}: tests/unit/http.c src/http.c src/http.h src/html.c src/html.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/http.c \
+ src/http.c \
+ src/html.c
+
+${COMMON_TEST}: tests/unit/common.c \
+ src/common.c \
+ src/common.h \
+ src/config.c \
+ src/config.h \
+ src/http.c \
+ src/http.h \
+ src/html.c \
+ src/html.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/common.c \
+ src/common.c \
+ src/config.c \
+ src/http.c \
+ src/html.c
+
+${CONFIG_TEST}: tests/unit/config.c \
+ src/config.c \
+ src/config.h \
+ src/common.c \
+ src/common.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/config.c \
+ src/config.c \
+ src/common.c
+
+${MULTIPART_TEST}: tests/unit/multipart.c \
+ src/multipart.c \
+ src/multipart.h \
+ src/config.c \
+ src/config.h \
+ src/http.c \
+ src/http.h \
+ src/common.c \
+ src/common.h \
+ src/html.c \
+ src/html.h
+ mkdir -p ${BUILDDIR}
+ ${CC} ${CFLAGS} -o $@ \
+ tests/unit/multipart.c \
+ src/multipart.c \
+ src/config.c \
+ src/http.c \
+ src/common.c \
+ src/html.c
+
+test-unit: ${UNIT_TESTS}
+ @for test in ${UNIT_TESTS}; do \
+ echo "==> $$test"; \
+ ./$$test || exit 1; \
+ done
+
+#
+# Regression tests
+#
+
+test-regression: ${CGI_TARGETS}
+ sh ${UPLOAD_REGRESSION} ${UPLOAD_CGI}
+ sh ${LIST_REGRESSION} ${LIST_CGI}
+ sh ${DELETE_REGRESSION} ${DELETE_CGI}
+ sh ${DOWNLOAD_REGRESSION} ${DOWNLOAD_CGI}
+ sh ${RENAME_REGRESSION} ${RENAME_CGI}
+
+#
+# All tests
+#
+
+test: test-unit test-regression
+
+#
+# Test root
+#
+
+prepare-testroot:
+ mkdir -p \
+ ${HTDOCS} \
+ ${CGIBIN} \
+ $(ETCDIR) \
+ ${RUN} \
+ ${LOGS} \
+ ${STORAGEROOT} \
+ ${UPLOADDIR} \
+ ${LOCKSDIR} \
+ ${TEMPLATE_ROOT} \
+ ${AUTHDIR}
+
+testroot: ${CGI_TARGETS} prepare-testroot
+ install -m 755 ${UPLOAD_CGI} ${CGIBIN}/upload.cgi
+ install -m 755 ${LIST_CGI} ${CGIBIN}/list.cgi
+ install -m 755 ${DELETE_CGI} ${CGIBIN}/delete.cgi
+ install -m 755 ${DOWNLOAD_CGI} ${CGIBIN}/download.cgi
+ install -m 755 ${RENAME_CGI} ${CGIBIN}/rename.cgi
+
+ printf '%s\n' \
+ 'CSRF_ORIGIN=${TEST_CSRF_ORIGIN}' \
+ > ${GLOBAL_CONFIG}
+ chmod 644 ${GLOBAL_CONFIG}
+
+ chmod 755 ${TESTROOT}
+ chmod 755 ${HTDOCS}
+ chmod 755 ${CGIBIN}
+ chmod 755 ${RUN}
+ chmod 755 ${LOGS}
+ chmod 755 ${STORAGEROOT}
+ chmod 755 ${UPLOADDIR}
+ chmod 755 ${LOCKSDIR}
+ chmod 755 ${TEMPLATE_ROOT}
+ chmod 755 ${AUTHDIR}
+
+ mkdir -p ${HTDOCS}/default
+ chmod 755 ${HTDOCS}/default
+
+ mkdir -p ${STORAGEROOT}/default
+ mkdir -p ${TEMPLATE_ROOT}
+
+ ln -sf ../../cgi-bin/list.cgi \
+ ${HTDOCS}/default/list.cgi
+
+ ln -sf ../../cgi-bin/upload.cgi \
+ ${HTDOCS}/default/upload.cgi
+
+ ln -sf ../../cgi-bin/delete.cgi \
+ ${HTDOCS}/default/delete.cgi
+
+ ln -sf ../../cgi-bin/download.cgi \
+ ${HTDOCS}/default/download.cgi
+
+ ln -sf ../../cgi-bin/rename.cgi \
+ ${HTDOCS}/default/rename.cgi
+
+ install -m 644 css/style.css \
+ ${HTDOCS}/default/style.css
+
+ install -m 644 ${UPLOAD_TEMPLATE} \
+ ${TEMPLATE_ROOT}/upload.html.template
+
+ install -m 644 ${DELETE_TEMPLATE} \
+ ${TEMPLATE_ROOT}/delete.html.template
+
+ install -m 644 ${LIST_TEMPLATE} \
+ ${TEMPLATE_ROOT}/list.html.template
+
+ install -m 644 ${RENAME_TEMPLATE} \
+ ${TEMPLATE_ROOT}/rename.html.template
+
+ install -m 644 ${LIST_READONLY_TEMPLATE} \
+ ${TEMPLATE_ROOT}/list-readonly.html.template
+
+ install -m 644 ${AUTH_FILE} ${TESTROOT}/auth/bob
+ printf "%s\n" "ADMIN_USER=bob" > "${TESTROOT}/storage/default/.config"
+#
+# Housekeeping
+#
+
+clean:
+ rm -rf ${BUILDDIR}
+
+cleantestroot:
+ rm -rf ${TESTROOT}
+
+#
+# Deployment Package
+#
+
+dist: clean build
+ echo "Creating deployment package: ${DIST_ARCHIVE}"
+ mkdir -p ${DIST_DIR}
+
+ cp ${UPLOAD_CGI} ${DIST_DIR}/
+ cp ${LIST_CGI} ${DIST_DIR}/
+ cp ${DELETE_CGI} ${DIST_DIR}/
+ cp ${DOWNLOAD_CGI} ${DIST_DIR}/
+ cp ${RENAME_CGI} ${DIST_DIR}/
+
+ cp css/style.css ${DIST_DIR}/
+
+ cp ${UPLOAD_TEMPLATE} ${DIST_DIR}/
+ cp ${DELETE_TEMPLATE} ${DIST_DIR}/
+ cp ${LIST_TEMPLATE} ${DIST_DIR}/
+ cp ${RENAME_TEMPLATE} ${DIST_DIR}/
+ cp ${LIST_READONLY_TEMPLATE} ${DIST_DIR}/
+
+ cd ${DIST_DIR} && tar -czf ../${DIST_ARCHIVE} .
+
+clean-dist:
+ rm -rf ${DIST_DIR} ${DIST_ARCHIVE}
diff --git a/README.md b/README.md
@@ -0,0 +1,220 @@
+# SimpleFiles
+
+SimpleFiles is a lightweight C CGI web application for managing files stored in
+separate filesystem-backed repositories.
+
+It provides a browser-based interface for browsing, uploading, downloading,
+renaming, and deleting files.
+
+The current reference deployment uses OpenBSD `httpd` with `slowcgi`. Other
+CGI-capable web servers may work, but have not yet been tested.
+
+> **Security status:** SimpleFiles is currently pre-release software and
+> is undergoing security hardening. It should not yet be considered suitable
+> for production deployments where files are exposed to untrusted users or
+> hostile input.
+>
+> Known areas still under review include concurrent upload disk-space
+> accounting, strict multipart parsing, filesystem TOCTOU handling, and the
+> filesystem trust model. See `SECURITY.md` for details.
+
+## Concepts
+
+A **repository** is a directory managed by SimpleFiles that contains uploaded
+files and optional repository-specific configuration.
+
+Repositories are stored below the configured storage root:
+
+```text
+/storage/
+└── default/
+```
+
+SimpleFiles does not provide user authentication itself. Authentication is
+expected to be handled by the web server, which may provide the authenticated
+username through `REMOTE_USER`.
+
+## How to install
+
+SimpleFiles requires a CGI-capable web server and a POSIX-compatible
+environment.
+
+The following installation layout describes the filesystem as seen by the CGI
+application. When using a web server with a chroot environment, such as the
+default OpenBSD `httpd` setup, these paths must exist inside that environment.
+
+### 1. Create the directories
+
+The default installation uses:
+
+| Path | Purpose | CGI user |
+| --- | --- | --- |
+| `/storage` | Repository files | read/write |
+| `/simplefiles` | HTML templates | read |
+| `/run/simplefiles/lock` | Repository locks | read/write |
+| `/run/simplefiles/uploads` | Temporary uploads | read/write |
+| `/etc/simplefiles.conf` | Global configuration | read |
+
+For example, if CGI runs as `www-data`:
+
+```sh
+mkdir -p /storage
+mkdir -p /simplefiles
+mkdir -p /run/simplefiles/lock
+mkdir -p /run/simplefiles/uploads
+
+chown www-data:www-data /storage
+chown www-data:www-data /run/simplefiles/lock
+chown www-data:www-data /run/simplefiles/uploads
+
+chmod 750 /storage
+chmod 755 /simplefiles
+chmod 750 /run/simplefiles/lock
+chmod 750 /run/simplefiles/uploads
+```
+
+The upload directory must not be group- or world-writable.
+
+### 2. Install the templates
+
+Copy these files into `/simplefiles`:
+
+```text
+upload.html.template
+delete.html.template
+list.html.template
+list-readonly.html.template
+rename.html.template
+```
+
+They only need to be readable by the CGI user.
+
+### 3. Build and install the CGI programs
+
+Compile the C sources and install the resulting CGI programs in a directory
+configured for CGI execution by the web server.
+
+The source distribution does not currently include a Makefile or web-server
+configuration examples.
+
+The CGI programs must run as an unprivileged user with the permissions described
+above.
+
+## Reference deployment
+
+SimpleFiles is currently developed and tested with:
+
+- OpenBSD `httpd`
+- OpenBSD `slowcgi`
+- CGI execution within the web server's runtime filesystem environment
+
+OpenBSD `httpd` normally uses a chroot environment. When using this setup, all
+SimpleFiles paths, including storage, templates, configuration, locks, and
+temporary uploads, must be available inside the chroot environment.
+
+## Configuration
+
+Create:
+
+```text
+/etc/simplefiles.conf
+```
+
+with at least:
+
+```text
+CSRF_ORIGIN=https://files.example.com
+```
+
+Replace the URL with the origin used to access the application.
+
+### Global configuration
+
+`/etc/simplefiles.conf`:
+
+| Setting | Description |
+| --- | --- |
+| `CSRF_ORIGIN` | Required origin for modifying POST requests |
+
+The configuration file can be relocated with `SIMPLEFILES_CONFIG`.
+
+### Repository configuration
+
+`/storage/<repository>/.config`:
+
+| Setting | Default | Description |
+| --- | ---: | --- |
+| `MAX_UPLOAD` | `104857600` | Maximum upload size in bytes |
+| `QUOTA` | `5368709120` | Maximum repository size in bytes |
+| `READ_ONLY` | `0` | Set to `1` to disable modifications |
+| `ADMIN_USER` | unset | Restrict administration to this `REMOTE_USER` |
+
+Example:
+
+```text
+MAX_UPLOAD=52428800
+QUOTA=1073741824
+ADMIN_USER=alice
+```
+
+## Runtime settings
+
+The default paths can be changed with environment variables when supported by
+the CGI server:
+
+| Variable | Default |
+| --- | --- |
+| `STORAGE_ROOT` | `/storage` |
+| `SIMPLEFILES_ROOT` | `/simplefiles` |
+| `REPOSITORY_LOCK_DIR` | `/run/simplefiles/lock` |
+| `UPLOAD_TMP_DIR` | `/run/simplefiles/uploads` |
+| `SIMPLEFILES_CONFIG` | `/etc/simplefiles.conf` |
+
+The following limits can also be overridden:
+
+| Variable | Default |
+| --- | ---: |
+| `REPO_QUOTA_BYTES` | repository `QUOTA` |
+| `MIN_FREE_BYTES` | `1073741824` |
+
+> Note: OpenBSD `httpd` does not provide a way to configure arbitrary CGI
+environment variables. On OpenBSD, use the default paths or provide a wrapper
+mechanism if custom values are required.
+
+## Web server
+
+Configure the CGI programs with the web server and ensure it supplies:
+
+```text
+REQUEST_METHOD
+SCRIPT_NAME
+QUERY_STRING
+CONTENT_TYPE
+CONTENT_LENGTH
+HTTP_ORIGIN
+REMOTE_USER
+```
+
+`REMOTE_USER` is only needed when `ADMIN_USER` is configured.
+
+Do not expose `/storage` as a static web directory.
+
+## Security
+
+For a production deployment:
+
+- Run CGI as a dedicated, unprivileged user.
+- Keep `/storage` inaccessible as a static web directory.
+- Keep `/run/simplefiles/uploads` non-world-writable.
+- Configure `CSRF_ORIGIN`.
+- Use the web server for authentication when `ADMIN_USER` is used.
+
+Repositories are trusted filesystem locations. SimpleFiles does not currently
+protect against a malicious local user modifying repository contents outside the
+application.
+
+## Default limits
+
+- Maximum upload: **100 MiB**
+- Repository quota: **5 GiB**
+- Minimum free space: **1 GiB**
diff --git a/conf/bob b/conf/bob
@@ -0,0 +1 @@
+bob:$2b$08$hs6U/we7.j7JaXgBkDn3suR7sP1d7s04lwGiQraNPZISEwy7j9OPa
diff --git a/conf/httpd-test.conf b/conf/httpd-test.conf
@@ -0,0 +1,19 @@
+chroot "testroot"
+
+server "local" {
+ listen on localhost port 8000
+ root "/htdocs"
+
+ authenticate with "auth/bob"
+
+ connection max request body 524288000
+
+ location "/default/" {
+ request rewrite "/default/list.cgi"
+ }
+
+ location "/*.cgi" {
+ fastcgi socket "/run/slowcgi.sock"
+ }
+
+}
diff --git a/css/style.css b/css/style.css
@@ -0,0 +1,138 @@
+:root {
+ --bg: #f7f7f9;
+ --fg: #222;
+ --muted: #666;
+ --accent: #2563eb;
+ --accent-hover: #1d4ed8;
+ --border: #ddd;
+ --danger: #b91c1c;
+ --danger-hover: #991b1b;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 1.25rem;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: var(--fg);
+ background: var(--bg);
+ line-height: 1.5;
+}
+
+h1 {
+ margin: 0 0 1rem 0;
+ font-size: 1.5rem;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+nav {
+ margin-bottom: 1rem;
+}
+
+nav a {
+ margin-right: 0.75rem;
+}
+
+/* Forms */
+form {
+ margin: 1rem 0;
+}
+
+input[type="file"],
+input[type="text"],
+input[type="password"] {
+ padding: 0.4rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: #fff;
+ color: var(--fg);
+}
+
+input[type="submit"],
+button {
+ padding: 0.4rem 0.8rem;
+ border: none;
+ border-radius: 4px;
+ background: var(--accent);
+ color: #fff;
+ cursor: pointer;
+}
+
+input[type="submit"]:hover,
+button:hover {
+ background: var(--accent-hover);
+}
+
+/* Upload progress */
+#progressContainer {
+ margin-top: 1em;
+ display: none;
+}
+
+#progressBar {
+ width: 100%;
+ height: 20px;
+}
+
+#status {
+ margin-top: 0.25em;
+ font-size: 0.9em;
+ color: var(--muted);
+}
+
+/* File table */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 1rem;
+ background: #fff;
+ border: 1px solid var(--border);
+}
+
+th, td {
+ padding: 0.5rem 0.6rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: middle;
+}
+
+th {
+ background: #f3f4f6;
+ font-weight: 600;
+}
+
+tr:last-child td {
+ border-bottom: none;
+}
+
+/* Actions column (Delete etc.) */
+td.actions a {
+ padding-right: 0.5rem;
+ color: var(--danger);
+}
+
+td.actions a:hover {
+ color: var(--danger-hover);
+ text-decoration: underline;
+}
+
+/* Small helpers */
+.muted {
+ color: var(--muted);
+}
+
+.error {
+ color: var(--danger);
+ margin: 0.5rem 0;
+}
diff --git a/html/delete.html.template b/html/delete.html.template
@@ -0,0 +1,22 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<h1>SimpleFiles</h1>
+
+<p>Delete <strong>{{FILENAME}}</strong>?</p>
+
+<form method="post" action="delete.cgi">
+ <input type="hidden" name="file" value="{{FILENAME}}">
+ <input type="submit" value="Delete">
+</form>
+
+<p><a href="{{CANCEL_URI}}">Cancel</a></p>
+
+</body>
+</html>
diff --git a/html/index.html b/html/index.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>SimpleFiles</title>
+</head>
+<body>
+ <h1>It works</h1>
+</body>
+</html>
diff --git a/html/list-readonly.html.template b/html/list-readonly.html.template
@@ -0,0 +1,37 @@
+<!doctype html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<h1>SimpleFiles</h1>
+
+<table>
+ <tr>
+ <th>Name</th>
+ <th>Size</th>
+ <th>Modified</th>
+ <th>Actions</th>
+ </tr>
+
+{{#FILES}}
+ <tr>
+ <td>
+ <a href="download.cgi?file={{URL}}">{{NAME}}</a>
+ </td>
+ <td>{{SIZE}}</td>
+ <td>{{MODIFIED}}</td>
+ <td class="actions">
+ <a href="download.cgi?file={{URL}}">Download</a>
+ </td>
+ </tr>
+{{/FILES}}
+
+</table>
+
+</body>
+</html>
diff --git a/html/list.html.template b/html/list.html.template
@@ -0,0 +1,41 @@
+<!doctype html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<h1>SimpleFiles</h1>
+
+<p><a href="upload.cgi">Upload file</a></p>
+
+<table>
+ <tr>
+ <th>Name</th>
+ <th>Size</th>
+ <th>Modified</th>
+ <th>Actions</th>
+ </tr>
+
+{{#FILES}}
+ <tr>
+ <td>
+ <a href="download.cgi?file={{URL}}">{{NAME}}</a>
+ </td>
+ <td>{{SIZE}}</td>
+ <td>{{MODIFIED}}</td>
+ <td class="actions">
+ <a href="download.cgi?file={{URL}}">Download</a>
+ <a href="delete.cgi?file={{URL}}">Delete</a>
+ <a href="rename.cgi?file={{URL}}">Rename</a>
+ </td>
+ </tr>
+{{/FILES}}
+
+</table>
+
+</body>
+</html>
diff --git a/html/rename.html.template b/html/rename.html.template
@@ -0,0 +1,44 @@
+<!doctype html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<h1>SimpleFiles</h1>
+
+<p>Rename <strong>{{FILENAME}}</strong>?</p>
+
+<form method="post">
+
+ <input type="hidden"
+ name="file"
+ value="{{FILENAME}}">
+
+ <label for="new_filename">
+ New filename:
+ </label>
+
+ <input type="text"
+ id="new_filename"
+ name="new_filename"
+ value="{{FILENAME}}"
+ required>
+
+ <button type="submit">
+ Rename
+ </button>
+
+</form>
+
+<p>
+ <a href="{{CANCEL_URI}}">
+ Cancel
+ </a>
+</p>
+
+</body>
+</html>
diff --git a/html/upload.html.template b/html/upload.html.template
@@ -0,0 +1,114 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>SimpleFiles</title>
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+ <h1>SimpleFiles</h1>
+
+ <p>Upload a file (max {{MAX_UPLOAD}}, total limit {{REPO_QUOTA}})</p>
+
+ <nav>
+ <a href="./">Back to files</a>
+ </nav>
+
+ <form id="uploadForm"
+ method="post"
+ action="upload.cgi"
+ enctype="multipart/form-data">
+
+ <p>
+ <input type="file" name="file" id="fileInput" required>
+ </p>
+
+ <p>
+ <input type="submit" value="Upload">
+ </p>
+
+ </form>
+
+ <div id="progressContainer" style="display: none">
+ <progress id="progressBar" value="0" max="100"></progress>
+ <div id="status">0%</div>
+ </div>
+
+ <script>
+ const MAX_UPLOAD = {{MAX_UPLOAD_BYTES}};
+
+ document.getElementById('uploadForm').addEventListener(
+ 'submit',
+ function (e) {
+ e.preventDefault();
+
+ const fileInput =
+ document.getElementById('fileInput');
+
+ if (!fileInput.files.length)
+ return;
+
+ const file = fileInput.files[0];
+
+ const progressContainer =
+ document.getElementById('progressContainer');
+
+ const progressBar =
+ document.getElementById('progressBar');
+
+ const status =
+ document.getElementById('status');
+
+ if (file.size > MAX_UPLOAD) {
+ progressContainer.style.display = 'block';
+ status.textContent =
+ 'File exceeds {{MAX_UPLOAD}} limit';
+ return;
+ }
+
+ const formData = new FormData();
+
+ formData.append('file', file);
+
+ progressContainer.style.display = 'block';
+ progressBar.value = 0;
+ status.textContent = '0%';
+
+ const xhr = new XMLHttpRequest();
+
+ xhr.open('POST', 'upload.cgi', true);
+
+ xhr.upload.onprogress = function (event) {
+ if (event.lengthComputable) {
+ const percent =
+ Math.round(
+ (event.loaded / event.total) * 100
+ );
+
+ progressBar.value = percent;
+ status.textContent = percent + '%';
+ }
+ };
+
+ xhr.onload = function () {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ status.textContent = 'Upload complete';
+ window.location.href = './';
+ } else {
+ status.textContent =
+ 'Upload failed (HTTP ' + xhr.status + ')';
+ }
+ };
+
+ xhr.onerror = function () {
+ status.textContent =
+ 'Upload failed (network error)';
+ };
+
+ xhr.send(formData);
+ }
+ );
+ </script>
+</body>
+</html>
diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+set -e
+
+ROOT="$(pwd)"
+
+SOCK="$ROOT/testroot/run/slowcgi.sock"
+CHROOT="$ROOT/testroot"
+
+if [ ! -d "$CHROOT" ]; then
+ echo "Missing testroot. Run make testroot first."
+ exit 1
+fi
+
+tmux new-session -d -s simpleupload
+
+tmux rename-window -t simpleupload:0 slowcgi
+tmux send-keys -t simpleupload:0 \
+ "doas /usr/sbin/slowcgi -u dev -dvvv -s $SOCK -p $CHROOT/" C-m
+
+tmux split-window -h -t simpleupload:0
+
+tmux send-keys -t simpleupload:0.1 \
+ "doas /usr/sbin/httpd -dvvv -f conf/httpd-test.conf" C-m
+
+if [ -n "$TMUX" ]; then
+ tmux switch-client -t simpleupload
+else
+ tmux attach-session -t simpleupload
+fi
diff --git a/src/common.c b/src/common.c
@@ -0,0 +1,237 @@
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/file.h>
+#include <unistd.h>
+
+#include "common.h"
+#include "config.h"
+#include "html.h"
+
+#define REPOSITORY_MAXLEN 255
+
+static int
+repository_from_script_name(const char *script_name,
+ char *repository,
+ size_t len);
+
+static int valid_repository(const char *);
+
+int
+build_repository_path(char *path,
+ size_t len,
+ const char *storage_root,
+ const char *repository)
+{
+ if (snprintf(path,
+ len,
+ "%s/%s",
+ storage_root,
+ repository) >= (int) len)
+ return -1;
+
+ return 0;
+}
+
+int
+build_storage_path(char *path,
+ size_t len,
+ const char *storage_root,
+ const char *repository,
+ const char *filename)
+{
+ if (snprintf(path, len,
+ "%s/%s/%s",
+ storage_root,
+ repository,
+ filename) >= (int) len)
+ return -1;
+
+ return 0;
+}
+
+const char *
+storage_root(void)
+{
+ const char *root;
+
+ root = getenv("STORAGE_ROOT");
+ if (root != NULL)
+ return root;
+
+ return DEFAULT_STORAGE_ROOT;
+}
+
+const char *
+repository_lock_dir(void)
+{
+ const char *dir;
+
+ dir = getenv("REPOSITORY_LOCK_DIR");
+ if (dir != NULL && *dir != '\0')
+ return dir;
+
+ return DEFAULT_REPOSITORY_LOCK_DIR;
+}
+
+int
+repository_lock(const char *repo)
+{
+ char path[PATH_MAX];
+ int fd;
+
+ if (repo == NULL || !valid_repository(repo))
+ return -1;
+
+ if (snprintf(path, sizeof(path), "%s/%s",
+ repository_lock_dir(), repo) >= (int) sizeof(path))
+ return -1;
+
+ fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
+ if (fd == -1)
+ return -1;
+
+ if (flock(fd, LOCK_EX) == -1) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+void
+repository_unlock(int fd)
+{
+ if (fd != -1) {
+ flock(fd, LOCK_UN);
+ close(fd);
+ }
+}
+
+const char *
+repository(void)
+{
+ static char repository[REPOSITORY_MAXLEN + 1];
+
+ if (repository_from_script_name(
+ getenv("SCRIPT_NAME"),
+ repository,
+ sizeof(repository)) == -1)
+ return DEFAULT_REPOSITORY;
+
+ if (!valid_repository(repository))
+ return DEFAULT_REPOSITORY;
+
+ return repository;
+}
+
+int
+valid_repository(const char *repository)
+{
+ const unsigned char *p;
+
+ if (repository == NULL || *repository == '\0')
+ return 0;
+
+ for (p = (const unsigned char *) repository; *p != '\0'; p++) {
+ if (!isalnum(*p) &&
+ *p != '_' &&
+ *p != '-')
+ return 0;
+ }
+
+ return 1;
+}
+
+static int
+repository_from_script_name(const char *script_name,
+ char *repository,
+ size_t len)
+{
+ const char *start;
+ const char *end;
+ size_t namelen;
+
+ if (script_name == NULL)
+ return -1;
+
+ if (script_name[0] != '/')
+ return -1;
+
+ start = script_name + 1;
+ if (*start == '\0')
+ return -1;
+
+ end = strchr(start, '/');
+ if (end == NULL)
+ return -1;
+
+ namelen = (size_t) (end - start);
+ if (namelen == 0)
+ return -1;
+
+ if (namelen >= len)
+ return -1;
+
+ memcpy(repository, start, namelen);
+ repository[namelen] = '\0';
+
+ return 0;
+}
+
+int
+valid_filename(const char *filename)
+{
+ const unsigned char *p;
+
+ if (filename == NULL || *filename == '\0')
+ return 0;
+
+ if (strcmp(filename, CONFIG_FILENAME) == 0)
+ return 0;
+
+ if (strncmp(filename, ".upload-", 8) == 0)
+ return 0;
+
+ if (strcmp(filename, ".") == 0 ||
+ strcmp(filename, "..") == 0)
+ return 0;
+
+ for (p = (const unsigned char *) filename; *p != '\0'; p++) {
+ if (*p == '/' ||
+ *p == '\\' ||
+ *p == '"' ||
+ *p == '\r' ||
+ *p == '\n')
+ return 0;
+
+ if (iscntrl(*p))
+ return 0;
+ }
+
+ return 1;
+}
+
+int
+check_csrf_origin(void)
+{
+ const char *origin;
+ char expected[CSRF_ORIGIN_MAX];
+
+ if (global_config_csrf_origin(
+ expected, sizeof(expected)) == -1)
+ return -2;
+
+ origin = getenv("HTTP_ORIGIN");
+
+ if (origin == NULL)
+ return -1;
+
+ if (strcmp(origin, expected) != 0)
+ return -1;
+
+ return 0;
+}
diff --git a/src/common.h b/src/common.h
@@ -0,0 +1,32 @@
+#ifndef COMMON_H
+#define COMMON_H
+
+#include <stdio.h>
+
+#include "config.h"
+
+#if DEBUG
+#define DPRINTF(fmt, ...) \
+ fprintf(stderr, "DEBUG: " fmt, ##__VA_ARGS__)
+#else
+#define DPRINTF(fmt, ...) \
+ do { } while (0)
+#endif
+
+void reply(const char *, const char *);
+void redirect(const char *);
+
+int build_repository_path(char *path, size_t len, const char *storage_root, const char *repository);
+int build_storage_path(char *path, size_t len, const char *storage_root, const char *repository, const char *filename);
+
+const char *storage_root(void);
+const char *repository(void);
+const char *repository_lock_dir(void);
+int valid_filename(const char *);
+
+int repository_lock(const char *repository);
+void repository_unlock(int);
+
+int check_csrf_origin(void);
+
+#endif
diff --git a/src/config.c b/src/config.c
@@ -0,0 +1,309 @@
+#include <ctype.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "common.h"
+#include "config.h"
+
+static int
+build_config_path(char *path, size_t len)
+{
+ char repository_path[PATH_MAX];
+
+ if (build_repository_path(repository_path,
+ sizeof(repository_path),
+ storage_root(),
+ repository()) == -1)
+ return -1;
+
+ if (snprintf(path, len, "%s/%s", repository_path, CONFIG_FILENAME) >=
+ (int) len)
+ return -1;
+
+ return 0;
+}
+
+static off_t
+config_value(const char *name, off_t default_value)
+{
+ char path[PATH_MAX];
+ char line[256];
+ FILE *fp;
+ size_t namelen;
+
+ if (build_config_path(path, sizeof(path)) == -1)
+ return default_value;
+
+ fp = fopen(path, "r");
+ if (fp == NULL)
+ return default_value;
+
+ namelen = strlen(name);
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *p;
+ char *end;
+ char *value;
+ char *ep;
+ long long parsed;
+
+ /* Strip newline. */
+ line[strcspn(line, "\r\n")] = '\0';
+
+ /* Skip leading whitespace. */
+ p = line;
+ while (isspace((unsigned char) *p))
+ p++;
+
+ /* Ignore blank lines and comments. */
+ if (*p == '\0' || *p == '#')
+ continue;
+
+ /*
+ * Match:
+ *
+ * NAME=value
+ *
+ * and allow whitespace around the '='.
+ */
+ if (strncmp(p, name, namelen) != 0)
+ continue;
+
+ end = p + namelen;
+
+ if (*end != '=' && !isspace((unsigned char) *end))
+ continue;
+
+ while (isspace((unsigned char) *end))
+ end++;
+
+ if (*end != '=')
+ continue;
+
+ end++;
+ while (isspace((unsigned char) *end))
+ end++;
+
+ value = end;
+
+ /* Trim trailing whitespace. */
+ end = value + strlen(value);
+ while (end > value &&
+ isspace((unsigned char) end[-1]))
+ end--;
+ *end = '\0';
+
+ if (*value == '\0')
+ continue;
+
+ errno = 0;
+ parsed = strtoll(value, &ep, 10);
+
+ if (errno != 0 || ep == value || *ep != '\0')
+ continue;
+
+ if (parsed <= 0)
+ continue;
+
+ fclose(fp);
+ return (off_t) parsed;
+ }
+
+ fclose(fp);
+ return default_value;
+}
+
+static int
+config_string_file(const char *path,
+ const char *name,
+ char *value,
+ size_t value_len)
+{
+ char line[256];
+ FILE *fp;
+ size_t namelen;
+
+ if (path == NULL || name == NULL ||
+ value == NULL || value_len == 0)
+ return -1;
+
+ fp = fopen(path, "r");
+ if (fp == NULL)
+ return -1;
+
+ namelen = strlen(name);
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *p;
+ char *end;
+ char *v;
+ size_t len;
+
+ line[strcspn(line, "\r\n")] = '\0';
+
+ p = line;
+ while (isspace((unsigned char) *p))
+ p++;
+
+ if (*p == '\0' || *p == '#')
+ continue;
+
+ if (strncmp(p, name, namelen) != 0)
+ continue;
+
+ end = p + namelen;
+
+ if (*end != '=' && !isspace((unsigned char) *end))
+ continue;
+ while (isspace((unsigned char) *end))
+ end++;
+
+ if (*end != '=')
+ continue;
+
+ end++;
+ while (isspace((unsigned char) *end))
+ end++;
+
+ v = end;
+
+ end = v + strlen(v);
+ while (end > v &&
+ isspace((unsigned char) end[-1]))
+ end--;
+
+ *end = '\0';
+
+ if (*v == '\0')
+ continue;
+
+ len = strlen(v);
+
+ if (len >= value_len) {
+ fclose(fp);
+ return -1;
+ }
+ memcpy(value, v, len + 1);
+
+ fclose(fp);
+ return 0;
+ }
+
+ fclose(fp);
+ return -1;
+}
+
+static const char *
+global_config_filename(void)
+{
+ const char *path;
+
+ path = getenv("SIMPLEFILES_CONFIG");
+
+ if (path != NULL && *path != '\0')
+ return path;
+
+ return GLOBAL_CONFIG_FILENAME;
+}
+
+int
+global_config_string(const char *name,
+ char *value,
+ size_t value_len)
+{
+ return config_string_file(
+ global_config_filename(),
+ name,
+ value,
+ value_len);
+}
+
+int
+global_config_csrf_origin(char *origin,
+ size_t origin_len)
+{
+ return global_config_string(
+ "CSRF_ORIGIN",
+ origin,
+ origin_len);
+}
+
+const char *
+simplefiles_root(void)
+{
+ const char *root;
+
+ root = getenv("SIMPLEFILES_ROOT");
+ if (root != NULL && *root != '\0')
+ return root;
+
+ return SIMPLEFILES_ROOT_DEFAULT;
+}
+off_t
+config_max_upload(void)
+{
+ return config_value("MAX_UPLOAD", DEFAULT_MAX_UPLOAD);
+}
+off_t
+config_repo_quota(void)
+{
+ return config_value("QUOTA", DEFAULT_REPO_QUOTA);
+}
+
+int
+config_write_allowed(void)
+{
+ char path[PATH_MAX];
+ char value[16];
+ char admin[64];
+ const char *remote_user;
+
+ if (build_config_path(path, sizeof(path)) == -1)
+ return 0;
+
+ if (config_string_file(path,
+ "READ_ONLY",
+ value,
+ sizeof(value)) == 0 &&
+ strcmp(value, "1") == 0)
+ return 0;
+
+ if (config_string_file(path,
+ "ADMIN_USER",
+ admin,
+ sizeof(admin)) == -1)
+ return 0;
+
+ remote_user = getenv("REMOTE_USER");
+
+ if (remote_user == NULL || *remote_user == '\0')
+ return 0;
+
+ return strcmp(remote_user, admin) == 0;
+}
+
+int
+config_access_allowed(void)
+{
+ char value[16];
+ const char *user;
+
+ user = getenv("REMOTE_USER");
+
+ /*
+ * Authenticated users may read.
+ */
+ if (user != NULL && *user != '\0')
+ return 1;
+
+ /* Anonymous read must be explicitly enabled. */
+ if (global_config_string("ANONYMOUS_READ",
+ value, sizeof(value)) == 0 &&
+ strcmp(value, "1") == 0)
+ return 1;
+
+ return 0;
+}
diff --git a/src/config.h b/src/config.h
@@ -0,0 +1,40 @@
+#ifndef CONFIG_H
+#define CONFIG_H
+
+#include <stddef.h>
+#include <sys/types.h>
+
+#define DEFAULT_REDIRECT_URI "./"
+#define DEFAULT_STORAGE_ROOT "/storage"
+#define DEFAULT_REPOSITORY "default"
+#define DEFAULT_REPOSITORY_LOCK_DIR "/run/simplefiles/lock"
+
+#define SIMPLEFILES_ROOT_DEFAULT "/simplefiles"
+#define GLOBAL_CONFIG_FILENAME "/etc/simplefiles.conf"
+
+#define UPLOAD_TEMPLATE_NAME "upload.html.template"
+#define DELETE_TEMPLATE_NAME "delete.html.template"
+#define LIST_TEMPLATE_NAME "list.html.template"
+#define LIST_READONLY_TEMPLATE_NAME "list-readonly.html.template"
+#define RENAME_TEMPLATE_NAME "rename.html.template"
+
+#define DEFAULT_MAX_UPLOAD (100LL * 1024 * 1024)
+#define DEFAULT_REPO_QUOTA (5LL * 1024 * 1024 * 1024)
+#define DEFAULT_MIN_FREE (1LL * 1024 * 1024 * 1024)
+
+#define DEBUG 0
+
+#define CONFIG_FILENAME ".config"
+#define CSRF_ORIGIN_MAX 256
+
+const char *simplefiles_root(void);
+off_t config_max_upload(void);
+off_t config_repo_quota(void);
+
+int global_config_string(const char *, char *, size_t);
+int global_config_csrf_origin(char *, size_t);
+
+int config_write_allowed(void);
+int config_access_allowed(void);
+
+#endif
diff --git a/src/delete.c b/src/delete.c
@@ -0,0 +1,191 @@
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "common.h"
+#include "config.h"
+#include "html.h"
+#include "http.h"
+#include "request.h"
+#include "url.h"
+
+static int do_get(void);
+static int do_post(void);
+
+static int
+do_get(void)
+{
+ char filename[NAME_MAX + 1];
+ char path[PATH_MAX];
+ struct stat st;
+ char template[PATH_MAX];
+
+ if (!config_write_allowed()) {
+ reply("403 Forbidden", "Repository is read-only");
+ return 0;
+ }
+ if (get_filename(getenv("QUERY_STRING"), filename, sizeof(filename)) == -1)
+ return 0;
+
+ if (build_storage_path(path,
+ sizeof(path),
+ storage_root(),
+ repository(),
+ filename) == -1) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return 1;
+ }
+ if (stat(path, &st) == -1 || !S_ISREG(st.st_mode)) {
+ reply("404 Not Found", "File not found");
+ return 0;
+ }
+ struct html_var vars[] = {
+ {"FILENAME", filename},
+ {"CANCEL_URI", DEFAULT_REDIRECT_URI},
+ };
+
+ if (snprintf(template, sizeof(template),
+ "%s/%s",
+ simplefiles_root(),
+ DELETE_TEMPLATE_NAME) >= (int) sizeof(template)) {
+ reply("500 Internal Server Error", "Template path too long");
+ return 0;
+ }
+ if (render_template_response(template,
+ vars, sizeof(vars) / sizeof(vars[0]),
+ NULL, 0) == -1) {
+ fprintf(stderr, "render_template failed: %s\n", template);
+ reply("500 Internal Server Error", "Template error");
+ return 0;
+ }
+ return 0;
+}
+
+static int
+do_post(void)
+{
+ const char *p;
+ char *body;
+ char filename[NAME_MAX + 1];
+ char path[PATH_MAX];
+ char *end;
+ long len;
+ int rc;
+ int lock_fd;
+
+ if (!config_write_allowed()) {
+ reply("403 Forbidden", "Repository is read-only");
+ return 0;
+ }
+ rc = check_csrf_origin();
+
+ if (rc == -2) {
+ reply("500 Internal Server Error",
+ "CSRF origin is not configured");
+ return 0;
+ }
+ if (rc == -1) {
+ reply("403 Forbidden", "Forbidden");
+ return 0;
+ }
+ p = getenv("CONTENT_LENGTH");
+ if (p == NULL) {
+ reply("400 Bad Request",
+ "Missing content length");
+ return 0;
+ }
+ errno = 0;
+ len = strtol(p, &end, 10);
+
+ if (errno != 0 ||
+ end == p ||
+ *end != '\0' ||
+ len <= 0 ||
+ len > 1024) {
+ reply("400 Bad Request",
+ "Invalid content length");
+ return 0;
+ }
+ body = malloc((size_t) len + 1);
+ if (body == NULL) {
+ reply("500 Internal Server Error",
+ "Out of memory");
+ return 0;
+ }
+ if (fread(body, 1, (size_t) len, stdin) != (size_t) len) {
+ free(body);
+ reply("400 Bad Request",
+ "Could not read request");
+ return 0;
+ }
+ body[len] = '\0';
+
+ if (get_filename(body, filename, sizeof(filename)) == -1) {
+ free(body);
+ return 0;
+ }
+ free(body);
+ body = NULL;
+
+ if (build_storage_path(path,
+ sizeof(path),
+ storage_root(),
+ repository(),
+ filename) == -1) {
+
+ reply("500 Internal Server Error",
+ "Path too long");
+
+ return 0;
+ }
+ lock_fd = repository_lock(repository());
+
+ if (lock_fd == -1) {
+ reply("500 Internal Server Error",
+ "Could not lock repository");
+ return 0;
+ }
+ if (unlink(path) == -1) {
+ if (errno == ENOENT) {
+ reply("404 Not Found",
+ "File not found");
+ } else {
+ reply("500 Internal Server Error",
+ "Could not delete file");
+ }
+ repository_unlock(lock_fd);
+ return 0;
+ }
+ repository_unlock(lock_fd);
+ redirect(DEFAULT_REDIRECT_URI);
+
+ return 0;
+}
+
+int
+main(void)
+{
+ const char *method;
+
+ method = getenv("REQUEST_METHOD");
+
+ if (method == NULL) {
+ reply("400 Bad Request", "Missing request method");
+ return 0;
+ }
+ if (strcmp(method, "GET") == 0)
+ return do_get();
+
+ if (strcmp(method, "POST") == 0)
+ return do_post();
+
+ reply("405 Method Not Allowed",
+ "GET or POST required");
+
+ return 0;
+}
diff --git a/src/download.c b/src/download.c
@@ -0,0 +1,68 @@
+#include <limits.h>
+#include <sys/stat.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "common.h"
+#include "config.h"
+#include "http.h"
+#include "request.h"
+#include "url.h"
+
+int
+main(void)
+{
+ const char *method;
+ char filename[NAME_MAX + 1];
+ char path[PATH_MAX];
+ struct stat st;
+ FILE *fp;
+ char buffer[8192];
+ size_t n;
+
+ method = getenv("REQUEST_METHOD");
+ if (method == NULL || strcmp(method, "GET") != 0) {
+ reply("405 Method Not Allowed", "GET required");
+ return 0;
+ }
+
+ if (!config_access_allowed()) {
+ reply("403 Forbidden", "Forbidden");
+ return 0;
+ }
+
+ if (get_filename(getenv("QUERY_STRING"), filename, sizeof(filename)) == -1)
+ return 0;
+ if (build_storage_path(path,
+ sizeof(path),
+ storage_root(),
+ repository(),
+ filename) == -1) {
+ reply("500 Internal Server Error", "Path too long");
+ return 0;
+ }
+ if (lstat(path, &st) == -1 || !S_ISREG(st.st_mode)) {
+ reply("404 Not Found", "File not found");
+ return 0;
+ }
+ if (download_headers(filename, st.st_size) == -1) {
+ reply("400 Bad Request", "Invalid filename");
+ return 0;
+ }
+
+ fp = fopen(path, "rb");
+ if (fp == NULL) {
+ reply("500 Internal Server Error", "Could not open file");
+ return 0;
+ }
+ while ((n = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
+ if (fwrite(buffer, 1, n, stdout) != n) {
+ break;
+ }
+ }
+
+ fclose(fp);
+ return 0;
+}
diff --git a/src/html.c b/src/html.c
@@ -0,0 +1,361 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "html.h"
+
+static const char *
+find_variable(const char *,
+ const struct html_var *, size_t,
+ const struct html_var *, size_t);
+
+static int
+render_text(FILE *,
+ const char *,
+ size_t,
+ const struct html_var *,
+ size_t,
+ const struct html_var *,
+ size_t);
+
+static const struct html_block *
+find_block(const char *, const struct html_block *, size_t);
+static const char *
+find_marker(const char *, size_t, const char[2]);
+
+void
+html_escape(FILE * fp, const char *s)
+{
+ unsigned char c;
+
+ while ((c = (unsigned char) *s++) != '\0') {
+ switch (c) {
+ case '&':
+ fputs("&", fp);
+ break;
+
+ case '<':
+ fputs("<", fp);
+ break;
+
+ case '>':
+ fputs(">", fp);
+ break;
+
+ case '"':
+ fputs(""", fp);
+ break;
+
+ case '\'':
+ fputs("'", fp);
+ break;
+
+ default:
+ fputc(c, fp);
+ break;
+ }
+ }
+}
+
+/*
+ * NOTE: Templates may contain at most one block.
+ */
+int
+render_template(FILE *fp, const char *filename,
+ const struct html_var *vars, size_t nvars,
+ const struct html_block *blocks, size_t nblocks)
+{
+ FILE *template;
+ char *text;
+ long size;
+ size_t len;
+ const char *block_start;
+ const char *block_end;
+ const char *p;
+ char block_name[128];
+ size_t block_name_len;
+ const struct html_block *block;
+ size_t i;
+
+ template = fopen(filename, "r");
+ if (template == NULL)
+ return -1;
+
+ if (fseek(template, 0, SEEK_END) == -1) {
+ fclose(template);
+ return -1;
+ }
+
+ size = ftell(template);
+ if (size < 0) {
+ fclose(template);
+ return -1;
+ }
+
+ if (fseek(template, 0, SEEK_SET) == -1) {
+ fclose(template);
+ return -1;
+ }
+
+ len = (size_t)size;
+
+ text = malloc(len + 1);
+ if (text == NULL) {
+ fclose(template);
+ return -1;
+ }
+
+ if (fread(text, 1, len, template) != len) {
+ free(text);
+ fclose(template);
+ return -1;
+ }
+
+ text[len] = '\0';
+
+ fclose(template);
+
+ /*
+ * Find the first block.
+ */
+ block_start = strstr(text, "{{#");
+
+ if (block_start == NULL) {
+ /*
+ * No blocks: render the whole template normally.
+ */
+ if (render_text(fp, text, len,
+ vars, nvars,
+ NULL, 0) == -1) {
+ free(text);
+ return -1;
+ }
+
+ free(text);
+ return 0;
+ }
+
+ /*
+ * Render text before the block.
+ */
+ if (render_text(fp,
+ text,
+ (size_t)(block_start - text),
+ vars, nvars,
+ NULL, 0) == -1) {
+ free(text);
+ return -1;
+ }
+
+ /*
+ * Find the end of the block name.
+ */
+ block_end = strstr(block_start + 3, "}}");
+ if (block_end == NULL) {
+ free(text);
+ return -1;
+ }
+
+ block_name_len =
+ (size_t)(block_end - (block_start + 3));
+
+ if (block_name_len == 0 ||
+ block_name_len >= sizeof(block_name)) {
+ free(text);
+ return -1;
+ }
+
+ memcpy(block_name,
+ block_start + 3,
+ block_name_len);
+ block_name[block_name_len] = '\0';
+
+ block = find_block(block_name, blocks, nblocks);
+ if (block == NULL) {
+ free(text);
+ return -1;
+ }
+
+ /*
+ * Find the closing block marker.
+ */
+ p = block_end + 2;
+
+ {
+ char closing[128];
+
+ if (snprintf(closing, sizeof(closing),
+ "{{/%s}}", block_name) >=
+ (int)sizeof(closing)) {
+ free(text);
+ return -1;
+ }
+
+ block_start = strstr(p, closing);
+ if (block_start == NULL) {
+ free(text);
+ return -1;
+ }
+
+ block_end = block_start;
+ }
+
+ /*
+ * Render the block once for every row.
+ */
+ for (i = 0; i < block->nrows; i++) {
+ const char *row_start = p;
+ size_t row_len =
+ (size_t)(block_end - row_start);
+
+ if (render_text(fp,
+ row_start,
+ row_len,
+ vars, nvars,
+ block->rows[i].vars,
+ block->rows[i].nvars) == -1) {
+ free(text);
+ return -1;
+ }
+ }
+
+ /*
+ * Render anything after the block.
+ */
+ p = block_end + strlen(
+ "{{/");
+
+ p = strstr(p, "}}");
+ if (p == NULL) {
+ free(text);
+ return -1;
+ }
+
+ p += 2;
+
+ if (render_text(fp,
+ p,
+ strlen(p),
+ vars, nvars,
+ NULL, 0) == -1) {
+ free(text);
+ return -1;
+ }
+
+ free(text);
+ return 0;
+}
+
+static const char *
+find_variable(const char *name,
+ const struct html_var * vars, size_t nvars,
+ const struct html_var * row_vars, size_t nrowvars)
+{
+ size_t i;
+
+ for (i = 0; i < nrowvars; i++) {
+ if (strcmp(name, row_vars[i].name) == 0)
+ return row_vars[i].value;
+ }
+
+ for (i = 0; i < nvars; i++) {
+ if (strcmp(name, vars[i].name) == 0)
+ return vars[i].value;
+ }
+
+ return NULL;
+}
+
+static int
+render_text(FILE * fp,
+ const char *text,
+ size_t len,
+ const struct html_var * vars,
+ size_t nvars,
+ const struct html_var * row_vars,
+ size_t nrowvars)
+{
+ const char *p;
+ const char *end;
+
+ p = text;
+ end = text + len;
+
+ while (p < end) {
+ const char *start;
+ const char *close;
+ char name[128];
+ size_t namelen;
+ const char *value;
+
+ start = find_marker(p, (size_t)(end - p), "{{");
+
+ if (start == NULL) {
+ if (fwrite(p, 1, (size_t) (end - p), fp) !=
+ (size_t) (end - p))
+ return -1;
+ break;
+ }
+ if (fwrite(p, 1, (size_t) (start - p), fp) !=
+ (size_t) (start - p))
+ return -1;
+
+ close = find_marker(start + 2, (size_t)(end - (start + 2)), "}}");
+
+ if (close == NULL)
+ return -1;
+
+ namelen = (size_t) (close - (start + 2));
+
+ if (namelen == 0 || namelen >= sizeof(name))
+ return -1;
+
+ memcpy(name, start + 2, namelen);
+ name[namelen] = '\0';
+
+ value = find_variable(name,
+ vars, nvars,
+ row_vars, nrowvars);
+
+ if (value == NULL)
+ return -1;
+
+ html_escape(fp, value);
+
+ p = close + 2;
+ }
+
+ return 0;
+}
+
+static const struct html_block *
+find_block(const char *name,
+ const struct html_block *blocks, size_t nblocks)
+{
+ size_t i;
+
+ for (i = 0; i < nblocks; i++) {
+ if (strcmp(name, blocks[i].name) == 0)
+ return &blocks[i];
+ }
+
+ return NULL;
+}
+
+static const char *
+find_marker(const char *text, size_t len, const char marker[2])
+{
+ size_t i;
+
+ if (len < 2)
+ return NULL;
+
+ for (i = 0; i + 1 < len; i++) {
+ if (text[i] == marker[0] &&
+ text[i + 1] == marker[1])
+ return text + i;
+ }
+
+ return NULL;
+}
diff --git a/src/html.h b/src/html.h
@@ -0,0 +1,29 @@
+#ifndef HTML_H
+#define HTML_H
+
+#include <stdio.h>
+#include <stddef.h>
+
+struct html_var {
+ const char *name;
+ const char *value;
+};
+
+struct html_row {
+ const struct html_var *vars;
+ size_t nvars;
+};
+
+struct html_block {
+ const char *name;
+ const struct html_row *rows;
+ size_t nrows;
+};
+
+void html_escape(FILE *fp, const char *s);
+
+int render_template(FILE *, const char *,
+ const struct html_var *, size_t,
+ const struct html_block *, size_t);
+
+#endif
diff --git a/src/http.c b/src/http.c
@@ -0,0 +1,107 @@
+#include <stdio.h>
+
+#include "html.h"
+#include "http.h"
+
+void
+html_headers(const char *status)
+{
+ printf("Status: %s\r\n", status);
+ printf("Content-Type: text/html; charset=utf-8\r\n");
+ printf("X-Content-Type-Options: nosniff\r\n");
+ printf("X-Frame-Options: DENY\r\n");
+ printf("\r\n");
+}
+
+int
+download_headers(const char *filename, off_t size)
+{
+ const unsigned char *p;
+
+ if (filename == NULL)
+ return -1;
+
+ for (p = (const unsigned char *) filename; *p != '\0'; p++) {
+ if (*p == '\r' || *p == '\n')
+ return -1;
+ }
+
+ printf("Status: 200 OK\r\n");
+ printf("Content-Type: application/octet-stream\r\n");
+ printf("Content-Disposition: attachment; filename=\"%s\"\r\n",
+ filename);
+ printf("Content-Length: %lld\r\n", (long long) size);
+ printf("X-Content-Type-Options: nosniff\r\n");
+ printf("X-Frame-Options: DENY\r\n");
+ printf("\r\n");
+
+ return 0;
+}
+
+void
+reply(const char *status, const char *msg)
+{
+ html_headers(status);
+
+ printf("<!doctype html>\n");
+ printf("<html>\n");
+ printf("<body>\n");
+ printf("<h1>");
+ html_escape(stdout, msg);
+ printf("</h1>\n");
+ printf("</body>\n");
+ printf("</html>\n");
+}
+
+void
+redirect(const char *uri)
+{
+ printf("Status: 303 See Other\r\n");
+ printf("Location: %s\r\n", uri);
+ printf("\r\n");
+}
+
+int
+render_template_response(const char *template,
+ const struct html_var *vars, size_t nvars,
+ const struct html_block *blocks, size_t nblocks)
+{
+ FILE *tmp;
+ char buffer[8192];
+ size_t n;
+
+ tmp = tmpfile();
+ if (tmp == NULL)
+ return -1;
+
+ if (render_template(tmp,
+ template,
+ vars, nvars,
+ blocks, nblocks) == -1) {
+ fclose(tmp);
+ return -1;
+ }
+
+ if (fflush(tmp) == EOF ||
+ fseek(tmp, 0, SEEK_SET) == -1) {
+ fclose(tmp);
+ return -1;
+ }
+
+ html_headers("200 OK");
+
+ while ((n = fread(buffer, 1, sizeof(buffer), tmp)) > 0) {
+ if (fwrite(buffer, 1, n, stdout) != n) {
+ fclose(tmp);
+ return -1;
+ }
+ }
+
+ if (ferror(tmp)) {
+ fclose(tmp);
+ return -1;
+ }
+
+ fclose(tmp);
+ return 0;
+}
diff --git a/src/http.h b/src/http.h
@@ -0,0 +1,17 @@
+#ifndef HTTP_H
+#define HTTP_H
+
+#include <sys/types.h>
+
+struct html_var;
+struct html_block;
+
+void html_headers(const char *status);
+int download_headers(const char *, off_t);
+void reply(const char *, const char *);
+void redirect(const char *);
+int render_template_response(const char *,
+ const struct html_var *, size_t,
+ const struct html_block *, size_t);
+
+#endif
diff --git a/src/list.c b/src/list.c
@@ -0,0 +1,217 @@
+#include <sys/stat.h>
+#include <dirent.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "config.h"
+#include "common.h"
+#include "url.h"
+#include "html.h"
+#include "http.h"
+
+static int list_files(const char *repository_path, struct html_row ** rows, size_t * nrows);
+static void format_size(char *buf, size_t len, off_t size);
+static void free_rows(struct html_row *rows, size_t nrows);
+
+int
+main(void)
+{
+ char path[PATH_MAX];
+ char template[PATH_MAX];
+ struct html_row *rows;
+ size_t nrows;
+ struct html_block blocks[1];
+ const char *template_name;
+
+ if (!config_access_allowed()) {
+ reply("403 Forbidden", "Forbidden");
+ return 0;
+ }
+
+ if (build_repository_path(path,
+ sizeof(path),
+ storage_root(),
+ repository()) == -1) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return 0;
+ }
+ if (list_files(path, &rows, &nrows) == -1) {
+ reply("500 Internal Server Error",
+ "Unable to list files");
+ return 0;
+ }
+
+ blocks[0].name = "FILES";
+ blocks[0].rows = rows;
+ blocks[0].nrows = nrows;
+
+
+ if (!config_write_allowed())
+ template_name = LIST_READONLY_TEMPLATE_NAME;
+ else
+ template_name = LIST_TEMPLATE_NAME;
+
+ if (snprintf(template, sizeof(template),
+ "%s/%s",
+ simplefiles_root(),
+ template_name) >= (int) sizeof(template)) {
+ reply("500 Internal Server Error",
+ "Template path too long");
+ free_rows(rows, nrows);
+ return 0;
+ }
+
+ if (render_template_response(template,
+ NULL, 0,
+ blocks, 1) == -1) {
+ free_rows(rows, nrows);
+ reply("500 Internal Server Error", "Unable to render template");
+ return 0;
+ }
+ free_rows(rows, nrows);
+ return 0;
+}
+
+static void
+format_size(char *buf, size_t len, off_t size)
+{
+ if (size >= 1073741824LL)
+ snprintf(buf, len, "%.1f GB", size / 1073741824.0);
+ else if (size >= 1048576LL)
+ snprintf(buf, len, "%.1f MB", size / 1048576.0);
+ else if (size >= 1024LL)
+ snprintf(buf, len, "%.1f KB", size / 1024.0);
+ else
+ snprintf(buf, len, "%lld B", (long long) size);
+}
+
+static int
+list_files(const char *repository_path,
+ struct html_row ** rows, size_t * nrows)
+{
+ DIR *dir;
+ struct dirent *de;
+ struct stat st;
+ char path[PATH_MAX];
+ char timebuf[32];
+ struct tm *tm;
+ char sizebuf[32];
+ char *encoded;
+ struct html_row *new_rows;
+ struct html_var *vars;
+ size_t count = 0;
+
+ *rows = NULL;
+ *nrows = 0;
+
+ dir = opendir(repository_path);
+ if (dir == NULL)
+ return 0;
+
+ while ((de = readdir(dir)) != NULL) {
+ if (strcmp(de->d_name, ".") == 0 ||
+ strcmp(de->d_name, "..") == 0)
+ continue;
+
+ if (strcmp(de->d_name, CONFIG_FILENAME) == 0)
+ continue;
+
+ if (snprintf(path, sizeof(path),
+ "%s/%s",
+ repository_path,
+ de->d_name) >= (int) sizeof(path))
+ continue;
+
+ if (stat(path, &st) == -1)
+ continue;
+
+ if (!S_ISREG(st.st_mode))
+ continue;
+
+ tm = localtime(&st.st_mtime);
+ if (tm == NULL)
+ continue;
+
+ if (strftime(timebuf, sizeof(timebuf),
+ "%Y-%m-%d %H:%M", tm) == 0)
+ continue;
+
+ encoded = url_encode(de->d_name);
+ if (encoded == NULL)
+ continue;
+
+
+ new_rows = realloc(*rows, (count + 1) * sizeof(**rows));
+ if (new_rows == NULL) {
+ free_rows(*rows, count);
+ free(encoded);
+ closedir(dir);
+ return -1;
+ }
+ *rows = new_rows;
+
+ vars = malloc(4 * sizeof(*vars));
+ if (vars == NULL) {
+ free_rows(*rows, count);
+ free(encoded);
+ closedir(dir);
+ return -1;
+ }
+ vars[0].name = "NAME";
+ vars[0].value = strdup(de->d_name);
+
+ vars[1].name = "URL";
+ vars[1].value = encoded;
+
+ format_size(sizebuf, sizeof(sizebuf), st.st_size);
+ vars[2].name = "SIZE";
+ vars[2].value = strdup(sizebuf);
+
+ vars[3].name = "MODIFIED";
+ vars[3].value = strdup(timebuf);
+
+ if (vars[0].value == NULL ||
+ vars[2].value == NULL ||
+ vars[3].value == NULL) {
+ free((char *) vars[0].value);
+ free((char *) vars[1].value);
+ free((char *) vars[2].value);
+ free((char *) vars[3].value);
+ free(vars);
+ free_rows(*rows, count);
+ *rows = NULL;
+ closedir(dir);
+ return -1;
+ }
+ (*rows)[count].vars = vars;
+ (*rows)[count].nvars = 4;
+ count++;
+ }
+
+ closedir(dir);
+
+ *nrows = count;
+ return 0;
+}
+
+static void
+free_rows(struct html_row *rows, size_t nrows)
+{
+ size_t i;
+ size_t j;
+
+ for (i = 0; i < nrows; i++) {
+ for (j = 0; j < rows[i].nvars; j++)
+ free((char *) rows[i].vars[j].value);
+
+ free((struct html_var *) rows[i].vars);
+ }
+
+ free(rows);
+}
diff --git a/src/multipart.c b/src/multipart.c
@@ -0,0 +1,1006 @@
+#include <ctype.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "config.h"
+#include "multipart.h"
+
+#define BOUNDARY_MAX 128
+#define MULTIPART_BOUNDARY_MAX 70
+#define MIME_TYPE "multipart/form-data"
+
+static int
+parse_content_disposition(char *value, char *filename, size_t size)
+{
+ char *p;
+ char *start;
+ char *end;
+ size_t len;
+ int seen_filename;
+ int seen_name;
+
+ if (value == NULL || filename == NULL || size == 0)
+ return MULTIPART_ERROR;
+
+ p = value;
+ seen_filename = 0;
+ seen_name = 0;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (strncasecmp(p, "form-data", 9) != 0)
+ return MULTIPART_MALFORMED;
+
+ p += 9;
+
+ if (*p != ';' && *p != '\0')
+ return MULTIPART_MALFORMED;
+
+ while (*p != '\0') {
+ /*
+ * Parameter separator.
+ */
+ if (*p != ';')
+ return MULTIPART_MALFORMED;
+
+ p++;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ /*
+ * Parameter name.
+ */
+ start = p;
+
+ while (*p != '\0' &&
+ *p != '=' &&
+ *p != ';' &&
+ *p != ' ' &&
+ *p != '\t')
+ p++;
+
+ if (p == start)
+ return MULTIPART_MALFORMED;
+
+ end = p;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '=')
+ return MULTIPART_MALFORMED;
+
+ p++;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if ((size_t) (end - start) == 4 &&
+ strncasecmp(start, "name", 4) == 0) {
+ if (seen_name)
+ return MULTIPART_MALFORMED;
+
+ seen_name = 1;
+
+ if (*p != '"')
+ return MULTIPART_MALFORMED;
+
+ p++;
+ start = p;
+
+ while (*p != '\0' && *p != '"')
+ p++;
+
+ if (*p != '"' || p == start)
+ return MULTIPART_MALFORMED;
+
+ len = (size_t) (p - start);
+
+ if (len != 4 || strncasecmp(start, "file", 4) != 0)
+ return MULTIPART_MALFORMED;
+
+ p++;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '\0' && *p != ';')
+ return MULTIPART_MALFORMED;
+
+ } else if ((size_t) (end - start) == 8 &&
+ strncasecmp(start, "filename", 8) == 0) {
+ if (seen_filename)
+ return MULTIPART_MALFORMED;
+
+ seen_filename = 1;
+
+ if (*p != '"')
+ return MULTIPART_MALFORMED;
+
+ p++;
+ start = p;
+
+ while (*p != '\0' && *p != '"')
+ p++;
+
+ if (*p != '"' || p == start)
+ return MULTIPART_MALFORMED;
+
+ len = (size_t) (p - start);
+
+ if (len >= size)
+ return MULTIPART_MALFORMED;
+
+ memcpy(filename, start, len);
+ filename[len] = '\0';
+
+ p++;
+
+ /*
+ * After a quoted filename, only whitespace,
+ * another parameter, or end-of-line is allowed.
+ */
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '\0' && *p != ';')
+ return MULTIPART_MALFORMED;
+ } else {
+ /*
+ * Ignore unrelated parameters, but require a
+ * syntactically valid value.
+ */
+ if (*p == '"') {
+ p++;
+
+ while (*p != '\0' && *p != '"')
+ p++;
+
+ if (*p != '"')
+ return MULTIPART_MALFORMED;
+
+ p++;
+ } else {
+ start = p;
+
+ while (*p != '\0' &&
+ *p != ';' &&
+ *p != ' ' &&
+ *p != '\t')
+ p++;
+
+ if (p == start)
+ return MULTIPART_MALFORMED;
+ }
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '\0' && *p != ';')
+ return MULTIPART_MALFORMED;
+ }
+ }
+
+ if (!seen_name)
+ return MULTIPART_MALFORMED;
+
+ if (!seen_filename)
+ return MULTIPART_MISSING_FILENAME;
+
+ return MULTIPART_OK;
+}
+
+static int
+next_part_is_file(int fd, off_t offset,
+ const struct multipart_request *req)
+{
+ char headers[8192];
+ char filename[NAME_MAX + 1];
+ char *line;
+ char *next;
+ char *disposition;
+ size_t used;
+ ssize_t n;
+ int disposition_found;
+
+ if (req == NULL)
+ return MULTIPART_ERROR;
+
+ if (lseek(fd, offset, SEEK_SET) == (off_t)-1)
+ return MULTIPART_ERROR;
+
+ used = 0;
+
+ while (used < sizeof(headers) - 1) {
+ n = read(fd, headers + used, 1);
+
+ if (n == 0)
+ return MULTIPART_MALFORMED;
+
+ if (n < 0)
+ return MULTIPART_ERROR;
+
+ used++;
+
+ if (used >= 4 &&
+ headers[used - 4] == '\r' &&
+ headers[used - 3] == '\n' &&
+ headers[used - 2] == '\r' &&
+ headers[used - 1] == '\n')
+ break;
+ }
+
+ if (used >= sizeof(headers) - 1)
+ return MULTIPART_MALFORMED;
+
+ headers[used] = '\0';
+
+ line = headers;
+ disposition_found = 0;
+
+ while (*line != '\0') {
+ next = strstr(line, "\r\n");
+
+ if (next == NULL)
+ return MULTIPART_MALFORMED;
+
+ /*
+ * Empty line here would mean the header terminator was
+ * encountered. Since we already read CRLFCRLF, this is
+ * the end of the header section.
+ */
+ if (next == line)
+ break;
+
+ *next = '\0';
+
+ if (strncasecmp(line, "Content-Disposition:", 20) == 0) {
+ if (disposition_found)
+ return MULTIPART_MALFORMED;
+
+ disposition_found = 1;
+
+ disposition = line + 20;
+
+ while (*disposition == ' ' ||
+ *disposition == '\t')
+ disposition++;
+
+ n = parse_content_disposition(
+ disposition,
+ filename,
+ sizeof(filename));
+
+ if (n != MULTIPART_OK)
+ return (int)n;
+ }
+
+ line = next + 2;
+ }
+
+ if (!disposition_found)
+ return MULTIPART_MISSING_FILENAME;
+
+ return MULTIPART_OK;
+}
+
+static int
+write_all(int fd, const void *buf, size_t len)
+{
+ const unsigned char *p = buf;
+ size_t remaining = len;
+
+ while (remaining > 0) {
+ ssize_t n;
+
+ n = write(fd, p, remaining);
+
+ if (n <= 0)
+ return -1;
+
+ p += n;
+ remaining -= (size_t) n;
+ }
+
+ return 0;
+}
+
+static int
+is_boundary_char(unsigned char c)
+{
+ if (isalnum(c))
+ return 1;
+
+ switch (c) {
+ case '\'':
+ case '(':
+ case ')':
+ case '+':
+ case '_':
+ case ',':
+ case '-':
+ case '.':
+ case '/':
+ case ':':
+ case '=':
+ case '?':
+ return 1;
+ default:
+ return 0;
+ }
+}
+/*
+ * Parse the Content-Type boundary.
+ *
+ * This intentionally preserves the existing parser behavior for now.
+ * Boundary parsing can be hardened in a later step.
+ */
+static int
+parse_boundary(struct multipart_request * req, const char *ctype)
+{
+ const char *p;
+ const char *start;
+ size_t len;
+ int seen_boundary;
+
+ /*
+ * Content-Type must begin with multipart/form-data.
+ * The media type comparison is case-insensitive.
+ */
+ if (strncasecmp(ctype, "multipart/form-data", 19) != 0)
+ return MULTIPART_INVALID_CONTENT_TYPE;
+
+ p = ctype + 19;
+ seen_boundary = 0;
+
+ /*
+ * Parse MIME parameters.
+ *
+ * We accept optional whitespace around ';', parameter names,
+ * and '='. The boundary value may be quoted.
+ */
+ for (;;) {
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p == '\0') {
+ if (!seen_boundary)
+ return MULTIPART_MISSING_BOUNDARY;
+ return MULTIPART_OK;
+ }
+ if (*p != ';')
+ return MULTIPART_INVALID_CONTENT_TYPE;
+
+ p++;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ /* Parameter name. */
+ start = p;
+
+ while (*p != '\0' &&
+ *p != '=' &&
+ *p != ';' &&
+ *p != ' ' &&
+ *p != '\t')
+ p++;
+
+ len = (size_t) (p - start);
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '=')
+ return MULTIPART_INVALID_CONTENT_TYPE;
+
+ p++;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ /*
+ * We only care about the boundary parameter.
+ * Other parameters are syntactically allowed.
+ */
+ if (len == 8 &&
+ strncasecmp(start, "boundary", 8) == 0) {
+ if (seen_boundary)
+ return MULTIPART_INVALID_BOUNDARY;
+
+ seen_boundary = 1;
+
+ /*
+ * Quoted boundary.
+ */
+ if (*p == '"') {
+ p++;
+ start = p;
+
+ while (*p != '\0' && *p != '"') {
+ if (!is_boundary_char(
+ (unsigned char) *p))
+ return MULTIPART_INVALID_BOUNDARY;
+ p++;
+ }
+
+ if (*p != '"')
+ return MULTIPART_INVALID_BOUNDARY;
+
+ len = (size_t) (p - start);
+
+ if (len == 0 || len >= MULTIPART_BOUNDARY_MAX)
+ return MULTIPART_INVALID_BOUNDARY;
+
+ memcpy(req->boundary, start, len);
+ req->boundary[len] = '\0';
+
+ p++;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '\0' && *p != ';')
+ return MULTIPART_INVALID_CONTENT_TYPE;
+ } else {
+ /*
+ * Unquoted boundary.
+ */
+ start = p;
+
+ while (*p != '\0' && *p != ';' &&
+ *p != ' ' && *p != '\t') {
+ if (!is_boundary_char(
+ (unsigned char) *p))
+ return MULTIPART_INVALID_BOUNDARY;
+ p++;
+ }
+
+ len = (size_t) (p - start);
+
+ if (len == 0 || len >= MULTIPART_BOUNDARY_MAX)
+ return MULTIPART_INVALID_BOUNDARY;
+
+ memcpy(req->boundary, start, len);
+ req->boundary[len] = '\0';
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ if (*p != '\0' && *p != ';')
+ return MULTIPART_INVALID_CONTENT_TYPE;
+ }
+ } else {
+ /*
+ * Skip the value of an unrelated parameter.
+ * Handle quoted values so semicolons inside quotes
+ * aren't mistaken for parameter separators.
+ */
+ if (*p == '"') {
+ p++;
+
+ while (*p != '\0' && *p != '"')
+ p++;
+
+ if (*p != '"')
+ return MULTIPART_INVALID_CONTENT_TYPE;
+
+ p++;
+ } else {
+ while (*p != '\0' && *p != ';')
+ p++;
+ }
+ }
+ }
+}
+
+int
+multipart_parse_request(struct multipart_request * req)
+{
+ const char *ctype;
+ const char *clen;
+ char *ep;
+ unsigned long long len;
+ off_t max_upload;
+ int ret;
+ const char *p;
+
+ if (req == NULL)
+ return MULTIPART_ERROR;
+
+ memset(req, 0, sizeof(*req));
+
+ ctype = getenv("CONTENT_TYPE");
+
+ if (ctype == NULL)
+ return MULTIPART_MISSING_CONTENT_TYPE;
+
+ ret = parse_boundary(req, ctype);
+
+ if (ret != MULTIPART_OK)
+ return ret;
+
+ clen = getenv("CONTENT_LENGTH");
+
+ if (clen == NULL)
+ return MULTIPART_MISSING_LENGTH;
+
+ for (p = clen; *p != '\0'; p++) {
+ if (*p < '0' || *p > '9')
+ return MULTIPART_INVALID_LENGTH;
+ }
+
+ errno = 0;
+
+ len = strtoull(clen, &ep, 10);
+
+ if (errno != 0 ||
+ ep == clen ||
+ *ep != '\0')
+ return MULTIPART_INVALID_LENGTH;
+
+ if (errno == ERANGE || ep == clen || *ep != '\0')
+ return MULTIPART_INVALID_LENGTH;
+
+ max_upload = config_max_upload();
+
+ if (len == 0 ||
+ len > (unsigned long long) max_upload)
+ return MULTIPART_TOO_LARGE;
+
+ req->length = (off_t) len;
+
+ return MULTIPART_OK;
+}
+
+int
+multipart_spool_request(int fd, off_t length)
+{
+ unsigned char buffer[8192];
+ off_t remaining = length;
+
+ while (remaining > 0) {
+ size_t want;
+ ssize_t n;
+
+ want = remaining > (off_t) sizeof(buffer)
+ ? sizeof(buffer)
+ : (size_t) remaining;
+
+ n = read(STDIN_FILENO, buffer, want);
+
+ if (n <= 0)
+ return MULTIPART_ERROR;
+
+ /*
+ * Keep the write loop local to the multipart/spooling
+ * implementation for now.
+ */
+ {
+ const unsigned char *p = buffer;
+ size_t left = (size_t) n;
+
+ while (left > 0) {
+ ssize_t written;
+
+ written = write(fd, p, left);
+
+ if (written <= 0)
+ return MULTIPART_ERROR;
+
+ p += written;
+ left -= (size_t) written;
+ }
+ }
+
+ remaining -= n;
+ }
+
+ if (lseek(fd, 0, SEEK_SET) == (off_t) - 1)
+ return MULTIPART_ERROR;
+
+ return MULTIPART_OK;
+}
+
+static int
+extract_filename(int fd, const struct multipart_request * req,
+ char *filename, size_t size)
+{
+ char headers[8192];
+ size_t used = 0;
+ size_t header_end = 0;
+ char *line;
+ char *next;
+ char *disposition;
+ ssize_t n;
+
+ if (req == NULL || filename == NULL || size == 0)
+ return MULTIPART_ERROR;
+
+ if (lseek(fd, 0, SEEK_SET) == (off_t) - 1)
+ return MULTIPART_ERROR;
+
+ /*
+ * Read only the first multipart part's headers.
+ *
+ * The header terminator is part of the multipart structure, so
+ * anything after it belongs to the payload and must not be used
+ * when identifying the filename.
+ */
+ while (used < sizeof(headers) - 1) {
+ n = read(fd, headers + used, 1);
+
+ if (n == 0)
+ return MULTIPART_MALFORMED;
+
+ if (n < 0)
+ return MULTIPART_ERROR;
+
+ used++;
+
+ if (used >= 4 &&
+ headers[used - 4] == '\r' &&
+ headers[used - 3] == '\n' &&
+ headers[used - 2] == '\r' &&
+ headers[used - 1] == '\n') {
+ header_end = used - 4;
+ break;
+ }
+ }
+
+ if (header_end == 0)
+ return MULTIPART_MALFORMED;
+
+ headers[header_end + 2] = '\0';
+
+ /*
+ * The first line must be the opening multipart boundary.
+ */
+ {
+ size_t boundary_len = strlen(req->boundary);
+ size_t opening_len;
+
+ opening_len = boundary_len + 4; /* "--" + boundary + "\r\n" */
+
+ if (header_end < opening_len ||
+ headers[0] != '-' ||
+ headers[1] != '-' ||
+ memcmp(headers + 2,
+ req->boundary, boundary_len) != 0 ||
+ headers[2 + boundary_len] != '\r' ||
+ headers[3 + boundary_len] != '\n')
+ return MULTIPART_MALFORMED;
+ }
+
+ /*
+ * Examine each header line. Header names are case-insensitive.
+ */
+ line = strchr(headers, '\n');
+ if (line == NULL)
+ return MULTIPART_MALFORMED;
+
+ line++;
+
+ while (*line != '\0') {
+ next = strstr(line, "\r\n");
+
+ if (next == NULL)
+ break;
+
+ *next = '\0';
+
+ if (strncasecmp(line, "Content-Disposition:", 20) == 0) {
+ disposition = line + 20;
+ while (*disposition == ' ' || *disposition == '\t')
+ disposition++;
+
+ return parse_content_disposition(disposition, filename, size);
+
+ }
+ line = next + 2;
+ }
+
+ return MULTIPART_MISSING_FILENAME;
+}
+
+static int
+find_payload(int fd,
+ const struct multipart_request * req,
+ off_t * payload_offset,
+ off_t * payload_length)
+{
+ unsigned char buffer[8192];
+ char delimiter[BOUNDARY_MAX + 3];
+ size_t delimiter_len;
+ size_t match;
+ off_t payload_start;
+ off_t pos;
+ off_t remaining;
+
+ if (req == NULL ||
+ payload_offset == NULL ||
+ payload_length == NULL)
+ return MULTIPART_ERROR;
+
+ {
+ struct stat st;
+
+ if (req->length < 0)
+ return MULTIPART_MALFORMED;
+
+ if (fstat(fd, &st) == -1)
+ return MULTIPART_ERROR;
+
+ if (st.st_size < req->length)
+ return MULTIPART_MALFORMED;
+ }
+
+ if (snprintf(delimiter, sizeof(delimiter),
+ "\r\n--%s", req->boundary) >=
+ (int) sizeof(delimiter))
+ return MULTIPART_ERROR;
+
+ delimiter_len = strlen(delimiter);
+
+ /*
+ * The payload starts after the first CRLFCRLF
+ * terminating the part headers.
+ */
+ if (lseek(fd, 0, SEEK_SET) == (off_t) - 1)
+ return MULTIPART_ERROR;
+
+ pos = 0;
+ remaining = req->length;
+
+ {
+ const char header_end[] = "\r\n\r\n";
+ size_t header_match = 0;
+
+ while (remaining > 0) {
+ size_t want;
+ ssize_t n;
+ size_t i;
+
+ want = remaining > (off_t) sizeof(buffer)
+ ? sizeof(buffer)
+ : (size_t) remaining;
+
+ n = read(fd, buffer, want);
+ if (n < 0)
+ return MULTIPART_ERROR;
+ if (n == 0)
+ return MULTIPART_MALFORMED;
+
+ for (i = 0; i < (size_t) n; i++) {
+ if (buffer[i] ==
+ (unsigned char) header_end[header_match]) {
+ header_match++;
+
+ if (header_match ==
+ sizeof(header_end) - 1) {
+ payload_start =
+ pos + (off_t) i + 1;
+ goto header_found;
+ }
+ } else {
+ header_match = 0;
+ }
+ }
+
+ pos += n;
+ remaining -= n;
+ }
+ }
+
+ /*
+ * No CRLFCRLF means the part headers were never
+ * properly terminated.
+ */
+ return MULTIPART_MALFORMED;
+
+header_found:
+ if (payload_start > req->length)
+ return MULTIPART_MALFORMED;
+
+ if (lseek(fd, payload_start, SEEK_SET) == (off_t) - 1)
+ return MULTIPART_ERROR;
+
+ pos = payload_start;
+ remaining = req->length - payload_start;
+ match = 0;
+
+ while (remaining > 0) {
+ size_t want;
+ ssize_t n;
+ size_t i;
+
+ want = remaining > (off_t) sizeof(buffer)
+ ? sizeof(buffer)
+ : (size_t) remaining;
+
+ n = read(fd, buffer, want);
+ if (n < 0)
+ return MULTIPART_ERROR;
+ if (n == 0)
+ return MULTIPART_MALFORMED;
+
+ for (i = 0; i < (size_t) n; i++) {
+ unsigned char c;
+
+ c = buffer[i];
+
+ if (c == (unsigned char) delimiter[match]) {
+ match++;
+
+ if (match == delimiter_len) {
+ off_t delimiter_end;
+ unsigned char suffix[2];
+ ssize_t suffix_len;
+
+ delimiter_end =
+ pos + (off_t) i + 1;
+
+ /*
+ * The boundary delimiter must be
+ * followed by exactly one of:
+ *
+ * "--" final boundary
+ * "\r\n" next part
+ */
+ suffix_len = pread(fd,
+ suffix, sizeof(suffix),
+ delimiter_end);
+
+ if (suffix_len == -1)
+ return MULTIPART_ERROR;
+
+ /*
+ * We found the beginning of a
+ * boundary, but the request ends
+ * before its required suffix.
+ */
+ if (suffix_len != 2)
+ return MULTIPART_MALFORMED;
+
+ if (suffix[0] == '-' &&
+ suffix[1] == '-') {
+ /*
+ * payload ends immediately before
+ * the CRLF that introduced this
+ * delimiter.
+ */
+ *payload_offset =
+ payload_start;
+
+ *payload_length =
+ delimiter_end -
+ delimiter_len -
+ payload_start;
+
+ if (*payload_length < 0)
+ return MULTIPART_MALFORMED;
+
+ return MULTIPART_OK;
+ }
+ if (suffix[0] == '\r' &&
+ suffix[1] == '\n') {
+ int ret;
+
+ ret = next_part_is_file(fd, delimiter_end + 2, req);
+
+ if (ret == MULTIPART_OK)
+ return MULTIPART_MULTIPLE_FILES;
+
+ if (ret == MULTIPART_MISSING_FILENAME)
+ return MULTIPART_MALFORMED;
+
+ return ret;
+ }
+ /*
+ * This only looked like a boundary.
+ * It is ordinary payload data.
+ */
+ match = 0;
+ continue;
+ }
+ } else {
+ /*
+ * If this byte can begin a new delimiter,
+ * retain it as the first match.
+ */
+ if (c == (unsigned char) delimiter[0])
+ match = 1;
+ else
+ match = 0;
+ }
+ }
+
+ pos += n;
+ remaining -= n;
+ }
+
+ /*
+ * Reaching the end of CONTENT_LENGTH without a valid
+ * closing boundary means the multipart body is malformed.
+ */
+ return MULTIPART_MALFORMED;
+}
+
+int
+multipart_extract_part(int fd,
+ const struct multipart_request * req,
+ struct multipart_part * part)
+{
+ int ret;
+
+ if (req == NULL || part == NULL)
+ return MULTIPART_ERROR;
+
+ memset(part, 0, sizeof(*part));
+
+ ret = extract_filename(fd,
+ req,
+ part->filename,
+ sizeof(part->filename));
+
+ if (ret != MULTIPART_OK)
+ return ret;
+
+ ret = find_payload(fd,
+ req,
+ &part->offset,
+ &part->length);
+
+ if (ret != MULTIPART_OK)
+ return ret;
+
+ return MULTIPART_OK;
+}
+
+int
+multipart_copy_part(int spool_fd,
+ const struct multipart_part * part,
+ int output_fd)
+{
+ unsigned char buffer[8192];
+ off_t remaining;
+
+ if (part == NULL)
+ return MULTIPART_ERROR;
+
+ if (part->offset < 0 || part->length < 0)
+ return MULTIPART_ERROR;
+
+ if (lseek(spool_fd, part->offset, SEEK_SET) == (off_t) - 1)
+ return MULTIPART_ERROR;
+
+ remaining = part->length;
+
+ while (remaining > 0) {
+ size_t want;
+ ssize_t n;
+
+ want = remaining > (off_t) sizeof(buffer)
+ ? sizeof(buffer)
+ : (size_t) remaining;
+
+ n = read(spool_fd, buffer, want);
+
+ if (n <= 0)
+ return MULTIPART_ERROR;
+
+ if (write_all(output_fd, buffer, (size_t) n) == -1)
+ return MULTIPART_ERROR;
+
+ remaining -= n;
+ }
+
+ return MULTIPART_OK;
+}
diff --git a/src/multipart.h b/src/multipart.h
@@ -0,0 +1,40 @@
+#ifndef MULTIPART_H
+#define MULTIPART_H
+
+#include <limits.h>
+#include <sys/types.h>
+
+#define MULTIPART_OK 0
+#define MULTIPART_ERROR -1
+#define MULTIPART_MISSING_CONTENT_TYPE -2
+#define MULTIPART_INVALID_CONTENT_TYPE -3
+#define MULTIPART_MISSING_BOUNDARY -4
+#define MULTIPART_INVALID_BOUNDARY -5
+#define MULTIPART_MISSING_LENGTH -6
+#define MULTIPART_INVALID_LENGTH -7
+#define MULTIPART_TOO_LARGE -8
+#define MULTIPART_MALFORMED -9
+#define MULTIPART_MISSING_FILENAME -10
+#define MULTIPART_MULTIPLE_FILES -11
+
+struct multipart_request {
+ off_t length;
+ char boundary[128];
+};
+
+struct multipart_part {
+ char filename[NAME_MAX + 1];
+ off_t offset;
+ off_t length;
+};
+
+int multipart_parse_request(struct multipart_request *);
+int multipart_spool_request(int, off_t);
+int multipart_extract_part(int,
+ const struct multipart_request *,
+ struct multipart_part *);
+int multipart_copy_part(int,
+ const struct multipart_part *,
+ int);
+
+#endif
diff --git a/src/rename.c b/src/rename.c
@@ -0,0 +1,247 @@
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "common.h"
+#include "config.h"
+#include "html.h"
+#include "http.h"
+#include "request.h"
+
+static int do_get(void);
+static int do_post(void);
+
+
+static int
+do_get(void)
+{
+ char filename[NAME_MAX + 1];
+ char path[PATH_MAX];
+ char template[PATH_MAX];
+ struct stat st;
+
+ if (!config_write_allowed()) {
+ reply("403 Forbidden", "Repository is read-only");
+ return 0;
+ }
+ if (get_filename(getenv("QUERY_STRING"),
+ filename,
+ sizeof(filename)) == -1)
+ return 0;
+
+ if (build_storage_path(path,
+ sizeof(path),
+ storage_root(),
+ repository(),
+ filename) == -1) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return 0;
+ }
+ if (stat(path, &st) == -1 || !S_ISREG(st.st_mode)) {
+ reply("404 Not Found",
+ "File not found");
+ return 0;
+ }
+ struct html_var vars[] = {
+ {"FILENAME", filename},
+ {"CANCEL_URI", DEFAULT_REDIRECT_URI},
+ };
+
+ if (snprintf(template,
+ sizeof(template),
+ "%s/%s",
+ simplefiles_root(), RENAME_TEMPLATE_NAME) >= (int) sizeof(template)) {
+ reply("500 Internal Server Error",
+ "Template path too long");
+ return 0;
+ }
+ if (render_template_response(
+ template,
+ vars,
+ sizeof(vars) / sizeof(vars[0]),
+ NULL,
+ 0) == -1) {
+ reply("500 Internal Server Error", "Template error");
+ return 0;
+ }
+ return 0;
+}
+
+
+static int
+do_post(void)
+{
+ char *body;
+ const char *p;
+ char oldname[NAME_MAX + 1];
+ char newname[NAME_MAX + 1];
+ char oldpath[PATH_MAX];
+ char newpath[PATH_MAX];
+ long len;
+ char *end;
+ int lock_fd = -1;
+ struct stat st;
+ int rc;
+
+ if (!config_write_allowed()) {
+ reply("403 Forbidden", "Repository is read-only");
+ return 0;
+ }
+ rc = check_csrf_origin();
+
+ if (rc == -2) {
+ reply("500 Internal Server Error",
+ "CSRF origin is not configured");
+ return 0;
+ }
+ if (rc == -1) {
+ reply("403 Forbidden",
+ "Forbidden");
+ return 0;
+ }
+ p = getenv("CONTENT_LENGTH");
+
+ if (p == NULL) {
+ reply("400 Bad Request",
+ "Missing content length");
+ return 0;
+ }
+ errno = 0;
+
+ len = strtol(p, &end, 10);
+
+ if (errno != 0 ||
+ end == p ||
+ *end != '\0' ||
+ len <= 0 ||
+ len > 1024) {
+ reply("400 Bad Request",
+ "Invalid content length");
+ return 0;
+ }
+ body = malloc((size_t) len + 1);
+
+ if (body == NULL) {
+ reply("500 Internal Server Error",
+ "Out of memory");
+ return 0;
+ }
+ if (fread(body, 1, (size_t) len, stdin)
+ != (size_t) len) {
+ free(body);
+ reply("400 Bad Request",
+ "Could not read request");
+ return 0;
+ }
+ body[len] = '\0';
+
+
+ if (get_filename(body,
+ oldname,
+ sizeof(oldname)) == -1) {
+ free(body);
+ return 0;
+ }
+ if (get_new_filename(body,
+ newname,
+ sizeof(newname)) == -1) {
+ free(body);
+ return 0;
+ }
+ free(body);
+
+
+ if (strcmp(oldname, newname) == 0) {
+ redirect(DEFAULT_REDIRECT_URI);
+ return 0;
+ }
+ if (build_storage_path(oldpath,
+ sizeof(oldpath),
+ storage_root(),
+ repository(),
+ oldname) == -1) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return 0;
+ }
+ if (build_storage_path(newpath,
+ sizeof(newpath),
+ storage_root(),
+ repository(),
+ newname) == -1) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return 0;
+ }
+ lock_fd = repository_lock(repository());
+
+ if (lock_fd == -1) {
+ reply("500 Internal Server Error",
+ "Could not lock repository");
+ return 0;
+ }
+ if (stat(oldpath, &st) == -1 ||
+ !S_ISREG(st.st_mode)) {
+ reply("404 Not Found",
+ "File not found");
+ goto out;
+ }
+ if (stat(newpath, &st) == 0) {
+ reply("409 Conflict",
+ "Destination already exists");
+ goto out;
+ }
+ if (errno != ENOENT) {
+ reply("500 Internal Server Error",
+ "Could not check destination");
+ goto out;
+ }
+ if (rename(oldpath, newpath) == -1) {
+ reply("500 Internal Server Error",
+ "Could not rename file");
+ goto out;
+ }
+ repository_unlock(lock_fd);
+ lock_fd = -1;
+
+ redirect(DEFAULT_REDIRECT_URI);
+
+
+out:
+ if (lock_fd != -1)
+ repository_unlock(lock_fd);
+
+ return 0;
+}
+
+
+int
+main(void)
+{
+ const char *method;
+
+ method = getenv("REQUEST_METHOD");
+
+ if (method == NULL) {
+ reply("400 Bad Request",
+ "Missing request method");
+ return 0;
+ }
+ if (strcmp(method, "GET") == 0)
+ return do_get();
+
+
+ if (strcmp(method, "POST") == 0)
+ return do_post();
+
+
+ reply("405 Method Not Allowed",
+ "GET or POST required");
+
+ return 0;
+}
diff --git a/src/request.c b/src/request.c
@@ -0,0 +1,54 @@
+#include <stddef.h>
+#include <limits.h>
+
+#include "common.h"
+#include "http.h"
+#include "url.h"
+#include "request.h"
+
+int
+get_filename(const char *query, char *filename, size_t len)
+{
+ int ret;
+
+ ret = query_param(query, "file", filename, len);
+
+ if (ret == 1) {
+ reply("400 Bad Request", "Missing filename");
+ return -1;
+ }
+
+ if (ret == -1 || !valid_filename(filename)) {
+ reply("400 Bad Request", "Invalid filename");
+ return -1;
+ }
+
+ return 0;
+}
+
+int
+get_new_filename(const char *body,
+ char *filename,
+ size_t len)
+{
+ int ret;
+
+ ret = query_param(body,
+ "new_filename",
+ filename,
+ len);
+
+ if (ret == 1) {
+ reply("400 Bad Request",
+ "Missing new filename");
+ return -1;
+ }
+
+ if (ret == -1 || !valid_filename(filename)) {
+ reply("400 Bad Request",
+ "Invalid filename");
+ return -1;
+ }
+
+ return 0;
+}
diff --git a/src/request.h b/src/request.h
@@ -0,0 +1,9 @@
+#ifndef REQUEST_H
+#define REQUEST_H
+
+#include <stddef.h>
+
+int get_filename(const char *, char *, size_t);
+int get_new_filename(const char *, char *, size_t);
+
+#endif
diff --git a/src/upload.c b/src/upload.c
@@ -0,0 +1,683 @@
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/statvfs.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include "common.h"
+#include "config.h"
+#include "http.h"
+#include "html.h"
+#include "multipart.h"
+
+#define MAX_DUPLICATES 1000
+#define DEFAULT_UPLOAD_TMP_DIR "/run/simplefiles/uploads"
+
+/* Helper */
+static int do_get(void);
+static int do_post(void);
+static const char *upload_tmp_dir(void);
+
+/* Utility */
+static off_t repo_quota_bytes(void);
+static off_t min_free_bytes(void);
+
+/* Multipart parsing */
+static int open_spool(const char *, char *, size_t);
+
+/* File handling */
+static int open_upload_temp(const char *, char *, size_t);
+static int finalize_upload(const char *, const char *, const char *, char *, size_t);
+static int check_free_space(const char *dir, off_t required);
+static int check_repo_quota(const char *dir, off_t incoming_size);
+
+/* Utility */
+static off_t
+repo_quota_bytes(void)
+{
+ const char *val;
+ char *end;
+ long long parsed;
+
+ val = getenv("REPO_QUOTA_BYTES");
+
+ if (val == NULL || *val == '\0')
+ return config_repo_quota();
+
+ errno = 0;
+ parsed = strtoll(val, &end, 10);
+
+ if (errno != 0 ||
+ end == val ||
+ *end != '\0' ||
+ parsed <= 0)
+ return config_repo_quota();
+
+ return (off_t) parsed;
+}
+
+static off_t
+min_free_bytes(void)
+{
+ const char *val;
+ char *ep;
+ long long parsed;
+
+ val = getenv("MIN_FREE_BYTES");
+
+ if (val == NULL || *val == '\0')
+ return DEFAULT_MIN_FREE;
+
+ errno = 0;
+ parsed = strtoll(val, &ep, 10);
+
+ if (errno != 0 ||
+ ep == val ||
+ *ep != '\0' ||
+ parsed <= 0)
+ return DEFAULT_MIN_FREE;
+
+ return (off_t) parsed;
+}
+/* File handling. */
+static int
+open_upload_temp(const char *dir, char *path, size_t len)
+{
+ int fd;
+ struct stat st;
+
+ if (mkdir(dir, 0700) == -1 && errno != EEXIST) {
+ reply("500 Internal Server Error",
+ "Could not create upload directory");
+ return -1;
+ }
+ if (stat(dir, &st) == -1 || !S_ISDIR(st.st_mode)) {
+ reply("500 Internal Server Error",
+ "Upload directory is not a directory");
+ return -1;
+ }
+ if (st.st_mode & (S_IWGRP | S_IWOTH)) {
+ reply("500 Internal Server Error",
+ "Unsafe upload directory permissions");
+ return -1;
+ }
+ if (snprintf(path, len, "%s/.upload-XXXXXX", dir) >= (int) len) {
+ reply("500 Internal Server Error", "Path too long");
+ return -1;
+ }
+ fd = mkstemp(path);
+ if (fd == -1) {
+ reply("500 Internal Server Error",
+ "Could not create temporary upload");
+ return -1;
+ }
+ return fd;
+}
+
+static int
+open_spool(const char *dir, char *path, size_t len)
+{
+ int fd;
+ struct stat st;
+
+ if (mkdir(dir, 0700) == -1 && errno != EEXIST) {
+ reply("500 Internal Server Error",
+ "Could not create upload directory");
+ return -1;
+ }
+ if (stat(dir, &st) == -1 || !S_ISDIR(st.st_mode)) {
+ reply("500 Internal Server Error",
+ "Upload directory is not a directory");
+ return -1;
+ }
+ if (st.st_mode & (S_IWGRP | S_IWOTH)) {
+ reply("500 Internal Server Error",
+ "Unsafe upload directory permissions");
+ return -1;
+ }
+ if (snprintf(path, len, "%s/.spool-XXXXXX", dir) >= (int) len) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return -1;
+ }
+ fd = mkstemp(path);
+ if (fd == -1) {
+ reply("500 Internal Server Error",
+ "Could not create request spool");
+ return -1;
+ }
+ if (fchmod(fd, 0600) == -1) {
+ close(fd);
+ unlink(path);
+ reply("500 Internal Server Error",
+ "Could not set spool permissions");
+ return -1;
+ }
+ return fd;
+}
+
+static int
+finalize_upload(const char *dir,
+ const char *filename,
+ const char *temp_path,
+ char *path,
+ size_t len)
+{
+ const char *ext;
+ char base[NAME_MAX + 1];
+ size_t baselen;
+ int i;
+
+ ext = strrchr(filename, '.');
+ if (ext == filename)
+ ext = NULL;
+
+ if (ext == NULL) {
+ if (strlcpy(base, filename, sizeof(base)) >= sizeof(base)) {
+ reply("500 Internal Server Error", "Filename too long");
+ return -1;
+ }
+ ext = "";
+ } else {
+ baselen = (size_t) (ext - filename);
+
+ if (baselen >= sizeof(base)) {
+ reply("500 Internal Server Error", "Filename too long");
+ return -1;
+ }
+ memcpy(base, filename, baselen);
+ base[baselen] = '\0';
+ }
+
+ /* Try the original filename first. */
+ if (snprintf(path, len, "%s/%s", dir, filename) >= (int) len) {
+ reply("500 Internal Server Error", "Path too long");
+ return -1;
+ }
+ if (link(temp_path, path) == 0)
+ return 0;
+
+ if (errno != EEXIST) {
+ reply("500 Internal Server Error",
+ "Could not finalize upload");
+ return -1;
+ }
+ /* Original filename exists; try .1, .2, ... */
+ for (i = 1; i < MAX_DUPLICATES; i++) {
+ if (snprintf(path, len, "%s/%s.%d%s",
+ dir, base, i, ext) >= (int) len) {
+ reply("500 Internal Server Error", "Path too long");
+ return -1;
+ }
+ if (link(temp_path, path) == 0)
+ return 0;
+
+ if (errno != EEXIST) {
+ reply("500 Internal Server Error",
+ "Could not finalize upload");
+ return -1;
+ }
+ }
+
+ reply("500 Internal Server Error", "Too many duplicate filenames");
+ return -1;
+}
+
+static int
+check_free_space(const char *path, off_t required)
+{
+ struct statvfs vfs;
+ off_t min_free = min_free_bytes();
+ off_t avail;
+ char buf[PATH_MAX];
+ char *p;
+ char *slash;
+
+ if (required < 0)
+ return -1;
+
+ if (statvfs(path, &vfs) == 0) {
+ avail = (off_t) vfs.f_bavail * (off_t) vfs.f_frsize;
+
+ if (avail < min_free ||
+ required > avail - min_free)
+ return -1;
+
+ return 0;
+ }
+ if (errno != ENOENT)
+ return -1;
+
+ if (strlcpy(buf, path, sizeof(buf)) >= sizeof(buf))
+ return -1;
+
+ p = buf;
+ for (;;) {
+ if (statvfs(p, &vfs) == 0) {
+ avail = (off_t) vfs.f_bavail * (off_t) vfs.f_frsize;
+
+ if (avail < min_free ||
+ required > avail - min_free)
+ return -1;
+
+ return 0;
+ }
+ if (errno != ENOENT)
+ return -1;
+
+ slash = strrchr(p, '/');
+ if (slash == NULL || slash == p) {
+ if (statvfs("/", &vfs) == 0) {
+ avail =
+ (off_t) vfs.f_bavail *
+ (off_t) vfs.f_frsize;
+
+ if (avail < min_free ||
+ required > avail - min_free)
+ return -1;
+
+ return 0;
+ }
+ return -1;
+ }
+ *slash = '\0';
+ }
+}
+static int
+check_repo_quota(const char *dir, off_t incoming_size)
+{
+ DIR *d;
+ struct dirent *de;
+ char path[PATH_MAX];
+ struct stat st;
+ off_t used = 0;
+ off_t repo_quota = repo_quota_bytes();
+
+ /* Calculate current repository usage */
+ d = opendir(dir);
+ if (d == NULL) {
+ if (errno == ENOENT) {
+ /*
+ * Directory doesn't exist yet → no files,
+ * so usage is 0.
+ */
+ used = 0;
+ } else {
+ /* Real error (permissions, etc.) */
+ return -1;
+ }
+ } else {
+ while ((de = readdir(d)) != NULL) {
+ if (strcmp(de->d_name, ".") == 0 ||
+ strcmp(de->d_name, "..") == 0)
+ continue;
+
+ if (snprintf(path, sizeof(path), "%s/%s",
+ dir, de->d_name) >= (int) sizeof(path))
+ continue;
+
+ if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) {
+ if (st.st_size > repo_quota || used > repo_quota - st.st_size)
+ return -1;
+ used += st.st_size;
+ }
+ }
+ closedir(d);
+ }
+
+ /* Check if incoming file fits within quota */
+ if (incoming_size > repo_quota || used > repo_quota - incoming_size)
+ return -1;
+
+ return 0;
+}
+
+static int
+do_get(void)
+{
+ char max_upload[32];
+ char max_upload_bytes[32];
+ char quota[32];
+ char template[PATH_MAX];
+ off_t max_upload_value;
+ off_t quota_value;
+
+ if (!config_write_allowed()) {
+ reply("403 Forbidden", "Repository is read-only");
+ return 0;
+ }
+ max_upload_value = config_max_upload();
+ quota_value = config_repo_quota();
+
+ struct html_var vars[] = {
+ {"MAX_UPLOAD", max_upload},
+ {"MAX_UPLOAD_BYTES", max_upload_bytes},
+ {"REPO_QUOTA", quota},
+ };
+
+ snprintf(max_upload, sizeof(max_upload),
+ "%lld MB",
+ (long long) (max_upload_value / (1024LL * 1024)));
+
+ snprintf(max_upload_bytes, sizeof(max_upload_bytes),
+ "%lld",
+ (long long) max_upload_value);
+
+ snprintf(quota, sizeof(quota),
+ "%lld GB",
+ (long long) (quota_value / (1024LL * 1024 * 1024)));
+
+ if (snprintf(template, sizeof(template),
+ "%s/%s",
+ simplefiles_root(),
+ UPLOAD_TEMPLATE_NAME) >= (int) sizeof(template)) {
+ reply("500 Internal Server Error", "Template path too long");
+ return 0;
+ }
+ if (render_template_response(
+ template,
+ vars,
+ sizeof(vars) / sizeof(vars[0]),
+ NULL,
+ 0) == -1) {
+ reply("500 Internal Server Error", "Template error");
+ return 0;
+ }
+ return 0;
+}
+
+static int
+do_post(void)
+{
+ struct multipart_request mreq = {0};
+ struct multipart_part part;
+
+ char path[PATH_MAX];
+ char repodir[PATH_MAX];
+ char temppath[PATH_MAX];
+ char spoolpath[PATH_MAX];
+
+ int spool_fd = -1;
+ int spool_created = 0;
+ int fd = -1;
+ int temp_created = 0;
+ int lock_fd = -1;
+ int rc;
+ int ret;
+
+ if (!config_write_allowed()) {
+ reply("403 Forbidden", "Repository is read-only");
+ return 0;
+ }
+ rc = check_csrf_origin();
+
+ if (rc == -2) {
+ reply("500 Internal Server Error",
+ "CSRF origin is not configured");
+ return 0;
+ }
+ if (rc == -1) {
+ reply("403 Forbidden", "Forbidden");
+ return 0;
+ }
+ /*
+ * Parse the multipart request metadata:
+ *
+ * - Content-Type
+ * - boundary
+ * - Content-Length
+ * - maximum upload size
+ */
+ ret = multipart_parse_request(&mreq);
+
+ switch (ret) {
+ case MULTIPART_OK:
+ break;
+
+ case MULTIPART_MISSING_CONTENT_TYPE:
+ reply("400 Bad Request", "Missing CONTENT_TYPE");
+ goto cleanup;
+
+ case MULTIPART_INVALID_CONTENT_TYPE:
+ reply("400 Bad Request",
+ "Expected multipart/form-data");
+ goto cleanup;
+
+ case MULTIPART_MISSING_BOUNDARY:
+ reply("400 Bad Request", "Missing boundary");
+ goto cleanup;
+
+ case MULTIPART_INVALID_BOUNDARY:
+ reply("400 Bad Request", "Invalid boundary");
+ goto cleanup;
+
+ case MULTIPART_MISSING_LENGTH:
+ reply("411 Length Required", "Missing CONTENT_LENGTH");
+ goto cleanup;
+
+ case MULTIPART_INVALID_LENGTH:
+ reply("400 Bad Request", "Invalid CONTENT_LENGTH");
+ goto cleanup;
+
+ case MULTIPART_TOO_LARGE:
+ reply("413 Payload Too Large", "Upload too large");
+ goto cleanup;
+
+ default:
+ reply("400 Bad Request", "Invalid multipart request");
+ goto cleanup;
+ }
+
+ if (build_repository_path(repodir, sizeof(repodir),
+ storage_root(), repository()) == -1) {
+ reply("500 Internal Server Error",
+ "Path too long");
+ return 0;
+ }
+ if (check_free_space(repodir, mreq.length) == -1) {
+ reply("507 Insufficient Storage",
+ "Server running low on disk space");
+ return 0;
+ }
+ /*
+ * Spool the complete multipart request to disk.
+ *
+ * multipart.c owns the request-body reading logic.
+ */
+ spool_fd = open_spool(upload_tmp_dir(),
+ spoolpath, sizeof(spoolpath));
+ if (spool_fd == -1)
+ return 0;
+
+ spool_created = 1;
+
+ if (multipart_spool_request(spool_fd, mreq.length) != MULTIPART_OK) {
+ reply("400 Bad Request",
+ "Could not read request body");
+ goto cleanup;
+ }
+ /*
+ * Extract the uploaded part from the spooled multipart body.
+ *
+ * multipart.c now owns:
+ * - filename extraction
+ * - multipart header parsing
+ * - boundary detection
+ * - payload offset calculation
+ * - payload length calculation
+ */
+ if (multipart_extract_part(spool_fd, &mreq, &part) != MULTIPART_OK) {
+ reply("400 Bad Request",
+ "Malformed multipart body");
+ goto cleanup;
+ }
+ if (!valid_filename(part.filename)) {
+ reply("400 Bad Request",
+ "Invalid filename");
+ goto cleanup;
+ }
+ /*
+ * Check that creating the temporary upload will still leave
+ * MIN_FREE_BYTES available. The request spool already exists,
+ * so its disk usage is already reflected in statvfs().
+ */
+ if (check_free_space(repodir, part.length) == -1) {
+ reply("507 Insufficient Storage",
+ "Server running low on disk space");
+ goto cleanup;
+ }
+ /*
+ * Write the uploaded file to a temporary file first.
+ *
+ * This is deliberately done without holding the repository
+ * lock because this operation may take a long time.
+ */
+ fd = open_upload_temp(upload_tmp_dir(),
+ temppath, sizeof(temppath));
+ if (fd == -1)
+ goto cleanup;
+
+ temp_created = 1;
+
+ if (multipart_copy_part(spool_fd, &part, fd) != MULTIPART_OK) {
+ reply("500 Internal Server Error", "Write failed");
+ goto cleanup;
+ }
+ if (close(fd) == -1) {
+ reply("500 Internal Server Error",
+ "Close failed");
+ fd = -1;
+ goto cleanup;
+ }
+ fd = -1;
+
+ /*
+ * Serialize repository quota checking and finalization.
+ *
+ * All uploads to the same repository use the same lock.
+ */
+ lock_fd = repository_lock(repository());
+ if (lock_fd == -1) {
+ reply("500 Internal Server Error",
+ "Could not lock repository");
+ goto cleanup;
+ }
+ /*
+ * The quota check must happen while holding the repository
+ * lock. Otherwise two concurrent uploads could both observe
+ * the same available quota and both proceed.
+ */
+ if (check_repo_quota(repodir, part.length) == -1) {
+ reply("507 Insufficient Storage",
+ "Repository quota exceeded");
+ goto cleanup;
+ }
+ /*
+ * Finalization must also happen while holding the lock,
+ * because it modifies the repository contents.
+ */
+ if (finalize_upload(repodir,
+ part.filename,
+ temppath,
+ path,
+ sizeof(path)) == -1)
+ goto cleanup;
+
+ /*
+ * The repository has now been modified successfully.
+ * No more repository operation needs to be protected.
+ */
+ repository_unlock(lock_fd);
+ lock_fd = -1;
+
+ /*
+ * The temporary file is now no longer needed.
+ */
+ if (unlink(temppath) == -1) {
+ fprintf(stderr, "warning: could not remove temporary upload %s: %s\n", temppath, strerror(errno));
+ } else {
+ temp_created = 0;
+ }
+
+ if (close(spool_fd) == -1) {
+ spool_fd = -1;
+ reply("500 Internal Server Error",
+ "Could not close request spool");
+ unlink(spoolpath);
+ return 0;
+ }
+ spool_fd = -1;
+
+ if (unlink(spoolpath) == -1) {
+ reply("500 Internal Server Error",
+ "Could not remove request spool");
+ return 0;
+ }
+ spool_created = 0;
+
+ redirect(DEFAULT_REDIRECT_URI);
+
+ return 0;
+
+cleanup:
+ if (lock_fd != -1)
+ repository_unlock(lock_fd);
+
+ if (fd != -1)
+ close(fd);
+
+ if (temp_created)
+ unlink(temppath);
+
+ if (spool_fd != -1)
+ close(spool_fd);
+
+ if (spool_created)
+ unlink(spoolpath);
+
+ return 0;
+}
+
+
+static const char *
+upload_tmp_dir(void)
+{
+ const char *dir;
+
+ dir = getenv("UPLOAD_TMP_DIR");
+ if (dir != NULL && *dir != '\0')
+ return dir;
+
+ return DEFAULT_UPLOAD_TMP_DIR;
+}
+
+int
+main(void)
+{
+ const char *method;
+
+ method = getenv("REQUEST_METHOD");
+
+ if (method == NULL) {
+ reply("400 Bad Request",
+ "Missing request method");
+ return 0;
+ }
+ if (strcmp(method, "GET") == 0)
+ return do_get();
+
+ if (strcmp(method, "POST") == 0)
+ return do_post();
+
+ reply("405 Method Not Allowed",
+ "GET or POST required");
+
+ return 0;
+}
diff --git a/src/url.c b/src/url.c
@@ -0,0 +1,203 @@
+#include <ctype.h>
+#include <string.h>
+#include <stdlib.h>
+
+#include "url.h"
+#include "common.h"
+#include "http.h"
+
+static int hex_value(unsigned char c);
+
+char *
+url_encode(const char *s)
+{
+ const unsigned char *p;
+ char *out;
+ char *q;
+ size_t len = 0;
+
+ if (s == NULL)
+ return NULL;
+
+ for (p = (const unsigned char *) s; *p != '\0'; p++) {
+ if (isalnum(*p) ||
+ *p == '-' ||
+ *p == '_' ||
+ *p == '.' ||
+ *p == '~') {
+ len++;
+ } else {
+ len += 3;
+ }
+ }
+
+ out = malloc(len + 1);
+ if (out == NULL)
+ return NULL;
+
+ q = out;
+
+ for (p = (const unsigned char *) s; *p != '\0'; p++) {
+ if (isalnum(*p) ||
+ *p == '-' ||
+ *p == '_' ||
+ *p == '.' ||
+ *p == '~') {
+ *q++ = (char) *p;
+ } else {
+ static const char hex[] = "0123456789ABCDEF";
+
+ *q++ = '%';
+ *q++ = hex[*p >> 4];
+ *q++ = hex[*p & 0x0f];
+ }
+ }
+
+ *q = '\0';
+
+ return out;
+}
+
+int
+url_decode(char *s)
+{
+ char *src;
+ char *dst;
+
+ src = s;
+ dst = s;
+
+ while (*src != '\0') {
+ unsigned char c;
+
+ if (*src == '%') {
+ int hi;
+ int lo;
+
+ if (src[1] == '\0' || src[2] == '\0')
+ return -1;
+
+ hi = hex_value((unsigned char) src[1]);
+ lo = hex_value((unsigned char) src[2]);
+
+ if (hi == -1 || lo == -1)
+ return -1;
+
+ c = (unsigned char) ((hi << 4) | lo);
+ src += 3;
+ } else if (*src == '+') {
+ c = ' ';
+ src++;
+ } else {
+ c = (unsigned char) *src++;
+ }
+
+ if (c == '\0')
+ return -1;
+
+ *dst++ = (char) c;
+ }
+
+ *dst = '\0';
+
+ return 0;
+}
+
+static int
+hex_value(unsigned char c)
+{
+ if (c >= '0' && c <= '9')
+ return c - '0';
+
+ if (c >= 'a' && c <= 'f')
+ return c - 'a' + 10;
+
+ if (c >= 'A' && c <= 'F')
+ return c - 'A' + 10;
+
+ return -1;
+}
+
+int
+query_param(const char *query,
+ const char *name,
+ char *value,
+ size_t value_len)
+{
+ const char *p;
+ const char *eq;
+ const char *field_end;
+ size_t name_len;
+ size_t encoded_len;
+ char *encoded;
+ int found = 0;
+
+ if (query == NULL || name == NULL ||
+ value == NULL || value_len == 0)
+ return -1;
+
+ name_len = strlen(name);
+
+ p = query;
+
+ while (*p != '\0') {
+ const char *field_start = p;
+ size_t field_len;
+ size_t key_len;
+
+ field_end = strchr(p, '&');
+ if (field_end == NULL)
+ field_end = p + strlen(p);
+
+ field_len = (size_t)(field_end - field_start);
+
+ eq = memchr(field_start, '=', field_len);
+
+ if (eq != NULL) {
+ key_len = (size_t)(eq - field_start);
+
+ if (key_len == name_len &&
+ memcmp(field_start, name, name_len) == 0) {
+
+ /* Reject duplicate parameters. */
+ if (found)
+ return -1;
+
+ encoded_len = field_len - key_len - 1;
+
+ /*
+ * Need a writable, NUL-terminated copy because
+ * url_decode() works in-place.
+ */
+ encoded = malloc(encoded_len + 1);
+ if (encoded == NULL)
+ return -1;
+
+ memcpy(encoded, eq + 1, encoded_len);
+ encoded[encoded_len] = '\0';
+
+ if (url_decode(encoded) == -1) {
+ free(encoded);
+ return -1;
+ }
+
+ if (strlen(encoded) >= value_len) {
+ free(encoded);
+ return -1;
+ }
+
+ memcpy(value, encoded, strlen(encoded) + 1);
+ free(encoded);
+
+ found = 1;
+ }
+ }
+
+ if (*field_end == '\0')
+ break;
+
+ p = field_end + 1;
+ }
+
+ return found ? 0 : 1;
+}
diff --git a/src/url.h b/src/url.h
@@ -0,0 +1,8 @@
+#ifndef URL_H
+#define URL_H
+
+int query_param(const char *query, const char *name, char *value, size_t value_len);
+char *url_encode(const char *);
+int url_decode(char *);
+
+#endif
diff --git a/tests/regression/delete.sh b/tests/regression/delete.sh
@@ -0,0 +1,686 @@
+#!/bin/sh
+
+set -eu
+
+. "$(dirname "$0")/testlib.sh"
+
+CGI=${1:-build/delete.cgi}
+
+delete() {
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="${1:-}" \
+ REMOTE_USER=bob \
+ "$CGI"
+}
+
+delete_user() {
+ user=$1
+ query=${2:-}
+
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="$query" \
+ REMOTE_USER="$user" \
+ "$CGI"
+}
+
+delete_no_user() {
+ query=${1:-}
+
+ (
+ unset REMOTE_USER
+
+ export SCRIPT_NAME=/default/delete.cgi
+ export STORAGE_ROOT="$TESTROOT/storage"
+ export SIMPLEFILES_ROOT=./html
+ export SIMPLEFILES_CONFIG="$TEST_CONFIG"
+ export REQUEST_METHOD=GET
+ export QUERY_STRING="$query"
+
+ "$CGI"
+ )
+}
+
+delete_post() {
+ body=${1:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+delete_post_user() {
+ user=$1
+ body=${2:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER="$user" \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+delete_post_no_user() {
+ body=${1:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+delete_post_origin() {
+ origin=$1
+ body=${2:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$origin" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+delete_post_no_origin() {
+ body=${1:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+####################################################
+
+#
+# Confirmation page
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/file1.txt"
+printf 'second\n' > "$TESTROOT/storage/default/file2.txt"
+
+check_status \
+ "confirmation page" \
+ "200 OK" \
+ delete \
+ "file=file1.txt"
+
+check_body \
+ "filename shown" \
+ "file1.txt" \
+ delete \
+ "file=file1.txt"
+
+check_body \
+ "delete form" \
+ '<form method="post"' \
+ delete \
+ "file=file1.txt"
+
+check_body \
+ "cancel link" \
+ "./" \
+ delete \
+ "file=file1.txt"
+
+check_body \
+ "file parameter with extra query parameters" \
+ "file2.txt" \
+ delete \
+ "foo=bar&file=file2.txt&baz=qux"
+
+#
+# Parameter validation
+#
+
+check_body \
+ "missing file parameter" \
+ "Missing filename" \
+ delete \
+ "foo=file1.txt"
+
+check_body \
+ "empty file parameter" \
+ "Invalid filename" \
+ delete \
+ "file="
+
+check_body \
+ "missing file" \
+ "404 Not Found" \
+ delete \
+ "file=missing.txt"
+
+check_body \
+ "invalid filename" \
+ "400 Bad Request" \
+ delete \
+ "file=../passwd"
+
+check_body \
+ "encoded path traversal" \
+ "400 Bad Request" \
+ delete \
+ "file=%2e%2e/passwd"
+
+check_body \
+ "mixed encoded traversal" \
+ "400 Bad Request" \
+ delete \
+ "file=.%2e/passwd"
+
+check_body \
+ "encoded NUL" \
+ "400 Bad Request" \
+ delete \
+ "file=file%00.txt"
+
+check_body \
+ "malformed percent encoding" \
+ "400 Bad Request" \
+ delete \
+ "file=%"
+
+check_body \
+ "short percent encoding" \
+ "400 Bad Request" \
+ delete \
+ "file=%2"
+
+check_body \
+ "invalid percent encoding" \
+ "400 Bad Request" \
+ delete \
+ "file=%ZZ"
+
+check_status \
+ "reject duplicate file parameter" \
+ "400 Bad Request" \
+ delete \
+ "file=file1.txt&file=file2.txt"
+
+check_body \
+ "file parameter after another parameter" \
+ "200 OK" \
+ delete \
+ "foo=bar&file=file2.txt"
+
+check_body \
+ "file parameter before another parameter" \
+ "200 OK" \
+ delete \
+ "file=file2.txt&foo=bar"
+
+#
+# Delete
+#
+
+check_body \
+ "encoded POST path traversal" \
+ "400 Bad Request" \
+ delete_post \
+ "file=%2e%2e/passwd"
+
+check_body \
+ "encoded POST NUL" \
+ "400 Bad Request" \
+ delete_post \
+ "file=file%00.txt"
+
+check_body \
+ "reject duplicate POST file parameter" \
+ "400 Bad Request" \
+ delete_post \
+ "file=file1.txt&file=file2.txt"
+
+check_redirect \
+ "delete redirects" \
+ "303 See Other" \
+ "./" \
+ delete_post \
+ "file=file1.txt"
+
+check_file_missing \
+ "file deleted" \
+ "$TESTROOT/storage/default/file1.txt"
+
+check_body \
+ "delete missing file" \
+ "File not found" \
+ delete_post \
+ "file=file1.txt"
+
+check_body \
+ "malformed POST percent encoding" \
+ "400 Bad Request" \
+ delete_post \
+ "file=%"
+
+check_body \
+ "invalid POST percent encoding" \
+ "400 Bad Request" \
+ delete_post \
+ "file=%ZZ"
+
+#
+# Filename decoding
+#
+
+space_file="$TESTROOT/storage/default/hello world.txt"
+
+printf 'hello' > "$space_file"
+
+check_redirect \
+ "plus-decoded delete redirects" \
+ "303 See Other" \
+ "./" \
+ delete_post \
+ "file=hello+world.txt"
+
+check_file_missing \
+ "plus-decoded file deleted" \
+ "$space_file"
+
+special_file="$TESTROOT/storage/default/<test>&file.txt"
+special_body='file=%3Ctest%3E%26file.txt'
+
+printf 'hello' > "$special_file"
+
+check_body \
+ "delete encoded filename" \
+ "Delete " \
+ delete \
+ "$special_body"
+
+printf 'hello' > "$special_file"
+
+check_redirect \
+ "encoded delete redirects" \
+ "303 See Other" \
+ "./" \
+ delete_post \
+ "$special_body"
+
+check_file_missing \
+ "encoded file deleted" \
+ "$special_file"
+
+#
+# Protected file
+#
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "reject .config delete" \
+ "400 Bad Request" \
+ delete \
+ "file=.config"
+
+check_file_exists \
+ ".config still exists" \
+ "$TESTROOT/storage/default/.config"
+
+check_status \
+ "reject .config POST delete" \
+ "400 Bad Request" \
+ delete_post \
+ "file=.config"
+
+check_file_exists \
+ ".config survives POST" \
+ "$TESTROOT/storage/default/.config"
+
+#
+# CSRF
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "accept configured Origin" \
+ "303 See Other" \
+ delete_post_origin \
+ "http://localhost:8000" \
+ "file=csrf.txt"
+
+check_file_missing \
+ "configured Origin deletes file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "reject missing Origin" \
+ "403 Forbidden" \
+ delete_post_no_origin \
+ "file=csrf.txt"
+
+check_file_exists \
+ "missing Origin preserves file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "reject wrong Origin" \
+ "403 Forbidden" \
+ delete_post_origin \
+ "https://evil.example" \
+ "file=csrf.txt"
+
+check_file_exists \
+ "wrong Origin preserves file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "reject Origin prefix" \
+ "403 Forbidden" \
+ delete_post_origin \
+ "https://localhost.evil.example" \
+ "file=csrf.txt"
+
+check_status \
+ "reject Origin in path" \
+ "403 Forbidden" \
+ delete_post_origin \
+ "https://evil.example/?origin=https://localhost:8000" \
+ "file=csrf.txt"
+
+check_status \
+ "reject wrong Origin scheme" \
+ "403 Forbidden" \
+ delete_post_origin \
+ "https://localhost:8000" \
+ "file=csrf.txt"
+
+reset_testroot
+
+write_global_config "CSRF_ORIGIN=https://files.example"
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "accept custom Origin" \
+ "303 See Other" \
+ delete_post_origin \
+ "https://files.example" \
+ "file=csrf.txt"
+
+check_file_missing \
+ "custom Origin deletes file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+reset_testroot
+
+write_global_config "CSRF_ORIGIN=https://files.example"
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "reject old Origin" \
+ "403 Forbidden" \
+ delete_post_origin \
+ "https://localhost" \
+ "file=csrf.txt"
+
+check_file_exists \
+ "old Origin preserves file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+#
+# Read-only repository
+#
+
+reset_testroot
+
+write_default_repo_config "READ_ONLY=1"
+
+printf 'readonly' > "$TESTROOT/storage/default/readonly.txt"
+
+check_body \
+ "readonly GET rejected" \
+ "read-only" \
+ delete \
+ "file=readonly.txt"
+
+check_file_exists \
+ "readonly GET preserves file" \
+ "$TESTROOT/storage/default/readonly.txt"
+
+check_status \
+ "readonly POST rejected" \
+ "403 Forbidden" \
+ delete_post \
+ "file=readonly.txt"
+
+check_file_exists \
+ "readonly POST preserves file" \
+ "$TESTROOT/storage/default/readonly.txt"
+
+#
+# Access control
+#
+
+reset_testroot
+
+write_default_repo_config ""
+
+printf 'legacy\n' > "$TESTROOT/storage/default/legacy.txt"
+
+check_status \
+ "delete rejected without ADMIN_USER" \
+ "403 Forbidden" \
+ delete_post \
+ "file=legacy.txt"
+
+check_file_exists \
+ "no ADMIN_USER preserves file" \
+ "$TESTROOT/storage/default/legacy.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'admin\n' > "$TESTROOT/storage/default/admin.txt"
+
+check_status \
+ "delete allowed for admin" \
+ "303 See Other" \
+ delete_post_user \
+ "bob" \
+ "file=admin.txt"
+
+check_file_missing \
+ "admin delete succeeded" \
+ "$TESTROOT/storage/default/admin.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'readonly\n' > "$TESTROOT/storage/default/readonly.txt"
+
+check_status \
+ "delete rejected for non-admin" \
+ "403 Forbidden" \
+ delete_post_user \
+ "alice" \
+ "file=readonly.txt"
+
+check_file_exists \
+ "non-admin preserves file" \
+ "$TESTROOT/storage/default/readonly.txt"
+
+#
+# GET access control
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello' > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "non-admin GET rejected" \
+ "403 Forbidden" \
+ delete_user \
+ "alice" \
+ "file=file.txt"
+
+check_file_exists \
+ "non-admin GET preserves file" \
+ "$TESTROOT/storage/default/file.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello' > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "anonymous GET rejected" \
+ "403 Forbidden" \
+ delete_no_user \
+ "file=file.txt"
+
+check_file_exists \
+ "anonymous GET preserves file" \
+ "$TESTROOT/storage/default/file.txt"
+
+#
+# Anonymous read does not grant write access
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+write_global_config "ANONYMOUS_READ=1"
+
+printf 'hello' > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "anonymous GET rejected despite anonymous read" \
+ "403 Forbidden" \
+ delete_no_user \
+ "file=file.txt"
+
+check_status \
+ "anonymous POST rejected despite anonymous read" \
+ "403 Forbidden" \
+ delete_post_no_user \
+ "file=file.txt"
+
+check_file_exists \
+ "anonymous source preserved" \
+ "$TESTROOT/storage/default/file.txt"
+
+check_file_missing \
+ "anonymous destination not created" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+#
+# Response generation
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/file1.txt"
+
+mkdir -p "$TESTROOT/html"
+
+printf '{{UNKNOWN_VARIABLE}}' > "$TESTROOT/html/delete.html"
+
+check_status \
+ "template failure returns 500" \
+ "500 Internal Server Error" \
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT="$TESTROOT/html" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="file=file1.txt" \
+ REMOTE_USER=bob \
+ "$CGI"
+
+check_not_body \
+ "template failure does not return 200" \
+ "Status: 200 OK" \
+ env \
+ SCRIPT_NAME=/default/delete.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT="$TESTROOT/html" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="file=file1.txt" \
+ REMOTE_USER=bob \
+ "$CGI"
+
+test_result
diff --git a/tests/regression/download.sh b/tests/regression/download.sh
@@ -0,0 +1,471 @@
+#!/bin/sh
+
+set -eu
+
+. "$(dirname "$0")/testlib.sh"
+
+CGI=${1:-build/download.cgi}
+
+download() {
+ env \
+ SCRIPT_NAME=/default/download.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="${1:-}" \
+ REMOTE_USER=bob \
+ "$CGI"
+}
+
+download_post() {
+ env \
+ SCRIPT_NAME=/default/download.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH=0 \
+ QUERY_STRING="${1:-}" \
+ REMOTE_USER=bob \
+ "$CGI"
+}
+
+download_user() {
+ user=$1
+
+ env \
+ SCRIPT_NAME=/default/download.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="file=download.txt" \
+ REMOTE_USER="$user" \
+ "$CGI"
+}
+
+download_no_user() {
+ query=${1:-}
+
+ (
+ unset REMOTE_USER
+
+ export SCRIPT_NAME=/default/download.cgi
+ export STORAGE_ROOT="$TESTROOT/storage"
+ export SIMPLEFILES_CONFIG="$TEST_CONFIG"
+ export REQUEST_METHOD=GET
+ export QUERY_STRING="$query"
+
+ "$CGI"
+ )
+}
+
+####################################################
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+echo "hello world" > "$TESTROOT/storage/default/file1.txt"
+echo "hello world" > "$TESTROOT/storage/default/file2.txt"
+echo "special filename" > "$TESTROOT/storage/default/<test>&file.txt"
+printf 'space filename\n' > "$TESTROOT/storage/default/hello world.txt"
+
+dd if=/dev/urandom \
+ of="$TESTROOT/storage/default/binary.bin" \
+ bs=1024 count=1 2>/dev/null
+
+#
+# Valid download
+#
+
+check_status \
+ "valid download status" \
+ "200 OK" \
+ download \
+ "file=file1.txt"
+
+check_header \
+ "content-type header" \
+ "Content-Type" \
+ "application/octet-stream" \
+ download \
+ "file=file1.txt"
+
+check_header \
+ "content-disposition header" \
+ "Content-Disposition" \
+ 'attachment; filename="file1.txt"' \
+ download \
+ "file=file1.txt"
+
+check_header \
+ "content-length header" \
+ "Content-Length" \
+ "12" \
+ download \
+ "file=file1.txt"
+
+#
+# Binary file
+#
+
+check_header \
+ "binary content-length" \
+ "Content-Length" \
+ "1024" \
+ download \
+ "file=binary.bin"
+
+#
+# Missing / invalid files
+#
+
+check_body \
+ "missing file" \
+ "404 Not Found" \
+ download \
+ "file=nonexistent.txt"
+
+printf 'QUOTA=1024\n' > "$TESTROOT/storage/default/.config"
+
+check_body \
+ ".config not downloadable" \
+ "400 Bad Request" \
+ download \
+ "file=.config"
+
+check_body \
+ "path traversal ../" \
+ "400 Bad Request" \
+ download \
+ "file=../passwd"
+
+check_body \
+ "path traversal .." \
+ "400 Bad Request" \
+ download \
+ "file=.."
+
+check_body \
+ "path traversal with slash" \
+ "400 Bad Request" \
+ download \
+ "file=subdir/file.txt"
+
+check_body \
+ "encoded dot traversal" \
+ "400 Bad Request" \
+ download \
+ "file=%2e%2e/passwd"
+
+check_body \
+ "mixed encoded traversal" \
+ "400 Bad Request" \
+ download \
+ "file=.%2e/passwd"
+
+check_body \
+ "mixed encoded slash traversal" \
+ "400 Bad Request" \
+ download \
+ "file=..%2Fpasswd"
+
+check_body \
+ "encoded nested traversal" \
+ "400 Bad Request" \
+ download \
+ "file=%2e%2e%2f%2e%2e%2fpasswd"
+
+check_body \
+ "encoded NUL" \
+ "400 Bad Request" \
+ download \
+ "file=file%00.txt"
+
+check_body \
+ "malformed percent encoding" \
+ "400 Bad Request" \
+ download \
+ "file=file%ZZ.txt"
+
+check_body \
+ "incomplete percent encoding" \
+ "400 Bad Request" \
+ download \
+ "file=file%2.txt"
+
+check_body \
+ "empty filename" \
+ "400 Bad Request" \
+ download \
+ "file="
+
+check_body \
+ "missing query string" \
+ "400 Bad Request" \
+ download
+
+check_body \
+ "missing file parameter" \
+ "Missing filename" \
+ download \
+ "foo=bar"
+
+check_body \
+ "file parameter without equals" \
+ "Missing filename" \
+ download \
+ "file"
+
+check_body \
+ "empty file parameter" \
+ "Invalid filename" \
+ download \
+ "file="
+
+check_body \
+ "empty file among parameters" \
+ "Invalid filename" \
+ download \
+ "foo=bar&file=&baz=qux"
+
+check_status \
+ "file parameter after another parameter" \
+ "200 OK" \
+ download \
+ "foo=bar&file=file1.txt"
+
+check_status \
+ "file parameter before another parameter" \
+ "200 OK" \
+ download \
+ "file=file1.txt&foo=bar"
+
+check_status \
+ "file parameter with extra query parameters" \
+ "200 OK" \
+ download \
+ "foo=bar&file=file1.txt&baz=qux"
+
+check_status \
+ "reject duplicate file parameter" \
+ "400 Bad Request" \
+ download \
+ "file=file1.txt&file=file2.txt"
+
+check_status \
+ "reject duplicate file parameter reversed" \
+ "400 Bad Request" \
+ download \
+ "file=file2.txt&file=file1.txt"
+
+#
+# Invalid method
+#
+
+check_body \
+ "post not allowed" \
+ "GET required" \
+ download_post \
+ "file=file1.txt"
+
+#
+# Directory
+#
+
+mkdir -p "$TESTROOT/storage/default/subdir"
+
+check_body \
+ "directory not downloadable" \
+ "404 Not Found" \
+ download \
+ "file=subdir"
+
+check_body \
+ "encoded path traversal" \
+ "400 Bad Request" \
+ download \
+ "file=subdir%2ffile.txt"
+
+#
+# Special filenames
+#
+
+check_body \
+ "encoded special filename" \
+ "200 OK" \
+ download \
+ "file=%3Ctest%3E%26file.txt"
+
+check_body \
+ "plus decoded to space" \
+ "200 OK" \
+ download \
+ "file=hello+world.txt"
+
+check_body \
+ "plus decoded filename content" \
+ "space filename" \
+ download \
+ "file=hello+world.txt"
+
+#
+# Header injection
+#
+
+check_body \
+ "encoded CR rejected" \
+ "400 Bad Request" \
+ download \
+ "file=foo%0d%0aX-Test%3A%20injected.txt"
+
+check_body \
+ "encoded LF rejected" \
+ "400 Bad Request" \
+ download \
+ "file=foo%0aX-Test%3A%20injected.txt"
+
+#
+# Symlink outside repository
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+mkdir -p "$TESTROOT/storage/other"
+
+printf 'secret\n' > "$TESTROOT/storage/other/secret.txt"
+
+ln -s "$TESTROOT/storage/other/secret.txt" \
+ "$TESTROOT/storage/default/linked.txt"
+
+check_body \
+ "symlink outside repository" \
+ "404 Not Found" \
+ download \
+ "file=linked.txt"
+
+check_not_body \
+ "symlink target not exposed" \
+ "secret" \
+ download \
+ "file=linked.txt"
+
+#
+# Authentication
+#
+
+reset_testroot
+
+write_default_repo_config ""
+
+printf '%s' 'download content' \
+ > "$TESTROOT/storage/default/download.txt"
+
+check_status \
+ "anonymous download rejected without ADMIN_USER" \
+ "403 Forbidden" \
+ download_no_user \
+ "file=download.txt"
+
+check_file \
+ "anonymous download preserves file" \
+ "$TESTROOT/storage/default/download.txt" \
+ "download content"
+
+#
+# ADMIN_USER configured: admin user allowed
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf '%s' 'download content' \
+ > "$TESTROOT/storage/default/download.txt"
+
+check_status \
+ "admin user allowed to download" \
+ "200 OK" \
+ download_user \
+ "bob"
+
+check_body \
+ "admin download succeeds" \
+ "download content" \
+ download_user \
+ "bob"
+
+#
+# ADMIN_USER configured: non-admin still has read access
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf '%s' 'download content' \
+ > "$TESTROOT/storage/default/download.txt"
+
+check_status \
+ "non-admin user allowed to download" \
+ "200 OK" \
+ download_user \
+ "alice"
+
+check_body \
+ "non-admin download succeeds" \
+ "download content" \
+ download_user \
+ "alice"
+
+#
+# Anonymous access with ADMIN_USER
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf '%s' 'download content' \
+ > "$TESTROOT/storage/default/download.txt"
+
+check_status \
+ "anonymous download rejected" \
+ "403 Forbidden" \
+ download_no_user \
+ "file=download.txt"
+
+check_file \
+ "anonymous download preserves file" \
+ "$TESTROOT/storage/default/download.txt" \
+ "download content"
+
+#
+# Anonymous read access
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+write_global_config "ANONYMOUS_READ=1"
+
+printf '%s' 'download content' \
+ > "$TESTROOT/storage/default/download.txt"
+
+check_status \
+ "anonymous download allowed with anonymous read" \
+ "200 OK" \
+ download_no_user \
+ "file=download.txt"
+
+check_body \
+ "anonymous download succeeds with anonymous read" \
+ "download content" \
+ download_no_user \
+ "file=download.txt"
+
+test_result
+
+test_result
diff --git a/tests/regression/httpd.sh b/tests/regression/httpd.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+
+set -eu
+
+PASS=0
+FAIL=0
+
+HTTP_URL=${HTTP_URL:-http://127.0.0.1:8000}
+UPLOAD="$HTTP_URL/default/upload.cgi"
+
+TMPDIR=${TMPDIR:-/tmp}
+
+fixture() {
+ file="$TMPDIR/upload-test.txt"
+
+ printf '%s\n' "hello upload test" > "$file"
+
+ printf '%s\n' "$file"
+}
+
+check() {
+ name=$1
+ expected=$2
+ shift 2
+
+ printf '%-35s' "$name"
+
+ output=$("$@" 2>&1 || true)
+
+ if printf '%s\n' "$output" | grep -q "$expected"; then
+ printf '%s\n' "ok"
+ PASS=$((PASS + 1))
+ else
+ printf '%s\n' "failed"
+ printf ' expected: %s\n' "$expected"
+ printf '%s\n' "$output"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+test_upload() {
+ file=$1
+
+ curl -s -i \
+ -F "file=@$file" \
+ "$UPLOAD"
+}
+
+main() {
+ file=$(fixture)
+
+ check \
+ "upload through httpd" \
+ "303 See Other" \
+ test_upload \
+ "$file"
+
+ check \
+ "redirect location" \
+ "Location: list.cgi" \
+ test_upload \
+ "$file"
+
+ rm -f "$file"
+
+ printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
+
+ [ "$FAIL" -eq 0 ]
+}
+
+main "$@"
diff --git a/tests/regression/list.sh b/tests/regression/list.sh
@@ -0,0 +1,403 @@
+#!/bin/sh
+
+set -eu
+
+. "$(dirname "$0")/testlib.sh"
+
+CGI=${1:-build/list.cgi}
+
+list() {
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ REMOTE_USER=alice \
+ REQUEST_METHOD=GET \
+ "$CGI"
+}
+
+list_repo() {
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ SCRIPT_NAME="/$1/list.cgi" \
+ REMOTE_USER=alice \
+ REQUEST_METHOD=GET \
+ "$CGI"
+}
+
+list_user() {
+ user=$1
+
+ env \
+ SCRIPT_NAME=/default/list.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ REQUEST_METHOD=GET \
+ REMOTE_USER="$user" \
+ "$CGI"
+}
+
+list_no_user() {
+ (
+ unset REMOTE_USER
+
+ export SCRIPT_NAME=/default/list.cgi
+ export SIMPLEFILES_ROOT=./html
+ export SIMPLEFILES_CONFIG="$TEST_CONFIG"
+ export STORAGE_ROOT="$TESTROOT/storage"
+ export REQUEST_METHOD=GET
+
+ "$CGI"
+ )
+}
+
+list_no_user_root() {
+ root=$1
+
+ (
+ unset REMOTE_USER
+
+ export SCRIPT_NAME=/default/list.cgi
+ export SIMPLEFILES_ROOT="$root"
+ export SIMPLEFILES_CONFIG="$TEST_CONFIG"
+ export STORAGE_ROOT="$TESTROOT/storage"
+ export REQUEST_METHOD=GET
+
+ "$CGI"
+ )
+}
+
+##############################################################################
+
+reset_testroot
+
+check_body \
+ "empty page" \
+ "SimpleFiles" \
+ list
+
+check_body \
+ "table header" \
+ "<th>Name</th>" \
+ list
+
+##############################################################################
+
+printf 'dummy\n' > "$TESTROOT/storage/default/file1.txt"
+
+check_body \
+ "one filename" \
+ "file1.txt" \
+ list
+
+##############################################################################
+
+printf 'dummy\n' > "$TESTROOT/storage/default/file2.txt"
+
+check_body \
+ "second filename" \
+ "file2.txt" \
+ list
+
+##############################################################################
+
+mkdir "$TESTROOT/storage/default/subdir"
+
+check_not_body \
+ "ignore directories" \
+ "subdir" \
+ list
+
+##############################################################################
+
+mkdir -p "$TESTROOT/storage/music"
+mkdir -p "$TESTROOT/storage/poetry"
+
+printf 'music\n' > "$TESTROOT/storage/music/song.mp3"
+printf 'poetry\n' > "$TESTROOT/storage/poetry/poem.txt"
+
+check_body \
+ "music repository" \
+ "song.mp3" \
+ list_repo music
+
+check_not_body \
+ "music excludes poetry" \
+ "poem.txt" \
+ list_repo music
+
+check_body \
+ "poetry repository" \
+ "poem.txt" \
+ list_repo poetry
+
+check_not_body \
+ "poetry excludes music" \
+ "song.mp3" \
+ list_repo poetry
+
+##############################################################################
+
+mkdir -p "$TESTROOT/secret"
+
+printf 'secret\n' > "$TESTROOT/secret/password.txt"
+
+check_not_body \
+ "reject repository traversal" \
+ "password.txt" \
+ list_repo "../secret"
+
+##############################################################################
+
+# Test HTML escaping of filenames
+
+escaped_filename='<test>&file.txt'
+raw_filename='<test>&file.txt'
+
+printf 'dummy\n' > "$TESTROOT/storage/default/$raw_filename"
+
+check_body \
+ "escape filename in html" \
+ "$escaped_filename" \
+ list
+
+check_body \
+ "url encode filename" \
+ "download.cgi?file=%3Ctest%3E%26file.txt" \
+ list
+
+##############################################################################
+
+printf 'QUOTA=1024\n' > "$TESTROOT/storage/default/.config"
+
+check_not_body \
+ "hide .config" \
+ ".config" \
+ list
+
+##############################################################################
+#
+# Read-only repository
+#
+
+reset_testroot
+
+printf 'READ_ONLY=1\n' > "$TESTROOT/storage/default/.config"
+printf 'hello\n' > "$TESTROOT/storage/default/file1.txt"
+
+check_body \
+ "readonly list still shows files" \
+ "file1.txt" \
+ list
+
+check_not_body \
+ "readonly list hides upload link" \
+ "upload.cgi" \
+ list
+
+check_not_body \
+ "readonly list hides delete link" \
+ "delete.cgi" \
+ list
+
+####################################################
+#
+# Authentication-aware access control
+#
+
+# 1 Anonymous access is rejected by default
+
+reset_testroot
+
+write_default_repo_config ""
+
+printf '%s' 'list content' \
+ > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "anonymous listing rejected by default" \
+ "403 Forbidden" \
+ list_no_user
+
+# 2 Anonymous listing is allowed when explicitly set
+
+reset_testroot
+
+write_default_repo_config ""
+
+printf '%s' 'list content' \
+ > "$TESTROOT/storage/default/file.txt"
+
+write_global_config "ANONYMOUS_READ=1"
+
+check_status \
+ "anonymous listing allowed with ANONYMOUS_READ" \
+ "200 OK" \
+ list_no_user
+
+check_body \
+ "anonymous listing with ANONYMOUS_READ succeeds" \
+ "file.txt" \
+ list_no_user
+
+check_not_body \
+ "anonymous listing allowed hides upload link" \
+ "upload.cgi" \
+ list_no_user
+
+check_not_body \
+ "anonymous listing allowed hides delete link" \
+ "delete.cgi?file=file.txt" \
+ list_no_user
+
+# 3 Authenticated non-admin listing remains allowed
+
+reset_testroot
+
+write_default_repo_config ""
+
+printf '%s' 'list content' \
+ > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "authenticated listing allowed without ADMIN_USER" \
+ "200 OK" \
+ list_user \
+ "alice"
+
+check_body \
+ "authenticated listing without ADMIN_USER succeeds" \
+ "file.txt" \
+ list_user \
+ "alice"
+
+check_not_body \
+ "non-admin listing hides upload link" \
+ "upload.cgi" \
+ list_user \
+ "alice"
+
+check_not_body \
+ "non-admin listing hides delete link" \
+ "delete.cgi?file=file.txt" \
+ list_user \
+ "alice"
+
+# 4 Matching admin user is allowed
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf '%s' 'list content' \
+ > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "admin user allowed to list" \
+ "200 OK" \
+ list_user \
+ "bob"
+
+check_body \
+ "admin listing succeeds" \
+ "file.txt" \
+ list_user \
+ "bob"
+
+check_body \
+ "admin listing shows upload link" \
+ "upload.cgi" \
+ list_user \
+ "bob"
+
+check_body \
+ "admin listing shows delete link" \
+ "delete.cgi?file=file.txt" \
+ list_user \
+ "bob"
+
+# 5 Non-admin authenticated user can read but cannot write
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf '%s' 'list content' \
+ > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "non-admin user allowed to list" \
+ "200 OK" \
+ list_user \
+ "alice"
+
+check_body \
+ "non-admin listing succeeds" \
+ "file.txt" \
+ list_user \
+ "alice"
+
+check_not_body \
+ "non-admin listing hides upload link" \
+ "upload.cgi" \
+ list_user \
+ "alice"
+
+check_not_body \
+ "non-admin listing hides delete link" \
+ "delete.cgi?file=file.txt" \
+ list_user \
+ "alice"
+
+# 6 ANONYMOUS_READ allows anonymous access even when ADMIN_USER is configured
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf '%s' 'list content' \
+ > "$TESTROOT/storage/default/file.txt"
+
+write_global_config "ANONYMOUS_READ=1"
+
+check_status \
+ "anonymous listing allowed with ADMIN_USER and ANONYMOUS_READ" \
+ "200 OK" \
+ list_no_user
+
+check_body \
+ "anonymous listing succeeds with ADMIN_USER and ANONYMOUS_READ" \
+ "file.txt" \
+ list_no_user
+
+check_not_body \
+ "anonymous listing hides upload link" \
+ "upload.cgi" \
+ list_no_user
+
+check_not_body \
+ "anonymous listing hides delete link" \
+ "delete.cgi?file=file.txt" \
+ list_no_user
+
+#
+# Response generation
+#
+
+reset_testroot
+
+write_default_repo_config ""
+write_global_config "ANONYMOUS_READ=1"
+
+printf 'hello\n' > "$TESTROOT/storage/default/file1.txt"
+
+mkdir -p "$TESTROOT/html"
+printf '{{UNKNOWN_VARIABLE}}' > "$TESTROOT/html/list.html"
+
+check_status \
+ "template failure returns 500" \
+ "500 Internal Server Error" \
+ list_no_user_root \
+ "$TESTROOT/html"
+
+test_result
diff --git a/tests/regression/rename.sh b/tests/regression/rename.sh
@@ -0,0 +1,663 @@
+#!/bin/sh
+
+#
+# Regression tests for rename.cgi
+#
+
+set -eu
+
+. "$(dirname "$0")/testlib.sh"
+
+CGI=${1:-build/rename.cgi}
+
+##############################################################################
+# HTTP helpers
+##############################################################################
+
+rename() {
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="${1:-}" \
+ REMOTE_USER=bob \
+ "$CGI"
+}
+
+rename_user() {
+ user=$1
+ query=${2:-}
+
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="$query" \
+ REMOTE_USER="$user" \
+ "$CGI"
+}
+
+rename_no_user() {
+ query=${1:-}
+
+ (
+ unset REMOTE_USER
+
+ export SCRIPT_NAME=/default/rename.cgi
+ export STORAGE_ROOT="$TESTROOT/storage"
+ export SIMPLEFILES_ROOT=./html
+ export SIMPLEFILES_CONFIG="$TEST_CONFIG"
+ export REQUEST_METHOD=GET
+ export QUERY_STRING="$query"
+
+ "$CGI"
+ )
+}
+
+rename_post() {
+ body=${1:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+rename_post_user() {
+ user=$1
+ body=${2:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER="$user" \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+rename_post_no_user() {
+ body=${1:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+rename_post_origin() {
+ origin=$1
+ body=${2:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ HTTP_ORIGIN="$origin" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+rename_post_no_origin() {
+ body=${1:-}
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ SIMPLEFILES_ROOT=./html \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+rename_template_failure() {
+ env \
+ SCRIPT_NAME=/default/rename.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT="$TESTROOT/html" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=GET \
+ QUERY_STRING="file=file1.txt" \
+ REMOTE_USER=bob \
+ "$CGI"
+}
+
+##############################################################################
+# Basic functionality
+##############################################################################
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello' > "$TESTROOT/storage/default/file1.txt"
+printf 'second' > "$TESTROOT/storage/default/file2.txt"
+
+check_status \
+ "rename confirmation page" \
+ "200 OK" \
+ rename \
+ "file=file1.txt"
+
+check_body \
+ "old filename shown" \
+ "file1.txt" \
+ rename \
+ "file=file1.txt"
+
+check_body \
+ "rename form" \
+ '<form method="post"' \
+ rename \
+ "file=file1.txt"
+
+check_body \
+ "new name field" \
+ "new_filename" \
+ rename \
+ "file=file1.txt"
+
+check_redirect \
+ "rename redirects" \
+ "303 See Other" \
+ "./" \
+ rename_post \
+ "file=file1.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "new filename exists" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+check_file_missing \
+ "old filename removed" \
+ "$TESTROOT/storage/default/file1.txt"
+
+check_file \
+ "contents preserved" \
+ "$TESTROOT/storage/default/renamed.txt" \
+ "hello"
+
+##############################################################################
+# Parameter validation
+##############################################################################
+
+check_body \
+ "missing file parameter" \
+ "Missing filename" \
+ rename \
+ "new_filename=file2.txt"
+
+check_body \
+ "empty file parameter" \
+ "Invalid filename" \
+ rename \
+ "file=&new_filename=file2.txt"
+
+check_body \
+ "missing new name" \
+ "Missing new filename" \
+ rename_post \
+ "file=file1.txt"
+
+check_body \
+ "empty new name" \
+ "Invalid filename" \
+ rename_post \
+ "file=file1.txt&new_filename="
+
+check_status \
+ "reject duplicate file parameter" \
+ "400 Bad Request" \
+ rename \
+ "file=file1.txt&file=file2.txt"
+
+check_status \
+ "reject duplicate new_filename parameter" \
+ "400 Bad Request" \
+ rename_post \
+ "file=file1.txt&new_filename=a.txt&new_filename=b.txt"
+
+##############################################################################
+# Filename validation
+##############################################################################
+
+check_body \
+ "reject source traversal" \
+ "400 Bad Request" \
+ rename \
+ "file=../passwd"
+
+check_body \
+ "reject destination traversal" \
+ "400 Bad Request" \
+ rename_post \
+ "file=file2.txt&new_filename=../passwd"
+
+check_body \
+ "reject encoded traversal" \
+ "400 Bad Request" \
+ rename_post \
+ "file=file2.txt&new_filename=%2e%2e/passwd"
+
+check_body \
+ "reject encoded NUL destination" \
+ "400 Bad Request" \
+ rename_post \
+ "file=file2.txt&new_filename=test%00.txt"
+
+check_body \
+ "reject malformed percent encoding" \
+ "400 Bad Request" \
+ rename_post \
+ "file=file2.txt&new_filename=%ZZ"
+
+##############################################################################
+# Rename failures
+##############################################################################
+
+printf 'original' > "$TESTROOT/storage/default/source.txt"
+printf 'destination' > "$TESTROOT/storage/default/destination.txt"
+
+check_body \
+ "reject overwrite" \
+ "Destination already exists" \
+ rename_post \
+ "file=source.txt&new_filename=destination.txt"
+
+check_file \
+ "destination unchanged" \
+ "$TESTROOT/storage/default/destination.txt" \
+ "destination"
+
+check_file \
+ "source survives failed overwrite" \
+ "$TESTROOT/storage/default/source.txt" \
+ "original"
+
+check_body \
+ "rename missing file" \
+ "File not found" \
+ rename_post \
+ "file=missing.txt&new_filename=new.txt"
+
+##############################################################################
+# Special filenames
+##############################################################################
+
+special="$TESTROOT/storage/default/<test>&file.txt"
+
+printf 'special\n' > "$special"
+
+check_redirect \
+ "rename encoded filename" \
+ "303 See Other" \
+ "./" \
+ rename_post \
+ "file=%3Ctest%3E%26file.txt&new_filename=special.txt"
+
+check_file_exists \
+ "encoded source renamed" \
+ "$TESTROOT/storage/default/special.txt"
+
+printf 'space\n' > "$TESTROOT/storage/default/hello world.txt"
+
+check_redirect \
+ "rename plus decoded source" \
+ "303 See Other" \
+ "./" \
+ rename_post \
+ "file=hello+world.txt&new_filename=space.txt"
+
+check_file_exists \
+ "space source renamed" \
+ "$TESTROOT/storage/default/space.txt"
+
+##############################################################################
+# .config protection
+##############################################################################
+
+printf 'QUOTA=1024\n' >> "$TESTROOT/storage/default/.config"
+
+check_status \
+ "reject .config source rename" \
+ "400 Bad Request" \
+ rename \
+ "file=.config"
+
+check_status \
+ "reject .config destination" \
+ "400 Bad Request" \
+ rename_post \
+ "file=file2.txt&new_filename=.config"
+
+check_file_exists \
+ ".config survives" \
+ "$TESTROOT/storage/default/.config"
+
+##############################################################################
+# CSRF protection
+##############################################################################
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'csrf\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "rename accepts configured Origin" \
+ "303 See Other" \
+ rename_post_origin \
+ "http://localhost:8000" \
+ "file=csrf.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "csrf rename succeeded" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'csrf\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "rename rejects missing Origin" \
+ "403 Forbidden" \
+ rename_post_no_origin \
+ "file=csrf.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "missing Origin preserves source" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'csrf\n' > "$TESTROOT/storage/default/csrf.txt"
+
+check_status \
+ "rename rejects wrong Origin" \
+ "403 Forbidden" \
+ rename_post_origin \
+ "https://evil.example" \
+ "file=csrf.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "wrong Origin preserves source" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+##############################################################################
+# Other valid renames
+##############################################################################
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello' > "$TESTROOT/storage/default/same.txt"
+
+check_redirect \
+ "rename to same filename" \
+ "303 See Other" \
+ "./" \
+ rename_post \
+ "file=same.txt&new_filename=same.txt"
+
+check_file \
+ "same filename preserved" \
+ "$TESTROOT/storage/default/same.txt" \
+ "hello"
+
+: > "$TESTROOT/storage/default/empty.txt"
+
+check_redirect \
+ "rename empty file" \
+ "303 See Other" \
+ "./" \
+ rename_post \
+ "file=empty.txt&new_filename=empty-renamed.txt"
+
+check_file_exists \
+ "empty file renamed" \
+ "$TESTROOT/storage/default/empty-renamed.txt"
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'extension' > "$TESTROOT/storage/default/file.txt"
+
+check_redirect \
+ "rename extension" \
+ "303 See Other" \
+ "./" \
+ rename_post \
+ "file=file.txt&new_filename=file.md"
+
+check_file_exists \
+ "renamed extension exists" \
+ "$TESTROOT/storage/default/file.md"
+
+check_file_missing \
+ "old extension removed" \
+ "$TESTROOT/storage/default/file.txt"
+
+check_file \
+ "extension rename preserves contents" \
+ "$TESTROOT/storage/default/file.md" \
+ "extension"
+
+##############################################################################
+# Read-only repository
+##############################################################################
+
+reset_testroot
+
+printf 'readonly' > "$TESTROOT/storage/default/readonly.txt"
+
+write_default_repo_config "READ_ONLY=1"
+
+check_status \
+ "reject rename in readonly repository" \
+ "403 Forbidden" \
+ rename_post \
+ "file=readonly.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "readonly source preserved" \
+ "$TESTROOT/storage/default/readonly.txt"
+
+check_file_missing \
+ "readonly destination not created" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+check_body \
+ "readonly GET rename rejected" \
+ "read-only" \
+ rename \
+ "file=readonly.txt"
+
+check_file \
+ "readonly source contents preserved" \
+ "$TESTROOT/storage/default/readonly.txt" \
+ "readonly"
+
+check_status \
+ "readonly rename rejects missing Origin" \
+ "403 Forbidden" \
+ rename_post_no_origin \
+ "file=readonly.txt&new_filename=new.txt"
+
+##############################################################################
+# Authorization
+##############################################################################
+
+reset_testroot
+
+printf 'readonly\n' > "$TESTROOT/storage/default/readonly.txt"
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "rename rejected for non-admin user" \
+ "403 Forbidden" \
+ rename_post_user \
+ "alice" \
+ "file=readonly.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "non-admin source preserved" \
+ "$TESTROOT/storage/default/readonly.txt"
+
+check_file_missing \
+ "non-admin destination not created" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+check_status \
+ "non-admin rename GET rejected" \
+ "403 Forbidden" \
+ rename_user \
+ "alice" \
+ "file=readonly.txt"
+
+reset_testroot
+
+printf 'anonymous\n' > "$TESTROOT/storage/default/anonymous.txt"
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "anonymous rename POST rejected" \
+ "403 Forbidden" \
+ rename_post_no_user \
+ "file=anonymous.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "anonymous source preserved" \
+ "$TESTROOT/storage/default/anonymous.txt"
+
+check_file_missing \
+ "anonymous destination not created" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+check_status \
+ "anonymous rename GET rejected" \
+ "403 Forbidden" \
+ rename_no_user \
+ "file=anonymous.txt"
+
+##############################################################################
+# Anonymous read does not grant write access
+##############################################################################
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello' > "$TESTROOT/storage/default/file.txt"
+
+write_global_config "ANONYMOUS_READ=1"
+
+check_status \
+ "anonymous rename GET rejected despite anonymous read" \
+ "403 Forbidden" \
+ rename_no_user \
+ "file=file.txt"
+
+check_status \
+ "anonymous rename POST rejected despite anonymous read" \
+ "403 Forbidden" \
+ rename_post_no_user \
+ "file=file.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "anonymous rename source preserved" \
+ "$TESTROOT/storage/default/file.txt"
+
+check_file_missing \
+ "anonymous rename destination not created" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+##############################################################################
+# Authentication alone does not grant write access
+##############################################################################
+
+reset_testroot
+
+write_default_repo_config ""
+
+printf 'hello' > "$TESTROOT/storage/default/file.txt"
+
+check_status \
+ "authenticated user cannot rename without ADMIN_USER" \
+ "403 Forbidden" \
+ rename_post_user \
+ "alice" \
+ "file=file.txt&new_filename=renamed.txt"
+
+check_file_exists \
+ "source preserved without ADMIN_USER" \
+ "$TESTROOT/storage/default/file.txt"
+
+check_file_missing \
+ "destination not created without ADMIN_USER" \
+ "$TESTROOT/storage/default/renamed.txt"
+
+#
+# Response generation
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+printf 'hello\n' > "$TESTROOT/storage/default/file1.txt"
+
+mkdir -p "$TESTROOT/html"
+
+printf '{{UNKNOWN_VARIABLE}}' > "$TESTROOT/html/rename.html"
+
+check_status \
+ "template failure returns 500" \
+ "500 Internal Server Error" \
+ rename_template_failure
+
+check_not_body \
+ "template failure does not return 200" \
+ "Status: 200 OK" \
+ rename_template_failure
+
+test_result
diff --git a/tests/regression/testlib.sh b/tests/regression/testlib.sh
@@ -0,0 +1,311 @@
+#!/bin/sh
+
+PASS=0
+FAIL=0
+
+TESTROOT=${TESTROOT:-testroot}
+TEST_LOCK_DIR="$TESTROOT/run/simplefiles/lock"
+TEST_UPLOAD_DIR="$TESTROOT/run/simplefiles/uploads"
+TEST_CONFIG="$TESTROOT/etc/simplefiles.conf"
+TEST_CSRF_ORIGIN=${TEST_CSRF_ORIGIN:-http://localhost:8000}
+
+#
+# Reporting
+#
+
+ok() {
+ printf '%-60s ok\n' "$1"
+ PASS=$((PASS + 1))
+}
+
+not_ok() {
+ printf '%-60s failed\n' "$1"
+ printf ' %s\n' "$2"
+ FAIL=$((FAIL + 1))
+}
+
+#
+# Run a command and capture its output.
+#
+# These are globals deliberately. POSIX sh has no local variables,
+# and keeping command execution in one place makes the assertions
+# simpler and safer with "set -e".
+#
+
+run() {
+ TEST_OUTPUT=
+ TEST_RC=0
+
+ if TEST_OUTPUT=$("$@" 2>&1); then
+ TEST_RC=0
+ else
+ TEST_RC=$?
+ fi
+
+ return 0
+}
+
+#
+# HTTP helpers
+#
+
+status_from_output() {
+ printf '%s\n' "$1" |
+ sed -n 's/^Status: //p' |
+ sed -n '1p' |
+ tr -d '\r'
+}
+
+header_from_output() {
+ header_name=$1
+ output=$2
+
+ printf '%s\n' "$output" |
+ sed -n "s/^${header_name}: //p" |
+ sed -n '1p' |
+ tr -d '\r'
+}
+
+#
+# Assertions
+#
+
+check_status() {
+ name=$1
+ expected=$2
+ shift 2
+
+ run "$@"
+
+ got=$(status_from_output "$TEST_OUTPUT")
+
+ if [ "$got" = "$expected" ]; then
+ ok "$name"
+ else
+ not_ok "$name" \
+ "expected status: $expected (got: ${got:-none})"
+ printf '%s\n' "$TEST_OUTPUT"
+ fi
+}
+
+check_body() {
+ name=$1
+ expected=$2
+ shift 2
+
+ run "$@"
+
+ if printf '%s' "$TEST_OUTPUT" | grep -F "$expected" > /dev/null; then
+ ok "$name"
+ else
+ not_ok "$name" "missing body: $expected"
+ fi
+}
+
+check_not_body() {
+ name=$1
+ unexpected=$2
+ shift 2
+
+ run "$@"
+
+ if printf '%s' "$TEST_OUTPUT" | grep -F "$unexpected" > /dev/null; then
+ not_ok "$name" "unexpected body: $unexpected"
+ else
+ ok "$name"
+ fi
+}
+
+check_header() {
+ name=$1
+ header_name=$2
+ expected=$3
+ shift 3
+
+ run "$@"
+
+ got=$(header_from_output "$header_name" "$TEST_OUTPUT")
+
+ if [ "$got" = "$expected" ]; then
+ ok "$name"
+ else
+ not_ok "$name" \
+ "expected $header_name: $expected (got: ${got:-none})"
+ fi
+}
+
+check_redirect() {
+ name=$1
+ expected_status=$2
+ expected_location=$3
+ shift 3
+
+ run "$@"
+
+ got_status=$(status_from_output "$TEST_OUTPUT")
+
+ if [ "$got_status" != "$expected_status" ]; then
+ not_ok "$name" \
+ "expected status: $expected_status (got: ${got_status:-none})"
+ return
+ fi
+
+ got_location=$(header_from_output Location "$TEST_OUTPUT")
+
+ if [ "$got_location" != "$expected_location" ]; then
+ not_ok "$name" \
+ "expected Location: $expected_location (got: ${got_location:-none})"
+ return
+ fi
+
+ ok "$name"
+}
+
+#
+# Filesystem assertions
+#
+
+check_file() {
+ name=$1
+ file=$2
+ expected=$3
+
+ if [ ! -f "$file" ]; then
+ not_ok "$name" "missing file: $file"
+ return
+ fi
+
+ size=$(wc -c < "$file" | tr -d ' ')
+
+ #
+ # POSIX sh does not have ${#string}, so use wc for the expected
+ # byte count too.
+ #
+ expected_size=$(printf '%s' "$expected" | wc -c | tr -d ' ')
+
+ if [ "$size" -ne "$expected_size" ]; then
+ not_ok "$name" \
+ "wrong size: expected $expected_size bytes, got $size"
+ return
+ fi
+
+ actual=$(cat "$file")
+
+ if [ "$actual" != "$expected" ]; then
+ not_ok "$name" "unexpected file contents"
+ return
+ fi
+
+ ok "$name"
+}
+
+check_file_exists() {
+ name=$1
+ file=$2
+
+ if [ -f "$file" ]; then
+ ok "$name"
+ else
+ not_ok "$name" "missing file: $file"
+ fi
+}
+
+check_file_missing() {
+ name=$1
+ file=$2
+
+ if [ ! -e "$file" ]; then
+ ok "$name"
+ else
+ not_ok "$name" "unexpected file: $file"
+ fi
+}
+
+check_dir_missing() {
+ name=$1
+ dir=$2
+
+ if [ ! -d "$dir" ]; then
+ ok "$name"
+ else
+ not_ok "$name" "unexpected directory: $dir"
+ fi
+}
+
+check_no_temp_uploads() {
+ name=$1
+ dir=$2
+
+ if [ ! -d "$dir" ]; then
+ ok "$name"
+ return
+ fi
+
+ if find "$dir" -name '.upload-*' -type f -print |
+ grep -q .; then
+ not_ok "$name" "temporary upload remains"
+ else
+ ok "$name"
+ fi
+}
+
+check_file_bytes() {
+ name=$1
+ file=$2
+ expected=$3
+
+ tmp=$(mktemp)
+
+ printf '%b' "$expected" > "$tmp"
+
+ if [ ! -f "$file" ]; then
+ rm -f "$tmp"
+ not_ok "$name" "file missing: $file"
+ return
+ fi
+
+ if cmp -s "$file" "$tmp"; then
+ ok "$name"
+ else
+ not_ok "$name" "file contents differ: $file"
+ fi
+
+ rm -f "$tmp"
+}
+
+#
+# Test environment helpers
+#
+
+reset_testroot() {
+ rm -rf "$TESTROOT"
+ mkdir -p "$TESTROOT/storage/default"
+ mkdir -p "$TEST_LOCK_DIR"
+ mkdir -p "$TEST_UPLOAD_DIR"
+ mkdir -p "$TESTROOT/etc"
+ chmod go-w "$TEST_UPLOAD_DIR"
+ printf 'CSRF_ORIGIN=%s\n' "$TEST_CSRF_ORIGIN" > "$TEST_CONFIG"
+ chmod 644 "$TEST_CONFIG"
+}
+
+make_repo() {
+ mkdir -p "$TESTROOT/storage/$1"
+}
+
+write_global_config() {
+ printf '%s\n' "$1" > "$TEST_CONFIG"
+}
+
+write_default_repo_config() {
+ mkdir -p "$TESTROOT/storage/default"
+ printf '%s\n' "$1" > "$TESTROOT/storage/default/.config"
+}
+
+#
+# Final result
+#
+
+test_result() {
+ printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
+ [ "$FAIL" -eq 0 ]
+}
diff --git a/tests/regression/upload.sh b/tests/regression/upload.sh
@@ -0,0 +1,1423 @@
+#!/bin/sh
+
+set -eu
+
+. "$(dirname "$0")/testlib.sh"
+
+CGI=${1:-build/upload.cgi}
+
+upload_get() {
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_ROOT=./html \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=GET \
+ "$CGI"
+}
+
+upload_put() {
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=PUT \
+ "$CGI"
+}
+
+upload_post() {
+ content_type=$1
+ content_length=$2
+
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="$content_type" \
+ CONTENT_LENGTH="$content_length" \
+ "$CGI"
+}
+
+upload_post_no_length() {
+ content_type=$1
+
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="$content_type" \
+ "$CGI"
+}
+
+upload() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="hello world.mp4"'
+ printf '%s\r\n' 'Content-Type: video/mp4'
+ printf '\r\n'
+ printf '%s' 'dummy data'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_boundary_in_file() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="boundary.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'before
+--test-not-a-boundary
+after'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_boundary_split() {
+ tmp=$(mktemp)
+
+ {
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="split.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+
+ awk 'BEGIN {
+ for (i = 0; i < 8192; i++)
+ printf "A"
+ }'
+
+ printf '\r\n--test--\r\n'
+ } > "$tmp"
+
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \
+ "$CGI" < "$tmp"
+
+ rm -f "$tmp"
+}
+
+upload_quota_ok() {
+ tmp=$(mktemp)
+
+ {
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="quota_ok.txt"'
+ printf '%s\r\n' 'Content-Type: application/octet-stream'
+ printf '\r\n'
+ awk 'BEGIN { for (i = 0; i < 100; i++) printf "A" }'
+ printf '\r\n--test--\r\n'
+ } > "$tmp"
+
+ env \
+ REPO_QUOTA_BYTES=10240 \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \
+ "$CGI" < "$tmp"
+
+ rm -f "$tmp"
+}
+
+upload_quota_exceeds() {
+ tmp=$(mktemp)
+
+ {
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="toobig.txt"'
+ printf '%s\r\n' 'Content-Type: application/octet-stream'
+ printf '\r\n'
+ dd if=/dev/zero bs=1 count=2000 2> /dev/null
+ printf '\r\n--test--\r\n'
+ } > "$tmp"
+
+ env \
+ REPO_QUOTA_BYTES=10240 \
+ MIN_FREE_BYTES=1024 \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \
+ "$CGI" < "$tmp"
+
+ rm -f "$tmp"
+}
+
+upload_exact_quota() {
+ quota=$1
+ tmp=$(mktemp)
+
+ {
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="exact.txt"'
+ printf '%s\r\n' 'Content-Type: application/octet-stream'
+ printf '\r\n'
+ dd if=/dev/zero bs=1 count=1024 2> /dev/null
+ printf '\r\n--test--\r\n'
+ } > "$tmp"
+
+ env \
+ REPO_QUOTA_BYTES="$quota" \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \
+ "$CGI" < "$tmp"
+
+ rm -f "$tmp"
+}
+
+upload_filename() {
+ filename=$1
+
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ "Content-Disposition: form-data; name=\"file\"; filename=\"$filename\""
+ printf '%s\r\n' 'Content-Type: video/mp4'
+ printf '\r\n'
+ printf '%s' 'dummy data'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_truncated() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="truncated.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'partial data'
+ )
+
+ printf '%s' "$body" |
+ env \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_no_filename() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'dummy data'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_malformed() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="bad.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '%s\r\n' 'dummy data'
+ printf '%s\r\n' '--test--'
+ )
+
+ printf '%s' "$body" |
+ env \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_failed_after_data() {
+ tmp=$(mktemp)
+
+ {
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="failed.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+
+ awk 'BEGIN {
+ for (i = 0; i < 8192; i++)
+ printf "A"
+ }'
+ } > "$tmp"
+
+ env \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \
+ "$CGI" < "$tmp"
+
+ rm -f "$tmp"
+}
+
+upload_short_body() {
+ body='short body'
+
+ printf '%s' "$body" |
+ env \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH=999 \
+ "$CGI"
+}
+
+upload_min_free_rejected() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="minfree.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'dummy data'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ MIN_FREE_BYTES=9223372036854775807 \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_min_free_checked_before_spool() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="minfree.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'dummy data'
+ printf '\r\n--test--\r\n'
+ )
+
+ bad_tmp="$TESTROOT/not-a-directory"
+ printf '%s' > "$bad_tmp"
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$bad_tmp" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ MIN_FREE_BYTES=9223372036854775807 \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_min_free_allows() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="minfree-ok.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'dummy data'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ MIN_FREE_BYTES=1 \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_post_origin() {
+ origin=$1
+
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="csrf.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'csrf test'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$origin" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_post_no_origin() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="csrf.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'csrf test'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REQUEST_METHOD=POST \
+ REMOTE_USER=bob \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_binary() {
+ tmp=$(mktemp)
+
+ {
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="binary.bin"'
+ printf '%s\r\n' 'Content-Type: application/octet-stream'
+ printf '\r\n'
+ printf '\000\001\002\003\377'
+ printf '\r\n--test--\r\n'
+ } > "$tmp"
+
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(wc -c < "$tmp" | tr -d ' ')" \
+ "$CGI" < "$tmp"
+
+ rm -f "$tmp"
+}
+
+upload_post_user() {
+ user=$1
+
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="upload.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'uploaded content'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ SCRIPT_NAME=/default/upload.cgi \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER="$user" \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_post_no_user() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="upload.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'uploaded content'
+ printf '\r\n--test--\r\n'
+ )
+
+ (
+ unset REMOTE_USER
+
+ printf '%s' "$body" |
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ SCRIPT_NAME=/default/upload.cgi \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+ )
+}
+
+upload_filename_in_body() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="safe.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'filename="evil.txt"'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_multiple_files() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="one.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s\r\n' 'first file'
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="two.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s\r\n' 'second file'
+ printf '%s\r\n' '--test--'
+ )
+
+ printf '%s' "$body" |
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+upload_cleanup_failure() {
+ body=$(
+ printf '%s\r\n' '--test'
+ printf '%s\r\n' \
+ 'Content-Disposition: form-data; name="file"; filename="cleanup-failure.txt"'
+ printf '%s\r\n' 'Content-Type: text/plain'
+ printf '\r\n'
+ printf '%s' 'published despite cleanup failure'
+ printf '\r\n--test--\r\n'
+ )
+
+ printf '%s' "$body" |
+ env \
+ TEST_FAIL_TEMP_UNLINK=1 \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ UPLOAD_TMP_DIR="$TEST_UPLOAD_DIR" \
+ REPOSITORY_LOCK_DIR="$TEST_LOCK_DIR" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ HTTP_ORIGIN="$TEST_CSRF_ORIGIN" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=POST \
+ CONTENT_TYPE="multipart/form-data; boundary=test" \
+ CONTENT_LENGTH="$(printf '%s' "$body" | wc -c | tr -d ' ')" \
+ "$CGI"
+}
+
+####################################################
+
+#
+# Request validation
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "GET upload page" \
+ "200 OK" \
+ upload_get
+
+check_status \
+ "reject invalid method" \
+ "405 Method Not Allowed" \
+ upload_put
+
+check_body \
+ "invalid method message" \
+ "GET or POST required" \
+ upload_put
+
+check_status \
+ "reject missing content type" \
+ "400 Bad Request" \
+ upload_post \
+ "" \
+ 10
+
+check_status \
+ "reject invalid content type" \
+ "400 Bad Request" \
+ upload_post \
+ "text/plain; boundary=test" \
+ 10
+
+check_status \
+ "reject missing boundary" \
+ "400 Bad Request" \
+ upload_post \
+ "multipart/form-data" \
+ 10
+
+check_status \
+ "reject invalid length" \
+ "400 Bad Request" \
+ upload_post \
+ "multipart/form-data; boundary=test" \
+ abc
+
+check_status \
+ "reject zero length" \
+ "413 Payload Too Large" \
+ upload_post \
+ "multipart/form-data; boundary=test" \
+ 0
+
+check_status \
+ "reject oversized upload" \
+ "413 Payload Too Large" \
+ upload_post \
+ "multipart/form-data; boundary=test" \
+ 999999999
+
+check_status \
+ "reject missing content length" \
+ "411 Length Required" \
+ upload_post_no_length \
+ "multipart/form-data; boundary=test"
+
+check_status \
+ "reject empty boundary" \
+ "400 Bad Request" \
+ upload_post \
+ "multipart/form-data; boundary=" \
+ 10
+
+long_boundary=$(printf '%0129d' 0 | tr '0' 'a')
+
+check_status \
+ "reject oversized boundary" \
+ "400 Bad Request" \
+ upload_post \
+ "multipart/form-data; boundary=$long_boundary" \
+ 10
+
+####################################################
+
+#
+# Multipart validation
+#
+
+check_status \
+ "reject missing filename" \
+ "400 Bad Request" \
+ upload_no_filename
+
+check_status \
+ "reject malformed multipart body" \
+ "400 Bad Request" \
+ upload_malformed
+
+check_status \
+ "reject short request body" \
+ "400 Bad Request" \
+ upload_short_body
+
+check_redirect \
+ "boundary-like string inside file" \
+ "303 See Other" \
+ "./" \
+ upload_boundary_in_file
+
+check_file \
+ "boundary-like string preserved" \
+ "$TESTROOT/storage/default/boundary.txt" \
+ "before
+--test-not-a-boundary
+after"
+
+check_redirect \
+ "multipart boundary across reads" \
+ "303 See Other" \
+ "./" \
+ upload_boundary_split
+
+check_file \
+ "multipart data preserved across reads" \
+ "$TESTROOT/storage/default/split.txt" \
+ "$(awk 'BEGIN {
+ for (i = 0; i < 8192; i++)
+ printf "A"
+ }')"
+
+check_redirect \
+ "filename is extracted from headers only" \
+ "303 See Other" \
+ "./" \
+ upload_filename_in_body
+
+check_file \
+ "filename from header is preserved" \
+ "$TESTROOT/storage/default/safe.txt" \
+ 'filename="evil.txt"'
+
+check_file_missing \
+ "filename from body is not used" \
+ "$TESTROOT/storage/default/evil.txt"
+
+####################################################
+
+#
+# Multipart cardinatlity
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "reject multiple file upload" \
+ "400 Bad Request" \
+ upload_multiple_files
+
+check_file_missing \
+ "multiple upload creates no first file" \
+ "$TESTROOT/storage/default/one.txt"
+
+check_file_missing \
+ "multiple upload creates no second file" \
+ "$TESTROOT/storage/default/two.txt"
+
+####################################################
+
+#
+# Standalone uploads
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_redirect \
+ "extract filename" \
+ "303 See Other" \
+ "./" \
+ upload
+
+check_file \
+ "file created" \
+ "$TESTROOT/storage/default/hello world.mp4" \
+ "dummy data"
+
+check_redirect \
+ "duplicate upload redirects" \
+ "303 See Other" \
+ "./" \
+ upload
+
+check_file \
+ "duplicate file created" \
+ "$TESTROOT/storage/default/hello world.1.mp4" \
+ "dummy data"
+
+####################################################
+
+#
+# Quota tests
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_redirect \
+ "upload within quota" \
+ "303 See Other" \
+ "./" \
+ upload_quota_ok
+
+check_file \
+ "quota ok file created" \
+ "$TESTROOT/storage/default/quota_ok.txt" \
+ "$(awk 'BEGIN { for (i = 0; i < 100; i++) printf "A" }')"
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+dd \
+ if=/dev/zero \
+ of="$TESTROOT/storage/default/filler.bin" \
+ bs=1 \
+ count=9000 \
+ 2> /dev/null
+
+check_status \
+ "upload exceeds repo quota" \
+ "507 Insufficient Storage" \
+ upload_quota_exceeds
+
+####################################################
+
+#
+# Upload at exact quota boundary
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+dd \
+ if=/dev/zero \
+ of="$TESTROOT/storage/default/filler.bin" \
+ bs=1 \
+ count=9216 \
+ 2> /dev/null
+
+config_size=$(wc -c < "$TESTROOT/storage/default/.config" | tr -d ' ')
+exact_quota=$((9216 + 1024 + config_size))
+
+check_redirect \
+ "upload at exact quota limit" \
+ "303 See Other" \
+ "./" \
+ upload_exact_quota \
+ "$exact_quota"
+
+####################################################
+
+#
+# Default quota
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_redirect \
+ "default quota allows upload" \
+ "303 See Other" \
+ "./" \
+ upload
+
+####################################################
+
+#
+# Repository QUOTA
+#
+
+reset_testroot
+
+write_default_repo_config 'ADMIN_USER=bob
+QUOTA=1'
+
+check_status \
+ "config quota rejects upload" \
+ "507 Insufficient Storage" \
+ upload
+
+reset_testroot
+
+write_default_repo_config 'ADMIN_USER=bob
+QUOTA=1024'
+
+check_redirect \
+ "config quota allows upload" \
+ "303 See Other" \
+ "./" \
+ upload
+
+####################################################
+
+#
+# Repository MAX_UPLOAD
+#
+
+reset_testroot
+
+write_default_repo_config 'ADMIN_USER=bob
+MAX_UPLOAD=1'
+
+check_status \
+ "config max upload rejects upload" \
+ "413 Payload Too Large" \
+ upload
+
+reset_testroot
+
+write_default_repo_config 'ADMIN_USER=bob
+MAX_UPLOAD=1024'
+
+check_redirect \
+ "config max upload allows upload" \
+ "303 See Other" \
+ "./" \
+ upload
+
+####################################################
+
+#
+# Repository MAX_UPLOAD + QUOTA
+#
+
+reset_testroot
+
+write_default_repo_config 'ADMIN_USER=bob
+MAX_UPLOAD=1024
+QUOTA=1024'
+
+check_redirect \
+ "config max upload+quota" \
+ "303 See Other" \
+ "./" \
+ upload
+
+reset_testroot
+
+write_default_repo_config 'ADMIN_USER=bob
+MAX_UPLOAD=1024
+QUOTA=1'
+
+check_status \
+ "config max upload+quota rejects upload" \
+ "507 Insufficient Storage" \
+ upload
+
+####################################################
+
+#
+# Invalid filenames
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "reject .config upload" \
+ "400 Bad Request" \
+ upload_filename \
+ ".config"
+
+check_status \
+ "reject path traversal filename" \
+ "400 Bad Request" \
+ upload_filename \
+ "../passwd"
+
+check_status \
+ "reject slash filename" \
+ "400 Bad Request" \
+ upload_filename \
+ "subdir/file.txt"
+
+check_status \
+ "reject empty filename" \
+ "400 Bad Request" \
+ upload_filename \
+ ""
+
+check_status \
+ "reject dot filename" \
+ "400 Bad Request" \
+ upload_filename \
+ "."
+
+check_status \
+ "reject dot-dot filename" \
+ "400 Bad Request" \
+ upload_filename \
+ ".."
+
+long_filename=$(awk 'BEGIN { for (i = 0; i < 300; i++) printf "a" }')
+
+check_status \
+ "reject oversized filename" \
+ "400 Bad Request" \
+ upload_filename \
+ "$long_filename"
+
+####################################################
+
+#
+# Temporary upload cleanup
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "reject missing closing boundary" \
+ "400 Bad Request" \
+ upload_truncated
+
+check_no_temp_uploads \
+ "no temporary upload remains" \
+ "$TEST_UPLOAD_DIR"
+
+####################################################
+
+#
+# Failed upload cleanup
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "reject failed upload" \
+ "400 Bad Request" \
+ upload_failed_after_data
+
+check_file_missing \
+ "failed upload leaves no destination file" \
+ "$TESTROOT/storage/default/failed.txt"
+
+check_no_temp_uploads \
+ "failed upload leaves no temporary file" \
+ "$TEST_UPLOAD_DIR"
+
+####################################################
+
+#
+# Minimum free space
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "reject upload when minimum free space is not available" \
+ "507 Insufficient Storage" \
+ upload_min_free_rejected
+
+check_status \
+ "minimum-free check happens before spool creation" \
+ "507 Insufficient Storage" \
+ upload_min_free_checked_before_spool
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_redirect \
+ "upload with sufficient minimum free space" \
+ "303 See Other" \
+ "./" \
+ upload_min_free_allows
+
+check_file \
+ "minimum-free upload created file" \
+ "$TESTROOT/storage/default/minfree-ok.txt" \
+ "dummy data"
+
+####################################################
+
+#
+# CSRF Origin protection
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "upload accepts configured Origin" \
+ "303 See Other" \
+ upload_post_origin \
+ "$TEST_CSRF_ORIGIN"
+
+check_file \
+ "configured Origin upload creates file" \
+ "$TESTROOT/storage/default/csrf.txt" \
+ "csrf test"
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "upload rejects missing Origin" \
+ "403 Forbidden" \
+ upload_post_no_origin
+
+check_file_missing \
+ "missing Origin creates no file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "upload rejects wrong Origin" \
+ "403 Forbidden" \
+ upload_post_origin \
+ "https://evil.example"
+
+check_file_missing \
+ "wrong Origin creates no file" \
+ "$TESTROOT/storage/default/csrf.txt"
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "upload rejects Origin with configured origin as prefix" \
+ "403 Forbidden" \
+ upload_post_origin \
+ "https://localhost.evil.example"
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "upload rejects wrong Origin scheme" \
+ "403 Forbidden" \
+ upload_post_origin \
+ "https://localhost:8000"
+
+####################################################
+
+#
+# Binary upload
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_redirect \
+ "binary upload succeeds" \
+ "303 See Other" \
+ "./" \
+ upload_binary
+
+check_file_bytes \
+ "binary file preserved" \
+ "$TESTROOT/storage/default/binary.bin" \
+ '\000\001\002\003\377'
+
+####################################################
+
+#
+# Read-only repository
+#
+
+reset_testroot
+write_default_repo_config 'ADMIN_USER=bob
+READ_ONLY=1'
+
+check_body \
+ "readonly GET upload rejected" \
+ "read-only" \
+ upload_get
+
+check_file_missing \
+ "readonly GET does not create file" \
+ "$TESTROOT/storage/default/readonly.txt"
+
+check_status \
+ "readonly POST upload rejected" \
+ "403 Forbidden" \
+ upload
+
+####################################################
+
+#
+# Authentication
+#
+
+#
+# ADMIN_USER configured: admin can upload.
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "admin user allowed to upload" \
+ "303 See Other" \
+ upload_post_user \
+ "bob"
+
+check_file \
+ "admin upload succeeds" \
+ "$TESTROOT/storage/default/upload.txt" \
+ "uploaded content"
+
+#
+# ADMIN_USER configured: non-admin is rejected.
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "non-admin user rejected from upload" \
+ "403 Forbidden" \
+ upload_post_user \
+ "alice"
+
+check_file_missing \
+ "non-admin upload not created" \
+ "$TESTROOT/storage/default/upload.txt"
+
+#
+# ADMIN_USER configured: anonymous is rejected.
+#
+
+reset_testroot
+
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "anonymous upload rejected" \
+ "403 Forbidden" \
+ upload_post_no_user
+
+check_file_missing \
+ "anonymous upload not created" \
+ "$TESTROOT/storage/default/upload.txt"
+
+#
+# No ADMIN_USER: anonymous is rejected.
+#
+
+reset_testroot
+
+write_default_repo_config ""
+
+check_status \
+ "anonymous upload rejected without ADMIN_USER" \
+ "403 Forbidden" \
+ upload_post_no_user
+
+check_file_missing \
+ "anonymous upload without ADMIN_USER not created" \
+ "$TESTROOT/storage/default/upload.txt"
+
+#
+# No ADMIN_USER: authenticated user is rejected.
+#
+
+reset_testroot
+
+write_default_repo_config ""
+
+check_status \
+ "authenticated upload rejected without ADMIN_USER" \
+ "403 Forbidden" \
+ upload_post_user \
+ "alice"
+
+check_file_missing \
+ "authenticated upload without ADMIN_USER not created" \
+ "$TESTROOT/storage/default/upload.txt"
+
+#
+# Template failure handling
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_status \
+ "template failure returns 500" \
+ "500 Internal Server Error" \
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=GET \
+ SIMPLEFILES_ROOT="$TESTROOT/does-not-exist" \
+ "$CGI"
+
+check_body \
+ "template failure does not return 200" \
+ "Template error" \
+ env \
+ STORAGE_ROOT="$TESTROOT/storage" \
+ SIMPLEFILES_CONFIG="$TEST_CONFIG" \
+ REMOTE_USER=bob \
+ REQUEST_METHOD=GET \
+ SIMPLEFILES_ROOT="$TESTROOT/does-not-exist" \
+ "$CGI"
+
+####################################################
+
+#
+# Temporary upload cleanup failure
+#
+
+reset_testroot
+write_default_repo_config "ADMIN_USER=bob"
+
+check_redirect \
+ "cleanup failure preserves published upload" \
+ "303 See Other" \
+ "./" \
+ upload_cleanup_failure
+
+check_file \
+ "cleanup failure preserves published file" \
+ "$TESTROOT/storage/default/cleanup-failure.txt" \
+ "published despite cleanup failure"
+
+test_result
diff --git a/tests/unit/common.c b/tests/unit/common.c
@@ -0,0 +1,218 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "common.h"
+
+static int
+write_config(const char *path, const char *content)
+{
+ FILE *fp;
+
+ fp = fopen(path, "w");
+ if (fp == NULL)
+ return -1;
+
+ if (fputs(content, fp) == EOF) {
+ fclose(fp);
+ return -1;
+ }
+
+ fclose(fp);
+ return 0;
+}
+
+static int
+test_csrf_missing_config(void)
+{
+ char path[] = "/tmp/simplefiles-common-test-XXXXXX";
+ int fd;
+ int rc;
+
+ fd = mkstemp(path);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: csrf missing config: mkstemp failed\n");
+ return 1;
+ }
+
+ close(fd);
+
+ /*
+ * Empty config file means CSRF_ORIGIN is not configured.
+ */
+ setenv("SIMPLEFILES_CONFIG", path, 1);
+ unsetenv("HTTP_ORIGIN");
+
+ rc = check_csrf_origin();
+
+ unlink(path);
+ unsetenv("SIMPLEFILES_CONFIG");
+
+ if (rc != -2) {
+ fprintf(stderr,
+ "FAIL: csrf missing config returned %d, expected -2\n",
+ rc);
+ return 1;
+ }
+
+ printf("ok: csrf missing config\n");
+ return 0;
+}
+
+static int
+test_csrf_missing_origin(void)
+{
+ char path[] = "/tmp/simplefiles-common-test-XXXXXX";
+ int fd;
+ int rc;
+
+ fd = mkstemp(path);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: csrf missing origin: mkstemp failed\n");
+ return 1;
+ }
+
+ close(fd);
+
+ if (write_config(path,
+ "CSRF_ORIGIN=http://localhost:8000\n") == -1) {
+ fprintf(stderr,
+ "FAIL: csrf missing origin: could not write config\n");
+ unlink(path);
+ return 1;
+ }
+
+ setenv("SIMPLEFILES_CONFIG", path, 1);
+ unsetenv("HTTP_ORIGIN");
+
+ rc = check_csrf_origin();
+
+ unlink(path);
+ unsetenv("SIMPLEFILES_CONFIG");
+
+ if (rc != -1) {
+ fprintf(stderr,
+ "FAIL: csrf missing origin returned %d, expected -1\n",
+ rc);
+ return 1;
+ }
+
+ printf("ok: csrf missing origin\n");
+ return 0;
+}
+
+static int
+test_csrf_wrong_origin(void)
+{
+ char path[] = "/tmp/simplefiles-common-test-XXXXXX";
+ int fd;
+ int rc;
+
+ fd = mkstemp(path);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: csrf wrong origin: mkstemp failed\n");
+ return 1;
+ }
+
+ close(fd);
+
+ if (write_config(path,
+ "CSRF_ORIGIN=http://localhost:8000\n") == -1) {
+ fprintf(stderr,
+ "FAIL: csrf wrong origin: could not write config\n");
+ unlink(path);
+ return 1;
+ }
+
+ setenv("SIMPLEFILES_CONFIG", path, 1);
+ setenv("HTTP_ORIGIN",
+ "http://evil.example.com",
+ 1);
+
+ rc = check_csrf_origin();
+
+ unlink(path);
+ unsetenv("SIMPLEFILES_CONFIG");
+ unsetenv("HTTP_ORIGIN");
+
+ if (rc != -1) {
+ fprintf(stderr,
+ "FAIL: csrf wrong origin returned %d, expected -1\n",
+ rc);
+ return 1;
+ }
+
+ printf("ok: csrf wrong origin\n");
+ return 0;
+}
+
+static int
+test_csrf_correct_origin(void)
+{
+ char path[] = "/tmp/simplefiles-common-test-XXXXXX";
+ int fd;
+ int rc;
+
+ fd = mkstemp(path);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: csrf correct origin: mkstemp failed\n");
+ return 1;
+ }
+
+ close(fd);
+
+ if (write_config(path,
+ "CSRF_ORIGIN=http://localhost:8000\n") == -1) {
+ fprintf(stderr,
+ "FAIL: csrf correct origin: could not write config\n");
+ unlink(path);
+ return 1;
+ }
+
+ setenv("SIMPLEFILES_CONFIG", path, 1);
+ setenv("HTTP_ORIGIN",
+ "http://localhost:8000",
+ 1);
+
+ rc = check_csrf_origin();
+
+ unlink(path);
+ unsetenv("SIMPLEFILES_CONFIG");
+ unsetenv("HTTP_ORIGIN");
+
+ if (rc != 0) {
+ fprintf(stderr,
+ "FAIL: csrf correct origin returned %d, expected 0\n",
+ rc);
+ return 1;
+ }
+
+ printf("ok: csrf correct origin\n");
+ return 0;
+}
+
+int
+main(void)
+{
+ int failures = 0;
+
+ failures += test_csrf_missing_config();
+ failures += test_csrf_missing_origin();
+ failures += test_csrf_wrong_origin();
+ failures += test_csrf_correct_origin();
+
+ if (failures != 0) {
+ fprintf(stderr,
+ "%d test(s) failed\n",
+ failures);
+ return EXIT_FAILURE;
+ }
+
+ printf("all tests passed\n");
+ return EXIT_SUCCESS;
+}
diff --git a/tests/unit/config.c b/tests/unit/config.c
@@ -0,0 +1,215 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "config.h"
+
+#define TEST_ROOT "/tmp/simplefiles-config-test"
+#define TEST_STORAGE TEST_ROOT "/storage"
+#define TEST_REPO TEST_STORAGE "/default"
+#define TEST_CONFIG TEST_REPO "/.config"
+
+struct config_test_case {
+ const char *name;
+ const char *contents;
+ off_t expected_upload;
+ off_t expected_quota;
+};
+
+static const struct config_test_case config_tests[] = {
+ {
+ "valid values",
+ "MAX_UPLOAD=123456\n"
+ "QUOTA=987654\n",
+ 123456,
+ 987654,
+ },
+
+ {
+ "whitespace around values",
+ "MAX_UPLOAD = 222222\n"
+ "QUOTA= 333333\n",
+ 222222,
+ 333333,
+ },
+
+ {
+ "invalid values rejected",
+ "MAX_UPLOAD=-1\n"
+ "QUOTA=abc\n",
+ DEFAULT_MAX_UPLOAD,
+ DEFAULT_REPO_QUOTA,
+ },
+
+ {
+ "zero values rejected",
+ "MAX_UPLOAD=0\n"
+ "QUOTA=0\n",
+ DEFAULT_MAX_UPLOAD,
+ DEFAULT_REPO_QUOTA,
+ },
+
+ {
+ "comments ignored",
+ "# MAX_UPLOAD=1\n"
+ "# QUOTA=2\n"
+ "MAX_UPLOAD=444444\n"
+ "QUOTA=555555\n",
+ 444444,
+ 555555,
+ },
+};
+
+
+static int
+mkdir_p(const char *path)
+{
+ if (mkdir(path, 0700) == -1) {
+ if (access(path, F_OK) == 0)
+ return 0;
+
+ return -1;
+ }
+
+ return 0;
+}
+
+
+static int
+setup_environment(void)
+{
+ if (mkdir_p(TEST_ROOT) == -1)
+ return -1;
+
+ if (mkdir_p(TEST_STORAGE) == -1)
+ return -1;
+
+ if (mkdir_p(TEST_REPO) == -1)
+ return -1;
+
+ setenv("STORAGE_ROOT", TEST_STORAGE, 1);
+ setenv("SCRIPT_NAME", "/default/upload.cgi", 1);
+
+ return 0;
+}
+
+
+static int
+write_config(const char *contents)
+{
+ FILE *fp;
+
+ fp = fopen(TEST_CONFIG, "w");
+ if (fp == NULL)
+ return -1;
+
+ if (contents != NULL)
+ fprintf(fp, "%s", contents);
+
+ fclose(fp);
+
+ return 0;
+}
+
+
+static int
+test_repository_config(void)
+{
+ size_t i;
+ int failures = 0;
+
+ for (i = 0;
+ i < sizeof(config_tests) / sizeof(config_tests[0]);
+ i++) {
+
+ if (write_config(config_tests[i].contents) == -1) {
+ fprintf(stderr,
+ "FAIL: %s: cannot write config\n",
+ config_tests[i].name);
+ failures++;
+ continue;
+ }
+
+ if (config_max_upload() !=
+ config_tests[i].expected_upload) {
+ fprintf(stderr,
+ "FAIL: %s: config_max_upload() returned %lld, "
+ "expected %lld\n",
+ config_tests[i].name,
+ (long long)config_max_upload(),
+ (long long)config_tests[i].expected_upload);
+ failures++;
+ continue;
+ }
+
+ if (config_repo_quota() !=
+ config_tests[i].expected_quota) {
+ fprintf(stderr,
+ "FAIL: %s: config_repo_quota() returned %lld, "
+ "expected %lld\n",
+ config_tests[i].name,
+ (long long)config_repo_quota(),
+ (long long)config_tests[i].expected_quota);
+ failures++;
+ continue;
+ }
+
+ printf("ok: %s\n", config_tests[i].name);
+ }
+
+ return failures;
+}
+
+
+static int
+test_missing_config(void)
+{
+ unlink(TEST_CONFIG);
+
+ if (config_max_upload() != DEFAULT_MAX_UPLOAD) {
+ fprintf(stderr,
+ "FAIL: missing config max upload\n");
+ return 1;
+ }
+
+ if (config_repo_quota() != DEFAULT_REPO_QUOTA) {
+ fprintf(stderr,
+ "FAIL: missing config quota\n");
+ return 1;
+ }
+
+ printf("ok: missing config uses defaults\n");
+
+ return 0;
+}
+
+
+int
+main(void)
+{
+ int failures;
+
+ failures = 0;
+
+ if (setup_environment() == -1) {
+ fprintf(stderr,
+ "FAIL: setup failed\n");
+ return EXIT_FAILURE;
+ }
+
+ failures += test_repository_config();
+ failures += test_missing_config();
+
+ if (failures != 0) {
+ fprintf(stderr,
+ "%d test(s) failed\n",
+ failures);
+ return EXIT_FAILURE;
+ }
+
+ printf("all tests passed\n");
+
+ return EXIT_SUCCESS;
+}
diff --git a/tests/unit/html.c b/tests/unit/html.c
@@ -0,0 +1,555 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "html.h"
+
+#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
+
+struct test_case {
+ const char *name;
+ const char *template;
+ const struct html_var *vars;
+ size_t nvars;
+ const struct html_block *blocks;
+ size_t nblocks;
+ const char *expected;
+ int expected_result;
+};
+
+struct escape_test_case {
+ const char *name;
+ const char *input;
+ const char *expected;
+};
+/* --- Test data --- */
+
+/* Basic Variables */
+
+static const struct html_var basic_vars[] = {
+ {"NAME", "SimpleFiles"},
+ {"MAX_UPLOAD", "100 MB"},
+};
+/* FILES: normal rows */
+static const struct html_var file1_vars[] = {
+ {"NAME", "one.txt"},
+ {"SIZE", "123 B"},
+};
+
+static const struct html_var file2_vars[] = {
+ {"NAME", "two.txt"},
+ {"SIZE", "456 B"},
+};
+
+static const struct html_row file_rows[] = {
+ {file1_vars, 2},
+ {file2_vars, 2},
+};
+
+static const struct html_block files_block[] = {
+ {"FILES", file_rows, 2},
+};
+/* FILES: empty block */
+static const struct html_block empty_block[] = {
+ {"FILES", NULL, 0},
+};
+/* FILES: variable precedence */
+static const struct html_var global_vars[] = {
+ {"NAME", "global"},
+};
+
+static const struct html_var row_vars[] = {
+ {"NAME", "row"},
+};
+
+static const struct html_row override_rows[] = {
+ {row_vars, 1},
+};
+
+static const struct html_block override_block[] = {
+ {"FILES", override_rows, 1},
+};
+/* FILES: global fallback */
+
+static const struct html_var site_vars[] = {
+ {"SITE", "SimpleFiles"},
+};
+
+static const struct html_var row_name_vars[] = {
+ {"NAME", "test.txt"},
+};
+
+static const struct html_row fallback_rows[] = {
+ {row_name_vars, 1},
+};
+
+static const struct html_block fallback_block[] = {
+ {"FILES", fallback_rows, 1},
+};
+
+static const struct html_var empty_vars[] = {
+ {"NAME", ""},
+};
+/* FILES: HTML escaping */
+
+static const struct html_var escaped_row_vars[] = {
+ {"NAME", "<script>alert(\"x\")</script>"},
+};
+
+static const struct html_row escaped_rows[] = {
+ {escaped_row_vars, 1},
+};
+
+static const struct html_block escaped_block[] = {
+ {"FILES", escaped_rows, 1},
+};
+/* FILES: empty variable value */
+
+static const struct html_var empty_row_vars[] = {
+ {"NAME", ""},
+};
+
+static const struct html_row empty_value_rows[] = {
+ {empty_row_vars, 1},
+};
+
+static const struct html_block empty_value_block[] = {
+ {"FILES", empty_value_rows, 1},
+};
+/* --- Test helpers --- */
+
+static int
+run_test(const struct test_case * test)
+{
+ FILE *template;
+ FILE *output;
+ char path[] = "/tmp/html-test-XXXXXX";
+ char buf[4096];
+ size_t n;
+ int fd;
+ int result;
+ int failures = 0;
+
+ fd = mkstemp(path);
+ if (fd == -1) {
+ perror("mkstemp");
+ return 1;
+ }
+ template = fdopen(fd, "w+");
+ if (template == NULL) {
+ perror("fdopen");
+ close(fd);
+ unlink(path);
+ return 1;
+ }
+ if (fputs(test->template, template) == EOF) {
+ perror("fputs");
+ fclose(template);
+ unlink(path);
+ return 1;
+ }
+ fflush(template);
+ rewind(template);
+
+ output = tmpfile();
+ if (output == NULL) {
+ perror("tmpfile");
+ fclose(template);
+ unlink(path);
+ return 1;
+ }
+ result = render_template(output, path,
+ test->vars, test->nvars, test->blocks, test->nblocks);
+
+ if (result != test->expected_result) {
+ fprintf(stderr,
+ "FAIL: %s: render_template() returned %d, "
+ "expected %d\n",
+ test->name,
+ result,
+ test->expected_result);
+ failures++;
+ goto done;
+ }
+ if (result == 0) {
+ fflush(output);
+ rewind(output);
+
+ n = fread(buf, 1, sizeof(buf) - 1, output);
+ buf[n] = '\0';
+
+ if (strcmp(buf, test->expected) != 0) {
+ fprintf(stderr,
+ "FAIL: %s: output was:\n"
+ "---\n%s---\n"
+ "expected:\n"
+ "---\n%s---\n",
+ test->name,
+ buf,
+ test->expected);
+ failures++;
+ goto done;
+ }
+ }
+ printf("ok: %s\n", test->name);
+
+done:
+ fclose(output);
+ fclose(template);
+ unlink(path);
+
+ return failures;
+}
+
+static int
+run_escape_test(const struct escape_test_case * test)
+{
+ FILE *output;
+ char buf[4096];
+ size_t n;
+ int failures = 0;
+
+ output = tmpfile();
+ if (output == NULL) {
+ perror("tmpfile");
+ return 1;
+ }
+ html_escape(output, test->input);
+
+ fflush(output);
+ rewind(output);
+
+ n = fread(buf, 1, sizeof(buf) - 1, output);
+ buf[n] = '\0';
+
+ if (strcmp(buf, test->expected) != 0) {
+ fprintf(stderr,
+ "FAIL: %s: html_escape() output was:\n"
+ "---\n%s---\n"
+ "expected:\n"
+ "---\n%s---\n",
+ test->name,
+ buf,
+ test->expected);
+ failures++;
+ }
+ if (failures == 0)
+ printf("ok: %s\n", test->name);
+
+ fclose(output);
+
+ return failures;
+}
+/* --- Template rendering tests --- */
+static const struct test_case tests[] = {
+ {
+ "plain text",
+ "hello world\n",
+ NULL,
+ 0,
+ NULL,
+ 0,
+ "hello world\n",
+ 0
+ },
+ {
+ "one variable",
+ "hello {{NAME}}\n",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ "hello SimpleFiles\n",
+ 0
+ },
+ {
+ "multiple variables",
+ "{{NAME}}: max {{MAX_UPLOAD}}\n",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ "SimpleFiles: max 100 MB\n",
+ 0
+ },
+ {
+ "unknown variable",
+ "hello {{MISSING}}\n",
+ NULL,
+ 0,
+ NULL,
+ 0,
+ NULL,
+ -1
+ },
+ {
+ "html is preserved",
+ "<h1>{{NAME}}</h1>\n"
+ "<p>Upload a file.</p>\n",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ "<h1>SimpleFiles</h1>\n"
+ "<p>Upload a file.</p>\n",
+ 0
+ },
+ {
+ "block with two rows",
+ "<ul>\n"
+ "{{#FILES}}"
+ "<li>{{NAME}} ({{SIZE}})</li>\n"
+ "{{/FILES}}"
+ "</ul>\n",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ "<ul>\n"
+ "<li>one.txt (123 B)</li>\n"
+ "<li>two.txt (456 B)</li>\n"
+ "</ul>\n",
+ 0
+ },
+ {
+ "empty block",
+ "before\n"
+ "{{#FILES}}"
+ "<li>{{NAME}}</li>\n"
+ "{{/FILES}}"
+ "after\n",
+ NULL,
+ 0,
+ empty_block,
+ ARRAY_SIZE(empty_block),
+ "before\n"
+ "after\n",
+ 0
+ },
+ {
+ "row variable overrides global variable",
+ "{{#FILES}}{{NAME}}{{/FILES}}",
+ global_vars,
+ ARRAY_SIZE(global_vars),
+ override_block,
+ ARRAY_SIZE(override_block),
+ "row",
+ 0
+ },
+ {
+ "global variable available inside block",
+ "{{#FILES}}{{SITE}}: {{NAME}}{{/FILES}}",
+ site_vars,
+ ARRAY_SIZE(site_vars),
+ fallback_block,
+ ARRAY_SIZE(fallback_block),
+ "SimpleFiles: test.txt",
+ 0
+ },
+ {
+ "unknown variable inside block",
+ "{{#FILES}}{{MISSING}}{{/FILES}}",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ NULL,
+ -1
+ },
+ {
+ "unknown block",
+ "before{{#MISSING}}x{{/MISSING}}after",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ NULL,
+ -1
+ },
+ {
+ "missing closing block",
+ "{{#FILES}}{{NAME}}",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ NULL,
+ -1
+ },
+ {
+ "malformed block opening",
+ "{{#FILES",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ NULL,
+ -1
+ },
+ {
+ "unterminated variable",
+ "hello {{NAME",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ NULL,
+ -1
+ },
+ {
+ "empty variable",
+ "hello {{}}",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ NULL,
+ -1
+ },
+ {
+ "overlong variable name",
+ "{{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}}",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ NULL,
+ -1
+ },
+ {
+ "empty variable value",
+ "before[{{NAME}}]after",
+ empty_vars,
+ ARRAY_SIZE(empty_vars),
+ NULL,
+ 0,
+ "before[]after",
+ 0
+ },
+ {
+ "HTML escaping inside block",
+ "{{#FILES}}{{NAME}}{{/FILES}}",
+ NULL,
+ 0,
+ escaped_block,
+ ARRAY_SIZE(escaped_block),
+ "<script>alert("x")</script>",
+ 0
+ },
+ {
+ "unterminated variable with whitespace",
+ "hello {{ world",
+ basic_vars,
+ ARRAY_SIZE(basic_vars),
+ NULL,
+ 0,
+ NULL,
+ -1
+ },
+ {
+ "block with static text",
+ "before{{#FILES}}hello{{/FILES}}after",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ "beforehellohelloafter",
+ 0
+ },
+ {
+ "empty row variable value",
+ "{{#FILES}}[{{NAME}}]{{/FILES}}",
+ NULL,
+ 0,
+ empty_value_block,
+ ARRAY_SIZE(empty_value_block),
+ "[]",
+ 0
+ },
+ {
+ "text surrounding block",
+ "before\n"
+ "{{#FILES}}"
+ "{{NAME}}\n"
+ "{{/FILES}}"
+ "after\n",
+ NULL,
+ 0,
+ files_block,
+ ARRAY_SIZE(files_block),
+ "before\n"
+ "one.txt\n"
+ "two.txt\n"
+ "after\n",
+ 0
+ },
+};
+/* --- HTML escaping tests --- */
+
+static const struct escape_test_case escape_tests[] = {
+ {
+ "escape plain text",
+ "hello world",
+ "hello world"
+ },
+ {
+ "escape ampersand",
+ "a & b",
+ "a & b"
+ },
+ {
+ "escape less-than",
+ "<script>",
+ "<script>"
+ },
+ {
+ "escape greater-than",
+ "1 > 0",
+ "1 > 0"
+ },
+ {
+ "escape double quote",
+ "hello \"world\"",
+ "hello "world""
+ },
+ {
+ "escape single quote",
+ "it's",
+ "it's"
+ },
+ {
+ "escape multiple characters",
+ "<a href=\"foo?a=1&b=2\">it's ok</a>",
+ "<a href="foo?a=1&b=2">it's ok</a>"
+ },
+ {
+ "escape empty string",
+ "",
+ ""
+ },
+};
+
+int
+main(void)
+{
+ size_t i;
+ int failures = 0;
+
+ for (i = 0; i < ARRAY_SIZE(tests);
+ i++)
+ failures += run_test(&tests[i]);
+
+ for (i = 0; i < ARRAY_SIZE(escape_tests);
+ i++)
+ failures += run_escape_test(&escape_tests[i]);
+
+ if (failures != 0) {
+ fprintf(stderr,
+ "%d test(s) failed\n",
+ failures);
+ return EXIT_FAILURE;
+ }
+ printf("all tests passed\n");
+ return EXIT_SUCCESS;
+}
diff --git a/tests/unit/http.c b/tests/unit/http.c
@@ -0,0 +1,85 @@
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "http.h"
+
+static int saved_stdout;
+
+static void
+capture_stdout(FILE *fp)
+{
+ saved_stdout = dup(STDOUT_FILENO);
+ assert(saved_stdout != -1);
+
+ assert(dup2(fileno(fp), STDOUT_FILENO) != -1);
+}
+
+static void
+restore_stdout(void)
+{
+ assert(dup2(saved_stdout, STDOUT_FILENO) != -1);
+ close(saved_stdout);
+}
+
+static void
+test_valid_filename(void)
+{
+ FILE *fp;
+ char buffer[1024];
+ size_t n;
+
+ fp = tmpfile();
+ assert(fp != NULL);
+
+ capture_stdout(fp);
+
+ assert(download_headers("hello.txt", 123) == 0);
+
+ fflush(stdout);
+ rewind(fp);
+
+ n = fread(buffer, 1, sizeof(buffer) - 1, fp);
+ buffer[n] = '\0';
+
+ restore_stdout();
+
+ assert(strstr(buffer,
+ "Content-Disposition: attachment; filename=\"hello.txt\"\r\n")
+ != NULL);
+
+ assert(strstr(buffer, "Content-Length: 123\r\n") != NULL);
+
+ fclose(fp);
+
+ printf("ok: valid download headers\n");
+}
+
+static void
+test_reject_cr(void)
+{
+ assert(download_headers("hello\r\nX-Injected: yes", 123) == -1);
+
+ printf("ok: reject CR in filename\n");
+}
+
+static void
+test_reject_lf(void)
+{
+ assert(download_headers("hello\nX-Injected: yes", 123) == -1);
+
+ printf("ok: reject LF in filename\n");
+}
+
+int
+main(void)
+{
+ test_valid_filename();
+ test_reject_cr();
+ test_reject_lf();
+
+ printf("all tests passed\n");
+
+ return 0;
+}
diff --git a/tests/unit/multipart.c b/tests/unit/multipart.c
@@ -0,0 +1,1816 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <fcntl.h>
+#include <unistd.h>
+
+#include "multipart.h"
+
+#define TEST_SPOOL "/tmp/simplefiles-multipart-test-spool"
+#define TEST_OUTPUT "/tmp/simplefiles-multipart-test-output"
+#define TEST_BOUNDARY_MAX 70
+
+static int write_file(int fd, const char *data);
+static int create_spool(const char *body, struct multipart_request * req);
+
+static int
+test_parse_request(const char *ctype,
+ const char *expected_boundary,
+ int expected)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE", ctype, 1) == -1 ||
+ setenv("CONTENT_LENGTH", "100", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: parse request: could not set environment\n");
+ return 1;
+ }
+ ret = multipart_parse_request(&req);
+
+ if (ret != expected) {
+ fprintf(stderr,
+ "FAIL: parse request: '%s' returned %d, "
+ "expected %d\n",
+ ctype, ret, expected);
+ return 1;
+ }
+ if (expected_boundary != NULL &&
+ strcmp(req.boundary, expected_boundary) != 0) {
+ fprintf(stderr,
+ "FAIL: parse request: boundary was '%s', "
+ "expected '%s'\n",
+ req.boundary, expected_boundary);
+ return 1;
+ }
+ return 0;
+}
+
+static int
+create_spool(const char *body, struct multipart_request * req)
+{
+ int fd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1)
+ return -1;
+
+ if (write_file(fd, body) == -1) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return -1;
+ }
+ memset(req, 0, sizeof(*req));
+
+ if (strlcpy(req->boundary, "test-boundary",
+ sizeof(req->boundary)) >= sizeof(req->boundary)) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return -1;
+ }
+ req->length = (off_t) strlen(body);
+
+ return fd;
+}
+
+static int
+write_file(int fd, const char *data)
+{
+ size_t len;
+ size_t written;
+ ssize_t n;
+
+ len = strlen(data);
+ written = 0;
+
+ while (written < len) {
+ n = write(fd, data + written, len - written);
+ if (n <= 0)
+ return -1;
+
+ written += (size_t) n;
+ }
+
+ if (lseek(fd, 0, SEEK_SET) == (off_t) - 1)
+ return -1;
+
+ return 0;
+}
+
+static int
+test_extract_part(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "hello world\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ char output[64];
+ int fd;
+ int outfd;
+ ssize_t n;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: extract part: cannot create spool\n");
+ return 1;
+ }
+ if (write_file(fd, body) == -1) {
+ fprintf(stderr,
+ "FAIL: extract part: cannot write spool\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ memset(&req, 0, sizeof(req));
+
+ if (strlcpy(req.boundary, "test-boundary",
+ sizeof(req.boundary)) >= sizeof(req.boundary)) {
+ fprintf(stderr,
+ "FAIL: extract part: boundary too long\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ req.length = (off_t) strlen(body);
+
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) {
+ fprintf(stderr,
+ "FAIL: extract part: multipart_extract_part() failed\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ if (strcmp(part.filename, "hello.txt") != 0) {
+ fprintf(stderr,
+ "FAIL: extract part: filename was '%s', "
+ "expected 'hello.txt'\n",
+ part.filename);
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ if (part.length != 11) {
+ fprintf(stderr,
+ "FAIL: extract part: length was %lld, expected 11\n",
+ (long long) part.length);
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (outfd == -1) {
+ fprintf(stderr,
+ "FAIL: extract part: cannot create output\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ if (multipart_copy_part(fd, &part, outfd) != MULTIPART_OK) {
+ fprintf(stderr,
+ "FAIL: extract part: multipart_copy_part() failed\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+ if (lseek(outfd, 0, SEEK_SET) == (off_t) - 1) {
+ fprintf(stderr,
+ "FAIL: extract part: cannot seek output\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+ n = read(outfd, output, sizeof(output) - 1);
+ if (n < 0) {
+ fprintf(stderr,
+ "FAIL: extract part: cannot read output\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+ output[n] = '\0';
+
+ if (strcmp(output, "hello world") != 0) {
+ fprintf(stderr,
+ "FAIL: extract part: output was '%s', "
+ "expected 'hello world'\n",
+ output);
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+
+ printf("ok: extract and copy multipart part\n");
+
+ return 0;
+}
+
+static int
+test_multipart_copy_empty_part(void)
+{
+ struct multipart_part part;
+ int fd;
+ int outfd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: empty part: cannot create spool\n");
+ return 1;
+ }
+ memset(&part, 0, sizeof(part));
+
+ part.offset = 0;
+ part.length = 0;
+
+ outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (outfd == -1) {
+ fprintf(stderr,
+ "FAIL: empty part: cannot create output\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ if (multipart_copy_part(fd, &part, outfd) == -1) {
+ fprintf(stderr,
+ "FAIL: empty part: multipart_copy_part() failed\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+
+ printf("ok: copy empty multipart part\n");
+
+ return 0;
+}
+
+static int
+test_filename_not_found_in_payload(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "this is file data containing filename=\"fake.txt\"\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: filename payload: cannot create spool\n");
+ return 1;
+ }
+ if (write_file(fd, body) == -1) {
+ fprintf(stderr,
+ "FAIL: filename payload: cannot write spool\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ memset(&req, 0, sizeof(req));
+
+ if (strlcpy(req.boundary, "test-boundary",
+ sizeof(req.boundary)) >= sizeof(req.boundary)) {
+ fprintf(stderr,
+ "FAIL: filename payload: boundary too long\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ req.length = (off_t) strlen(body);
+
+ if (multipart_extract_part(fd, &req, &part) == -1) {
+ fprintf(stderr,
+ "FAIL: filename payload: extraction failed\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ if (strcmp(part.filename, "hello.txt") != 0) {
+ fprintf(stderr,
+ "FAIL: filename payload: filename was '%s', "
+ "expected 'hello.txt'\n",
+ part.filename);
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: filename is extracted from headers only\n");
+
+ return 0;
+}
+
+static int
+test_reject_multiple_file_parts(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"one.txt\"\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "one\r\n"
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"two.txt\"\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "two\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: multiple file parts: cannot create spool\n");
+ return 1;
+ }
+ if (write_file(fd, body) == -1) {
+ fprintf(stderr,
+ "FAIL: multiple file parts: cannot write spool\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ memset(&req, 0, sizeof(req));
+
+ if (strlcpy(req.boundary, "test-boundary",
+ sizeof(req.boundary)) >= sizeof(req.boundary)) {
+ fprintf(stderr,
+ "FAIL: multiple file parts: boundary too long\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ req.length = (off_t) strlen(body);
+
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_MULTIPLE_FILES) {
+ fprintf(stderr,
+ "FAIL: multiple file parts: "
+ "multipart_extract_part() accepted multiple files\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject multiple file parts\n");
+
+ return 0;
+}
+
+static int
+test_missing_opening_boundary(void)
+{
+ const char *body =
+ "garbage\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: missing opening boundary: setup failed\n");
+ return 1;
+ }
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: missing opening boundary: "
+ "accepted malformed body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject missing opening boundary\n");
+ return 0;
+}
+
+static int
+test_wrong_opening_boundary(void)
+{
+ const char *body =
+ "--wrong-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--wrong-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: wrong opening boundary: setup failed\n");
+ return 1;
+ }
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: wrong opening boundary: "
+ "accepted malformed body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject wrong opening boundary\n");
+ return 0;
+}
+
+static int
+test_missing_header_terminator(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "Content-Type: text/plain\r\n"
+ "hello";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: missing header terminator: setup failed\n");
+ return 1;
+ }
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: missing header terminator: "
+ "accepted malformed body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject missing header terminator\n");
+ return 0;
+}
+
+static int
+test_missing_closing_boundary(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: missing closing boundary: setup failed\n");
+ return 1;
+ }
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: missing closing boundary: "
+ "accepted malformed body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject missing closing boundary\n");
+ return 0;
+}
+
+static int
+test_truncated_closing_boundary(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: truncated closing boundary: setup failed\n");
+ return 1;
+ }
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: truncated closing boundary: "
+ "accepted malformed body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject truncated closing boundary\n");
+ return 0;
+}
+
+static int
+test_invalid_boundary_suffix(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundaryX\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1) {
+ fprintf(stderr,
+ "FAIL: invalid boundary suffix: setup failed\n");
+ return 1;
+ }
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: invalid boundary suffix: "
+ "accepted malformed body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject invalid boundary suffix\n");
+ return 0;
+}
+
+static int
+test_missing_content_disposition(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Type: text/plain\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MISSING_FILENAME) {
+ fprintf(stderr,
+ "FAIL: missing Content-Disposition: "
+ "accepted body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject missing Content-Disposition\n");
+ return 0;
+}
+
+static int
+test_wrong_content_disposition(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: attachment; filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: wrong Content-Disposition: "
+ "accepted body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject wrong Content-Disposition\n");
+ return 0;
+}
+
+static int
+test_unquoted_filename(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=hello.txt\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: unquoted filename: accepted body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject unquoted filename\n");
+ return 0;
+}
+
+static int
+test_empty_filename(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: empty filename: accepted body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject empty filename\n");
+ return 0;
+}
+
+static int
+test_boundary_like_payload(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundaryX\r\n"
+ "still file data\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) {
+ fprintf(stderr,
+ "FAIL: boundary-like payload: extraction failed\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: ignore boundary-like payload\n");
+ return 0;
+}
+
+static int
+test_content_type_normal(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; boundary=test-boundary",
+ "test-boundary",
+ MULTIPART_OK) != 0)
+ return 1;
+
+ printf("ok: parse normal Content-Type boundary\n");
+ return 0;
+}
+
+static int
+test_content_type_case_insensitive(void)
+{
+ if (test_parse_request(
+ "Multipart/Form-Data; boundary=test-boundary",
+ "test-boundary",
+ MULTIPART_OK) != 0)
+ return 1;
+
+ printf("ok: Content-Type media type is case-insensitive\n");
+ return 0;
+}
+
+static int
+test_content_type_boundary_case_insensitive(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; BOUNDARY=test-boundary",
+ "test-boundary",
+ MULTIPART_OK) != 0)
+ return 1;
+
+ printf("ok: boundary parameter name is case-insensitive\n");
+ return 0;
+}
+
+static int
+test_content_type_quoted_boundary(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; boundary=\"test-boundary\"",
+ "test-boundary",
+ MULTIPART_OK) != 0)
+ return 1;
+
+ printf("ok: parse quoted boundary\n");
+ return 0;
+}
+
+static int
+test_content_type_boundary_whitespace(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; boundary = test-boundary",
+ "test-boundary",
+ MULTIPART_OK) != 0)
+ return 1;
+
+ printf("ok: parse boundary with whitespace\n");
+ return 0;
+}
+
+static int
+test_content_type_parameter_before_boundary(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; charset=utf-8; "
+ "boundary=test-boundary",
+ "test-boundary",
+ MULTIPART_OK) != 0)
+ return 1;
+
+ printf("ok: parse boundary after another parameter\n");
+ return 0;
+}
+
+static int
+test_content_type_missing_boundary(void)
+{
+ if (test_parse_request(
+ "multipart/form-data",
+ NULL,
+ MULTIPART_MISSING_BOUNDARY) != 0)
+ return 1;
+
+ printf("ok: reject missing Content-Type boundary\n");
+ return 0;
+}
+
+static int
+test_content_type_invalid_boundary_character(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; boundary=\"test boundary\"",
+ NULL,
+ MULTIPART_INVALID_BOUNDARY) != 0)
+ return 1;
+
+ printf("ok: reject invalid boundary character\n");
+ return 0;
+}
+
+static int
+test_content_type_boundary_too_long(void)
+{
+ char ctype[128];
+ size_t prefix_len;
+ size_t i;
+ int ret;
+
+ strcpy(ctype, "multipart/form-data; boundary=");
+ prefix_len = strlen(ctype);
+
+ for (i = 0; i <= TEST_BOUNDARY_MAX; i++)
+ ctype[prefix_len + i] = 'a';
+
+ ctype[prefix_len + TEST_BOUNDARY_MAX + 1] = '\0';
+
+ ret = test_parse_request(
+ ctype,
+ NULL,
+ MULTIPART_INVALID_BOUNDARY);
+
+ if (ret != 0)
+ return 1;
+
+ printf("ok: reject oversized boundary\n");
+ return 0;
+}
+
+static int
+test_content_type_malformed_boundary_parameter(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; boundary",
+ NULL,
+ MULTIPART_INVALID_CONTENT_TYPE) != 0)
+ return 1;
+
+ printf("ok: reject malformed boundary parameter\n");
+ return 0;
+}
+
+static int
+test_content_type_wrong_media_type(void)
+{
+ if (test_parse_request(
+ "application/x-www-form-urlencoded; boundary=test-boundary",
+ NULL,
+ MULTIPART_INVALID_CONTENT_TYPE) != 0)
+ return 1;
+
+ printf("ok: reject wrong media type\n");
+ return 0;
+}
+
+static int
+test_content_type_duplicate_boundary(void)
+{
+ if (test_parse_request(
+ "multipart/form-data; boundary=one; boundary=two",
+ NULL,
+ MULTIPART_INVALID_BOUNDARY) != 0)
+ return 1;
+
+ printf("ok: reject duplicate boundary parameters\n");
+ return 0;
+}
+
+static int
+test_filename_is_actual_parameter(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; "
+ "name=\"file\"; "
+ "filename=\"filename=not-the-filename.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) {
+ fprintf(stderr,
+ "FAIL: actual filename parameter: extraction failed\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ if (strcmp(part.filename, "filename=not-the-filename.txt") != 0) {
+ fprintf(stderr,
+ "FAIL: actual filename parameter: filename was '%s', "
+ "expected 'filename=not-the-filename.txt'\n",
+ part.filename);
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: filename is an actual parameter\n");
+ return 0;
+}
+
+static int
+test_filename_parameter_name_must_match(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; "
+ "name=\"file\"; filenameX=\"wrong.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MISSING_FILENAME) {
+ fprintf(stderr,
+ "FAIL: filename parameter name: "
+ "accepted non-filename parameter\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject filename-like parameter name\n");
+ return 0;
+}
+
+static int
+test_duplicate_filename(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; "
+ "name=\"file\"; filename=\"one.txt\"; "
+ "filename=\"two.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: duplicate filename: accepted body\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject duplicate filename parameter\n");
+ return 0;
+}
+
+static int
+test_filename_parameter_whitespace(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; "
+ "name=\"file\"; filename = \"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) != MULTIPART_OK) {
+ fprintf(stderr,
+ "FAIL: filename whitespace: extraction failed\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ if (strcmp(part.filename, "hello.txt") != 0) {
+ fprintf(stderr,
+ "FAIL: filename whitespace: filename was '%s'\n",
+ part.filename);
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: accept whitespace around filename '='\n");
+ return 0;
+}
+
+static int
+test_reject_extra_non_file_part(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"extra\"\r\n"
+ "\r\n"
+ "unexpected\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: extra non-file part: "
+ "accepted unexpected part\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject extra non-file part\n");
+ return 0;
+}
+
+static int
+test_reject_wrong_field_name(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"upload\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: wrong field name: accepted unexpected field\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject wrong field name\n");
+ return 0;
+}
+
+static int
+test_reject_missing_field_name(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: missing field name: accepted malformed part\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject missing field name\n");
+ return 0;
+}
+
+static int
+test_reject_non_file_part_before_file(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"extra\"\r\n"
+ "\r\n"
+ "unexpected\r\n"
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: non-file part before file: "
+ "accepted unexpected structure\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject non-file part before file\n");
+ return 0;
+}
+
+static int
+test_reject_negative_part_offset(void)
+{
+ struct multipart_part part;
+ int fd;
+ int outfd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1)
+ return 1;
+
+ outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (outfd == -1) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ memset(&part, 0, sizeof(part));
+
+ part.offset = -1;
+ part.length = 1;
+
+ if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) {
+ fprintf(stderr,
+ "FAIL: negative part offset: accepted invalid offset\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+
+ printf("ok: reject negative part offset\n");
+ return 0;
+}
+
+static int
+test_reject_negative_part_length(void)
+{
+ struct multipart_part part;
+ int fd;
+ int outfd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1)
+ return 1;
+
+ outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (outfd == -1) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ memset(&part, 0, sizeof(part));
+
+ part.offset = 0;
+ part.length = -1;
+
+ if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) {
+ fprintf(stderr,
+ "FAIL: negative part length: accepted invalid length\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+
+ printf("ok: reject negative part length\n");
+ return 0;
+}
+
+static int
+test_reject_part_beyond_spool(void)
+{
+ const char *data = "hello";
+ struct multipart_part part;
+ int fd;
+ int outfd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1)
+ return 1;
+
+ if (write_file(fd, data) == -1) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (outfd == -1) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ memset(&part, 0, sizeof(part));
+
+ part.offset = 3;
+ part.length = 3;
+
+ if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) {
+ fprintf(stderr,
+ "FAIL: part beyond spool: accepted invalid range\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+
+ printf("ok: reject part beyond spool\n");
+ return 0;
+}
+
+static int
+test_reject_part_range_overflow(void)
+{
+ struct multipart_part part;
+ int fd;
+ int outfd;
+
+ fd = open(TEST_SPOOL, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd == -1)
+ return 1;
+
+ outfd = open(TEST_OUTPUT, O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (outfd == -1) {
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ memset(&part, 0, sizeof(part));
+
+ part.offset = (off_t) -1;
+ part.length = 2;
+
+ if (multipart_copy_part(fd, &part, outfd) != MULTIPART_ERROR) {
+ fprintf(stderr,
+ "FAIL: part range overflow: accepted overflowing range\n");
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+ return 1;
+ }
+
+ close(outfd);
+ close(fd);
+ unlink(TEST_SPOOL);
+ unlink(TEST_OUTPUT);
+
+ printf("ok: reject overflowing part range\n");
+ return 0;
+}
+
+static int
+test_reject_request_length_beyond_spool(void)
+{
+ const char *body =
+ "--test-boundary\r\n"
+ "Content-Disposition: form-data; name=\"file\"; "
+ "filename=\"hello.txt\"\r\n"
+ "\r\n"
+ "hello\r\n"
+ "--test-boundary--\r\n";
+ struct multipart_request req;
+ struct multipart_part part;
+ int fd;
+
+ fd = create_spool(body, &req);
+ if (fd == -1)
+ return 1;
+
+ /*
+ * Claim that CONTENT_LENGTH extends beyond the actual spool.
+ */
+ req.length += 100;
+
+ if (multipart_extract_part(fd, &req, &part) !=
+ MULTIPART_MALFORMED) {
+ fprintf(stderr,
+ "FAIL: request length beyond spool: "
+ "accepted invalid request length\n");
+ close(fd);
+ unlink(TEST_SPOOL);
+ return 1;
+ }
+
+ close(fd);
+ unlink(TEST_SPOOL);
+
+ printf("ok: reject request length beyond spool\n");
+ return 0;
+}
+
+static int
+test_content_length_valid(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "100", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: valid Content-Length: could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_OK) {
+ fprintf(stderr,
+ "FAIL: valid Content-Length: returned %d\n", ret);
+ return 1;
+ }
+
+ if (req.length != 100) {
+ fprintf(stderr,
+ "FAIL: valid Content-Length: parsed length %lld, "
+ "expected 100\n",
+ (long long) req.length);
+ return 1;
+ }
+
+ printf("ok: accept valid Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_empty(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: empty Content-Length: could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: empty Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject empty Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_negative(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "-1", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: negative Content-Length: could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: negative Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject negative Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_plus_sign(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "+100", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: signed Content-Length: could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: signed Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject plus-signed Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_leading_whitespace(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", " 100", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: whitespace Content-Length: "
+ "could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: leading whitespace Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject leading whitespace in Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_trailing_whitespace(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "100 ", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: whitespace Content-Length: "
+ "could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: trailing whitespace Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject trailing whitespace in Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_non_digit(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "100abc", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: non-digit Content-Length: "
+ "could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: non-digit Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject non-digit Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_decimal(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH", "10.0", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: decimal Content-Length: "
+ "could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: decimal Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject decimal Content-Length\n");
+ return 0;
+}
+
+static int
+test_content_length_overflow(void)
+{
+ struct multipart_request req;
+ int ret;
+
+ memset(&req, 0, sizeof(req));
+
+ if (setenv("CONTENT_TYPE",
+ "multipart/form-data; boundary=test-boundary", 1) == -1 ||
+ setenv("CONTENT_LENGTH",
+ "184467440737095516160", 1) == -1) {
+ fprintf(stderr,
+ "FAIL: overflowing Content-Length: "
+ "could not set environment\n");
+ return 1;
+ }
+
+ ret = multipart_parse_request(&req);
+
+ if (ret != MULTIPART_INVALID_LENGTH) {
+ fprintf(stderr,
+ "FAIL: overflowing Content-Length: returned %d, "
+ "expected %d\n",
+ ret, MULTIPART_INVALID_LENGTH);
+ return 1;
+ }
+
+ printf("ok: reject overflowing Content-Length\n");
+ return 0;
+}
+
+int
+main(void)
+{
+ int failures;
+
+ failures = 0;
+
+ failures += test_extract_part();
+ failures += test_filename_not_found_in_payload();
+ failures += test_reject_multiple_file_parts();
+ failures += test_multipart_copy_empty_part();
+ failures += test_missing_opening_boundary();
+ failures += test_wrong_opening_boundary();
+ failures += test_missing_header_terminator();
+ failures += test_missing_closing_boundary();
+ failures += test_truncated_closing_boundary();
+ failures += test_invalid_boundary_suffix();
+ failures += test_missing_content_disposition();
+ failures += test_wrong_content_disposition();
+ failures += test_unquoted_filename();
+ failures += test_empty_filename();
+ failures += test_filename_is_actual_parameter();
+ failures += test_filename_parameter_name_must_match();
+ failures += test_duplicate_filename();
+ failures += test_filename_parameter_whitespace();
+ failures += test_boundary_like_payload();
+ failures += test_content_type_normal();
+ failures += test_content_type_case_insensitive();
+ failures += test_content_type_boundary_case_insensitive();
+ failures += test_content_type_quoted_boundary();
+ failures += test_content_type_boundary_whitespace();
+ failures += test_content_type_parameter_before_boundary();
+ failures += test_content_type_missing_boundary();
+ failures += test_content_type_invalid_boundary_character();
+ failures += test_content_type_boundary_too_long();
+ failures += test_content_type_malformed_boundary_parameter();
+ failures += test_content_type_wrong_media_type();
+ failures += test_content_type_duplicate_boundary();
+ failures += test_reject_extra_non_file_part();
+ failures += test_reject_wrong_field_name();
+ failures += test_reject_missing_field_name();
+ failures += test_reject_non_file_part_before_file();
+ failures += test_reject_negative_part_offset();
+ failures += test_reject_negative_part_length();
+ failures += test_reject_part_beyond_spool();
+ failures += test_reject_part_range_overflow();
+ failures += test_reject_request_length_beyond_spool();
+ failures += test_content_length_valid();
+ failures += test_content_length_empty();
+ failures += test_content_length_negative();
+ failures += test_content_length_plus_sign();
+ failures += test_content_length_leading_whitespace();
+ failures += test_content_length_trailing_whitespace();
+ failures += test_content_length_non_digit();
+ failures += test_content_length_decimal();
+ failures += test_content_length_overflow();
+
+ if (failures != 0) {
+ fprintf(stderr,
+ "%d test(s) failed\n",
+ failures);
+ return EXIT_FAILURE;
+ }
+ printf("all tests passed\n");
+
+ return EXIT_SUCCESS;
+}
diff --git a/tests/unit/url.c b/tests/unit/url.c
@@ -0,0 +1,212 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "url.h"
+
+struct encode_test_case {
+ const char *name;
+ const char *input;
+ const char *expected;
+};
+
+struct decode_test_case {
+ const char *name;
+ const char *input;
+ const char *expected;
+ int expected_result;
+};
+
+static const struct encode_test_case encode_tests[] = {
+ /* Unreserved characters should not be encoded. */
+ { "simple filename", "file.txt", "file.txt" },
+ { "alphanumeric", "file123", "file123" },
+ { "hyphen", "my-file.txt", "my-file.txt" },
+ { "underscore", "my_file.txt", "my_file.txt" },
+ { "dot", "file.txt", "file.txt" },
+ { "tilde", "~file.txt", "~file.txt" },
+
+ /* Characters requiring encoding. */
+ { "space", "my file.txt", "my%20file.txt" },
+ { "ampersand", "&file.txt", "%26file.txt" },
+ { "plus", "a+b.txt", "a%2Bb.txt" },
+ { "percent", "100%.txt", "100%25.txt" },
+ { "question mark", "file?.txt", "file%3F.txt" },
+ { "equals", "file=name.txt", "file%3Dname.txt" },
+ { "slash", "foo/bar.txt", "foo%2Fbar.txt" },
+ { "backslash", "foo\\bar.txt", "foo%5Cbar.txt" },
+ { "hash", "file#1.txt", "file%231.txt" },
+ { "quote", "file\".txt", "file%22.txt" },
+
+ /* Empty string. */
+ { "empty", "", "" },
+};
+
+static const struct decode_test_case decode_tests[] = {
+ /* Plain strings. */
+ { "simple filename", "file.txt", "file.txt", 0 },
+ { "alphanumeric", "file123", "file123", 0 },
+ { "hyphen", "my-file.txt", "my-file.txt", 0 },
+ { "underscore", "my_file.txt", "my_file.txt", 0 },
+ { "dot", "file.txt", "file.txt", 0 },
+ { "tilde", "~file.txt", "~file.txt", 0 },
+
+ /* Encoded characters. */
+ { "space", "my%20file.txt", "my file.txt", 0 },
+ { "plus", "my+file.txt", "my file.txt", 0 },
+ { "ampersand", "%26file.txt", "&file.txt", 0 },
+ { "percent", "100%25.txt", "100%.txt", 0 },
+ { "question mark", "file%3F.txt", "file?.txt", 0 },
+ { "equals", "file%3Dname.txt", "file=name.txt", 0 },
+ { "slash", "foo%2Fbar.txt", "foo/bar.txt", 0 },
+ { "backslash", "foo%5Cbar.txt", "foo\\bar.txt", 0 },
+ { "hash", "file%23.txt", "file#.txt", 0 },
+ { "quote", "file%22.txt", "file\".txt", 0 },
+
+ /* Hexadecimal case should both work. */
+ { "lowercase hex", "%2f", "/", 0 },
+ { "uppercase hex", "%2F", "/", 0 },
+ { "mixed hex", "%2f%2F", "//", 0 },
+
+ /* Encoded traversal must decode successfully;
+ * valid_filename() is responsible for rejecting it. */
+ { "encoded dot", "%2e", ".", 0 },
+ { "encoded dot dot", "%2e%2e", "..", 0 },
+ { "encoded traversal", "%2e%2e%2ffile.txt",
+ "../file.txt", 0 },
+
+ /* Empty string. */
+ { "empty", "", "", 0 },
+
+ /* Invalid percent encodings. */
+ { "trailing percent", "%", NULL, -1 },
+ { "one hex digit", "%2", NULL, -1 },
+ { "invalid hex", "%ZZ", NULL, -1 },
+ { "invalid high digit", "%G0", NULL, -1 },
+ { "invalid low digit", "%0G", NULL, -1 },
+ { "incomplete encoding", "file%2", NULL, -1 },
+
+ /* Encoded NUL must be rejected. */
+ { "encoded NUL", "file%00.txt", NULL, -1 },
+};
+
+static int
+test_encode(void)
+{
+ size_t i;
+ int failures = 0;
+
+ for (i = 0;
+ i < sizeof(encode_tests) / sizeof(encode_tests[0]);
+ i++) {
+ char *got;
+
+ got = url_encode(encode_tests[i].input);
+
+ if (got == NULL) {
+ fprintf(stderr,
+ "FAIL: %s: url_encode() returned NULL\n",
+ encode_tests[i].name);
+ failures++;
+ continue;
+ }
+
+ if (strcmp(got, encode_tests[i].expected) != 0) {
+ fprintf(stderr,
+ "FAIL: %s: url_encode(%s) returned %s, "
+ "expected %s\n",
+ encode_tests[i].name,
+ encode_tests[i].input,
+ got,
+ encode_tests[i].expected);
+ failures++;
+ } else {
+ printf("ok: encode %s\n",
+ encode_tests[i].name);
+ }
+
+ free(got);
+ }
+
+ return failures;
+}
+
+static int
+test_decode(void)
+{
+ size_t i;
+ int failures = 0;
+
+ for (i = 0;
+ i < sizeof(decode_tests) / sizeof(decode_tests[0]);
+ i++) {
+ char *input;
+ int got;
+
+ input = malloc(strlen(decode_tests[i].input) + 1);
+ if (input == NULL) {
+ fprintf(stderr,
+ "FAIL: %s: malloc failed\n",
+ decode_tests[i].name);
+ failures++;
+ continue;
+ }
+
+ strcpy(input, decode_tests[i].input);
+
+ got = url_decode(input);
+
+ if (got != decode_tests[i].expected_result) {
+ fprintf(stderr,
+ "FAIL: %s: url_decode() returned %d, "
+ "expected %d\n",
+ decode_tests[i].name,
+ got,
+ decode_tests[i].expected_result);
+ failures++;
+ free(input);
+ continue;
+ }
+
+ if (got == 0 &&
+ strcmp(input, decode_tests[i].expected) != 0) {
+ fprintf(stderr,
+ "FAIL: %s: url_decode(%s) returned %s, "
+ "expected %s\n",
+ decode_tests[i].name,
+ decode_tests[i].input,
+ input,
+ decode_tests[i].expected);
+ failures++;
+ } else {
+ printf("ok: decode %s\n",
+ decode_tests[i].name);
+ }
+
+ free(input);
+ }
+
+ return failures;
+}
+
+int
+main(void)
+{
+ int failures;
+
+ failures = 0;
+
+ failures += test_encode();
+ failures += test_decode();
+
+ if (failures != 0) {
+ fprintf(stderr,
+ "%d test(s) failed\n",
+ failures);
+ return EXIT_FAILURE;
+ }
+
+ printf("all tests passed\n");
+ return EXIT_SUCCESS;
+}
+
diff --git a/tests/unit/valid_filename.c b/tests/unit/valid_filename.c
@@ -0,0 +1,75 @@
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "common.h"
+
+struct test_case {
+const char *name;
+const char *filename;
+int expected;
+};
+
+static const struct test_case tests[] = {
+/* Valid filenames. */
+{ "simple filename", "file.txt", 1 },
+{ "alphanumeric", "file123", 1 },
+{ "hyphen", "my-file.txt", 1 },
+{ "underscore", "my_file.txt", 1 },
+{ "multiple dots", "archive.tar.gz", 1 },
+{ "space", "my file.txt", 1 },
+{ "ampersand", "&file.txt", 1 },
+
+/* Invalid filenames. */
+{ "NULL", NULL, 0 },
+{ "empty", "", 0 },
+{ "dot", ".", 0 },
+{ "dot dot", "..", 0 },
+{ "slash", "file/name.txt", 0 },
+{ "leading slash", "/file.txt", 0 },
+{ "trailing slash", "file.txt/", 0 },
+{ "parent traversal", "../file.txt", 0 },
+{ "embedded traversal", "foo/../file.txt", 0 },
+{ "backslash", "foo\\bar.txt", 0 },
+{ "double quote", "foo\"bar.txt", 0 },
+{ "quote with header syntax", "foo\"; x=\"bar", 0 },
+{ "carriage return", "foo\rbar.txt", 0 },
+{ "line feed", "foo\nbar.txt", 0 },
+{ "tab", "foo\tbar.txt", 0 },
+{ "control character", "foo\x01" "bar.txt", 0 },
+{ "DEL character", "foo\x7f" "bar.txt", 0 },
+};
+
+int
+main(void)
+{
+size_t i;
+int failures = 0;
+int got;
+
+for (i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) {
+ got = valid_filename(tests[i].filename);
+
+ if (got != tests[i].expected) {
+ fprintf(stderr,
+ "FAIL: %s: valid_filename(%s) returned %d, "
+ "expected %d\n",
+ tests[i].name,
+ tests[i].filename != NULL ?
+ tests[i].filename : "NULL",
+ got,
+ tests[i].expected);
+ failures++;
+ } else {
+ printf("ok: %s\n", tests[i].name);
+ }
+}
+
+if (failures != 0) {
+ fprintf(stderr, "%d test(s) failed\n", failures);
+ return EXIT_FAILURE;
+}
+
+printf("all tests passed\n");
+return EXIT_SUCCESS;
+
+}