mirror of
https://github.com/xroche/httrack.git
synced 2026-07-28 11:32:48 +03:00
Under `set -e` a failing command in an EXIT trap becomes the script's exit status, so a hiccup while tearing down fixtures fails a test whose assertions all passed. That is what turned `57_local-proxy-connect.test` red on the Windows x64 leg of #765: five OK lines, no FAIL, exit 1. Every EXIT trap in the suite now runs teardown with errexit off, and the signal traps keep their own `trap` line, since sharing `set +e` with HUP/INT/QUIT/PIPE/TERM would leave errexit off for the rest of a signalled run and let a torn-down test still report success. A `|| true` on the `rm` would have been smaller, but it throws away the only diagnostic, and the evidence does not say which teardown command failed: a blocked `rm -rf` on the Windows runner exits 1 and prints "Device or resource busy", while the log shows exit 1 and nothing at all. The sharing violation in the issue is the plausible mechanism rather than a confirmed one, so whatever it really is now prints its own error. `99_teardown-status.test` pins the semantics both ways and scans the suite so a new test cannot reintroduce the shape, the leaky combined trap included. The `return 0` that three `cleanup()` bodies ended with never protected anything, since errexit fires at the failing command before it is reached. Closes #773 Signed-off-by: Xavier Roche <roche@httrack.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
6.7 KiB
Bash
185 lines
6.7 KiB
Bash
#!/bin/bash
|
|
#
|
|
# /website/ is served from the project directory htsserver set up itself, never
|
|
# from a root the request body names, and composing that path must stay bounded.
|
|
|
|
set -euo pipefail
|
|
|
|
testdir=$(cd "$(dirname "$0")" && pwd)
|
|
distdir=${top_srcdir:-$(cd "${testdir}/.." && pwd)}
|
|
distdir=$(cd "${distdir}" && pwd)
|
|
|
|
fail() {
|
|
echo "FAIL: $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
command -v htsserver >/dev/null || fail "no htsserver in PATH"
|
|
command -v python3 >/dev/null || {
|
|
echo "python3 not found; skipping" >&2
|
|
exit 77
|
|
}
|
|
|
|
srv=
|
|
log=$(mktemp)
|
|
base=$(mktemp -d)
|
|
cleanup() {
|
|
test -z "${srv}" || kill -9 "${srv}" 2>/dev/null || true
|
|
rm -f "${log}"
|
|
rm -rf "${base}"
|
|
}
|
|
trap 'set +e; cleanup' EXIT
|
|
trap cleanup HUP INT QUIT PIPE TERM
|
|
|
|
freeport() {
|
|
python3 -c 'import socket
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
print(s.getsockname()[1])
|
|
s.close()'
|
|
}
|
|
|
|
# Echo the announced URL. Runs in a command substitution, so it is a
|
|
# subshell and cannot export the pid: the caller reads it back with srvpid.
|
|
start() {
|
|
local port url
|
|
port=$(freeport)
|
|
: >"${log}"
|
|
(
|
|
trap '' TERM TTOU
|
|
exec htsserver "${distdir}/" --port "${port}" >"${log}" 2>&1
|
|
) &
|
|
for _ in $(seq 1 40); do
|
|
url=$(sed -n 's/^URL=//p' "${log}" 2>/dev/null) && test -n "${url}" && break
|
|
sleep 0.25
|
|
done
|
|
test -n "${url:-}" || fail "htsserver did not come up: $(cat "${log}")"
|
|
echo "${url}"
|
|
}
|
|
|
|
# The server reports its own pid; the aliveness assertion below hangs off it.
|
|
srvpid() { sed -n 's/^PID=//p' "${log}" | head -1; }
|
|
|
|
alive() { kill -0 "$1" 2>/dev/null; }
|
|
|
|
portof() { echo "${1##*:}" | tr -d /; }
|
|
|
|
# Raw request to 127.0.0.1:$1: GET the path $2, or POST the body $3 to / when
|
|
# $2 is empty. Prints the reply.
|
|
request() {
|
|
python3 -c 'import socket, sys
|
|
port, path, body = int(sys.argv[1]), sys.argv[2], sys.argv[3]
|
|
if path:
|
|
req = "GET %s HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" % path
|
|
else:
|
|
req = ("POST / HTTP/1.0\r\nHost: 127.0.0.1\r\n"
|
|
"Content-type: application/x-www-form-urlencoded\r\n"
|
|
"Content-length: %d\r\n\r\n%s" % (len(body), body))
|
|
s = socket.create_connection(("127.0.0.1", port), 10)
|
|
s.settimeout(30)
|
|
s.sendall(req.encode())
|
|
out = b""
|
|
while True:
|
|
b = s.recv(65536)
|
|
if not b:
|
|
break
|
|
out += b
|
|
s.close()
|
|
sys.stdout.write(out.decode("latin-1"))' "$1" "$2" "$3"
|
|
}
|
|
|
|
get() { request "$1" "$2" ""; }
|
|
post() { request "$1" "" "$2"; }
|
|
|
|
# GET $2 into ${reply}, requiring status $3. Captured, not piped: a dead request
|
|
# reads as marker-absent, and so do a truncated body and a 302 to the file.
|
|
reply=
|
|
fetch() {
|
|
reply=$(get "$1" "$2") || fail "the request for $2 failed"
|
|
grep -q "^HTTP/1\.0 $3 " <<<"${reply}" ||
|
|
fail "$2: wanted a $3 reply, got '$(head -1 <<<"${reply}")'"
|
|
}
|
|
|
|
url=$(start)
|
|
port=$(portof "${url}")
|
|
srv=$(srvpid)
|
|
test -n "${srv}" || fail "htsserver did not report its pid"
|
|
|
|
# Every request body is gated by the session id (78_webhttrack-sid.test).
|
|
sid=$(get "${port}" /server/index.html |
|
|
sed -n 's/.*name="sid" value="\([0-9a-f]*\)".*/\1/p' | head -1)
|
|
test "${#sid}" -eq 32 || fail "did not scrape a 32-hex sid (got '${sid}')"
|
|
|
|
# A file the mirror must never expose, next to the project that may.
|
|
echo "SECRETMARKER" >"${base}/secret.txt"
|
|
mkdir -p "${base}/proj"
|
|
echo "LOGMARKER" >"${base}/proj/hts-log.txt"
|
|
|
|
# error_redirect is the branch that skipped the fsfile clearing, so fail the save
|
|
# with a component over NAME_MAX (mkdir refuses it whatever the uid). Must precede
|
|
# any successful save: commandEnd then swaps the error page for the finished one.
|
|
get "${port}" /server/style.css >/dev/null # leaves a path behind in fsfile
|
|
saved=$(post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=x&path=${base}/$(printf '%0300d' 0)&projname=p")
|
|
grep -q '^Location: /server/error.html' <<<"${saved}" ||
|
|
fail "the refused save did not redirect to the error page"
|
|
|
|
# No project yet, so no root to serve from.
|
|
post "${port}" "sid=${sid}&projpath=${base}/" >/dev/null
|
|
fetch "${port}" /website/secret.txt 404
|
|
grep -q SECRETMARKER <<<"${reply}" &&
|
|
fail "a posted projpath served a file outside any project"
|
|
|
|
# Positive control: step4's "save settings" flow registers the project without
|
|
# crawling, and browsing its mirror keeps working. Without it the assertions
|
|
# around it would pass on a server that never serves /website/ at all.
|
|
body="sid=${sid}&command=httrack&command_do=save&winprofile=x"
|
|
body="${body}&path=${base}&projname=proj&projpath=${base}/proj/"
|
|
post "${port}" "${body}" >/dev/null
|
|
test -f "${base}/proj/hts-cache/winprofile.ini" ||
|
|
fail "the project was not registered: $(cat "${log}")"
|
|
fetch "${port}" /website/hts-log.txt 200
|
|
grep -q LOGMARKER <<<"${reply}" ||
|
|
fail "the registered project's mirror is not served"
|
|
|
|
# Same request with the root repointed: the project is legitimate, projpath is
|
|
# not what decides where the bytes come from.
|
|
post "${port}" "sid=${sid}&projpath=${base}/" >/dev/null
|
|
fetch "${port}" /website/secret.txt 404
|
|
grep -q SECRETMARKER <<<"${reply}" &&
|
|
fail "a posted projpath repointed the served root"
|
|
post "${port}" "sid=${sid}&projpath=/etc/" >/dev/null
|
|
fetch "${port}" /website/passwd 404
|
|
grep -q '^root:' <<<"${reply}" &&
|
|
fail "a posted projpath read an arbitrary system file"
|
|
|
|
# A ".." anywhere in the recorded root would escape the mirror on every later
|
|
# request, so the save must be refused and the previous root kept.
|
|
post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=x&path=${base}/proj&projname=.." >/dev/null
|
|
fetch "${port}" /website/secret.txt 404
|
|
grep -q SECRETMARKER <<<"${reply}" &&
|
|
fail "a '..' in the saved project path escaped the mirror root"
|
|
fetch "${port}" /website/hts-log.txt 200
|
|
grep -q LOGMARKER <<<"${reply}" ||
|
|
fail "rejecting the '..' root also lost the previous one"
|
|
|
|
# The root is now the server's own, but it is still built from two posted
|
|
# fields: 800-odd bytes of them used to be sprintf'd into a 1024-byte buffer.
|
|
seg=$(printf '%0200d' 0)
|
|
longpath="${base}/${seg}/${seg}/${seg}/${seg}"
|
|
fspath="${longpath}/proj"
|
|
# structcheck() refuses a root over HTS_URLMAXSIZE, so the URL carries the rest.
|
|
test "$((${#fspath} + 11))" -le 1024 ||
|
|
fail "the long project path (${#fspath}) would not pass structcheck"
|
|
longurl=$(printf '%0800d' 0)
|
|
test "$((${#fspath} + 1 + ${#longurl}))" -gt 1024 ||
|
|
fail "the composed path (${#fspath} + ${#longurl}) would not overflow"
|
|
post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=x&path=${longpath}&projname=proj" >/dev/null
|
|
test -f "${fspath}/hts-cache/winprofile.ini" ||
|
|
fail "the long-path project was not registered: $(cat "${log}")"
|
|
get "${port}" "/website/${longurl}" >/dev/null 2>&1 || true
|
|
alive "${srv}" || fail "an over-long project path crashed the server: $(cat "${log}")"
|
|
# Not just alive: still answering.
|
|
fetch "${port}" /server/index.html 200
|
|
|
|
echo "PASS"
|