Compare commits

...

7 Commits

Author SHA1 Message Date
Xavier Roche
f49365fee3 ci: harden the webhttrack smoke and drop the leaky watchdog
Review found two issues. The content check matched only the HTTrack brand
string, which is a literal in every page header, so a truncated/degraded
template served 200 would still pass; also require the step-2 form action.
The hard watchdog subshell was un-redirected, so its orphaned sleep kept the
CI step stdout open ~35s after every run; the poll loop plus teardown already
bound the run, so drop the watchdog (SIGKILL webhttrack if it ignores TERM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-12 13:32:41 +02:00
Xavier Roche
d0ba2c67fa ci: route the webhttrack smoke past macOS open -W
On Darwin webhttrack hardcodes the browser to "open -W", which launches a real
GUI browser and blocks headless (the macOS smoke stalled here). Shadow uname so
webhttrack takes the generic path and uses the stub browser; htsserver and
webhttrack path resolution still run for real on macOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-12 13:04:41 +02:00
Xavier Roche
8b4faa5e2f ci: bound and instrument the webhttrack smoke test
The smoke step hung on the macOS runner. Add a hard watchdog (macOS has no
timeout(1)), drop the blocking wait for a bounded liveness poll, and log each
step plus webhttrack.log so a macOS-specific stall is visible instead of
running to the job timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-12 12:54:30 +02:00
Xavier Roche
61935f28eb ci: reap htsserver in the webhttrack smoke test
webhttrack backgrounds htsserver and leaves it running; killing only webhttrack
orphaned the server, which held the macOS CI step open until timeout. Kill
htsserver explicitly (scoped to the test prefix) in teardown and on exit, and
dump webhttrack.log so a real failure is diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-12 12:41:28 +02:00
Xavier Roche
8ae30c1f2c ci: smoke-test webhttrack on macOS
Nothing exercised the WebHTTrack GUI launcher or its htsserver backend, and
webhttrack has a Darwin-only browser path (open -W). Install into a temp prefix
on a macOS runner and assert webhttrack brings up htsserver and serves the UI
(fetched via a stub browser that shadows the first name in webhttrack's list).

The UI is served ISO-8859-1, so the content check uses grep -a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-12 12:14:26 +02:00
Xavier Roche
3d65bb2e3b Raise the test server's listen backlog so -c8 bigcrawl stops flaking on macOS (#538)
local-server.py used ThreadingHTTPServer's default request_queue_size of 5.
macOS/BSD drop SYNs once the accept queue overflows; Linux is lenient. Under a
parallel make check the single-threaded accept loop is starved for CPU, drains
the 5-deep queue slowly, and an -c8 bigcrawl burst overflows it. The
36_local-bigcrawl file count then flaked (e.g. 361 -> 283) because --retries=0
turns each dropped connection into a lost file. Raise the backlog to 128 for
ample headroom; -c8 is unchanged.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:38:20 +02:00
Xavier Roche
68897b09ce Re-fetch a self-redirect cookie wall instead of dropping it (#537)
* Re-fetch a self-redirect cookie wall instead of dropping it (#15)

A page that 302-redirects to itself only to set a cookie (a consent or session
"cookie wall") was lost: the self-redirect guard treated the loop as a crazy
server and never re-issued the request, so the real content behind the cookie
was never fetched.

The redirect's Set-Cookie is already folded into the shared jar before the
guard runs, so a re-issue would carry it. In the delayed-type loop
(hts_wait_delayed) snapshot the jar before the request; when a self-redirect
changed it, evict the cached fast-header for that URL and re-fetch once with the
new cookie. Termination is bounded: the jar stops changing once the cookie is
satisfied (real walls resolve in two requests), and the existing loops<7 cap
backstops a server that mints a fresh cookie on every hit.

Under the default HARD delayed-type mode every URL routes through this loop, so
both unknown-ext (wall.php) and known-ext (wall.html) walls are covered. A wall
reached only under -%N0/1 still takes the hts_mirror_check_moved path, which
cannot see the Set-Cookie without an ABI change; left as a known limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Test the cookie-wall give-up and retry-cap paths (#15)

The happy-path test only drove a wall that sets its cookie on the first hit, so
an engine that dropped the jar-changed gate and retried every self-redirect would
still pass. Add two cases that pin the gate and the bound:

- cookiewall3: a self-redirect that sets no cookie must give up at once (asserts
  the "loop to same filename" guard fires and the page is not mirrored). A
  dropped gate would retry and skip that log line.
- cookiewall4: a self-redirect that mints a fresh cookie every hit must stop at
  the loops<7 cap, not spin (asserts the crawl terminates, takes the retry path,
  and does not mirror the wall).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Scope the cookie-wall retry to this URL's own cookies (#15)

The retry trigger compared the whole shared cookie jar before and after the
request. Because the jar is one process-wide store mutated by every in-flight
fetch, a concurrent slot's Set-Cookie landing mid-loop could flip the compare
and force a needless (though bounded) re-fetch of an unrelated self-redirect.

Compare instead the Cookie header THIS url would send, built for its own
host/path. A Set-Cookie from another host no longer trips the retry; a change
in this URL's own outgoing cookies (its self-redirect setting one) still does,
which is exactly the wall we want to re-fetch. No jar-wide snapshot, and the
same natural termination: once the cookie stabilizes the header stops changing.

Reuses append_cookie_header via http_cookie_header (renamed from the
_selftest-suffixed wrapper, now that engine code calls it too).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:44:40 +02:00
9 changed files with 270 additions and 12 deletions

View File

@@ -150,6 +150,35 @@ jobs:
if: failure()
run: cat tests/test-suite.log 2>/dev/null || true
# Runtime smoke of the WebHTTrack launcher on macOS: it carries a Darwin-only
# browser path (open -W) and nothing else exercises htsserver. Install into a
# temp prefix, then check webhttrack brings up htsserver and serves the UI.
webhttrack-macos:
name: webhttrack smoke (macOS arm64)
runs-on: macos-14
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Install build dependencies
run: |
set -euo pipefail
brew install autoconf automake libtool autoconf-archive
- name: Build and install into a temp prefix
run: |
set -euo pipefail
ssl="$(brew --prefix openssl@3)"
autoreconf -fi
./configure CPPFLAGS="-I${ssl}/include" LDFLAGS="-L${ssl}/lib" \
--prefix="$RUNNER_TEMP/inst"
make -j"$(sysctl -n hw.ncpu)"
make install
- name: Smoke-test webhttrack
run: bash tests/webhttrack-smoke.sh "$RUNNER_TEMP/inst"
# Portability/hardening: 32-bit (i386) build on the x86-64 runner via multilib
# -- no extra hardware. Exercises the 32-bit size_t/pointer ABI, where size
# and bounds math can truncate or wrap in ways 64-bit never reveals (the axis

View File

@@ -1059,10 +1059,10 @@ static int append_cookie_header(buff_struct *bstr, t_cookie *cookie,
return cook;
}
/* Self-test entry for append_cookie_header(): build the request Cookie line
into dst (always NUL-terminated). Returns the number of cookies emitted. */
int http_cookie_header_selftest(t_cookie *cookie, const char *domain,
const char *path, char *dst, size_t dst_size) {
/* Build the request Cookie line for domain/path into dst (always
NUL-terminated). Returns the number of cookies emitted. */
int http_cookie_header(t_cookie *cookie, const char *domain, const char *path,
char *dst, size_t dst_size) {
buff_struct bstr = {dst, dst_size, 0};
assertf(dst != NULL && dst_size > 0);

View File

@@ -184,10 +184,10 @@ int http_sendhead(httrackp * opt, t_cookie * cookie, int mode, const char *xsend
const char *referer_adr, const char *referer_fil,
htsblk * retour);
/* Build the request "Cookie:" header line for stored cookies matching
domain/path into dst (NUL-terminated). Exposed for the -#Q self-test;
wraps the same logic http_sendhead() uses. Returns cookies emitted. */
int http_cookie_header_selftest(t_cookie *cookie, const char *domain,
const char *path, char *dst, size_t dst_size);
domain/path into dst (NUL-terminated), wrapping the logic http_sendhead()
uses. Returns cookies emitted. */
int http_cookie_header(t_cookie *cookie, const char *domain, const char *path,
char *dst, size_t dst_size);
T_SOC newhttp(httrackp * opt, const char *iadr, htsblk * retour, int port,
int waitconnect);

View File

@@ -4311,6 +4311,8 @@ int hts_wait_delayed(htsmoduleStruct * str, lien_adrfilsave *afs,
*forbidden_url == 0 && IS_DELAYED_EXT(afs->save) && !opt->state.stop) {
int loops;
int continue_loop;
char BIGSTK
cookie_before[16384]; /* #15: this URL's Cookie header, pre-request */
hts_log_print(opt, LOG_DEBUG, "Waiting for type to be known: %s%s", afs->af.adr,
afs->af.fil);
@@ -4319,6 +4321,12 @@ int hts_wait_delayed(htsmoduleStruct * str, lien_adrfilsave *afs,
for(loops = 0, continue_loop = 1;
IS_DELAYED_EXT(afs->save) && continue_loop && loops < 7; loops++) {
continue_loop = 0;
/* #15: snapshot the Cookie header THIS url would send, so only its own
self-redirect Set-Cookie trips the retry, not a concurrent slot's. */
cookie_before[0] = '\0';
if (opt->accept_cookie && opt->cookie != NULL)
http_cookie_header(opt->cookie, jump_identification_const(afs->af.adr),
afs->af.fil, cookie_before, sizeof(cookie_before));
/* Wait for an available slot */
if (!hts_wait_available_socket(sback, opt, cache, ptr))
@@ -4516,6 +4524,17 @@ int hts_wait_delayed(htsmoduleStruct * str, lien_adrfilsave *afs,
/* Moved! */
else if (HTTP_IS_REDIRECT(back[b].r.statuscode)) {
char BIGSTK mov_url[HTS_URLMAXSIZE * 2];
char BIGSTK cookie_after[16384];
hts_boolean cookies_changed;
/* #15: cookie-wall signal: this URL's own Cookie header changed
across its self-redirect (a Set-Cookie it just set). */
cookie_after[0] = '\0';
if (opt->accept_cookie && opt->cookie != NULL)
http_cookie_header(
opt->cookie, jump_identification_const(afs->af.adr),
afs->af.fil, cookie_after, sizeof(cookie_after));
cookies_changed = strcmp(cookie_before, cookie_after) != 0;
mov_url[0] = '\0';
strcpybuff(mov_url, back[b].r.location); // copier URL
@@ -4585,11 +4604,24 @@ int hts_wait_delayed(htsmoduleStruct * str, lien_adrfilsave *afs,
url_savename(afs, former, heap(ptr)->adr, heap(ptr)->fil,
opt, sback, cache, hash, ptr, numero_passe,
&delayed_back);
} else if (cookies_changed) {
// #15: cookie-wall self-redirect; evict the cached
// fast-header so the re-issue refetches with the cookie.
if (cache->cached_tests != NULL)
coucal_remove(cache->cached_tests,
concat(OPT_GET_BUFF(opt),
OPT_GET_BUFF_SIZE(opt), afs->af.adr,
afs->af.fil));
afs->save[0] = '\0';
url_savename(afs, former, heap(ptr)->adr, heap(ptr)->fil, opt,
sback, cache, hash, ptr, numero_passe,
&delayed_back);
continue_loop = 1;
} else {
hts_log_print(opt, LOG_WARNING,
"Unable to test %s%s (loop to same filename)",
afs->af.adr, afs->af.fil);
} // loop to same location
} // loop to same location
} // ident_url_relatif()
} // location
} // redirect

View File

@@ -1755,7 +1755,7 @@ static int st_cookies(httrackp *opt, int argc, char **argv) {
return 1;
}
http_cookie_header_selftest(&cookie, dom, "/", hdr, sizeof(hdr));
http_cookie_header(&cookie, dom, "/", hdr, sizeof(hdr));
if (strcmp(hdr, expected) != 0)
err = 1;
if (strstr(hdr, "$Version") != NULL || strstr(hdr, "$Path") != NULL)

34
tests/49_local-cookiewall.test Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
#
# Cookie-wall self-redirect (#15): httrack replays a Set-Cookie to mirror the
# real page, gives up when nothing changes, and stays bounded when it never does.
set -euo pipefail
: "${top_srcdir:=..}"
# Unknown-ext (wall.php) and known-ext (wall.html): the wall sets the cookie once,
# then serves real content; httrack must replay it and mirror that content.
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 \
--found 'cookiewall/wall.html' \
--file-matches 'cookiewall/wall.html' 'REAL-CONTENT-BEHIND-COOKIE-WALL' \
httrack 'BASEURL/cookiewall/index.html'
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 \
--found 'cookiewall2/wall.html' \
--file-matches 'cookiewall2/wall.html' 'REAL-CONTENT-BEHIND-COOKIE-WALL' \
httrack 'BASEURL/cookiewall2/index.html'
# No Set-Cookie: the jar never changes, so httrack must give up at once (retry
# stays gated) and not mirror the wall. A dropped gate would skip this log line.
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 \
--not-found 'cookiewall3/wall.html' \
--log-found 'loop to same filename' \
httrack 'BASEURL/cookiewall3/index.html'
# Ever-changing cookie never satisfies the wall: httrack must stop at the retry
# cap (bounded, no early give-up) and not mirror it.
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 \
--not-found 'cookiewall4/wall.html' \
--log-not-found 'loop to same filename' \
httrack 'BASEURL/cookiewall4/index.html'

View File

@@ -119,6 +119,7 @@ TESTS = \
45_local-maxsize-recv.test \
46_local-update-304-resume.test \
47_local-crange-overflow.test \
48_local-crange-memresume.test
48_local-crange-memresume.test \
49_local-cookiewall.test
CLEANFILES = check-network_sh.cache

View File

@@ -1143,6 +1143,56 @@ class Handler(SimpleHTTPRequestHandler):
def route_delayed_empty(self):
self.send_raw(b"", "text/html") # 200 + Content-Length: 0
# --- /cookiewall/ (#15): a self-redirect that only sets a cookie is a
# consent wall; httrack must replay the cookie and fetch the real page.
WALL_MARK = b"REAL-CONTENT-BEHIND-COOKIE-WALL"
def route_cookiewall_index(self):
self.send_html('\t<a href="wall.php">wall</a>')
def route_cookiewall_wall(self):
self._cookiewall_reply("wall.php")
# Known-extension twin: .html so the type is not delayed-resolved.
def route_cookiewall2_index(self):
self.send_html('\t<a href="wall.html">wall</a>')
def route_cookiewall2_wall(self):
self._cookiewall_reply("wall.html")
def _cookiewall_reply(self, location):
if self.request_cookies().get("gate") == "1":
self.send_raw(
b"<html><body>" + self.WALL_MARK + b"</body></html>\n", "text/html"
)
else:
self._wall_redirect(location, "gate=1; Path=/")
# No-cookie self-redirect: the jar never changes, so httrack must give up at
# once rather than re-fetch (proves the cookie-wall retry stays gated on #15).
def route_cookiewall3_index(self):
self.send_html('\t<a href="wall.php">wall</a>')
def route_cookiewall3_wall(self):
self._wall_redirect("wall.php", None)
# Ever-changing cookie: every hit sets a fresh value, so the jar keeps
# changing; httrack must stop at the loops<7 cap, not spin forever.
def route_cookiewall4_index(self):
self.send_html('\t<a href="wall.php">wall</a>')
def route_cookiewall4_wall(self):
nonce = int(self.request_cookies().get("gate", "0")) + 1
self._wall_redirect("wall.php", f"gate={nonce}; Path=/")
def _wall_redirect(self, location, set_cookie):
self.send_response(302, "Found")
self.send_header("Location", location)
if set_cookie is not None:
self.send_header("Set-Cookie", set_cookie)
self.send_header("Content-Length", "0")
self.end_headers()
# -E time-limit (#481): pages that trickle far longer than any -E budget,
# so only an engine-side abort can end the crawl.
TRICKLE_SECONDS = 60
@@ -1359,6 +1409,14 @@ class Handler(SimpleHTTPRequestHandler):
"/delayed/chain7.php": route_delayed_chain,
"/delayed/chain8.php": route_delayed_chain,
"/delayed/chain9.php": route_delayed_chain,
"/cookiewall/index.html": route_cookiewall_index,
"/cookiewall/wall.php": route_cookiewall_wall,
"/cookiewall2/index.html": route_cookiewall2_index,
"/cookiewall2/wall.html": route_cookiewall2_wall,
"/cookiewall3/index.html": route_cookiewall3_index,
"/cookiewall3/wall.php": route_cookiewall3_wall,
"/cookiewall4/index.html": route_cookiewall4_index,
"/cookiewall4/wall.php": route_cookiewall4_wall,
"/redir/index.html": route_redir_index,
"/redir/go.php": route_redir_go,
"/redir/target.html": route_redir_target,
@@ -1587,7 +1645,12 @@ def main():
def factory(*a, **kw):
return Handler(*a, directory=root, **kw)
httpd = ThreadingHTTPServer((args.bind, 0), factory)
# macOS/BSD drop SYNs when the listen backlog overflows (Linux is lenient);
# raise it from Python's default 5 so a busy -c8 crawl can't lose fetches.
class BacklogHTTPServer(ThreadingHTTPServer):
request_queue_size = 128
httpd = BacklogHTTPServer((args.bind, 0), factory)
if args.tls:
import ssl

99
tests/webhttrack-smoke.sh Normal file
View File

@@ -0,0 +1,99 @@
#!/bin/bash
# Smoke-test an installed webhttrack: launch it with a stub browser and assert
# htsserver comes up and serves the web UI. Arg: the install prefix.
set -euo pipefail
prefix="${1:?usage: webhttrack-smoke.sh <install-prefix>}"
wht="$prefix/bin/webhttrack"
test -x "$wht" || {
echo "no webhttrack at $wht" >&2
exit 1
}
work="$(mktemp -d)"
# webhttrack backgrounds htsserver, which outlives it; reap any stray one (scoped
# to this prefix) so a lingering server can never hold the CI step open.
trap 'pkill -f "$prefix/bin/htsserver" 2>/dev/null || true; rm -rf "$work"' EXIT
export HOME="$work/home"
mkdir -p "$HOME/websites"
marker="$work/marker"
stubdir="$work/bin"
mkdir -p "$stubdir"
# On Darwin webhttrack hardcodes "open -W", which launches a real GUI browser and
# blocks headless. Shadow uname so it takes the generic path and picks the stub
# browser below; htsserver and webhttrack's path resolution still run for real.
cat >"$stubdir/uname" <<'EOF'
#!/bin/bash
[ "${1:-}" = "-s" ] && {
echo Linux
exit 0
}
exec /usr/bin/uname "$@"
EOF
chmod +x "$stubdir/uname"
# Stub browser: webhttrack tries its browser-name list in order and runs the
# first it finds, so shadow the first entry, "x-www-browser". It fetches the
# server URL and records PASS only for the working UI: the brand string AND the
# step-2 form action, which a truncated/degraded template page would lack (the
# bare title alone is not enough). htsserver only lives until webhttrack exits,
# so the check has to happen here.
# -a: the UI is served ISO-8859-1, so grep must not treat it as binary.
cat >"$stubdir/x-www-browser" <<EOF
#!/bin/bash
echo "stub browser invoked with: \$1" >&2
if body="\$(curl -fsSL --max-time 20 "\$1")" && printf '%s' "\$body" | grep -qai httrack && printf '%s' "\$body" | grep -qaF step2.html; then
echo PASS >"$marker"
else
echo "FAIL: unexpected response from \$1" >"$marker"
fi
EOF
chmod +x "$stubdir/x-www-browser"
export PATH="$stubdir:$prefix/bin:$PATH"
echo "launching webhttrack"
"$wht" </dev/null >"$work/webhttrack.log" 2>&1 &
whpid=$!
# Bounded poll for the marker (macOS has no timeout(1)); teardown below kills
# webhttrack and reaps htsserver, so the run is bounded without a watchdog.
for i in $(seq 1 45); do
test -f "$marker" && {
echo "marker written after ${i}s"
break
}
kill -0 "$whpid" 2>/dev/null || {
echo "webhttrack exited on its own after ${i}s"
break
}
sleep 1
done
# Reap webhttrack and the htsserver it spawned. Confirm death with a bounded poll
# (not a blocking wait, which could hang on macOS); SIGKILL if it ignores TERM.
echo "tearing down"
kill "$whpid" 2>/dev/null || true
if pkill -f "$prefix/bin/htsserver" 2>/dev/null; then
echo "reaped a lingering htsserver"
else
echo "no lingering htsserver"
fi
for _ in $(seq 1 10); do
kill -0 "$whpid" 2>/dev/null || break
sleep 1
done
kill -9 "$whpid" 2>/dev/null || true
echo "--- webhttrack.log ---"
cat "$work/webhttrack.log" 2>/dev/null || true
echo "--- end ---"
echo "marker=[$(cat "$marker" 2>/dev/null || echo NONE)]"
if test "$(cat "$marker" 2>/dev/null || true)" = PASS; then
echo "webhttrack smoke: PASS"
else
echo "webhttrack smoke: FAIL" >&2
exit 1
fi