12 Commits

Author SHA1 Message Date
Xavier Roche
581784d374 Re-trigger CI
The pull_request run wedged: all 23 jobs finished or were cancelled while the
run itself stayed 'queued', so the required checks never reported and neither
rerun nor cancel was accepted (CI run 31124085297, windows-build 31124081217).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-08-06 22:00:10 +02:00
Xavier Roche
70f28202d1 Bound the fake engine, and check the argv[0] dispatch rather than the banner
Review found the fifo fake blocks in open() forever, where the sleep it
replaced self-healed in 60s: a test killed outright (kill_tree sends
SIGKILL, and the outsider sits in its own process group) would leave a
process named httrack on the runner for good. Opening read-write lets
the read time out instead.

The uutils check now renames sleep and requires it to be refused, which
is the property #1042 broke on. The banner works today but has already
been reworded once: 0.0.30 prints "sleep 0.0.30" and 0.8.0 "sleep
(uutils coreutils) 0.8.0". Configure also asserts the codecs and TLS the
way the sibling job does, so a renamed dev package cannot turn the tests
that cover them into skips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-08-06 19:42:47 +02:00
Xavier Roche
dd8b4ccdee Two tests break under uutils, the coreutils Ubuntu 25.10 made default
105 built its fake engine by symlinking sleep to a file named httrack,
and uutils' multi-call binary refuses an argv[0] it does not own, so
both fakes died before the first assertion and the oracle read an empty
pgid. 153 put env(1) in front of a backgrounded proxytrack; uutils' env
forks instead of exec'ing, so stop_server killed only the env and
proxytrack kept the pty open, leaving the drainer waiting for an EOF
that never came.

The fake is now a copy of the shell, which answers to any name, and 105
checks that it started: one that never ran showed up only as an empty
field in a later assertion, which reads as a bug in the code under test
rather than in the fixture.

The new CI job builds and runs the suite on ubuntu:devel, which is where
the Ubuntu buildds are and no runner here was.

Closes #1042

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-08-06 19:21:51 +02:00
Xavier Roche
f2abfc36ef Nothing survives a wedged Windows runner to say which test wedged it (#1033)
* Give the Windows suite a watchdog that does not need to fork

Every recorded #795 event dangles in the suite step, and the bash heartbeat
added for it has never once fired. Each tick costs a fork on sleep, tail, awk
and finally taskkill, while the leading explanation for the wedge is a box on
which process creation has stopped, so the watchdog is disarmed by the very
condition it exists to catch.

Add a native one: a single long-lived PowerShell process that spawns nothing,
reads the progress log, polls memory, handles, process count and free disk
in-process, and posts a commit status every 30 seconds. That status is the only
thing that leaves the VM before it dies, so the last one to land timestamps the
death and names the test in flight. On a static log it walks Win32_Process
downward from the driver's pid and kills that subtree, never upward, since
killing Runner.Worker.exe reproduces this signature exactly.

The bash heartbeat stays as the fallback for a box with no PowerShell; only one
of the two ever runs, or the loser of the race overwrites the winner's verdict.

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

* Test 224 ran the real watchdog because TMPDIR splits the PATH entry

The Windows runner hands the suite a TMPDIR of D:/a/_temp, so the stub bin
directory the test prepends to PATH is cut at the drive letter's colon and
never searched. The launch then found the runner's own pwsh and drove the
real watchdog with the real status token, and the stub's argv file the
assertions read never appeared.

Map the temp dir back to a POSIX path where cygpath exists, and assert the
stub is what pwsh now resolves to, so a shadow that fails again says so
instead of reporting a launch that did happen.

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

* Report the Windows suite step off-box while the runner is still alive

Every recorded #795 event dangles in the suite step and leaves nothing to read:
the log 404s, the annotations come back empty, and the if:always() uploads never
start. The bash heartbeat cannot help, because it reports through the same
channels the dying runner takes with it.

Add a PowerShell process that only observes. It spawns nothing, reads the
progress log, polls memory, handles, process count and free disk in-process, and
posts a commit status every 30 seconds. That status is the only thing that
leaves the VM before it dies, so the last one to land timestamps the death and
names the test in flight. The driver posts its own verdict as it exits, so a
status left pending means the runner really did go.

The bash heartbeat is untouched and still owns the kill. This one has no verdict
to race it over.

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

* Assert the stub shadows PowerShell before driving the launch

An unshadowed pwsh runs the real watchdog, and the argv assertions that follow
then read as a plain launch failure rather than as what happened.

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

* The status backoff must throttle the API, not the artifact

A fork PR's token cannot post, and the artifact log was the only record left of
those runs. Log every status line whatever the backoff decides, and let it skip
the request alone.

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

* Assert the launch arguments in the form a Windows interpreter can open

The driver hands PowerShell the native path, so comparing against the MSYS
one failed both Windows legs. nativepath() is the identity elsewhere, which
is why the Linux run never saw it.

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

* Post a verdict a killed step cannot forge, and keep the token out of the tests

bash reaches its EXIT trap with $? = 0 after a signal, so a cancelled or
timed-out suite posted `success`. The verdict now comes from explicit INT, TERM
and HUP handlers, and every exit path that can still run code posts it: the
heartbeat's kill path posts before it kills, since a taskkill runs no trap, and
the final post is retried so one transient 5xx cannot leave a finished suite
reading `pending`.

The step's token is taken out of the environment and handed to the watchdog
process alone, so none of the ~90 test scripts the driver runs can post a
status with it, and the checkout stops persisting it into .git/config.

A failing stdout write no longer kills the loop: the catch handler's own log
write was outside any try, so under ErrorActionPreference=Stop a full disk, the
state a sick runner reaches, ended the telemetry in silence.

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

* Grade the telemetry off the API it calls, not off the log it prints

Printing and posting are decoupled, so assertions that counted printed lines
could see neither the request rate nor the backoff: hard-coding the staticness,
posting on every poll and never skipping a post all passed the suite. The legs
now run the real posting path against a local sink that records each request,
and each is required to trip a mutant of its own.

172 copied everything but the .ps1, so the launch helper returned early and the
real driver launching a real interpreter was covered by nothing. It now drives
that path, and proves the token reaches the watchdog and no test.

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

* Renumber to 226: master took 225, and the union driver put 224 back

tests-list.mk carries merge=union, which never removes a line, so the rebase
resurrected the registration of a test file that no longer exists. The tell is
"make check" exiting 2 with no FAIL lines and no "# TOTAL:" summary at all.

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

* Drop the fork-failure claim from the watchdog's header

The 153,280-fork count behind it was an artefact of /proc/stat counting threads
and unrelated system-wide activity: the suite creates 26,158 processes, and the
engine and zlib blocks make 39.2% of them without holding one of the 34 deaths.
Spawning nothing is still worth having, as insurance rather than as a theory.

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

* Let the new watchdog test survive the macOS and Windows legs

Two platform assumptions the Linux leg cannot see. The signal teardown
kills $BASHPID, which macOS's bash 3.2 does not define, so the kill was a
no-op and the suite exited 0 and posted "success" instead of "failure".
And /dev/full only stops writes for a process of the shell's own runtime:
MSYS cannot hand its emulated device to a native PowerShell, which then
writes as if to NUL, so the log-write-fatal mutant survived a leg that
had nothing to grade it with. The old guard also returned 1 under errexit
on a box with no /dev/full at all, macOS included.

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

* Cut the watchdog's verdict down to telemetry

GitHub already records the job's conclusion, so the commit status does
not need to carry a second one, and every attempt to make it do so
produced a way for it to lie. Gone: the one-shot -Post mode and its
retry, the driver's INT/TERM/HUP handlers, and the post-before-kill
ordering on the heartbeat path. Every status now goes out as success
carrying elapsed time, staticness, the test in flight and the counters,
and Get-CimInstance goes with them, its connect to a wedged WMI service
being unbounded and able to hang the one reporter left.

The tests around the reduced surface absorb what those parts were
carrying. 172 stubs PowerShell unconditionally, so a shell holding a
real pwsh and a token can no longer drive the real watchdog at
api.github.com (measured: zero outbound connections, against a control
that reaches 140.82.121.5:443), and 226 audits that every test able to
source the driver scrubs the credentials first. The staticness leg
watches long enough to see an unbounded value and grades it against
elapsed time one for one, so a clamped q now dies. Both mutant harnesses
grade on the assertion the mutant must trip rather than on a nonzero
exit. The workflow keys are read from the block that owns them through a
YAML parse, with one control per key and a round-trip control proving
that reformatting moves no verdict. The /dev/full leg runs only where a
write through the same interpreter really fails on it.

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

* Grade the throttle on a ratio no slow box can move

The staticness leg carried an absolute post-count floor, which a loaded
Windows runner would have failed on its own. Every due tick logs its
line before the throttle decides, so posts against logged lines is the
same ratio at any tick rate; that check moves to its own 8s leg, and the
staticness window widens to 16s so the one-for-one comparison has room
a slow tick cannot shrink.

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

* Kill the reporter with the wedge, and test what nothing was testing

The wedge branch ended the suite with TerminateProcess, which runs no EXIT
trap, so the PowerShell reporter outlived the step it reports on and kept
re-posting a frozen tail over the informative final status. It now goes
first, ahead of the kill.

Nothing covered the teardown either: deleting it left three orphan stubs
and 172 still passed. The stub now ticks a marker, so both the EXIT trap
and the new wedge kill are graded on a reporter that has actually stopped,
and 171 pins the order the kills fire in.

The workflow audit resolved the top-level permissions only, so a job-level
"permissions: {contents: read}" dropping statuses: write passed, and the
env keys were graded on truthiness, so a WATCHDOG_SHA swapped for the PR
head sha passed. It now reads the effective permissions of the job running
the suite and asserts the values, with a control per key. On the .ps1 side,
Get-WatchdogCounters had no assertion anywhere and the production cadence
was never read: the self-test pins both, and MaxSeconds is bounded below as
well as above, since a reporter that stops early leaves the wedge unreported.

The posting legs now assert the log never carries the token, controlled by a
mutant that logs one: tests/*.log rides into an artifact nothing masks.
Smaller: 172 takes its noexec skip before the command -v that reads false
there, and the driver scrubs the token before forking its first child.

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

---------

Signed-off-by: Xavier Roche <xroche@gmail.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:32:50 +00:00
Xavier Roche
b2cd2a51d2 An over-long FTP user name in a URL logs in as a different account (#1035)
* FTP: refuse over-long URL userinfo instead of logging in as another account

ftp_split_userpass() clipped a URL's "user[:pass]@" into user[256]/pass[256],
so a URL naming one account put a shortened name on the wire and httrack
mirrored whatever that account served, exiting clean. The split now reports a
field that does not fit and the link fails with STATUSCODE_INVALID, the
refuse-don't-clip contract ftp_command() follows for an over-long path (#1019).
A compile-time assertion pins both buffers to what a "USER <user>" control line
holds, so widening one cannot reintroduce the clip in the command formatter.

Nothing bounded the user field at the '@' either, only at the first ':' or the
end of the string, so ftp://user@host:21/f logged in as "user@host".

The ftp-userpass self-test asserted the truncation as intended behaviour.

Closes #1032

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

* tests: exercise the colon-less split, and the credentials on the wire

The ftp-userpass sweep always wrote a ':', so the branch where only the '@'
bounds the user name was seen once, three bytes long, yet that is the one
reaching memcpy() with an unbounded length. Sweep it as a third case, and add
the bare over-long and at-boundary URLs to the crawl loops.

The crawl half probes a dead port, so no login is attempted and a clip applied
after the split went unseen. A new local-ftp test drives the test server and
asserts the exact USER/PASS bytes on the control channel; it needs the server
to answer 331, since httrack sends PASS only on a 3xx.

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

* tests: bound the fixture-server reap, and drop a fail-open pipe from the FTP login test

stop_server ran a bare `wait` from an EXIT trap, so a fixture the kill never
reached blocked a test that had already passed until the harness timed it out
and called it a failure. reap_bounded gives up after REAP_GRACE, and costs
nothing when the child is already gone.

The new login test read the mirrored file through `find | head -n1`: head
exits on the first line, find takes SIGPIPE, and under pipefail that 141 aborts
the test through set -e with nothing printed at all. The mirror path is
deterministic, so assert it directly.

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

* tests: skip the FTP login test on Windows, where it loses the x64 runner

230_local-ftp-userpass drives a real FTP login against the Python fixture, and
the x64 leg of windows-build has died every time it ran: the suite step sits
past its 45-minute timeout and the runner is lost to CPU/memory starvation,
while the Win32 leg of the same commits is green 3/3. Nothing separates the two
legs but matrix.platform, and none of the suite's own guards fired, so the cause
is on the 64-bit engine side and is not pinned yet.

Skip it there and pin the name in the driver's expected-skips ratchet, which
would otherwise red the job on an unannounced skip. The wire-level assertions
keep running on Linux and macOS, where they are what proves the credentials
reach the control channel unclipped.

Refs #1038

Co-Authored-By: Claude Opus 5 (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 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:33:03 +02:00
Xavier Roche
562cd1c867 The test harness forks a sleep for every poll tick (#1034)
* Poll the test harness without forking a sleep per tick

Every deadline loop in the suite -- test-timeout.sh, wait_bounded,
run_with_timeout, reap_bounded -- ticked with sleep(1), one process per tick for
as long as the test ran. Measured over a serial run of the 202-test Windows
subset that is 5815 of the 25961 processes the suite creates; poll_wait replaces
them with a read(1) timing out on a fifo nobody writes to, leaving 68.

The tick can then be fine without being expensive, so wait_bounded notices a
finished crawl in 0.1s rather than up to a second, which is where the wall-clock
gain comes from.

Classify the tick by whether fd 9 is still open, not by the read's status: bash
3.2 (macOS) reports a timeout as 1, which is indistinguishable from a closed fd,
and poll_wait would retire its timer on the first whole-second tick while still
reporting the fd tick.

Left on the forked tick under MSYS: its fifos are emulated, and the leg #795 is
about is no place to find out how its select() behaves.

The new test is 225, not 224: #1029 took that number in parallel. The Windows CI
selector keys on the *_watchdog*.test topic glob rather than the number, so the
coverage follows.

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

* Put the sleep shim on a PATH MSYS can parse

MSYS hands out a drive-letter TMPDIR, so prepending the shim directory to PATH
produced an entry split at the colon: the shim was never found, every count read
zero, and the Win32 leg failed the one assertion it could still reach. The shim
now lives under a colon-free root, and count_sleeps proves it is reachable before
anything asserts on the counts, since an unreachable shim counts zero and reads
as 'nothing forked'.

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

* Keep the starvation cases real, and a dead fixture server loud

58_watchdog's starvation case went inert once the poll stopped forking: the shim
only ever reached the payload, so the guard measured a wall clock nobody had
stretched. It forces the forked tick like 105 does, and the override is now read
per tick rather than once, since both tests turn it on after testlib has ticked.
A poll-counted deadline calibrated to the 0.1s tick passes the old case and fails
this one.

local-crawl.sh had folded the two port-wait failures into one message, and 72 and
105 grep for it to skip an announce race: a fixture server that died at startup
was silently skipped in both. discover_server_port now returns 2 for that, and
the crawl fails loudly.

Also: a full minute for the port wait, since the loop it replaced self-extended
under the load that makes the wait long and the new one is a hard wall; a unique
fifo name, because two subshells share $$ and the loser opens a vanished path
that bash's <> turns into a never-blocking regular file; `true` over `:`, whose
redirection error kills a POSIX-mode shell; a reachability canary on the sleep
shim, which MSYS drive-letter TMPDIRs leave off PATH; and 227, renumbered off
master's 225.

Co-Authored-By: Claude Opus 5 (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 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:35:05 +02:00
Xavier Roche
a2a20ef684 Fix what Fedora's build patches around, and pin the installed file set
Fedora ships httrack with no patches, but its spec worked around three things
that were ours to fix, and its build broke twice in three weeks on changes to
our installed file set.

The metainfo declared a stock icon that is not one (stock names come from the
freedesktop Icon Naming Specification), which appstream-util rejects and which
also suppressed the merge from WebHTTrack.desktop that supplies the real icon.
libtest/readme.txt carried the pre-2005 FSF postal address. CI and
210_appstream-metainfo.test now run both validators.

225_install-manifest.test pins the installed file set, the class that broke
their 3.49.13 and 3.49.18 builds. It found a real bug on its first outing: the
.desktop files installed to $(prefix)/share/applications, so any --datadir
override put them where no desktop environment looks.
2026-08-06 10:50:56 +00:00
Xavier Roche
bfe0a4a7f7 The 16 px icon antialiases every stem to grey (#1031)
* Draw the small application icons on the pixel grid

The 16 px icon was the vector master downscaled, so every stem edge landed
on a fraction of a pixel: 74 colours in a 16x16 tile, four of them black.
The small sizes are now built in a viewBox equal to their pixel size, with
the outline coordinates rounded to whole pixels and no downscale after, so
the raster carries the field and the ink and nothing between them.

Still generated from the same Jost* master; the generator change is
httrack-works 90ceb7e2.

Test 228 counts the shipped raster's colours through a stdlib PNG reader,
since the suite cannot assume Pillow or ImageMagick on a build host.

Closes #938

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

* Tighten the new test's comments

Co-Authored-By: Claude Opus 5 (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 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:33:03 +02:00
Xavier Roche
03c30e17ce The installed socket headers still need -D_POSIX_C_SOURCE under -std=c99 (#1028)
* The installed socket headers still need -D_POSIX_C_SOURCE under -std=c99

htsnet.h and htsopt.h were the last installed headers a strict-ISO consumer
had to prefix with -D_POSIX_C_SOURCE, and tests/206 carried an exception for
exactly those two. All of it came from one inline body: SOCaddr_inetntoa_
calls getnameinfo() and NI_NUMERICHOST, and no libc declares either to a
__STRICT_ANSI__ translation unit.

The body moves to a new src/htsnet.c and the exception in 206 goes away, so
all fourteen installed headers now compile under -std=c99 and -std=c11
against the strict-mode libc.

This turns a static inline into an exported symbol, an ABI addition rather
than a break, so VERSION_INFO gets a revision-only bump to 3:11:0 and the
soname and the Debian package stay put. proxytrack does not link libhttrack,
so it compiles htsnet.c in directly under -DHTS_NO_LIBHTTRACK, which leaves
the export marker bare there because MSVC rejects a dllimport definition.

Closes #1001

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

* Pin 206's header count to DevIncludes_DATA and unhide the strnlen check

The >= 10 floor let a header vanish from DevIncludes_DATA with 206 still
green, so #1001 could regress to "not installed" unnoticed: derive the
expected count from the list itself and name htsnet.h and htsopt.h beside
htssafe.h, since a dropped entry moves both sides of the count.

The "htssafe_strnlen_ is not libc's" preprocessor check sat inside the
gate asking whether a binary can run in TMPDIR, so a noexec TMPDIR or a
cross build skipped a check that needs no execution. Hoist it out.

Co-Authored-By: Claude Opus 5 (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 5 (1M context) <noreply@anthropic.com>
2026-08-06 08:59:27 +00:00
Xavier Roche
1a45e0237b An over-long FTP path or host name aborts the process instead of failing the link (#1029)
* FTP: refuse an over-long command instead of aborting on the copy

An FTP path or host name that survives the upstream clamps reached an
aborting strcpybuff and killed the process instead of failing the link.
The path case needs 1024 bytes exactly and nobody could reproduce it;
the host case is trivial, since the URL host was copied into a 256-byte
buffer with no check at all.

Every FTP control line now shares one FTP_LINE_SIZE, ftp_command()
reports a command that did not fit, and an over-long path or host fails
the transfer rather than being clipped into a request for another file.
send_line() also dropped the CRLF off a maximal command, and the
SIZE/MDTM probes reused the same builder.

Closes #1019

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

* Pin the FTP control-line capacity and probe the host gate at its edge

The tests passed the fix without pinning it. Six mutants survived: widening
line_retr past the line[] it is copied into (that is #1019 verbatim), a stray
write one byte past ftp_command()'s bound, a host gate widened to reject every
host, and one moved off _adr[256] so a 256-byte host aborts again.

ftp_command_line() replaces the size argument at every build site with a
compile-time check that the buffer is FTP_LINE_SIZE, and the copy destination
is asserted equal to line_retr. The self-test now compares the whole poisoned
tail instead of one canary byte, and the crawl leg drives 255/256/257-byte
hosts plus a loopback outsider, so a widened gate no longer reads as a pass.

Co-Authored-By: Claude Opus 5 (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 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:51:33 +02:00
Xavier Roche
2a44dc1728 doc: note the --changes example is one captured run (#1027)
generator and date are genuine per-run fields (htschanges.c stamps
HTTRACK_VERSION and the current time on every write), so dropping them would
misrepresent the payload. Label the sample instead, so a reader does not
compare it against their own output as if it should match.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:51:25 +02:00
Xavier Roche
23e182281c The suite watchdog cannot see the engine on an emulated buildd (#1026)
* The suite watchdog cannot see the engine on an emulated buildd

Under qemu-user the process the kernel reports is the binfmt interpreter and
the engine is only its first argument, so the name matchers find nothing to
name or signal. Skip an interpreter before reading the command, gated on the
interpreter's own name: matching the argument unconditionally would take any
wrapper (strace, timeout) for the engine and signal that instead.

request_engine_backtraces only sends SIGABRT on Linux, so 105_suite-timeout
now asserts the death there and the "no stack mechanism known" report on the
platforms that get none, which is what Hurd's "GNU" uname takes.

Closes #1025

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

* Only shift past a real emulator, and cover the branch Hurd takes

qemu-img and its friends match "qemu-<word>" but take an image, not a
program, so shifting past them read a disk path as the process name --
into a list that is fed to kill. Exclude them, anchor the -binfmt form,
and give each a control row.

No CI leg reaches the no-stack-mechanism branch, which is the one the
Hurd buildds take, so drive it through a uname shim: it has to report
itself and leave the engine unsignalled.

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

---------

Signed-off-by: Xavier Roche <xroche@gmail.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 06:55:55 +00:00
46 changed files with 2613 additions and 328 deletions

View File

@@ -41,7 +41,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential clang autoconf automake libtool autoconf-archive \
zlib1g-dev libssl-dev libbrotli-dev libzstd-dev
zlib1g-dev libssl-dev libbrotli-dev libzstd-dev \
appstream appstream-util
- name: Configure
run: |
@@ -137,6 +138,66 @@ jobs:
if: failure()
run: cat tests/test-suite.log 2>/dev/null || true
# The Ubuntu buildds run a development release every runner above is well behind.
# That gap is what shipped 3.49.18 broken on every Ubuntu architecture but
# riscv64, when 25.10 made uutils the default coreutils (#1042).
ubuntu-devel:
name: build (Ubuntu devel, uutils coreutils)
runs-on: ubuntu-24.04
container: ubuntu:devel
timeout-minutes: 40
steps:
# Before checkout: the container has no git, so submodules would be lost.
- name: Install build dependencies
run: |
set -euo pipefail
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential autoconf automake libtool autoconf-archive \
zlib1g-dev libssl-dev libbrotli-dev libzstd-dev \
git ca-certificates python3 procps
- name: Assert the coreutils dispatch on argv[0]
# The property #1042 broke on, not the version banner, which uutils has
# already reworded once. Without it the job quietly becomes a slower copy
# of the one above.
run: |
set -euo pipefail
ln -s "$(command -v sleep)" /tmp/not-a-coreutil
if /tmp/not-a-coreutil 0; then
echo "::error::coreutils here take any argv[0]; this image no longer guards uutils" >&2
exit 1
fi
- uses: actions/checkout@v7
with:
submodules: recursive
- name: Configure
run: |
set -euo pipefail
autoreconf -fi
./configure
# A renamed dev package would disable a codec or TLS here, and the tests
# that cover them would SKIP rather than fail.
grep -q "define HTS_USEBROTLI 1" config.h
grep -q "define HTS_USEZSTD 1" config.h
grep -q "define HTS_USEOPENSSL 1" config.h
- name: Build
run: make -j"$(nproc)"
- name: Test
timeout-minutes: 25
run: |
set -euo pipefail
jobs=$(( $(nproc) * 2 )); [ "$jobs" -le 16 ] || jobs=16
make check -j"$jobs"
- name: Print the test log on failure
if: failure()
run: cat tests/test-suite.log 2>/dev/null || true
# Portability: build and test on macOS (Darwin/clang) on a native runner --
# no VM. The tree has no __APPLE__ branches, so Darwin exercises the
# generic-Unix path on a second libc and kernel. brew's openssl@3 is keg-only,
@@ -715,7 +776,8 @@ jobs:
sudo apt-get update
# noble ships shfmt 3.8.0 (universe), matching the pinned local dev
# version; use it rather than fetching a release binary from github.com.
sudo apt-get install -y --no-install-recommends shellcheck shfmt appstream
sudo apt-get install -y --no-install-recommends shellcheck shfmt appstream \
appstream-util
shfmt --version
appstreamcli --version
@@ -742,6 +804,8 @@ jobs:
set -euo pipefail
appstreamcli validate --no-net --explain \
html/server/div/com.httrack.WebHTTrack.metainfo.xml
appstream-util validate-relax --nonet \
html/server/div/com.httrack.WebHTTrack.metainfo.xml
# Check clang-format on CHANGED LINES ONLY. The engine predates clang-format
# (it was shaped by an old Visual Studio formatter) and does not round-trip,

View File

@@ -14,6 +14,9 @@ on:
permissions:
contents: read
# The suite watchdog reports as a commit status, the only channel that
# outlives the runner it is reporting on (#795).
statuses: write
# Cancel superseded runs on the same branch or PR.
concurrency:
@@ -37,6 +40,9 @@ jobs:
- uses: actions/checkout@v7
with:
submodules: recursive # coucal lives in src/coucal
# Or the job token stays in .git/config, where every test the suite
# runs can read it.
persist-credentials: false
# Located through vswhere rather than microsoft/setup-msbuild: the repo
# only allows GitHub-owned actions.
@@ -171,6 +177,14 @@ jobs:
shell: bash
working-directory: tests
timeout-minutes: 45
env:
# Through the environment, never argv, which the process list exposes.
WATCHDOG_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WATCHDOG_REPO: ${{ github.repository }}
# github.sha here is the PR's merge commit, so statuses posted against it stay out of the PR's checks UI.
WATCHDOG_SHA: ${{ github.sha }}
WATCHDOG_CONTEXT: windows-suite (${{ matrix.platform }}, ${{ matrix.configuration }})
WATCHDOG_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
bash ./ci-windows-suite.sh \
"$(cygpath -u "$GITHUB_WORKSPACE")/src/${{ matrix.platform }}/${{ matrix.configuration }}"

View File

@@ -29,6 +29,8 @@ AC_CONFIG_SRCDIR(src/httrack.c)
AC_CONFIG_MACRO_DIR([m4])
AC_CONFIG_HEADERS(config.h)
AM_INIT_AUTOMAKE([subdir-objects])
# 3:11:0: revision-only bump. #1001 moved SOCaddr_inetntoa_ out of htsnet.h, adding
# an export where callers had an inline; nothing changed or went away.
# 3:10:0: revision-only bump; only the version macro moved, the engine is untouched.
# 3:9:0: revision-only bump. #991 and #1005 each added an export
# (hts_set_thread_hooks, escape_control_url); nothing changed or went away.
@@ -38,7 +40,7 @@ AM_INIT_AUTOMAKE([subdir-objects])
# moves nothing). Soname stays .so.3: HTTrackQt is the only consumer of the installed
# headers, so a libhttrack4 rename isn't worth it.
# (3:0:0 was the htsblk mime-buffer widening, the ABI break that moved .so.2 -> .so.3.)
VERSION_INFO="3:10:0"
VERSION_INFO="3:11:0"
AM_MAINTAINER_MODE
AC_USE_SYSTEM_EXTENSIONS

View File

@@ -15,7 +15,7 @@ WebIcon64x64dir = $(datadir)/icons/hicolor/64x64/apps
WebIcon128x128dir = $(datadir)/icons/hicolor/128x128/apps
WebIcon256x256dir = $(datadir)/icons/hicolor/256x256/apps
WebIconScalabledir = $(datadir)/icons/hicolor/scalable/apps
VFolderEntrydir = $(prefix)/share/applications
VFolderEntrydir = $(datadir)/applications
MetaInfodir = $(datadir)/metainfo
# Wildcards are globbed against $(srcdir): a bare "*.html" is resolved against

View File

@@ -155,6 +155,8 @@ rather than on <tt>url</tt> when a mirror is known to carry legacy-charset URLs.
<h3 id="example">Example</h3>
One captured run; <tt>generator</tt> and <tt>date</tt> will read differently in yours.
<pre>
{
"schema": 1,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 98 B

View File

@@ -26,7 +26,6 @@
</ul>
</description>
<launchable type="desktop-id">WebHTTrack.desktop</launchable>
<icon type="stock">httrack</icon>
<categories>
<category>Network</category>
</categories>

View File

@@ -1,37 +1,24 @@
/* XPM */
static char *httrack16x16[] = {
/* columns rows colors chars-per-pixel */
"16 16 15 1 ",
" c #0D0D10",
". c #191921",
"X c #2A2A38",
"o c #363647",
"O c #424257",
"+ c #53536E",
"@ c #717197",
"# c #7E7EA8",
"$ c #8585B1",
"% c #9898CB",
"& c #9999CC",
"* c #9A9ACE",
"= c #9696C9",
"- c #9C9CD0",
"; c #9999CC",
"16 16 2 1 ",
" c #040404",
". c #9999CC",
/* pixels */
"&&&&&&&&&%%&&&&&",
"&&&&&&&&%%%%&&&&",
"&&&&&&&&%%%%&&&&",
"&%%&&&%%%%%%%%%&",
"%+o$&%+o@ooooo+%",
"%o $-%X @X. .O%",
"%o @$$X $=@ o$=&",
"%o . $-# o&&&",
"%o oOo. $-# o%&&",
"%o $-%X $-# o%&&",
"%X $&%X $-# o%&&",
"%+o$&%+o$&$o+%&&",
"%%%&&&%%&&&%%&&&",
"%%&&&&&&&&&&&&&&",
"%&&&&&&&&&&&&&&&",
"&&&&&&&&&&&&&&&&"
"................",
"................",
"................",
"................",
". ... . .",
". ... . .",
". ... ... ...",
". ... ...",
". ... ...",
". ... ... ...",
". ... ... ...",
". ... ... ...",
"................",
"................",
"................",
"................"
};

View File

@@ -29,8 +29,7 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
along with this program. If not, see <http://www.gnu.org/licenses/>.
========================================================================
MAKEFILE PROJECT : libtest Project Overview

View File

@@ -43,7 +43,8 @@ htsserver_LDADD = $(THREADS_LIBS) $(SOCKET_LIBS) libhttrack.la
proxytrack_LDADD = $(THREADS_LIBS) $(SOCKET_LIBS)
httrack_CFLAGS = $(AM_CFLAGS) $(CFLAGS_PIE)
proxytrack_CFLAGS = $(AM_CFLAGS) $(CFLAGS_PIE) -DNO_MALLOCT -DZLIB_CONST -DHTS_INTHASH_USES_MD5
# -DHTS_NO_LIBHTTRACK: see htsnet.h's HTSNET_API guard.
proxytrack_CFLAGS = $(AM_CFLAGS) $(CFLAGS_PIE) -DNO_MALLOCT -DZLIB_CONST -DHTS_INTHASH_USES_MD5 -DHTS_NO_LIBHTTRACK
htsserver_CFLAGS = $(AM_CFLAGS) $(CFLAGS_PIE) -DZLIB_CONST -DHTS_INTHASH_USES_MD5
# @RPATH_ORIGIN_LDFLAGS@ makes a copied tree find libhttrack next to it (#906),
@@ -59,7 +60,7 @@ htsserver_SOURCES = htsserver.c htsserver.h htsweb.c htsweb.h htsstats.h \
htsurlport.c htsurlport.h
proxytrack_SOURCES = proxy/main.c \
proxy/proxytrack.c proxy/store.c \
htsurlport.c htsurlport.h \
htsurlport.c htsurlport.h htsnet.c \
coucal/coucal.c htsmd5.c md5.c \
minizip/ioapi.c minizip/mztools.c minizip/unzip.c minizip/zip.c
@@ -80,7 +81,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \
htsname.c htsrobots.c htstools.c htswizard.c \
htsalias.c htsthread.c htsindex.c htsbauth.c \
htscrashtest.c \
htsmd5.c htscodec.c htswarc.c htschanges.c htssinglefile.c htssitemap.c htsproxy.c htszlib.c htswrap.c htsconcat.c \
htsmd5.c htsnet.c htscodec.c htswarc.c htschanges.c htssinglefile.c htssitemap.c htsproxy.c htszlib.c htswrap.c htsconcat.c \
htsmodules.c htscharset.c punycode.c htsencoding.c htssniff.c \
md5.c \
minizip/ioapi.c minizip/mztools.c minizip/unzip.c minizip/zip.c \

View File

@@ -122,42 +122,48 @@ void launch_ftp(FTPDownloadStruct * params) {
return 0; \
}
/* Bounded split of a hostile-URL "user[:pass]@" prefix (see htsftp.h). */
void ftp_split_userpass(const char *src, const char *end, char *user,
size_t user_size, char *pass, size_t pass_size) {
size_t n = 0;
/* Split a hostile-URL "user[:pass]@" prefix (see htsftp.h). */
hts_boolean ftp_split_userpass(const char *src, const char *end, char *user,
size_t user_size, char *pass, size_t pass_size) {
size_t len = 0, user_len, pass_len;
const char *colon;
assertf(user_size > 0 && pass_size > 0); /* the size-1 math underflows on 0 */
assertf(end > src); /* end is one past the '@' */
while (src[n] != '\0' && src[n] != ':') {
if (n < user_size - 1)
user[n] = src[n];
n++;
}
user[n < user_size ? n : user_size - 1] = '\0';
pass[0] = '\0';
if (src[n] == ':') { // password follows the colon
const size_t base = n + 1;
size_t k = 0;
while (&src[base + k + 1] < end && src[base + k] != '\0') {
if (k < pass_size - 1)
pass[k] = src[base + k];
k++;
}
pass[k < pass_size ? k : pass_size - 1] = '\0';
}
user[0] = pass[0] = '\0'; // fail safe for a caller that ignores the result
while (len < (size_t) (end - src) - 1 && src[len] != '\0')
len++;
colon = memchr(src, ':', len);
user_len = colon != NULL ? (size_t) (colon - src) : len;
pass_len = colon != NULL ? len - user_len - 1 : 0;
/* a clipped name is another account's, so refuse rather than log in as it */
if (user_len >= user_size || pass_len >= pass_size)
return HTS_FALSE;
memcpy(user, src, user_len);
user[user_len] = '\0';
if (pass_len != 0)
memcpy(pass, colon + 1, pass_len);
pass[pass_len] = '\0';
return HTS_TRUE;
}
/* Build "<verb> <path>" (see htsftp.h). */
void ftp_command(char *line, size_t line_size, const char *verb,
const char *path) {
hts_boolean ftp_command(char *line, size_t line_size, const char *verb,
const char *path) {
int n;
/* A leading '-' would reach a server that shells out to ls as a flag. */
if (path[0] == '-' || strchr(path, ' ') != NULL ||
strchr(path, '\"') != NULL || strchr(path, '\'') != NULL)
snprintf(line, line_size, "%s \"%s\"", verb, path);
n = snprintf(line, line_size, "%s \"%s\"", verb, path);
else
snprintf(line, line_size, "%s %s", verb, path);
n = snprintf(line, line_size, "%s %s", verb, path);
if (n < 0 || (size_t) n >= line_size) {
line[0] = '\0'; // fail safe for a caller that ignores the result
return HTS_FALSE;
}
return HTS_TRUE;
}
/* MDTM reply "213 YYYYMMDDHHMMSS[.frac]" (RFC 3659, UTC) into tm_time. */
@@ -211,7 +217,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
httrackp *opt = pStruct->pOpt;
char user[256] = "anonymous";
char pass[256] = "user@";
char line_retr[2048];
char line_retr[FTP_LINE_SIZE];
int port = 21;
#if FTP_PASV
@@ -251,7 +257,11 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
while(*real_adr == '/')
real_adr++; // sauter /
if ((adr = jump_identification(real_adr)) != real_adr) { // user
ftp_split_userpass(real_adr, adr, user, sizeof(user), pass, sizeof(pass));
if (!ftp_split_userpass_buf(real_adr, adr, user, pass)) {
strcpybuff(back->r.msg, "FTP user name or password too long");
back->r.statuscode = STATUSCODE_INVALID;
_HALT_FTP return 0;
}
}
// Calculer RETR <nom>
{
@@ -262,13 +272,19 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
if (strnotempty(a)) {
const size_t len_a =
strlen(unescape_http(ftp_path, sizeof(ftp_path), a));
hts_boolean fits;
if (len_a > 0 &&
ftp_path[len_a - 1] == '/') { /* obviously a directory listing */
transfer_list = 1;
ftp_command(line_retr, sizeof(line_retr), "LIST -A", ftp_path);
fits = ftp_command_line(line_retr, "LIST -A", ftp_path);
} else {
ftp_command(line_retr, sizeof(line_retr), "RETR", ftp_path);
fits = ftp_command_line(line_retr, "RETR", ftp_path);
}
if (!fits) {
strcpybuff(back->r.msg, "FTP path too long");
back->r.statuscode = STATUSCODE_INVALID;
_HALT_FTP return 0;
}
} else {
transfer_list = 1;
@@ -298,6 +314,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
SOCaddr server;
char *a;
char _adr[256];
size_t adr_len;
const char *error = "unknown error";
_adr[0] = '\0';
@@ -315,9 +332,16 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
back->r.statuscode = STATUSCODE_INVALID; // permanent, unlike a DNS miss
_HALT_FTP return 0;
}
strncatbuff(_adr, adr, (int) (a - adr));
adr_len = (size_t) (a - adr);
} else
strcpybuff(_adr, adr);
adr_len = strlen(adr);
// no resolvable name is this long, and clipping would query another host
if (adr_len >= sizeof(_adr)) {
htsblk_failf(&back->r, "Host name too long");
back->r.statuscode = STATUSCODE_INVALID;
_HALT_FTP return 0;
}
strncatbuff(_adr, adr, (int) adr_len);
// récupérer adresse résolue
strcpybuff(back->info, "host name");
@@ -356,7 +380,10 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
_CHECK_HALT_FTP;
{
char BIGSTK line[1024];
char BIGSTK line[FTP_LINE_SIZE];
/* line_retr is copied here verbatim; a narrower line[] would clip it. */
HTS_COMPILE_ASSERT(sizeof(line) == sizeof(line_retr));
// envoi du login
@@ -514,9 +541,8 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
#endif
// SIZE
if (back->r.statuscode != -1) {
if (!transfer_list) {
ftp_command(line, sizeof(line), "SIZE", ftp_path);
// a clipped probe would size and date a different file
if (!transfer_list && ftp_command_line(line, "SIZE", ftp_path)) {
// SIZE?
strcpybuff(back->info, "size");
send_line(soc_ctl, line);
@@ -537,22 +563,24 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
}
// MDTM?
ftp_command(line, sizeof(line), "MDTM", ftp_path);
strcpybuff(back->info, "mdtm");
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
_CHECK_HALT_FTP;
if (ftp_parse_mdtm(line, &remote_tm)) {
char date[256];
if (ftp_command_line(line, "MDTM", ftp_path)) {
strcpybuff(back->info, "mdtm");
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
_CHECK_HALT_FTP;
if (ftp_parse_mdtm(line, &remote_tm)) {
char date[256];
time_rfc822(date, &remote_tm);
/* Stamp the mirror as the HTTP path does, so a later pass
compares server-clock times instead of crossing clocks. */
back->r.lastmodified[0] = '\0';
strlncatbuff(back->r.lastmodified, date,
sizeof(back->r.lastmodified),
sizeof(back->r.lastmodified) - 1);
remote_mtime = timegm(&remote_tm);
time_rfc822(date, &remote_tm);
/* Stamp the mirror as the HTTP path does, so a later pass
compares server-clock times instead of crossing clocks.
*/
back->r.lastmodified[0] = '\0';
strlncatbuff(back->r.lastmodified, date,
sizeof(back->r.lastmodified),
sizeof(back->r.lastmodified) - 1);
remote_mtime = timegm(&remote_tm);
}
}
/* Only over a copy back_add() judged partial: on --update every
@@ -570,7 +598,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
rest_understood = 1;
} // else never mind
}
} // sinon tant pis
} // sinon tant pis
}
}
#if FTP_PASV
@@ -613,7 +641,9 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
SOCaddr_initport(server, port_pasv);
if (connect(soc_dat, &SOCaddr_sockaddr(server), SOCaddr_size(server)) == 0) {
strcpybuff(back->info, "retr");
strcpybuff(line, line_retr);
// clip, never abort: this line is built from a crawled URL
line[0] = '\0';
strlncatbuff(line, line_retr, sizeof(line), sizeof(line) - 1);
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
_CHECK_HALT_FTP;
@@ -660,7 +690,9 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
_CHECK_HALT_FTP;
if (line[0] == '2') { // ok
strcpybuff(back->info, "retr");
strcpybuff(line, line_retr);
// clip, never abort: this line is built from a crawled URL
line[0] = '\0';
strlncatbuff(line, line_retr, sizeof(line), sizeof(line) - 1);
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
_CHECK_HALT_FTP;
@@ -922,7 +954,8 @@ FILE *dd = NULL;
// routines de réception/émission
// 0 = ERROR
int send_line(T_SOC soc, const char *data) {
char BIGSTK line[1024];
char BIGSTK line[FTP_LINE_SIZE + 2]; // room for the CRLF of a maximal command
int n;
// backstop: the driver fails earlier, but no injected byte reaches the wire
if (!hts_is_control_free(data))
@@ -942,7 +975,10 @@ int send_line(T_SOC soc, const char *data) {
printf("---> %s", data);
fflush(stdout);
#endif
snprintf(line, sizeof(line), "%s\x0d\x0a", data);
// an unterminated command would blend into whatever the server reads next
n = snprintf(line, sizeof(line), "%s\x0d\x0a", data);
if (n < 0 || n >= (int) sizeof(line))
return 0;
if (check_socket_connect(soc) != 1) {
#if FTP_DEBUG
printf("!SOC WRITE ERROR\n");

View File

@@ -59,6 +59,9 @@ struct FTPDownloadStruct {
/* Library internal definictions */
#ifdef HTS_INTERNAL_BYTECODE
/* Capacity of every FTP control-line buffer; send_line() adds the CRLF. */
#define FTP_LINE_SIZE 1024
#if USE_BEGINTHREAD
void launch_ftp(FTPDownloadStruct * params);
void back_launch_ftp(void *pP);
@@ -71,15 +74,29 @@ int run_launch_ftp(FTPDownloadStruct * params);
int send_line(T_SOC soc, const char *data);
int get_ftp_line(T_SOC soc, char *line, size_t line_size, int timeout);
/* Split a "user[:pass]@" prefix (end = jump_identification result) into
bounded, NUL-terminated user/pass buffers, truncating to fit.
NUL-terminated user/pass buffers. Returns HTS_FALSE and empties both when a
field does not fit, as a clipped one would name another account.
Both sizes must be nonzero. */
void ftp_split_userpass(const char *src, const char *end, char *user,
size_t user_size, char *pass, size_t pass_size);
/* Build "<verb> <path>" into line[line_size], truncating to fit. The path is
quoted whenever a bare one would give the server a second token; it must
already have been screened for control bytes. */
void ftp_command(char *line, size_t line_size, const char *verb,
const char *path);
hts_boolean ftp_split_userpass(const char *src, const char *end, char *user,
size_t user_size, char *pass, size_t pass_size);
/* ftp_split_userpass() into the caller's fixed buffers; a buffer too wide for
its "USER <user>" line would be clipped again when the command is built. */
#define ftp_split_userpass_buf(src, end, user, pass) \
(HTS_COMPILE_ASSERT(sizeof(user) + sizeof("USER ") - 1 <= FTP_LINE_SIZE && \
sizeof(pass) + sizeof("PASS ") - 1 <= FTP_LINE_SIZE), \
ftp_split_userpass((src), (end), (user), sizeof(user), (pass), \
sizeof(pass)))
/* Build "<verb> <path>" into line[line_size]. The path is quoted whenever a
bare one would give the server a second token; it must already have been
screened for control bytes. Returns HTS_FALSE and empties line when the
command does not fit, as a clipped one would name a different file. */
hts_boolean ftp_command(char *line, size_t line_size, const char *verb,
const char *path);
/* ftp_command() into a control line of the one capacity every FTP buffer has;
anything narrower fails the build rather than refusing a path that fits. */
#define ftp_command_line(line, verb, path) \
(HTS_COMPILE_ASSERT(sizeof(line) == FTP_LINE_SIZE), \
ftp_command((line), sizeof(line), (verb), (path)))
T_SOC get_datasocket(char *to_send, size_t to_send_size);
int stop_ftp(lien_back * back);
char *linejmp(char *line);

View File

@@ -273,6 +273,9 @@ typedef int hts_tristate;
/* True when A is a non-NULL, non-empty string. */
#define strnotempty(A) (((A) != NULL && (A)[0] != '\0'))
/* Compile-time check, usable as an expression. */
#define HTS_COMPILE_ASSERT(cond) ((void) sizeof(char[(cond) ? 1 : -1]))
/* 'inline' where the dialect supports it (C++), nothing in plain C. */
#ifdef __cplusplus
#define HTS_INLINE inline

52
src/htsnet.c Normal file
View File

@@ -0,0 +1,52 @@
/* ------------------------------------------------------------ */
/*
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) 2026 Xavier Roche and other contributors
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Ethical use: we kindly ask that you NOT use this software to harvest email
addresses or to collect any other private information about people. Doing so
would dishonor our work and waste the many hours we have spent on it.
Please visit our Website: http://www.httrack.com
*/
/* ------------------------------------------------------------ */
/* File: Out-of-line htsnet.h helpers, kept here so the installed */
/* header needs nothing beyond strict ISO C */
/* Author: Xavier Roche */
/* ------------------------------------------------------------ */
#include "htsnet.h"
HTSNET_API void SOCaddr_inetntoa_(char *namebuf, size_t namebuflen,
SOCaddr *const ss, const char *file,
const int line) {
assertf_(namebuf != NULL, file, line);
assertf_(ss != NULL, file, line);
if (getnameinfo(&ss->m_addr.sa, sizeof(ss->m_addr), namebuf, namebuflen, NULL,
0, NI_NUMERICHOST) == 0) {
/* remove scope id(s) */
char *const pos = strchr(namebuf, '%');
if (pos != NULL) {
*pos = '\0';
}
} else {
namebuf[0] = '\0';
}
}

View File

@@ -273,25 +273,20 @@ static HTS_UNUSED socklen_t SOCaddr_copyaddr_(SOCaddr *const server,
__LINE__); \
} while (0)
/** Write the numeric (dotted/colon) host of ss into namebuf (capacity
namebuflen), scope id stripped. On failure namebuf becomes "". */
static HTS_UNUSED void SOCaddr_inetntoa_(char *namebuf, size_t namebuflen,
SOCaddr *const ss, const char *file,
const int line) {
assertf_(namebuf != NULL, file, line);
assertf_(ss != NULL, file, line);
/* proxytrack compiles htsnet.c in rather than linking the library, and MSVC
rejects a dllimport definition. */
#ifdef HTS_NO_LIBHTTRACK
#define HTSNET_API
#else
#define HTSNET_API HTSEXT_API
#endif
if (getnameinfo(&ss->m_addr.sa, sizeof(ss->m_addr), namebuf, namebuflen, NULL,
0, NI_NUMERICHOST) == 0) {
/* remove scope id(s) */
char *const pos = strchr(namebuf, '%');
if (pos != NULL) {
*pos = '\0';
}
} else {
namebuf[0] = '\0';
}
}
/** Write the numeric (dotted/colon) host of ss into namebuf (capacity
namebuflen), scope id stripped. On failure namebuf becomes "". Out of line:
getnameinfo() isn't declared to a strict-ISO translation unit (#1001). */
HTSNET_API void SOCaddr_inetntoa_(char *namebuf, size_t namebuflen,
SOCaddr *const ss, const char *file,
const int line);
/** Numeric host of ss into namebuf (capacity namebuflen); "" on failure. */
#define SOCaddr_inetntoa(namebuf, namebuflen, ss) \

View File

@@ -4501,45 +4501,153 @@ static int st_ftpline(httrackp *opt, int argc, char **argv) {
return 0;
}
/* ftp_split_userpass: well-formed split, plus a hostile over-long userinfo
that pre-fix overran user[256]/pass[256]. */
/* ftp_split_userpass: the split itself, and userinfo refused rather than
clipped into another account's name (#1032). */
static int st_ftpuser(httrackp *opt, int argc, char **argv) {
char user[256], pass[256];
char in[1200];
static const size_t caps[] = {16, 256}; /* asymmetric: a shared bound shows */
char ubuf[256 + 32], pbuf[sizeof(ubuf)], poison[sizeof(ubuf)];
char in[2 * 256 + 8];
size_t c, over;
(void) opt;
(void) argc;
(void) argv;
memset(poison, '#', sizeof(poison));
{
const char ok[] = "bob:secret@host/f"; // '@' at index 10
ftp_split_userpass(ok, ok + 11, user, sizeof(user), pass, sizeof(pass));
assertf(strcmp(user, "bob") == 0);
assertf(strcmp(pass, "secret") == 0);
assertf(ftp_split_userpass(ok, ok + 11, ubuf, sizeof(ubuf), pbuf,
sizeof(pbuf)) == HTS_TRUE);
assertf(strcmp(ubuf, "bob") == 0);
assertf(strcmp(pbuf, "secret") == 0);
}
memset(in, 'u', 400);
in[400] = ':';
memset(in + 401, 'p', 400);
in[801] = '@';
in[802] = '\0';
ftp_split_userpass(in, in + 802, user, sizeof(user), pass, sizeof(pass));
assertf(strlen(user) == sizeof(user) - 1);
assertf(strlen(pass) == sizeof(pass) - 1);
{
/* tight sizes + guard byte catch an off-by-one the 256 case can't */
char ubuf[16], pbuf[16];
const char ok[] = "bob@host/f"; // no password: the '@' still ends the user
memset(ubuf, 'Z', sizeof(ubuf));
memset(pbuf, 'Z', sizeof(pbuf));
ftp_split_userpass(in, in + 802, ubuf, 8, pbuf, 8);
assertf(strcmp(ubuf, "uuuuuuu") == 0);
assertf(strcmp(pbuf, "ppppppp") == 0);
assertf(ubuf[8] == 'Z' && pbuf[8] == 'Z');
assertf(ftp_split_userpass(ok, ok + 4, ubuf, sizeof(ubuf), pbuf,
sizeof(pbuf)) == HTS_TRUE);
assertf(strcmp(ubuf, "bob") == 0);
assertf(pbuf[0] == '\0');
}
{
const char ok[] = "u@relay:pw@gw/f"; // only the last '@' ends the userinfo
assertf(ftp_split_userpass(ok, ok + 11, ubuf, sizeof(ubuf), pbuf,
sizeof(pbuf)) == HTS_TRUE);
assertf(strcmp(ubuf, "u@relay") == 0);
assertf(strcmp(pbuf, "pw") == 0);
}
for (c = 0; c < sizeof(caps) / sizeof(caps[0]); c++) {
const size_t ucap = caps[c], pcap = caps[1 - c];
/* overshoot the user, the pass, then a bare name bounded only by '@' */
for (over = 0; over <= 2; over++) {
const size_t cap = over == 1 ? pcap : ucap;
size_t len;
for (len = cap - 2; len <= cap + 1; len++) {
const size_t user_len = over == 1 ? 1 : len;
const size_t pass_len = over == 0 ? 1 : (over == 1 ? len : 0);
const size_t total = user_len + pass_len + (over == 2 ? 1 : 2);
const hts_boolean fits = len < cap ? HTS_TRUE : HTS_FALSE;
memset(in, 'u', user_len);
if (over != 2) {
in[user_len] = ':';
memset(in + user_len + 1, 'p', pass_len);
}
in[total - 1] = '@';
in[total] = '\0';
memcpy(ubuf, poison, sizeof(ubuf)); /* a zero canary would hide a NUL */
memcpy(pbuf, poison, sizeof(pbuf));
assertf(ftp_split_userpass(in, in + total, ubuf, ucap, pbuf, pcap) ==
fits);
if (fits) {
assertf(strlen(ubuf) == user_len && strlen(pbuf) == pass_len);
assertf(ubuf[user_len - 1] == 'u');
assertf(pass_len == 0 || pbuf[pass_len - 1] == 'p');
} else {
assertf(ubuf[0] == '\0' && pbuf[0] == '\0'); /* fail safe */
}
/* the whole tail: one canary byte misses a write just past it */
assertf(memcmp(ubuf + ucap, poison, sizeof(ubuf) - ucap) == 0);
assertf(memcmp(pbuf + pcap, poison, sizeof(pbuf) - pcap) == 0);
}
}
}
printf("ftp-userpass self-test OK\n");
return 0;
}
/* Both quoting forms at two capacities: the quoted form is two bytes wider
(#1019). */
static int st_ftpcmdlen(httrackp *opt, int argc, char **argv) {
static const size_t caps[] = {32, FTP_LINE_SIZE};
char BIGSTK buf[FTP_LINE_SIZE + 32];
char BIGSTK poison[FTP_LINE_SIZE + 32];
char BIGSTK path[FTP_LINE_SIZE + 2];
char BIGSTK wire[FTP_LINE_SIZE * 2];
size_t c, got = 0;
T_SOC sv[2];
(void) opt;
(void) argc;
(void) argv;
memset(poison, '#', sizeof(poison));
for (c = 0; c < sizeof(caps) / sizeof(caps[0]); c++) {
const size_t cap = caps[c];
int quoted;
for (quoted = 0; quoted <= 1; quoted++) {
const size_t verb = 5 + 2 * (size_t) quoted; /* "RETR " plus quotes */
const size_t fit = cap - 1 - verb; /* longest path still fitting */
size_t len;
for (len = fit - 1; len <= fit + 1; len++) {
memset(path, 'p', len);
path[len] = '\0';
if (quoted)
path[0] = ' '; /* any of these forces the quoted form */
memcpy(buf, poison, sizeof(buf)); /* a zero canary would hide a NUL */
if (len > fit) {
assertf(ftp_command(buf, cap, "RETR", path) == HTS_FALSE);
assertf(buf[0] == '\0'); /* fail-safe for an ignored result */
} else {
assertf(ftp_command(buf, cap, "RETR", path) == HTS_TRUE);
assertf(strlen(buf) == verb + len);
assertf(strncmp(buf, "RETR ", 5) == 0);
assertf(buf[verb + len - 1] == (quoted ? '\"' : 'p'));
}
/* the whole tail: one canary byte misses a write just past it */
assertf(memcmp(buf + cap, poison, sizeof(buf) - cap) == 0);
}
}
}
/* send_line() adds the CRLF and drops, rather than truncates, an over-length
line. */
memset(path, 'q', FTP_LINE_SIZE);
path[FTP_LINE_SIZE] = '\0';
assertf(st_socketpair(sv) == 0);
assertf(send_line(sv[0], path) == 0); /* one byte too long: never sent */
path[FTP_LINE_SIZE - 1] = '\0';
assertf(send_line(sv[0], path) != 0);
deletesoc(sv[0]);
for (;;) {
const int n = (int) recv(sv[1], wire + got, (int) (sizeof(wire) - got), 0);
if (n <= 0)
break;
got += (size_t) n;
}
deletesoc(sv[1]);
assertf(got == FTP_LINE_SIZE + 1); /* the maximal command alone */
assertf(memcmp(wire, path, FTP_LINE_SIZE - 1) == 0);
assertf(memcmp(wire + FTP_LINE_SIZE - 1, "\r\n", 2) == 0);
printf("ftp-cmdlen self-test OK (%d bytes sent)\n", (int) got);
return 0;
}
/* send_line() must drop a command line carrying a control byte (#1010). */
static int st_ftpctrl(httrackp *opt, int argc, char **argv) {
/* Verb and URL path as run_launch_ftp() hands them over, then the line the
@@ -8120,6 +8228,8 @@ static const struct selftest_entry {
{"ftp-userpass", "", "ftp_split_userpass bounds URL userinfo", st_ftpuser},
{"ftp-ctrlchars", "", "send_line rejects a control byte in an FTP command",
st_ftpctrl},
{"ftp-cmdlen", "",
"an FTP command too long for its control line is refused", st_ftpcmdlen},
{"warc", "<dir>", "WARC/1.1 writer: framing, digests, revisit dedup",
st_warc},
{"warc-trunc", "<dir>", "WARC-Truncated on a cap-truncated body",

View File

@@ -126,6 +126,7 @@
<ClCompile Include="htscmdline.c" />
<ClCompile Include="htsurlport.c" />
<ClCompile Include="htsmd5.c" />
<ClCompile Include="htsnet.c" />
<ClCompile Include="htsmodules.c" />
<ClCompile Include="htsname.c" />
<ClCompile Include="htsparse.c" />

View File

@@ -57,7 +57,7 @@
<ClCompile>
<!-- Matches proxytrack_CFLAGS in Makefile.am. Standalone: it does not link
libhttrack, it only borrows headers (hts_effective_mime is a macro). -->
<PreprocessorDefinitions>WIN32;_CONSOLE;_MBCS;NO_MALLOCT;ZLIB_CONST;HTS_INTHASH_USES_MD5;ZLIB_DLL;WINVER=0x0601;_WIN32_WINNT=0x0601;_CRT_SECURE_NO_WARNINGS;_CRT_NONSTDC_NO_DEPRECATE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<PreprocessorDefinitions>WIN32;_CONSOLE;_MBCS;NO_MALLOCT;HTS_NO_LIBHTTRACK;ZLIB_CONST;HTS_INTHASH_USES_MD5;ZLIB_DLL;WINVER=0x0601;_WIN32_WINNT=0x0601;_CRT_SECURE_NO_WARNINGS;_CRT_NONSTDC_NO_DEPRECATE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<AdditionalIncludeDirectories>$(MSBuildThisFileDirectory);$(MSBuildThisFileDirectory)coucal;$(MSBuildThisFileDirectory)proxy;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<WarningLevel>Level3</WarningLevel>
<MultiProcessorCompilation>true</MultiProcessorCompilation>
@@ -100,6 +100,7 @@
<ClCompile Include="proxy\proxytrack.c" />
<ClCompile Include="proxy\store.c" />
<ClCompile Include="htsurlport.c" />
<ClCompile Include="htsnet.c" />
<ClCompile Include="coucal\coucal.c" />
<ClCompile Include="htsmd5.c" />
<ClCompile Include="md5.c" />

View File

@@ -1,8 +1,43 @@
#!/bin/bash
#
# Over-long FTP userinfo must fail the link, never log in under a clipped name
# (#1032).
set -euo pipefail
# ftp_split_userpass bounds an over-long user:pass@ from a hostile ftp:// URL.
out=$(httrack -O /dev/null -#test=ftp-userpass run)
grep -q "ftp-userpass self-test OK" <<<"$out"
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_ftpuser.XXXXXX") || exit 1
trap 'set +e; rm -rf "${tmpdir}"' EXIT
trap 'exit 1' HUP INT QUIT TERM
out=$(httrack -O "${tmpdir}/st" "-#test=ftp-userpass" run 2>&1) ||
fail "self-test exited non-zero: ${out}"
grep -q "ftp-userpass self-test OK" <<<"${out}" || fail "unexpected output: ${out}"
# The reachable half: a URL carries the userinfo straight into user[256]/pass[256].
rep() { awk -v n="$1" 'BEGIN { while (i++ < n) printf "a" }'; }
probe() {
rm -rf "${tmpdir}/mir"
httrack "ftp://$1@127.0.0.1:1/f.txt" -O "${tmpdir}/mir" -q >/dev/null 2>&1 ||
fail "an FTP URL with ${#1}-byte userinfo crashed the engine"
cat "${tmpdir}/mir/hts-log.txt"
}
# The bare name has no ':' to bound it, only the '@', and is the worse branch.
for u in "$(rep 256):pw" "u:$(rep 256)" "$(rep 256)"; do
log=$(probe "${u}")
grep -q "FTP user name or password too long" <<<"${log}" ||
fail "over-long userinfo was not refused: ${log}"
done
# 255 still fits, and a plain login is the outsider a widened gate would refuse.
for u in "$(rep 255):pw" "u:$(rep 255)" "$(rep 255)" "bob:secret"; do
log=$(probe "${u}")
grep -q "Unable to connect to the server" <<<"${log}" ||
fail "userinfo that fits did not reach the connect: ${log}"
done

View File

@@ -52,19 +52,7 @@ serverlog="${tmpdir}/server.out"
"$python" "$server" --root "$(nativepath "$root")" \
--mode-file "$(nativepath "$mode")" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 300); do
line=$(grep -m1 '^PORT ' "$serverlog" 2>/dev/null) && port="${line#PORT }" && break
kill -0 "$serverpid" 2>/dev/null || {
echo "ftp server exited early: $(cat "$serverlog")" >&2
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover ftp server port: $(cat "$serverlog")" >&2
exit 1
}
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
host="127.0.0.1_${port}"
urls=()
for name in keep empty stay; do

View File

@@ -23,8 +23,11 @@ fail() {
}
tmp=$(mktemp -d "${TMPDIR:-/tmp}/httrack_sutmo.XXXXXX") || exit 1
trap 'set +e; rm -rf "$tmp"' EXIT
trap 'rm -rf "$tmp"' HUP INT QUIT PIPE TERM
# A colon-free root for the PATH shim below: MSYS hands out a drive-letter TMPDIR
# and a PATH entry carrying a colon is read as two, which leaves it unreachable.
shim=$(mktemp -d /tmp/httrack_sutmoshim.XXXXXX 2>/dev/null) || shim="$tmp/starve"
trap 'set +e; rm -rf "$tmp" "$shim"' EXIT
trap 'rm -rf "$tmp" "$shim"' HUP INT QUIT PIPE TERM
out="$tmp/out"
# --- a hanging test is killed, named, and reported 124 ----------------------
@@ -71,11 +74,14 @@ sleep 1
# --- the budget is wall clock, not a count of poll iterations ----------------
# Starve the poll and a counted budget stretches with it: 10 polls of a 0.1s tick
# become 40s, so the guard fires long after the step it was meant to beat (#795).
# HTTRACK_POLL_SLEEP forces the forked tick, the only one a shimmed sleep reaches;
# the loop it stretches is the same one poll_wait's fd tick drives.
(
starve_sleep "$tmp/starve" 4 || fail "could not install the slow sleep"
starve_sleep "$shim" 4 || fail "could not install the slow sleep"
start=$SECONDS
rc=0
HTTRACK_TEST_TIMEOUT=1 bash "$driver" "$tmp/90_wedged.test" >"$out" 2>&1 || rc=$?
HTTRACK_POLL_SLEEP=1 HTTRACK_TEST_TIMEOUT=1 \
bash "$driver" "$tmp/90_wedged.test" >"$out" 2>&1 || rc=$?
elapsed=$((SECONDS - start))
test "$rc" -eq 124 || fail "starved guard reported $rc, want 124"
# Generous: the diagnostics dump runs inside this window too.
@@ -145,23 +151,44 @@ if ! is_windows && test -r /proc/self/stat; then
mkdir -p "$tmp/nops"
printf '#!/bin/sh\nexit 127\n' >"$tmp/nops/ps"
chmod +x "$tmp/nops/ps"
# Both lists match on the basename alone, so any long-lived binary will do.
ln -sf "$(command -v sleep)" "$tmp/httrack"
# A shell copy, not a renamed sleep: uutils refuses an argv[0] it does not own
# (#1042). Either works, since both lists match on the basename alone.
cp "${BASH:-$(command -v sh)}" "$tmp/httrack" || fail "no shell to copy"
"$tmp/httrack" -c : || fail "cannot exec out of $tmp (noexec?)"
# Read-write, so the open does not block and the timeout can bound it: the
# fakes then fork nothing for the SIGABRT to orphan, and a test killed
# outright leaks no process named httrack onto the runner.
mkfifo "$tmp/hold"
hold="read -t 60 _ <>'$tmp/hold'"
# Started from a subshell so ppid and pgid differ: swapping those two columns
# is invisible whenever the test shell happens to lead its own group.
(
"$tmp/httrack" 60 &
"$tmp/httrack" -c "$hold" &
echo $! >"$tmp/fake.pid"
)
# Its own group, to prove the filter keeps an outsider out.
(
set -m
"$tmp/httrack" 60 &
"$tmp/httrack" -c "$hold" &
echo $! >"$tmp/outside.pid"
)
fake=$(cat "$tmp/fake.pid")
outside=$(cat "$tmp/outside.pid")
trap 'set +e; kill "$fake" "$outside" 2>/dev/null; rm -rf "$tmp"' EXIT
# /proc holds the pid from the fork on, so a fake that never exec'd would fail
# some later assertion instead of this one.
running() {
local i=0 argv0
while test "$i" -lt 20; do
{ read -r -d '' argv0 <"/proc/$1/cmdline"; } 2>/dev/null &&
test "${argv0##*/}" = httrack && return 0
sleep 0.1
i=$((i + 1))
done
return 1
}
running "$fake" || fail "the fake engine never started"
running "$outside" || fail "the outsider never started"
# ppid and pgid of $1: the oracle the listing is checked against.
procfields() { awk '{ sub(/.*\) /, ""); print $2, $3 }' "/proc/$1/stat"; }
procstate() { awk '{ sub(/.*\) /, ""); print $1 }' "/proc/$1/stat" 2>/dev/null || echo gone; }
@@ -186,16 +213,37 @@ if ! is_windows && test -r /proc/self/stat; then
grep -qx "$fake" <<<"$engines" || fail "no-ps engine list lost pid $fake"
! grep -qx "$outside" <<<"$engines" ||
fail "no-ps engine list reached outside the group (pid $outside)"
# A platform with no stack mechanism must say so and leave the engine
# alone. Shimmed, because no CI leg is one: the Hurd branch below runs
# only on the buildds that failed for want of it.
# shellcheck disable=SC2016 # the shim reads them, not us
printf '#!/bin/sh\ncase "$1" in -s) echo GNU;; *) exec %s "$@";; esac\n' \
"$(command -v uname)" >"$tmp/nops/uname"
chmod +x "$tmp/nops/uname"
grep -q "no stack mechanism known for GNU" <<<"$(request_engine_backtraces "$pgid")" ||
fail "a platform with no stack mechanism reported nothing"
case "$(procstate "$fake")" in
Z | gone) fail "the no-mechanism branch signalled pid $fake" ;;
esac
rm -f "$tmp/nops/uname"
# The chain, not just its input: this is what reported nothing on Fedora.
stacks=$(request_engine_backtraces "$pgid")
! grep -q 'no engine process left' <<<"$stacks" ||
fail "no-ps stack request found no engine to signal: $stacks"
# Gone, or a zombie: the fake is reparented, and a container's pid 1 does
# not always reap. kill -0 alone succeeds on a zombie and proves nothing.
case "$(procstate "$fake")" in
Z | gone) ;;
*) fail "the SIGABRT never reached pid $fake" ;;
esac
# Only Linux signals; elsewhere (Hurd's uname reports "GNU") the chain
# owes the reader its no-mechanism report instead of silence.
if test "$(uname -s)" = Linux; then
# Gone, or a zombie: the fake is reparented, and a container's pid 1
# does not always reap. kill -0 succeeds on a zombie, proving nothing.
case "$(procstate "$fake")" in
Z | gone) ;;
*) fail "the SIGABRT never reached pid $fake" ;;
esac
else
grep -q 'no stack mechanism known' <<<"$stacks" ||
fail "no stack mechanism was reported on $(uname -s): $stacks"
fi
# With neither source the dump must say so, not print an empty section.
# shellcheck disable=SC2317 # reached through ps_snapshot
proc_snapshot() { return 1; }
@@ -205,6 +253,34 @@ if ! is_windows && test -r /proc/self/stat; then
kill "$fake" "$outside" 2>/dev/null || true
fi
# --- an emulated buildd hides the engine behind its binfmt interpreter -------
# Synthetic rows: no runner here has qemu-user, and the column shift is the point.
if ! is_windows; then
(
# shellcheck disable=SC2317 # reached through the two matchers below
ps_snapshot() {
cat <<'EOF'
PID PPID PGID ELAPSED S COMMAND
11 1 40 12 S /usr/libexec/qemu-binfmt/hppa-binfmt-P /bld/src/httrack -q http://h/
12 1 40 12 S /usr/bin/qemu-hppa-static /usr/bin/python3 /t/local-server.py 8080
13 1 40 12 S /bld/src/httrack -q http://h/
14 1 40 12 S /usr/bin/strace /bld/src/httrack -q http://h/
15 1 40 12 S /usr/bin/qemu-img convert /srv/local-server.py out.raw
16 1 40 12 S /usr/bin/qemu-nbd /mnt/httrack
17 1 40 12 S /opt/my-custom-binfmt /mnt/httrack
EOF
}
# Rows 14 to 17 are the controls: shifting past anything but an emulator
# would take a wrapper, or a disk image, for the engine and kill it.
engines=$(list_engine_pids 40)
test "$engines" = $'11\n13' ||
fail "emulated engine list is '$engines', want pids 11 and 13"
named=$(list_stray_processes 0 named | awk 'NR > 1 { print $1 }')
test "$named" = $'11\n12\n13' ||
fail "emulated stray list is '$named', want pids 11, 12 and 13"
)
fi
# --- a wedged crawl yields a symbolized engine stack ------------------------
# The whole point of the dump: name the frame the engine is stuck in. Windows has
# neither half (MSYS signals do not reach a native httrack.exe, and that build has

View File

@@ -52,19 +52,7 @@ done
serverlog="${tmpdir}/server.out"
"$python" "$server" --root "$(nativepath "$root")" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 300); do
line=$(grep -m1 '^PORT ' "$serverlog" 2>/dev/null) && port="${line#PORT }" && break
kill -0 "$serverpid" 2>/dev/null || {
echo "ftp server exited early: $(cat "$serverlog")" >&2
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover ftp server port: $(cat "$serverlog")" >&2
exit 1
}
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
host="127.0.0.1_${port}"
urls=()

View File

@@ -64,19 +64,7 @@ serverlog="${tmpdir}/server.out"
--mode-file "$(nativepath "$mode")" --log "$(nativepath "$cmds")" \
>"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 300); do
line=$(grep -m1 '^PORT ' "$serverlog" 2>/dev/null) && port="${line#PORT }" && break
kill -0 "$serverpid" 2>/dev/null || {
echo "ftp server exited early: $(cat "$serverlog")" >&2
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover ftp server port: $(cat "$serverlog")" >&2
exit 1
}
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
host="127.0.0.1_${port}"
url="ftp://127.0.0.1:${port}/a.bin"
mirror="${out}/${host}/a.bin"

View File

@@ -79,19 +79,7 @@ serverlog="${tmpdir}/server.out"
--mode-file "$(nativepath "$mode")" --log "$(nativepath "$cmds")" \
>"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 300); do
line=$(grep -m1 '^PORT ' "$serverlog" 2>/dev/null) && port="${line#PORT }" && break
kill -0 "$serverpid" 2>/dev/null || {
echo "ftp server exited early: $(cat "$serverlog")" >&2
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover ftp server port: $(cat "$serverlog")" >&2
exit 1
}
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
host="127.0.0.1_${port}"
url="ftp://127.0.0.1:${port}/a.bin"
mirror="${out}/${host}/a.bin"

View File

@@ -126,8 +126,13 @@ wait_drained() { # log
quietport=$(freeport)
: >"$dir/quiet.log" # the drainer creates it asynchronously
env -u HTS_LOG "$python" -c "$pty_exec" "$dir/quiet.log" \
proxytrack "127.0.0.1:$quietport" "127.0.0.1:$(freeport)" "$dir/in.arc" &
# Subshell, not env(1): uutils' env forks rather than execs, so stop_server would
# kill the wrapper and leave proxytrack holding the pty open (#1042).
(
unset HTS_LOG
exec "$python" -c "$pty_exec" "$dir/quiet.log" \
proxytrack "127.0.0.1:$quietport" "127.0.0.1:$(freeport)" "$dir/in.arc"
) &
quietpid=$!
wait_listen "$dir/quiet.log" "$quietpid"

View File

@@ -8,6 +8,10 @@
set -euo pipefail
# Before the driver is sourced: with these set, anything it starts posts a commit
# status on whatever the surrounding shell was last looking at.
unset WATCHDOG_TOKEN WATCHDOG_REPO WATCHDOG_SHA WATCHDOG_URL
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/ci-windows-suite.sh
. "${testdir}/ci-windows-suite.sh"
@@ -138,7 +142,8 @@ test "$killed" -le 10080 || fail "killed at $killed, want 9960 within a tick"
# taskkill is a grandchild of its own target, so a leaves-first /T would reap the
# watchdog before the root (#953): kill_tree here never returns, and the stubs
# record the order, which the code under test cannot write to.
# record the order, which the code under test cannot write to. The reporter goes
# first: the kill runs no EXIT trap, and the tree walk reaches this heartbeat.
rec="$tmp/killed"
: >"$rec"
vnow=9000
@@ -146,6 +151,7 @@ ticks=0
printf 'RUN 96_wedged.test at 0s\n' >"$progress"
rc=0
(
watchdog=777
kill_pid() { echo "DIRECT $1" >>"$rec"; }
kill_tree() {
echo "TREE $1" >>"$rec"
@@ -154,10 +160,12 @@ rc=0
ci_suite_heartbeat 960 360 "$progress" 900 4242 >"$tmp/hedge" 2>&1
) || rc=$?
test "$rc" -eq 9 || fail "the tree kill never fired: watchdog returned $rc"
test "$(sed -n 1p "$rec")" = "DIRECT 4242" ||
test "$(sed -n 1p "$rec")" = "DIRECT 777" ||
fail "the reporter was not killed ahead of the suite: $(tr '\n' '/' <"$rec")"
test "$(sed -n 2p "$rec")" = "DIRECT 4242" ||
fail "the target was not signalled directly ahead of the tree walk: $(tr '\n' '/' <"$rec")"
test "$(sed -n 2p "$rec")" = "TREE 4242" ||
test "$(sed -n 3p "$rec")" = "TREE 4242" ||
fail "the tree was not killed after the direct signal: $(tr '\n' '/' <"$rec")"
test "$(sed -n '$=' "$rec")" -eq 2 || fail "extra kills: $(tr '\n' '/' <"$rec")"
test "$(sed -n '$=' "$rec")" -eq 3 || fail "extra kills: $(tr '\n' '/' <"$rec")"
echo "heartbeat OK"

View File

@@ -6,6 +6,10 @@
set -euo pipefail
# Before the driver is sourced: with these set, anything it starts posts a commit
# status on whatever the surrounding shell was last looking at.
unset WATCHDOG_TOKEN WATCHDOG_REPO WATCHDOG_SHA WATCHDOG_URL
testdir=$(cd "$(dirname "$0")" && pwd)
driver="$testdir/ci-windows-suite.sh"
@@ -39,7 +43,8 @@ grep -q usage <<<"$out" || fail "argless run said: $out"
# sibling's.
run=$tmp/run
mkdir -p "$run"
cp "$driver" "$testdir/testlib.sh" "$testdir/test-timeout.sh" "$run/"
cp "$driver" "$testdir/testlib.sh" "$testdir/test-timeout.sh" \
"$testdir/ci-windows-watchdog.ps1" "$run/"
chmod u+w "$run"/*.sh # distcheck's srcdir is read-only, and cp carries that over
cat >>"$run/testlib.sh" <<'EOF'
reap_leftover_processes() { return 0; }
@@ -48,26 +53,69 @@ EOF
bin=$tmp/bin
mkdir -p "$bin"
printf '#!/bin/sh\nexit 0\n' >"$bin/httrack"
# shellcheck disable=SC2016 # $2 is the stub's own argument
printf '#!/bin/sh\necho "$2"\n' >"$bin/cygpath"
# The first child the driver forks: the token has to be gone before it runs.
# shellcheck disable=SC2016 # $2 and the token are the stub's own expansions
printf '#!/bin/sh\necho "TOKEN=${WATCHDOG_TOKEN:-}" >>%s\necho "$2"\n' "$tmp/cygpathtoken" \
>"$bin/cygpath"
chmod +x "$bin/httrack" "$bin/cygpath"
test -x "$bin/httrack" || {
# The driver puts this bindir first on PATH, so this is the interpreter it finds.
# Unconditional, because a real PowerShell here runs the real watchdog, and only
# the scrub above stands between that and api.github.com.
wdargv=$tmp/wdargv
wdmark=$tmp/wdmark
: >"$wdargv"
cat >"$bin/pwsh" <<EOF
#!/bin/sh
{ echo "TOKEN=\${WATCHDOG_TOKEN:-}"; printf '%s\n' "\$@"; } >>"$wdargv"
echo "watchdog ready"
# Ticks rather than sleeps: a pid can be reused, a running reporter cannot.
n=0
while :; do
n=\$((n + 1))
echo "\$n" >"$wdmark.part"
mv "$wdmark.part" "$wdmark"
sleep 0.2
done
EOF
chmod +x "$bin/pwsh"
# Ahead of any command -v, which asks access(X_OK) and so reads false here.
test -x "$bin/pwsh" || {
echo "SKIP: ${TMPDIR:-/tmp} is noexec, the stub bindir cannot be run"
exit 77
}
test "$(PATH="$bin:$PATH" command -v pwsh)" = "$bin/pwsh" ||
fail "the stub does not shadow PowerShell: $(PATH="$bin:$PATH" command -v pwsh)"
# The stub advances $wdmark every 0.2s, so a marker that moves says it still runs.
# Sampled three times, or a loaded box starving the stub would read as a kill.
still_ticking() {
local a b
a=$(cat "$wdmark" 2>/dev/null || true)
for _ in 1 2 3; do
sleep 1
b=$(cat "$wdmark" 2>/dev/null || true)
test -n "$b" && test "$a" != "$b" && return 0
done
return 1
}
# One per glob the driver enumerates: an empty category is counted as a failing
# test named after the unexpanded pattern, which would drown the accounting.
for t in 00_runnable 10_engine-pass 13_zlib-pass 14_local-pass 15_watchdog-pass \
for t in 00_runnable 13_zlib-pass 14_local-pass 15_watchdog-pass \
16_crawl_proxy_https 17_crawl-log-salvage; do
printf '#!/bin/sh\nexit 0\n' >"$run/$t.test"
done
# One test reports back what it inherited: the token the step is handed can post
# commit statuses, and a forged one has already reached a commit under test.
# shellcheck disable=SC2016 # the stub expands it, not this shell
printf '#!/bin/sh\necho "TOKEN=${WATCHDOG_TOKEN:-}" >%s\nexit 0\n' "$tmp/childtoken" \
>"$run/10_engine-pass.test"
printf '#!/bin/sh\nexit 77\n' >"$run/11_engine-skip.test"
printf '#!/bin/sh\nexit 3\n' >"$run/12_engine-fail.test"
cd "$run"
rc=0
out=$(RUNNER_TEMP="$tmp" GITHUB_STEP_SUMMARY="$tmp/summary" \
out=$(RUNNER_TEMP="$tmp" GITHUB_STEP_SUMMARY="$tmp/summary" WATCHDOG_TOKEN=s3cr3t \
bash ./ci-windows-suite.sh "$bin" 2>&1) || rc=$?
# The skip set and the pass floor are pinned to the real suite, so a stub run
# ends on the floor; what is under test is the tally that reaches it.
@@ -77,6 +125,20 @@ grep -q '^ran=9 pass=7 fail=1 skip=1$' <<<"$out" ||
grep -q '::error::only 7 tests passed (1 skipped)' <<<"$out" ||
fail "the pass floor did not report the count"
grep -q 'FAIL 12_engine-fail.test (exit 3)' <<<"$out" || fail "the failing test was not named"
# This launches the watchdog the way the real driver does, not through a test
# sourcing the helper, proving it starts and holds a token nothing else sees.
grep -q '^watchdog ready$' watchdog.log || fail "no watchdog was launched: $(cat watchdog.log)"
test -r "$tmp/childtoken" || fail "the test that reads its environment did not run"
grep -qx 'TOKEN=' "$tmp/childtoken" ||
fail "a test inherited the status token: $(cat "$tmp/childtoken")"
grep -qx 'TOKEN=s3cr3t' "$wdargv" ||
fail "the watchdog was launched without its token: $(cat "$wdargv")"
test -s "$tmp/cygpathtoken" || fail "the cygpath stub never ran"
grep -q 'TOKEN=s3cr3t' "$tmp/cygpathtoken" &&
fail "the driver forked a child before scrubbing its token: $(cat "$tmp/cygpathtoken")"
# An orphan runs out its own deadline, re-posting a frozen tail past the step (#795).
! still_ticking || fail "the driver exited leaving its reporter running"
# Positive control for the gate below: with every category matched it stays silent.
grep -q 'matched no tests' <<<"$out" && fail "a full suite reported an empty category"
@@ -110,4 +172,34 @@ grep -q '::error::test category runnable matched no tests' <<<"$out" ||
fail "the empty single-test category was not named: $out"
grep -q 'FAIL 00_runnable' <<<"$out" && fail "the unexpanded name was run as a test: $out"
# The wedge branch has to end the reporter itself: its kill of the suite is a
# TerminateProcess, which runs no EXIT trap. Forked the way the driver forks it,
# since the branch reads $watchdog from the shell it was forked from.
wedge_leg() (
PATH="$bin:$PATH"
export PATH
prog=$run/wedge-progress.log
printf 'RUN 99_wedged.test at 0s\n' >"$prog"
ci_watchdog_pid=''
ci_start_native_watchdog "$prog" || fail "the wedge leg launched no watchdog"
watchdog=$ci_watchdog_pid
still_ticking || fail "the wedge leg's reporter was not running to begin with"
# Its own process group, so the tree kill below cannot reach this shell.
set -m
sleep 300 &
victim=$!
set +m
ci_suite_heartbeat 0 1 "$prog" 0 "$victim" >"$tmp/wedge.out" 2>&1 &
wait "$!"
grep -q '::error title=suite watchdog::' "$tmp/wedge.out" ||
fail "the wedge branch was never reached: $(cat "$tmp/wedge.out")"
! still_ticking || fail "the wedge kill orphaned the reporter"
)
# Held back: bash announces every job it reaps from a signal, which reads as a
# failure in a log that passed.
wedge_leg >"$tmp/wedge.log" 2>&1 || {
cat "$tmp/wedge.log" >&2
exit 1
}
echo "ci-windows driver OK"

View File

@@ -88,32 +88,36 @@ env MAKEFLAGS= MFLAGS= "$make" -C "$abs_top_builddir/src" install-DevIncludesDAT
}
headers=("$tmp/include/httrack"/*.h)
[ "${#headers[@]}" -ge 10 ] || fail "only ${#headers[@]} headers installed, the list cannot be right"
[ -f "$tmp/include/httrack/htssafe.h" ] || fail "htssafe.h was not installed"
# No libc hands getnameinfo() to a strict-ISO unit, so a consumer of the two
# socket headers must ask for POSIX. Prefer 2001: 2008 also un-hides strnlen,
# which would stop those two covering that half of #972.
posix_argv=(-D_POSIX_C_SOURCE=200112L)
printf '#include <httrack/htsnet.h>\n' >"$tmp/tu.c"
if ! "${cc_argv[@]}" "${cpp_argv[@]}" "${posix_argv[@]}" "-std=${stds[0]}" \
-fsyntax-only "$tmp/tu.c" 2>/dev/null; then
fail "htsnet.h needs POSIX.1-2008, which un-hides strnlen and would leave that half of #972 uncovered"
fi
# Derived from DevIncludes_DATA so a new header needs no edit here. A header
# dropped from that list moves both sides, hence the named checks below.
declared=$(awk '/^DevIncludes_DATA[[:space:]]*=/ { inlist = 1 }
inlist {
last = ($0 !~ /\\$/)
sub(/^[^=]*=/, "")
gsub(/\\/, " ")
for (i = 1; i <= NF; i++)
if ($i ~ /\.h$/) n++
if (last) exit
}
END { print n + 0 }' "${abs_top_srcdir:?}/src/Makefile.am")
[ "$declared" -ge 10 ] || fail "read only $declared headers out of DevIncludes_DATA, the parse is wrong"
[ "${#headers[@]}" -eq "$declared" ] ||
fail "${#headers[@]} headers installed, DevIncludes_DATA declares $declared"
for h in htssafe.h htsnet.h htsopt.h; do
[ -f "$tmp/include/httrack/$h" ] || fail "$h was not installed"
done
# No -D_POSIX_C_SOURCE anywhere below: every installed header, htsnet.h and
# htsopt.h included, must compile against the strict-ISO libc alone (#1001).
count=0
bad=0
for h in "${headers[@]}"; do
b=$(basename "$h")
argv=("${cpp_argv[@]}")
case "$b" in
htsnet.h | htsopt.h) argv+=("${posix_argv[@]}") ;;
esac
printf '#include <httrack/%s>\n' "$b" >"$tmp/tu.c"
for std in "${stds[@]}"; do
for mode in -UHTS_INTERNAL_BYTECODE -DHTS_INTERNAL_BYTECODE; do
count=$((count + 1))
if ! "${cc_argv[@]}" "${argv[@]}" "-std=$std" "$mode" -fsyntax-only "$tmp/tu.c" 2>"$tmp/cc.log"; then
if ! "${cc_argv[@]}" "${cpp_argv[@]}" "-std=$std" "$mode" -fsyntax-only "$tmp/tu.c" 2>"$tmp/cc.log"; then
echo "$b does not compile under -std=$std ($mode):" >&2
head -5 "$tmp/cc.log" >&2
bad=1
@@ -122,6 +126,7 @@ for h in "${headers[@]}"; do
done
done
echo "compiled ${#headers[@]} headers x ${#stds[@]} std x 2 bytecode modes = $count units" >&2
[ "$count" -eq $((${#headers[@]} * ${#stds[@]} * 2)) ] || fail "the compile loop skipped units"
[ "$bad" -eq 0 ] || fail "installed headers do not compile in strict-ISO mode"
# A macro body is only compiled where it expands, so the include-only loop above
@@ -178,19 +183,23 @@ int main(void) {
return 0;
}
EOF
# Vacuous if the gate handed us libc's strnlen. Match that branch, not the
# loop, or a mutated loop reads as a lost fallback instead. Needs no runnable
# binary, so it stays outside the gate below.
for std in "${stds[@]}"; do
pp=$("${cc_argv[@]}" "${cpp_argv[@]}" "-std=$std" -E "$tmp/strnlen.c") ||
fail "cannot preprocess the htssafe_strnlen_ probe under -std=$std"
if grep -q 'return strnlen' <<<"$pp"; then
fail "-std=$std left htssafe_strnlen_ on libc, the differential proves nothing"
fi
done
# A noexec TMPDIR or a cross-compiler would red the run for the wrong reason.
printf 'int main(void) { return 0; }\n' >"$tmp/tu.c"
if ! ("${cc_argv[@]}" "${cpp_argv[@]}" -o "$tmp/tu" "$tmp/tu.c" 2>/dev/null && "$tmp/tu"); then
echo "cannot run a binary built in $tmp, skipping the htssafe_strnlen_ differential" >&2
else
for std in "${stds[@]}"; do
# Vacuous if the gate handed us libc's strnlen. Match that branch, not
# the loop, or a mutated loop reads as a lost fallback instead.
pp=$("${cc_argv[@]}" "${cpp_argv[@]}" "-std=$std" -E "$tmp/strnlen.c") ||
fail "cannot preprocess the htssafe_strnlen_ probe under -std=$std"
if grep -q 'return strnlen' <<<"$pp"; then
fail "-std=$std left htssafe_strnlen_ on libc, the check below proves nothing"
fi
"${cc_argv[@]}" "${cpp_argv[@]}" "-std=$std" -o "$tmp/strnlen" "$tmp/strnlen.c" 2>"$tmp/cc.log" || {
head -5 "$tmp/cc.log" >&2
fail "the htssafe_strnlen_ probe does not build under -std=$std"
@@ -221,9 +230,6 @@ else
for h in "${headers[@]}"; do
b=$(basename "$h")
argv=("${base_cpp_argv[@]}" -Wall -Wextra -Werror)
case "$b" in
htsnet.h | htsopt.h) argv+=("${posix_argv[@]}") ;;
esac
printf '#include <httrack/%s>\nint main() { return 0; }\n' "$b" >"$tmp/tu.cpp"
for std in "${cxx_stds[@]}"; do
for mode in -UHTS_INTERNAL_BYTECODE -DHTS_INTERNAL_BYTECODE; do

View File

@@ -6,14 +6,34 @@
set -euo pipefail
srcdir="${abs_top_srcdir:?not run under make check}"
metainfo="${srcdir}/html/server/div/com.httrack.WebHTTrack.metainfo.xml"
command -v appstreamcli >/dev/null 2>&1 || {
echo "appstreamcli not installed; skipping" >&2
# --no-net/--nonet: validate the file, not the reachability of the screenshot
# host. Fedora's build gates on appstream-util, stricter about icons and
# metadata than appstreamcli.
validate() {
case $1 in
appstreamcli) appstreamcli validate --no-net --explain "$2" ;;
appstream-util) appstream-util validate-relax --nonet "$2" ;;
*)
echo "FAIL: no validator named $1" >&2
exit 1
;;
esac
}
tools=()
for tool in appstreamcli appstream-util; do
command -v "${tool}" >/dev/null 2>&1 && tools+=("${tool}")
done
test ${#tools[@]} -gt 0 || {
echo "no appstream validator installed; skipping" >&2
exit 77
}
test -f "${metainfo}" || {
echo "FAIL: ${metainfo} not found" >&2
# html/Makefile.am installs these by glob; a second one must not ship unchecked.
metainfos=("${srcdir}"/html/server/div/*.metainfo.xml)
test -f "${metainfos[0]}" || {
echo "FAIL: no metainfo under ${srcdir}/html/server/div" >&2
exit 1
}
@@ -23,21 +43,18 @@ work=$(mktemp -d "${TMPDIR:-/tmp}/appstream.XXXXXX") || {
}
trap 'set +e; rm -rf "${work}"' EXIT
# --no-net: validate the file, not the reachability of the screenshot host.
validate() { appstreamcli validate --no-net --explain "$1"; }
validate "${metainfo}" || {
echo "FAIL: metainfo does not validate" >&2
exit 1
}
# Positive control: an appstreamcli that never fails would pass the above.
grep -v '<id>com.httrack.WebHTTrack</id>' "${metainfo}" >"${work}/noid.xml"
if validate "${work}/noid.xml" >"${work}/noid.log" 2>&1; then
echo "FAIL: metainfo without <id> validated; the check proves nothing" >&2
exit 1
fi
grep -q component-id-missing "${work}/noid.log" ||
echo "note: id-less copy failed for another reason: $(tail -3 "${work}/noid.log")" >&2
echo "appstream metainfo: valid"
for metainfo in "${metainfos[@]}"; do
# Positive control: a validator that never fails would pass the check below.
sed '/<id>/d' "${metainfo}" >"${work}/noid.xml"
for tool in "${tools[@]}"; do
validate "${tool}" "${metainfo}" || {
echo "FAIL: ${metainfo##*/} does not validate under ${tool}" >&2
exit 1
}
if validate "${tool}" "${work}/noid.xml" >"${work}/noid.log" 2>&1; then
echo "FAIL: ${tool} validated a metainfo with no <id>; it proves nothing" >&2
exit 1
fi
done
echo "appstream metainfo: ${metainfo##*/} valid (${tools[*]})"
done

View File

@@ -0,0 +1,42 @@
#!/bin/bash
#
# A too-long FTP command must be refused, never truncated or aborted (#1019).
set -euo pipefail
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_ftpcmdlen.XXXXXX") || exit 1
trap 'set +e; rm -rf "${tmpdir}"' EXIT
trap 'exit 1' HUP INT QUIT TERM
out=$(httrack -O "${tmpdir}/st" "-#test=ftp-cmdlen" run 2>&1) ||
fail "self-test exited non-zero: ${out}"
grep -q "ftp-cmdlen self-test OK" <<<"${out}" || fail "unexpected output: ${out}"
# The reachable half: the copy into _adr[256] aborted on a long host.
rep() { awk -v n="$1" 'BEGIN { while (i++ < n) printf "a" }'; }
probe_host() {
rm -rf "${tmpdir}/host"
httrack "ftp://$1/f.txt" -O "${tmpdir}/host" -q >/dev/null 2>&1 ||
fail "a ${#1}-byte FTP host name crashed the engine"
cat "${tmpdir}/host/hts-log.txt"
}
for n in 256 257 300; do
log=$(probe_host "$(rep "${n}")")
grep -q "Host name too long" <<<"${log}" ||
fail "a ${n}-byte host was not rejected: ${log}"
done
# 255 still fits _adr[256]; 127.0.0.1:1 is the outsider a widened gate swallows.
log=$(probe_host "$(rep 255)")
grep -q "Unable to get server's address" <<<"${log}" ||
fail "a 255-byte host did not reach the resolver: ${log}"
log=$(probe_host "127.0.0.1:1")
grep -q "Unable to connect to the server" <<<"${log}" ||
fail "a normal host did not reach the connect: ${log}"

99
tests/225_install-manifest.test Executable file
View File

@@ -0,0 +1,99 @@
#!/bin/bash
#
# Distro packages (Fedora %files, debian/*.files) name every installed file, so a
# file appearing or vanishing here breaks their build and not ours; it broke
# Fedora's twice in three weeks.
set -euo pipefail
: "${abs_top_builddir:?not run under make check}"
manifest="${abs_top_srcdir:?not run under make check}/tests/install-manifest.txt"
fail() {
echo "FAIL: $*" >&2
exit 1
}
# macOS names the library .dylib, and Windows has no distro packaging to protect.
test "$(uname -s)" = Linux || {
echo "install layout is pinned on Linux only; skipping" >&2
exit 77
}
test -r "${manifest}" || fail "no ${manifest}"
# The docs are dropped below, so a docdir holding datadir would drop the pinned
# data files with them and leave nothing to compare.
docdir=${CONFIGURED_DOCDIR:?not run under make check}
case "${CONFIGURED_DATADIR:?not run under make check}" in
"${docdir%/}" | "${docdir%/}"/*)
echo "docdir contains datadir; skipping" >&2
exit 77
;;
esac
work=$(mktemp -d "${TMPDIR:-/tmp}/manifest.XXXXXX") || {
echo "no tmpdir" >&2
exit 1
}
trap 'set +e; rm -rf "${work}"' EXIT
stage=${work}/stage
env -u MAKEFLAGS -u MAKELEVEL "${MAKE:-make}" -C "${abs_top_builddir}" \
install DESTDIR="${stage}" >"${work}/install.log" 2>&1 || {
cat "${work}/install.log" >&2
fail "make install DESTDIR=${stage}"
}
# Fold the configured directories back to tokens so the pin survives any --prefix
# or --libdir. Longest path first, so a nested directory beats the one it sits in
# whatever the layout puts where.
: >"${work}/dirs"
for var in DOCDIR HTMLDIR MANDIR INCLUDEDIR LIBDIR BINDIR DATADIR; do
name=CONFIGURED_${var}
path=${!name:-}
test -n "${path}" || fail "${name} not set"
path=${path%/}
printf '%s\t%s\t%s\n' "${#path}" "${path}" "${var}" >>"${work}/dirs"
done
LC_ALL=C sort -rn "${work}/dirs" -o "${work}/dirs"
paths=() tokens=()
while IFS=$'\t' read -r _ path var; do
paths+=("${path}")
tokens+=("${var}")
done <"${work}/dirs"
# Matched literally: a directory holding a regex metacharacter would silently
# fold nothing if this were a pattern.
fold() {
local line=$1 i
for i in "${!paths[@]}"; do
case ${line} in
"${paths[i]}"/*)
printf '@%s@/%s\n' "${tokens[i]}" "${line#"${paths[i]}"/}"
return
;;
esac
done
printf '%s\n' "${line}"
}
# Dropped: the docs, since where they sit under docdir is a --htmldir choice each
# distro makes its own way and no package names them one by one; the .a/.la
# libtool bookkeeping no distro ships; and the library revision, which moves
# every release. The soname digit stays: a bump renames Debian's package.
(cd "${stage}" && find . \( -type f -o -type l \) | sed 's|^\.||') |
while IFS= read -r line; do fold "${line}"; done |
sed -e '/^@DOCDIR@\//d' -e '/^@HTMLDIR@\//d' \
-e '/\.la$/d' -e '/\.a$/d' \
-e 's|\(\.so\.[0-9][0-9]*\)\.[0-9][0-9]*\.[0-9][0-9]*$|\1.@revision@|' |
LC_ALL=C sort >"${work}/actual"
# An install that staged nothing matches nothing; say that, not a 300-line diff.
grep -qx '@BINDIR@/httrack' "${work}/actual" || fail "nothing staged under ${stage}"
LC_ALL=C sort "${manifest}" >"${work}/expected"
diff -u "${work}/expected" "${work}/actual" ||
fail "the installed file set changed; update tests/install-manifest.txt and say so in the release notes -- Fedora's %files and debian/*.files name these files"
echo "install manifest: $(wc -l <"${work}/actual") files"

View File

@@ -0,0 +1,726 @@
#!/bin/bash
#
# The off-box suite watchdog (#795): its pure decisions through -SelfTest, what
# reaches the status API, the process APIs it must never name, and the driver
# wiring no .ps1 can check for itself.
set -euo pipefail
# Before the driver is sourced: with these set, anything it starts posts a commit
# status on whatever the surrounding shell was last looking at.
unset WATCHDOG_TOKEN WATCHDOG_REPO WATCHDOG_SHA WATCHDOG_URL
export WATCHDOG_CONTEXT='226_watchdog-native (never posted)'
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "$testdir/testlib.sh"
top=${abs_top_srcdir:-$(cd "$testdir/.." && pwd)}
wdscript="$testdir/ci-windows-watchdog.ps1"
driver="$testdir/ci-windows-suite.sh"
workflow="$top/.github/workflows/windows-build.yml"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/httrack_wdnat.XXXXXX")
sinkpidfile="$tmp/sink.pid"
: >"$sinkpidfile"
toppid=$$
trap 'set +e; stop_server "$(cat "$sinkpidfile" 2>/dev/null)"; rm -rf "$tmp"' EXIT
trap 'exit 1' TERM
# Signalled, since a bare exit only ends the subshell a leg runs in.
fail() {
echo "FAIL: $*" >&2
kill -TERM "$toppid" 2>/dev/null || true
exit 1
}
test -r "$wdscript" || fail "no $wdscript"
test -z "${WATCHDOG_TOKEN:-}${WATCHDOG_SHA:-}" || fail "a credential survived the scrub"
# The prose in the script names the very calls the audits forbid. A trailing
# comment goes only on a quote-free line: cutting at any '#' would hide a call
# sitting after an issue number inside a string.
uncommented() { sed -e 's/^[[:space:]]*#.*$//' -e "/['\"]/! s/[[:space:]]#.*$//" "$1"; }
# Telemetry only, so every spawn and kill idiom is out, aliases and short forms
# included: PowerShell needs neither the System. prefix nor a space after '&'.
forbidden_calls() {
uncommented "$1" | grep -nEi \
'Start-Process|Start-Job|Start-ThreadJob|(Start|Stop)-Service|Stop-Process|Invoke-Expression|Invoke-Item|Invoke-Command|Invoke-(Cim|Wmi)Method|Get-WmiObject|Register-Scheduled(Job|Task)|WScript\.Shell|taskkill|cmd\.exe|wmic|schtasks|Diagnostics\.Process|\.Kill\(|(^|[^-a-zA-Z0-9_])(iex|saps|spps|spjb|sajb|spsv|sasv|start|kill)([^-a-zA-Z0-9_]|$)|(^|[^-a-zA-Z0-9_])&[^-a-zA-Z0-9_=&]'
}
if hits=$(forbidden_calls "$wdscript"); then
fail "the watchdog names a process API: $hits"
fi
# One control per idiom: a denylist is worth only the entries that actually fire.
# shellcheck disable=SC2016 # PowerShell source, not this shell's expansions
for probe in 'Start-Process notepad.exe' 'Stop-Process -Id 4 -Force' \
'[Diagnostics.Process]::Start("x")' '(Get-Process -Name httrack).Kill()' \
'Invoke-Expression $cmd' '& "C:\\x.exe" -y' 'Start-Job { 1 }' 'taskkill /F /IM x.exe' \
'iex $cmd' '$cmd | iex' '&"C:\\x.exe"' '&$exe' 'saps notepad.exe' 'start notepad.exe' \
'spps -Name httrack' 'spjb { 1 }' 'sajb { 1 }' 'kill -Name httrack' \
'Start-Service w32time' 'Stop-Service w32time' 'spsv w32time' 'sasv w32time' \
'Get-WmiObject Win32_Process' \
'Invoke-CimMethod -ClassName Win32_Process -MethodName Create' \
'Invoke-WmiMethod -Class Win32_Process -Name Create' \
'Invoke-Command -ScriptBlock { 1 }' 'Register-ScheduledJob -Name x -ScriptBlock { 1 }' \
'Register-ScheduledTask -TaskName x -Action $a' \
'(New-Object -ComObject WScript.Shell).Run("x")' \
'Write-Host "#795"; Start-Process x'; do
{
cat "$wdscript"
echo "$probe"
} >"$tmp/probe.ps1"
if ! forbidden_calls "$tmp/probe.ps1" >/dev/null; then
fail "the process-API audit cannot see: $probe"
fi
done
# shellcheck disable=SC2016 # $env: is PowerShell's, not this shell's
grep -q '\$env:WATCHDOG_TOKEN' "$wdscript" ||
fail "the watchdog does not read its token from the environment"
if sed -n '/^param(/,/^)/p' "$wdscript" | grep -qi 'token'; then
fail "the watchdog takes its token as a parameter, where the process list exposes it"
fi
grep -q "ApiBase = 'https://api.github.com'" "$wdscript" ||
fail "the watchdog's default endpoint is not the GitHub API"
# One state, so nothing downstream can read a verdict out of a status that only
# carries telemetry. The job's own conclusion is the verdict.
test "$(grep -c "state = 'success'" "$wdscript")" -eq 1 ||
fail "the watchdog posts something other than the one telemetry state"
# The token stays in the driver's own shell: exported, every test it runs would
# inherit a credential that can post statuses. 172 drives that end to end.
grep -q '^unset WATCHDOG_TOKEN$' "$driver" ||
fail "the driver leaves its token in the environment its tests inherit"
# A background holder of the step's stdout keeps the step open past the suite (#949).
grep -q '>>watchdog.log 2>&1' "$driver" ||
fail "the watchdog's output is not redirected off the step's stdout"
# Sourcing the driver is enough to start an interpreter that can post, so every
# test that does it has to scrub first.
scrubs() {
grep -q 'ci-windows-suite\.sh' "$1" || return 0
grep -qx 'unset WATCHDOG_TOKEN WATCHDOG_REPO WATCHDOG_SHA WATCHDOG_URL' "$1"
}
for t in "$testdir"/*.test; do
scrubs "$t" || fail "$(basename "$t") can reach the driver without scrubbing its credentials"
done
printf '#!/bin/sh\n. ./ci-windows-suite.sh\n' >"$tmp/unscrubbed.test"
scrubs "$tmp/unscrubbed.test" && fail "the scrub audit passes a test that does not scrub"
# PATH holds MSYS-style entries only, and TMPDIR on Windows is a D:/... path
# that would split it on the drive colon.
posixtmp=$tmp
if is_windows && command -v cygpath >/dev/null 2>&1; then
posixtmp=$(cygpath -u "$tmp")
fi
bin="$posixtmp/bin"
mkdir -p "$bin"
# Renamed into place, so the poll below cannot read a half-written file.
printf '#!/bin/sh\nprintf "%%s\\n" "$@" >%s/argv.part\nmv %s/argv.part %s/argv\necho "watchdog ready"\n' \
"$posixtmp" "$posixtmp" "$posixtmp" >"$bin/argvstub"
printf '#!/bin/sh\nexit 127\n' >"$bin/deadstub"
chmod +x "$bin/argvstub" "$bin/deadstub"
usestub() { cp "$bin/$1" "$bin/pwsh" && cp "$bin/$1" "$bin/powershell.exe"; }
usestub argvstub
if test -x "$bin/pwsh"; then
(
cd "$posixtmp"
# shellcheck disable=SC2030 # local to this subshell is the point
PATH="$bin:$PATH"
export PATH
# An unshadowed pwsh would drive the real watchdog, and every assertion
# below would still read as a plain launch failure.
test "$(command -v pwsh)" = "$bin/pwsh" ||
fail "the stub does not shadow PowerShell: $(command -v pwsh)"
# shellcheck source=tests/ci-windows-suite.sh
. "$driver"
ci_watchdog_pid=''
ci_start_native_watchdog "$posixtmp/progress.log" ||
fail "the launch reported no watchdog with a PowerShell on PATH"
test -n "$ci_watchdog_pid" || fail "the launch set no pid"
# wait only reaps a child, which is what the teardown has to signal.
wait "$ci_watchdog_pid" || fail "the watchdog is not a child of the driver"
# A pid comes from the fork, so a dead interpreter still yields one.
usestub deadstub
ci_watchdog_pid='dangling'
rc=0
ci_start_native_watchdog "$posixtmp/progress.log" || rc=$?
test "$rc" -ne 0 || fail "an interpreter exiting 127 was reported as launched"
test -z "$ci_watchdog_pid" || fail "a failed launch left pid $ci_watchdog_pid behind"
)
for _ in $(seq 1 100); do
test -r "$posixtmp/argv" && break
sleep 0.1
done
test -r "$posixtmp/argv" || fail "the watchdog was never launched"
grep -qx -- '-SelfTest' "$posixtmp/argv" && fail "the production launch runs the self-test"
# Bypass is load-bearing: Windows PowerShell refuses an unsigned .ps1 without it.
for flag in -NoProfile -NonInteractive -ExecutionPolicy Bypass -File; do
grep -qx -- "$flag" "$posixtmp/argv" ||
fail "the launch dropped $flag: $(tr '\n' ' ' <"$posixtmp/argv")"
done
grep -qx -- '-ApiBase' "$posixtmp/argv" &&
fail "the production launch redirects the status API away from GitHub"
grep -qx -- '-NoPost' "$posixtmp/argv" &&
fail "the production launch cannot post at all"
# Native form, because a non-MSYS interpreter cannot resolve a /d/a/... path.
grep -qx -- "$(nativepath "$wdscript")" "$posixtmp/argv" ||
fail "the launch did not name the script: $(tr '\n' ' ' <"$posixtmp/argv")"
grep -qx -- "$(nativepath "$posixtmp/progress.log")" "$posixtmp/argv" ||
fail "the launch did not pass the progress log"
else
echo "note: $tmp is noexec, the driver wiring was not driven"
fi
py=$(find_python || true)
if ! test -r "$workflow"; then
test -d "$top/.github" && fail "$workflow is gone but $top/.github is not"
echo "note: no .github under $top (dist tarball), the workflow audits did not run"
elif test -z "$py" || ! "$py" -c 'import yaml' 2>/dev/null; then
echo "note: no python3 with PyYAML here, the workflow audits did not run"
else
# Parsed, not grepped: every key below has to be read from the block that
# owns it, and an ordinary reformatting of the workflow must not move it.
cat >"$tmp/wf.py" <<'PY'
import sys
import yaml
def steps_of(wf):
return [s for j in (wf.get("jobs") or {}).values() for s in (j.get("steps") or [])]
def checkouts(wf):
return [s for s in steps_of(wf) if str(s.get("uses", "")).startswith("actions/checkout")]
def suite_steps(wf):
return [s for s in steps_of(wf) if "ci-windows-suite.sh" in str(s.get("run", ""))]
def suite_jobs(wf):
return [
j
for j in (wf.get("jobs") or {}).values()
if any("ci-windows-suite.sh" in str(s.get("run", "")) for s in (j.get("steps") or []))
]
# A job-level block replaces the workflow's outright, so one naming only contents
# silently drops statuses: write.
def perms_of(wf, job):
p = job.get("permissions", wf.get("permissions"))
if p in ("read-all", "write-all"):
w = "read" if p == "read-all" else "write"
return {"contents": w, "statuses": w}
return p if isinstance(p, dict) else {}
WANT_ENV = {
"WATCHDOG_TOKEN": "${{ secrets.GITHUB_TOKEN }}",
"WATCHDOG_REPO": "${{ github.repository }}",
# The merge commit, which no PR checks UI reads.
"WATCHDOG_SHA": "${{ github.sha }}",
}
def audit(wf):
bad = []
if not checkouts(wf):
bad.append("no actions/checkout step to audit")
for s in checkouts(wf):
if (s.get("with") or {}).get("persist-credentials") is not False:
bad.append("a checkout persists the job token for the whole job")
jobs, found = suite_jobs(wf), suite_steps(wf)
if len(jobs) != 1 or len(found) != 1:
bad.append("%d jobs and %d steps run the suite, cannot tell which is watched" % (len(jobs), len(found)))
return bad
perms = perms_of(wf, jobs[0])
if perms.get("statuses") != "write":
bad.append("the suite job cannot post a commit status: statuses is %r" % perms.get("statuses"))
if perms.get("contents") != "read":
bad.append("contents: read was dropped from the suite job")
env = found[0].get("env") or {}
for v, want in WANT_ENV.items():
if env.get(v) != want:
bad.append("%s is %r, want %r" % (v, env.get(v), want))
# Both axes, or the matrix legs overwrite each other's status.
for axis in ("matrix.platform", "matrix.configuration"):
if axis not in str(env.get("WATCHDOG_CONTEXT") or ""):
bad.append("WATCHDOG_CONTEXT does not name %s" % axis)
if "github.run_id" not in str(env.get("WATCHDOG_URL") or ""):
bad.append("WATCHDOG_URL does not link the run being watched")
cap = found[0].get("timeout-minutes")
if not isinstance(cap, int):
bad.append("the suite step has no timeout-minutes of its own")
else:
print("timeout=%d" % cap)
return bad
def mutate(wf, kind):
if kind == "perm":
wf["permissions"]["statuses"] = "read"
elif kind == "jobperm":
suite_jobs(wf)[0]["permissions"] = {"contents": "read"}
elif kind == "creds":
for s in checkouts(wf):
(s.get("with") or {}).pop("persist-credentials", None)
elif kind == "env":
suite_steps(wf)[0]["env"].pop("WATCHDOG_TOKEN")
elif kind == "token":
suite_steps(wf)[0]["env"]["WATCHDOG_TOKEN"] = "${{ secrets.WATCHDOG_PAT }}"
elif kind == "sha":
suite_steps(wf)[0]["env"]["WATCHDOG_SHA"] = "${{ github.event.pull_request.head.sha }}"
elif kind == "context":
suite_steps(wf)[0]["env"]["WATCHDOG_CONTEXT"] = "windows-suite"
elif kind == "url":
suite_steps(wf)[0]["env"]["WATCHDOG_URL"] = "${{ github.server_url }}"
elif kind == "cap":
suite_steps(wf)[0].pop("timeout-minutes")
elif kind != "none":
raise SystemExit("unknown mutation " + kind)
return wf
wf = yaml.safe_load(open(sys.argv[2]))
if sys.argv[1] == "mutate":
yaml.safe_dump(mutate(wf, sys.argv[3]), sys.stdout)
else:
for b in audit(wf):
print("BAD " + b)
PY
said=$("$py" "$tmp/wf.py" audit "$workflow") || fail "the workflow audit did not run: $said"
grep -q '^BAD ' <<<"$said" && fail "$(grep '^BAD ' <<<"$said" | sed 's/^BAD //')"
cap=$(($(sed -n 's/^timeout=//p' <<<"$said") * 60))
# One control per key, or an audit that stopped looking at one would ride on
# another still firing. "none" round-trips the file through the parser, which
# rewrites quoting, order and flow style: no verdict may move.
for m in none:'' perm:'cannot post' jobperm:'cannot post' creds:'persists the job token' \
env:'WATCHDOG_TOKEN is' token:'WATCHDOG_TOKEN is' sha:'WATCHDOG_SHA is' \
context:'does not name matrix.platform' url:'does not link the run' \
cap:'no timeout-minutes of its own'; do
"$py" "$tmp/wf.py" mutate "$workflow" "${m%%:*}" >"$tmp/wf.yml"
said=$("$py" "$tmp/wf.py" audit "$tmp/wf.yml") ||
fail "the workflow audit did not run on the ${m%%:*} control: $said"
if test -z "${m#*:}"; then
grep -q '^BAD ' <<<"$said" && fail "a reformatted workflow fails the audit: $said"
else
grep -q "^BAD .*${m#*:}" <<<"$said" ||
fail "the ${m%%:*} control did not trip the audit: $said"
fi
done
# The heartbeat still owns the kill and must beat the step timeout.
stuck=$(sed -n 's/^stuck=\([0-9]*\)$/\1/p' "$driver")
deadline=$(sed -n 's/^suite_deadline=\([0-9]*\)$/\1/p' "$driver")
test -n "$stuck" || fail "cannot read the heartbeat's static window"
test -n "$deadline" || fail "cannot read the driver's suite deadline"
# A test may start one second under the deadline and never report again.
test $((deadline + stuck + 240)) -le "$cap" ||
fail "a kill at ${deadline}+${stuck}s leaves under 240s before the ${cap}s step timeout"
# The watchdog's own stop is its MaxSeconds, which must land inside the step.
# shellcheck disable=SC2016 # $MaxSeconds is PowerShell's
maxsecs=$(sed -n 's/^[[:space:]]*\[int\]\$MaxSeconds = \([0-9]*\).*$/\1/p' "$wdscript")
test -n "$maxsecs" || fail "cannot read the watchdog's own deadline"
test "$maxsecs" -le "$cap" || fail "the watchdog runs ${maxsecs}s past the ${cap}s step timeout"
# Bounded below too: the reporter has to outlive the heartbeat's own kill.
test "$maxsecs" -ge $((deadline + stuck)) ||
fail "the watchdog stops at ${maxsecs}s, before the kill at $((deadline + stuck))s"
fi
psruns=()
for c in pwsh powershell.exe powershell; do
command -v "$c" >/dev/null 2>&1 || continue
# powershell and powershell.exe are one interpreter under two names.
seen=0
for p in ${psruns[@]+"${psruns[@]}"}; do
test "${p%.exe}" != "${c%.exe}" || seen=1
done
test "$seen" -eq 1 || psruns+=("$c")
done
if test "${#psruns[@]}" -eq 0; then
echo "SKIP: no PowerShell here, the watchdog's own self-test cannot run"
exit 77
fi
# Bounded: a mutant that loops forever would otherwise be caught only by the step
# timeout this work exists to beat. -NoPost layers over the scrub above.
run_wd() {
local secs=$1
shift
# -NoPost last: everything after -File <script> belongs to the script.
run_with_timeout "$secs" "$psrun" "${psargs[@]}" "$@" -NoPost 2>&1
}
# The legs below grade what reached the API. Printing and posting are decoupled,
# so counting what the loop wrote can see neither the request rate nor the backoff.
sink="$tmp/sink.py"
cat >"$sink" <<'PY'
import http.server
import socketserver
import sys
codes = [int(c) for c in sys.argv[1].split(",")]
rec = sys.argv[2]
seen = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
with open(rec, "ab") as f:
f.write(self.rfile.read(n) + b"\n")
seen.append(1)
self.send_response(codes[(len(seen) - 1) % len(codes)])
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, *args):
pass
class Sink(http.server.HTTPServer):
# Skip stock server_bind()'s getfqdn(): it stalls 35s on macOS (#870).
def server_bind(self):
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]
srv = Sink(("127.0.0.1", 0), Handler)
# LF, since discover_server_port parses this line: Windows would emit \r\n.
sys.stdout.reconfigure(newline="\n")
print("PORT %d" % srv.server_port, flush=True)
srv.serve_forever()
PY
posts="$tmp/posts"
sinklog="$tmp/sink.log"
legout="$tmp/leg.out"
port='' sinkpid=''
sink_start() {
: >"$posts"
: >"$sinklog"
# Its own log: a leg's command substitution would else wait on the pipe the
# sink inherited for as long as the sink lives.
"$py" "$sink" "$1" "$posts" >"$sinklog" 2>&1 &
sinkpid=$!
# Where the teardown can find it: sink_start runs in a subshell of its own.
echo "$sinkpid" >"$sinkpidfile"
port=$(discover_server_port "$sinklog" "$sinkpid") ||
fail "the status sink never bound a port: $(cat "$sinklog")"
}
sink_stop() {
stop_server "$sinkpid"
: >"$sinkpidfile"
sinkpid=''
}
# Ours, so a leg that lost its -ApiBase reaches GitHub with a credential it
# refuses. Distinctive, since the legs grade the log on never carrying it.
export WATCHDOG_TOKEN=n0tat0ken WATCHDOG_REPO=octo/nowhere WATCHDOG_SHA=0000000
# The log rides into the job's artifact, which nothing secret-masks.
token_free() { ! grep -q "$WATCHDOG_TOKEN" "$1"; }
run_posting() {
local secs=$1
shift
run_with_timeout "$secs" "$psrun" "${psargs[@]}" "$@" -ApiBase "http://127.0.0.1:$port"
}
# t and q of each posted status, in order and whatever key order the JSON took.
posted_pairs() {
sed -n 's/.*t=\([0-9]*\)s q=\([0-9?]*\)s.*/\1 \2/p' "$posts"
}
# One property per leg, each returning its diagnosis so a mutant can be required
# to trip that same check rather than merely to fail.
cadence_leg() {
local n
sink_start 201
printf 'RUN 36_local-bigcrawl.test at 41s\n' >"$tmp/progress.log"
run_posting 60 -File "$1" -ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 100 -PollSeconds 1 -MaxSeconds 4 >"$legout" 2>&1 || {
sink_stop
echo "the loop exited nonzero: $(cat "$legout")"
return 1
}
sink_stop
n=$(grep -c . "$posts" || true)
test "$n" -eq 1 || {
echo "$n API calls at a 100s cadence over 4s, want 1"
return 1
}
grep -q '36_local-bigcrawl.test' "$posts" || {
echo "the posted status does not name the test in flight: $(cat "$posts")"
return 1
}
case $(posted_pairs) in
*[0-9]' '[0-9]*) ;;
*)
echo "a readable log posted no staticness: $(posted_pairs)"
return 1
;;
esac
}
# Staticness has to follow the log through movement and then through silence,
# which is why this leg is the long one.
staticness_leg() {
local moved dq dt legpid stop
sink_start 201
printf 'RUN 36_local-bigcrawl.test at 0s\n' >"$tmp/progress.log"
run_posting 90 -File "$1" -ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 1 -PollSeconds 1 -MaxSeconds 16 >"$legout" 2>&1 &
legpid=$!
# Started on the first status that lands rather than on this shell's clock:
# an interpreter three seconds into its own startup would see silence only,
# and the moving half would go untested.
for _ in $(seq 1 300); do
test -s "$posts" && break
sleep 0.1
done
stop=$((SECONDS + 3))
while test "$SECONDS" -lt "$stop"; do
printf 'RUN 36_local-bigcrawl.test at %ss\n' "$SECONDS" >>"$tmp/progress.log"
sleep 0.4
done
wait "$legpid" || {
sink_stop
echo "the loop exited nonzero: $(cat "$legout")"
return 1
}
sink_stop
moved=$(posted_pairs | awk '$2 ~ /^[0-9]+$/ && $1 >= 2 && $2 <= 1 { n++ } END { print n + 0 }')
test "$moved" -ge 1 || {
echo "no post read the log as moving while it was being written: $(posted_pairs | tr '\n' '/')"
return 1
}
# Once the log stops, staticness is elapsed time minus a constant, so it has
# to track it one for one. A bound on it would report every wedge as the cap.
dq=$(posted_pairs | awk '$1 >= 6 && $2 ~ /^[0-9]+$/ { if (f == "") f = $2; l = $2 } END { print l - f }')
dt=$(posted_pairs | awk '$1 >= 6 && $2 ~ /^[0-9]+$/ { if (f == "") f = $1; l = $1 } END { print l - f }')
if test "${dq:-0}" -ne "${dt:-0}" || test "${dq:-0}" -lt 4; then
echo "staticness moved ${dq}s over ${dt}s of silence: $(posted_pairs | tr '\n' '/')"
return 1
fi
}
# Every due tick logs its line before the throttle decides, so these two counts
# are a ratio no slow box can move: one to one while nothing is being skipped.
throttle_leg() {
local calls lines
sink_start 201
printf 'RUN 36_local-bigcrawl.test at 41s\n' >"$tmp/progress.log"
run_posting 60 -File "$1" -ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 1 -PollSeconds 1 -MaxSeconds 8 >"$legout" 2>&1 || {
sink_stop
echo "the loop exited nonzero: $(cat "$legout")"
return 1
}
sink_stop
calls=$(grep -c . "$posts" || true)
lines=$(grep -c 't=[0-9]*s q=' "$legout" || true)
test "$lines" -ge 3 || {
echo "$lines due ticks over 8s at a 1s interval: too few to judge a throttle"
return 1
}
test $((calls * 4)) -ge $((lines * 3)) || {
echo "$calls of $lines due ticks reached an accepting API: a landed post throttles the next"
return 1
}
}
backoff_leg() {
local calls lines
sink_start 403
printf 'RUN 36_local-bigcrawl.test at 41s\n' >"$tmp/progress.log"
run_posting 60 -File "$1" -ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 1 -PollSeconds 1 -MaxSeconds 8 >"$legout" 2>&1 || {
sink_stop
echo "the loop exited nonzero: $(cat "$legout")"
return 1
}
sink_stop
calls=$(grep -c . "$posts" || true)
lines=$(grep -c 't=[0-9]*s q=' "$legout" || true)
test "$calls" -ge 2 || {
echo "a rejecting API was called $calls times: the loop stopped trying"
return 1
}
# One diagnosis for both: which one trips first depends on box speed. The
# backoff must thin the calls, not the log; a token-less run leaves only the log.
if test "$calls" -gt 5 || test "$lines" -lt $((2 * calls)); then
echo "$calls calls and $lines log lines against an API rejecting every one: nothing backs off"
return 1
fi
}
fullstdout_leg() {
local n
sink_start 201
printf 'RUN 36_local-bigcrawl.test at 41s\n' >"$tmp/progress.log"
run_posting 60 -File "$1" -ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 1 -PollSeconds 1 -MaxSeconds 5 >/dev/full 2>&1 || true
sink_stop
n=$(grep -c . "$posts" || true)
test "$n" -ge 2 || {
echo "$n posts with a full stdout: a failed log write took the loop with it"
return 1
}
}
first=1
for psrun in "${psruns[@]}"; do
psargs=(-NoProfile -NonInteractive)
case $psrun in
powershell*) psargs+=(-ExecutionPolicy Bypass) ;;
esac
out=$(run_wd 120 -File "$(nativepath "$wdscript")" -SelfTest) ||
fail "the $psrun self-test failed: $out"
grep -q 'watchdog self-test OK' <<<"$out" || fail "the $psrun self-test said: $out"
# A mutant the script survives is a property nothing is checking. Graded on
# the assertion named: one that merely fails to parse would read as caught.
mutate() {
local name=$1 expr=$2 want=$3 m="$tmp/$1.ps1" rc=0 said
sed "$expr" "$wdscript" >"$m"
cmp -s "$wdscript" "$m" && fail "mutant $name changed nothing, so it proves nothing"
said=$(run_wd 120 -File "$(nativepath "$m")" -SelfTest) || rc=$?
test "$rc" -ne 0 || fail "mutant $name passed the $psrun self-test: $said"
grep -q "self-test FAIL: .*$want" <<<"$said" ||
fail "mutant $name tripped no assertion of its own: $said"
}
# shellcheck disable=SC2016 # PowerShell variables, quoted for sed
mutate cadence-off-by-one 's/-ge \$Interval/-gt \$Interval/' 'due exactly on the interval'
# shellcheck disable=SC2016
mutate backoff-uncapped 's/\[Math\]::Min(\$Current \* 2, 32)/$Current * 2/' 'backoff is not capped'
# shellcheck disable=SC2016
mutate throttle-no-reset 's/if (\$Ok) { return @(0, 0) }/if ($Ok) { return @(0, $Backoff) }/' \
'landed post leaves'
# shellcheck disable=SC2016
mutate status-unclipped '/if (\$s.Length -gt 140)/d' 'was not clipped'
# An oversized status is cut from the tail: the head is what a wedge is read for.
# shellcheck disable=SC2016
mutate status-clipped-head 's/\$s = \$s.Substring(0, 140)/$s = $s.Substring($s.Length - 140)/' \
'dropped the leading fields'
# shellcheck disable=SC2016
mutate tail-always-ok 's/\$r = @{ Ok = \$false;/$r = @{ Ok = $true;/' 'reads as one that was read'
mutate counters-empty "s/return (\$f -join ' ')/return ''/" 'counters are not key=value'
# The production cadence: no leg below runs without a schedule of its own.
# shellcheck disable=SC2016
mutate default-cadence 's/\[int\]\$IntervalSeconds = 30/[int]$IntervalSeconds = 3000/' \
'default status cadence'
# shellcheck disable=SC2016
mutate default-poll 's/\[int\]\$PollSeconds = 5/[int]$PollSeconds = 50/' 'default poll'
# Timed and counted by this shell, never read out of what the watchdog
# writes: the cadence and the deadline are both mine to set.
printf 'RUN 36_local-bigcrawl.test at 41s\n' >"$tmp/progress.log"
out=$(run_wd 60 -File "$(nativepath "$wdscript")" \
-ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 100 -PollSeconds 1 -MaxSeconds 5) ||
fail "the $psrun slow-cadence loop exited nonzero: $out"
slow=$(grep -c 't=[0-9]*s q=' <<<"$out" || true)
test "$slow" -eq 1 || fail "$slow status lines at a 100s cadence over 5s, want 1: $out"
began=$SECONDS
out=$(run_wd 60 -File "$(nativepath "$wdscript")" \
-ProgressLog "$(nativepath "$tmp/progress.log")" \
-IntervalSeconds 1 -PollSeconds 1 -MaxSeconds 5) ||
fail "the $psrun loop exited nonzero: $out"
spent=$((SECONDS - began))
test "$spent" -ge 4 || fail "a 5s watchdog returned after ${spent}s: MaxSeconds is not seconds"
test "$spent" -le 20 || fail "a 5s watchdog ran ${spent}s: it does not stop on its own"
fast=$(grep -c 't=[0-9]*s q=' <<<"$out" || true)
# Against the slow leg, not a floor: a probe tick is not instant.
test "$fast" -gt "$slow" || fail "$fast status lines at 1s against $slow at 100s: $out"
grep -q '36_local-bigcrawl.test' <<<"$out" || fail "the status does not name the test in flight: $out"
grep -q '95_local-sitemap' <<<"$out" && fail "the status named a test that was never in flight"
# Trips if the scrub and -NoPost were both lost: only a real attempt logs this.
grep -q 'status post failed' <<<"$out" && fail "a test leg reached the status API"
# An unreadable log must not read as a wedged one.
out=$(run_wd 60 -File "$(nativepath "$wdscript")" \
-ProgressLog "$(nativepath "$tmp/no-such.log")" \
-IntervalSeconds 1 -PollSeconds 1 -MaxSeconds 3) ||
fail "the $psrun loop exited nonzero on a missing log: $out"
grep -q 'q=?s' <<<"$out" || fail "a missing log reported a staticness: $out"
# The legs below read the sink, not the loop, and they exercise the script's
# decisions rather than the interpreter's: one flavour is enough.
test "$first" -eq 1 || continue
first=0
if test -z "$py"; then
echo "note: no python3 here, the posting legs did not run"
continue
fi
# ENOSPC on stdout, a state the sick runner reaches. Measured, since the leg
# rests on it: MSYS hands a native PowerShell a /dev/full it can write to.
devfull=0
# shellcheck disable=SC2016 # PowerShell source, not this shell's expansions
if test -w /dev/full &&
! run_with_timeout 60 "$psrun" "${psargs[@]}" \
-Command '$ErrorActionPreference = "Stop"; Write-Host ("x" * 200000)' \
>/dev/full 2>/dev/null; then
devfull=1
fi
legs=(cadence throttle staticness backoff)
test "$devfull" -eq 0 || legs+=(fullstdout)
for leg in "${legs[@]}"; do
: >"$legout"
said=$("${leg}_leg" "$(nativepath "$wdscript")") ||
fail "the $psrun watchdog fails the $leg leg: $said"
token_free "$legout" || fail "the $leg leg logged the status token"
done
# Control: a catch block widened to the whole exception is how it would get out.
leaky="$tmp/token-in-log.ps1"
# shellcheck disable=SC2016 # PowerShell variables, quoted for sed
sed '/status post failed/s/$_.Exception.Message/$script:Token/' "$wdscript" >"$leaky"
cmp -s "$wdscript" "$leaky" && fail "the leaky mutant changed nothing, so it proves nothing"
backoff_leg "$(nativepath "$leaky")" >/dev/null || true
token_free "$legout" && fail "the token audit cannot see a logged token"
# Same shape as mutate() above, graded by a leg instead of the self-test, and
# on the diagnosis rather than the exit status for the same reason.
mutate_leg() {
local name=$1 expr=$2 leg=$3 want=$4 m="$tmp/$1.ps1" said
sed "$expr" "$wdscript" >"$m"
cmp -s "$wdscript" "$m" && fail "mutant $name changed nothing, so it proves nothing"
said=$("${leg}_leg" "$(nativepath "$m")") &&
fail "mutant $name passed the $leg leg"
grep -q "$want" <<<"$said" || fail "mutant $name tripped no check of its own: $said"
}
mutate_leg status-every-poll \
"s/if ((Get-WatchdogAction \$now \$postedAt \$IntervalSeconds) -eq 'post')/if (\$true)/" \
cadence 'API calls at a 100s cadence'
# shellcheck disable=SC2016 # PowerShell variables, quoted for sed
mutate_leg static-unknown 's/\$static = \$now - \$movedAt/$static = -1/' \
cadence 'posted no staticness'
# shellcheck disable=SC2016
mutate_leg static-elapsed 's/\$static = \$now - \$movedAt/$static = $now/' \
staticness 'read the log as moving'
# shellcheck disable=SC2016
mutate_leg static-capped 's/if (\$tail.Ok) { \$static = \$now - \$movedAt }/if ($tail.Ok) { $static = [Math]::Min($now - $movedAt, 5) }/' \
staticness 'staticness moved'
# shellcheck disable=SC2016
mutate_leg skip-when-none-owed 's/if (\$skip -gt 0)/if ($skip -ge 0)/' \
throttle 'a landed post throttles the next'
# shellcheck disable=SC2016
mutate_leg success-skips 's/if (\$Ok) { return @(0, 0) }/if ($Ok) { return @(1, 0) }/' \
throttle 'a landed post throttles the next'
# shellcheck disable=SC2016
mutate_leg backoff-never-skips 's/if (\$skip -gt 0)/if ($false)/' backoff 'nothing backs off'
if test "$devfull" -eq 1; then
mutate_leg log-write-fatal 's/try { \(Write-Host .*\) } catch { }/\1/' \
fullstdout 'took the loop with it'
fi
done
echo "native watchdog OK (${psruns[*]})"

View File

@@ -0,0 +1,151 @@
#!/bin/bash
#
# testlib's polling helpers: the deadline tick every watchdog turns on, and the
# wait for a fixture server's port line. Both used to fork a process per tick,
# which is what the whole harness is built out of (#795).
set -eu
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmp=$(mktemp -d "${TMPDIR:-/tmp}/httrack_poll.XXXXXX") || exit 1
# A colon-free root for the PATH shim below: MSYS hands out a drive-letter TMPDIR
# and a PATH entry carrying a colon is read as two, which left every count at zero.
shim=$(mktemp -d /tmp/httrack_pollshim.XXXXXX 2>/dev/null) || shim="$tmp/count"
trap 'set +e; rm -rf "$tmp" "$shim"' EXIT
trap 'rm -rf "$tmp" "$shim"' HUP INT QUIT PIPE TERM
# --- the tick waits, and costs no process where the fd timer works -----------
count_sleeps() { # count_sleeps <dir> <log>: a sleep shim recording each call
mkdir -p "$1"
printf '#!/bin/sh\necho tick >>"%s"\nexec %s "$@"\n' "$2" "$(command -v sleep)" >"$1/sleep"
chmod +x "$1/sleep"
PATH="$1:$PATH"
export PATH
# Proved, not assumed: an unreachable shim counts zero sleeps, which reads as
# "nothing forked" and would pass the very assertions it silently disarmed.
: >"$2"
sleep 0
test -s "$2" || fail "the sleep shim is not on PATH (entry '$1')"
}
ticks() { # ticks <label> <secs> <count> <floor>: polls, counting the sleeps forked
local began=$SECONDS
local i=0
: >"$tmp/sleeps"
while test "$i" -lt "$3"; do
poll_wait "$2"
i=$((i + 1))
done
# Floored at the whole wait asked for, so a tick running at half speed fails
# too, not only one that returns at once and spins every deadline loop.
test "$((SECONDS - began))" -ge "$4" || fail "$1: $3 polls of ${2}s took under ${4}s"
}
sleeps() { wc -l <"$tmp/sleeps" | tr -d ' '; }
# Probed, not assumed: a host that cannot make a fifo -- and Windows, which opts
# out -- is entitled to the forked tick, but one that can and took it anyway has
# lost the whole point. The override counts as opting out too, or the escape hatch
# for a host whose fd tick misbehaves would red the suite it exists to rescue.
canfifo=
if test -z "${HTTRACK_POLL_SLEEP:-}" && ! is_windows && mkfifo "$tmp/probe" 2>/dev/null; then
canfifo=1
fi
(
count_sleeps "$shim" "$tmp/sleeps"
poll_wait 0.1 # settles POLL_STATE before the count
echo "poll tick: $POLL_STATE"
ticks "$POLL_STATE tick" 0.1 30 3
test -z "$canfifo" || test "$POLL_STATE" = fifo ||
fail "the tick fell back to sleep on a host with working fifos"
if test "$POLL_STATE" = fifo; then
if test "${BASH_VERSINFO[0]}" -ge 4; then
test "$(sleeps)" -eq 0 || fail "the fd tick forked $(sleeps) sleeps"
else
# No fractional read -t below bash 4, so the documented fallback is the sleep asked for.
test "$(sleeps)" -eq 30 || fail "bash ${BASH_VERSINFO[0]} forked $(sleeps) of 30 fractional ticks"
fi
# The whole-second tick every watchdog runs on must take the fd on every bash:
# where a fraction cannot, it is the only win left. Sized for ~3s either way,
# bash 4 shortening its own wait to 0.1s.
if test "${BASH_VERSINFO[0]}" -ge 4; then n=30; else n=3; fi
ticks "fd 1s tick" 1 "$n" 3
test "$(sleeps)" -eq 0 || fail "the 1s fd tick forked $(sleeps) sleeps"
fi
# Forced back to sleep(1), which is the only tick a shimmed sleep can reach.
# POLL_STATE is left as it settled: a run already on the fd must give it up
# when a test starves the poll mid-run, so the override is read per tick.
export HTTRACK_POLL_SLEEP=1
ticks "forced sleep tick" 0.1 30 3
test "$(sleeps)" -eq 30 || fail "HTTRACK_POLL_SLEEP did not fork a sleep per tick"
# A timer fd that stops timing out must fall back, not spin. Whole seconds: a
# fraction never reaches the fd below bash 4.
unset HTTRACK_POLL_SLEEP # the fd is what is under test here, not the override
POLL_STATE=fifo
exec 9<&-
: >"$tmp/sleeps"
poll_wait 1 2>/dev/null
test -s "$tmp/sleeps" || fail "a dead timer fd did not fall back to sleep"
test "$POLL_STATE" = forked || fail "a dead timer fd was kept ($POLL_STATE)"
)
# --- the port wait outlasts a slow announcement ------------------------------
# A cold Python start lags seconds on a loaded runner, so falling through early
# would fail every crawl test on exactly the hosts the wait exists for.
log="$tmp/late.log"
: >"$log"
(
sleep 2
echo "warning: something merged from stderr"
echo "PORT 40001"
sleep 30
) >>"$log" 2>&1 &
writer=$!
began=$SECONDS
port=$(discover_server_port "$log" "$writer") || fail "the late port line was missed"
test "$port" = 40001 || fail "read port '$port', want 40001"
test "$((SECONDS - began))" -ge 2 || fail "the port arrived before it was written"
kill "$writer" 2>/dev/null || true
wait "$writer" 2>/dev/null || true
# --- a half-written line is not a port ---------------------------------------
# The announcement is one write in practice, but reading it in two would hand the
# crawl a truncated port number, which resolves to a different server or none.
log="$tmp/split.log"
printf 'PORT 40' >"$log"
(
sleep 2
printf '002\n'
sleep 30
) >>"$log" 2>&1 &
writer=$!
port=$(discover_server_port "$log" "$writer") || fail "the completed port line was missed"
test "$port" = 40002 || fail "read port '$port' from a split line, want 40002"
kill "$writer" 2>/dev/null || true
wait "$writer" 2>/dev/null || true
# --- a server that dies without announcing is reported, not waited out -------
log="$tmp/dead.log"
echo "Traceback: address already in use" >"$log"
(exit 1) &
writer=$!
wait "$writer" 2>/dev/null || true
began=$SECONDS
rc=0
err=$(discover_server_port "$log" "$writer" 2>&1 >/dev/null) || rc=$?
test "$rc" -ne 0 || fail "a dead server yielded a port"
test "$((SECONDS - began))" -lt 20 || fail "a dead server was waited out ($((SECONDS - began))s)"
case "$err" in
*"exited early"*) ;;
*) fail "a dead server reported '$err'" ;;
esac
# 2, not 1: only the deadline is the announce race 72 and 105 skip on, so one
# status for both had local-crawl.sh skipping a crashed server instead of failing.
test "$rc" -eq 2 || fail "a dead server exited $rc, want 2"
echo "poll helpers OK"

View File

@@ -0,0 +1,46 @@
#!/bin/bash
#
# Issue #938: downscaling the vector master to 16 px antialiased every stem to
# grey. The small raster is drawn on the pixel grid, so it must stay flat.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
srcdir="${abs_top_srcdir:?not run under make check}"
div="${srcdir}/html/server/div"
python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77
fail() {
echo "FAIL: $*" >&2
exit 1
}
colors() {
"${python}" "${testdir}/png-colors.py" "${div}/$1/httrack.png" ||
fail "$1: cannot read the icon"
}
small=$(colors 16x16)
n=$(wc -l <<<"${small}")
test "${n}" -le 4 || fail "16x16 icon has ${n} colours, the stems are antialiased"
# Both flats present: an all-field tile and an all-ink one are flat too.
for flat in '#9999cc' '#040404'; do
grep -q " ${flat}\$" <<<"${small}" || fail "16x16 icon has no ${flat} pixel"
done
# A hairline and a near-solid block both satisfy every count above.
ink=$(sed -n "s/ #040404\$//p" <<<"${small}")
if test "${ink}" -lt 26 || test "${ink}" -gt 128; then
fail "16x16 icon inks ${ink} of 256 pixels"
fi
# Control: a counter blind to antialiasing would pass every assertion above.
big=$(wc -l <<<"$(colors 48x48)")
test "${big}" -gt 16 || fail "48x48 icon has ${big} colours, the counter is blind"
echo "16x16 icon: ${n} colours, ${ink}/256 ink; 48x48: ${big} colours"

View File

@@ -0,0 +1,94 @@
#!/bin/bash
#
# The credentials an URL carries must reach the control channel whole: a clip
# logged in as a different account (#1032). The engine test only ever probes a
# dead port, so nothing there reads what the login actually sends.
set -euo pipefail
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
# Starves the x64 CI runner until the whole suite step is lost; Win32 is fine (#1038).
if is_windows; then
echo "windows: wedges the x64 runner (#1038), skipping"
exit 77
fi
python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77
command -v httrack >/dev/null || {
echo "could not find httrack" >&2
exit 1
}
server=$(nativepath "${testdir}/ftp-server.py")
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_ftpuser.XXXXXX")
serverpid=
cleanup() {
stop_server "$serverpid"
rm -rf "$tmpdir"
}
trap 'set +e; cleanup' EXIT
trap cleanup HUP INT QUIT PIPE TERM
root="${tmpdir}/root"
out="${tmpdir}/crawl"
cmds="${tmpdir}/cmds"
mkdir -p "$root" "$out"
printf 'body\n' >"${root}/f.txt"
serverlog="${tmpdir}/server.out"
"$python" "$server" --root "$(nativepath "$root")" --require-pass \
--log "$(nativepath "$cmds")" >"$serverlog" 2>&1 &
serverpid=$!
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
fail() {
echo "FAIL: $*" >&2
exit 1
}
ok() { echo "OK: $*"; }
rep() { awk -v n="$2" -v c="$1" 'BEGIN { while (i++ < n) printf "%s", c }'; }
crawl() {
: >"$cmds"
rm -rf "${out:?}"
mkdir -p "$out"
run_with_timeout 60 httrack "ftp://$1@127.0.0.1:${port}/f.txt" -O "$out" \
--quiet --disable-security-limits --robots=0 --timeout=20 \
--max-time=45 --retries=1 -c1 >"${tmpdir}/log" 2>&1
}
# --- a plain login goes out unchanged ----------------------------------------
crawl "bob:secret" || fail "the plain-login crawl never finished"
sent=$(cat "$cmds")
grep -qxF "USER bob" <<<"$sent" || fail "no USER bob on the wire: ${sent}"
grep -qxF "PASS secret" <<<"$sent" || fail "no PASS secret on the wire: ${sent}"
cmp -s "${out}/127.0.0.1_${port}/f.txt" "${root}/f.txt" ||
fail "the login did not bring the body back; mirror holds $(find "$out" -type f)"
ok "bob:secret reaches the server verbatim, and mirrors"
# --- the longest userinfo that fits, byte for byte ---------------------------
# 255 of each: a 256th byte is refused, so this is what the wire must carry.
user=$(rep a 255)
pass=$(rep b 255)
crawl "${user}:${pass}" || fail "the 255-byte login crawl never finished"
sent=$(cat "$cmds")
grep -qxF "USER ${user}" <<<"$sent" ||
fail "the wire user is not the 255 bytes the URL named: $(grep -a '^USER' <<<"$sent")"
grep -qxF "PASS ${pass}" <<<"$sent" ||
fail "the wire pass is not the 255 bytes the URL named: $(grep -a '^PASS' <<<"$sent")"
ok "a 255-byte user and password reach the server unclipped"
# --- over-long userinfo never opens a session --------------------------------
# The bare name has no ':' to bound it, only the '@', and is the worse branch.
for u in "$(rep a 256):pw" "$(rep a 256)"; do
crawl "$u" || fail "the over-long crawl never finished"
grep -aq "FTP user name or password too long" "${out}/hts-log.txt" ||
fail "over-long userinfo was not refused: $(cat "${out}/hts-log.txt")"
sent=$(cat "$cmds")
test -z "$sent" || fail "the engine logged in under a clipped name: ${sent}"
done
ok "an over-long name is refused before anything reaches the control channel"

View File

@@ -12,7 +12,10 @@ testdir=$(cd "$(dirname "$0")" && pwd)
. "${testdir}/testlib.sh"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/httrack_wd.XXXXXX")
trap 'set +e; rm -rf "$tmp"' EXIT
# A colon-free root for the PATH shim below: MSYS hands out a drive-letter TMPDIR
# and a PATH entry carrying a colon is read as two, which leaves it unreachable.
shim=$(mktemp -d /tmp/httrack_wdshim.XXXXXX 2>/dev/null) || shim="$tmp/starve"
trap 'set +e; rm -rf "$tmp" "$shim"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
@@ -73,7 +76,10 @@ fi
# Wall clock, not iteration count: under starvation a poll costs more than it asks,
# so a counted deadline never fires before the CI step it was meant to beat.
(
starve_sleep "$tmp/starve" 4 || fail "could not install the slow sleep"
# The forked tick is the only one a shimmed sleep can reach: against the fd
# tick nothing here would be starved but the payload, and the case goes inert.
export HTTRACK_POLL_SLEEP=1
starve_sleep "$shim" 4 || fail "could not install the slow sleep"
# Control: starvation must not make the watchdog kill work that is inside its
# budget. The command outlives several polls, so a guard that fires on the
# first one cannot pass this by exiting before it is ever looked at.

View File

@@ -8,14 +8,15 @@ EXTRA_DIST = $(TESTS) renamefail.c threadattrfail.c nobacktrace.c altstackprobe.
proxy-https-server.py socks5-server.py proxy-connect-server.py \
proxytestlib.py tls-stall-server.py warc-validate.py wacz-validate.py \
header-injection-server.py header-injection-check.py \
pty-resize.py test-timeout.sh \
pty-resize.py test-timeout.sh png-colors.py \
local-crawl.sh local-server.py ftp-server.py testlib.sh \
ci-windows-suite.sh \
ci-windows-suite.sh ci-windows-watchdog.ps1 \
server.crt server.key \
server-root/simple/basic.html server-root/simple/link.html \
server-root/stripquery/index.html server-root/stripquery/a.html \
server-root/fraglink/index.html server-root/fraglink/target.html \
fixtures/cache-golden/hts-cache/new.zip
fixtures/cache-golden/hts-cache/new.zip \
install-manifest.txt
TESTS_ENVIRONMENT =
TESTS_ENVIRONMENT += PATH=$(top_builddir)/src$(PATH_SEPARATOR)$$PATH
@@ -34,6 +35,10 @@ TESTS_ENVIRONMENT += abs_top_builddir=$(abs_top_builddir)
TESTS_ENVIRONMENT += CONFIGURED_DATADIR=$(datadir)
TESTS_ENVIRONMENT += CONFIGURED_LIBDIR=$(libdir)
TESTS_ENVIRONMENT += CONFIGURED_BINDIR=$(bindir)
# 225_install-manifest.test folds these back out of the staged paths.
TESTS_ENVIRONMENT += CONFIGURED_MANDIR=$(mandir)
TESTS_ENVIRONMENT += CONFIGURED_DOCDIR=$(docdir)
TESTS_ENVIRONMENT += CONFIGURED_HTMLDIR=$(htmldir)
TESTS_ENVIRONMENT += ORIGIN_RPATH=$(ORIGIN_RPATH)
# 222_pkgconfig-consumer.test asserts the staged .pc against these, and both
# sides of the gate deciding whether it hands a consumer an rpath.

View File

@@ -29,6 +29,41 @@ ci_annotate() {
# schedule the workflow really passes off a virtual clock instead of waiting it out.
hb_now() { echo "$SECONDS"; }
# Start the off-box telemetry over the progress log $1, setting ci_watchdog_pid;
# return 1 with no PowerShell available. Forks nothing and kills nothing, so it
# reports where the heartbeat below cannot (#795).
ci_start_native_watchdog() {
local progress=$1 ps1 c exe='' waited=0
ps1="$testdir/ci-windows-watchdog.ps1"
test -r "$ps1" || return 1
for c in pwsh powershell.exe; do
if command -v "$c" >/dev/null 2>&1; then
exe=$c
break
fi
done
test -n "$exe" || return 1
# Never the step's stdout: a background holder of that pipe keeps the step
# open past the suite (#949), and tests/*.log reaches the artifact anyway.
: >watchdog.log
WATCHDOG_TOKEN="${ci_watchdog_token:-}" \
"$exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass \
-File "$(nativepath "$ps1")" \
-ProgressLog "$(nativepath "$progress")" \
>>watchdog.log 2>&1 &
ci_watchdog_pid=$!
# $! comes from the fork, not the exec: wait for it to actually speak.
while test "$waited" -lt 30; do
grep -q '^watchdog ready$' watchdog.log && return 0
kill -0 "$ci_watchdog_pid" 2>/dev/null || break
sleep 1
waited=$((waited + 1))
done
kill_pid "$ci_watchdog_pid"
ci_watchdog_pid=''
return 1
}
ci_suite_heartbeat() {
local quiet=$1 every=$2 progress=$3 stuck=$4 main=$5
local tick=$2 begin now line said moved last=''
@@ -48,6 +83,9 @@ ci_suite_heartbeat() {
# outlive the step's own timeout before being caught.
if test $((now - moved)) -ge "$stuck"; then
ci_annotate error "suite watchdog" "killing the step: $((now - moved))s without progress, in flight: $last"
# Ahead of the kill, which runs no EXIT trap: an orphan would outlive the
# step and overwrite its last status with a frozen tail.
test -z "${watchdog:-}" || kill_pid "$watchdog"
# Direct first: kill_tree may reap this watchdog before its own root (#953).
kill_pid "$main"
kill_tree "$main"
@@ -77,6 +115,11 @@ command -v httrack >/dev/null || {
exit 1
}
# Out of the environment before the first child forks below: an inherited token
# could post a commit status in the name of the run.
ci_watchdog_token=${WATCHDOG_TOKEN:-}
unset WATCHDOG_TOKEN
# httrack.exe is native, so MSYS rewrites any argument shaped like a
# POSIX path, and a URL path is shaped exactly like one: "/a/b.html"
# reached the engine as "C:/Program Files/Git/a/b.html". Switch that
@@ -119,9 +162,16 @@ export HTTRACK_PROGRESS_LOG="$PWD/$progress"
# its own terms keeps its log. Quiet past 16 min, clear of the 13 a healthy
# run measures here, and a kill 900s after the last progress line clears
# the longest legitimate gap, one $per_test.
ci_suite_heartbeat 960 360 "$progress" 900 $$ &
stuck=900
# Orthogonal to that heartbeat rather than a spare of it: the heartbeat needs
# 960s of quiet and 900s of static log, and every #795 death measured so far
# lands inside the first 750s of the step.
watchdog='' ci_watchdog_pid=''
ci_start_native_watchdog "$PWD/$progress" && watchdog=$ci_watchdog_pid
test -n "$watchdog" || echo "no off-box watchdog: no usable PowerShell"
ci_suite_heartbeat 960 360 "$progress" "$stuck" $$ &
heartbeat=$!
trap 'kill "$heartbeat" 2>/dev/null || true' EXIT
trap 'set +e; kill "$heartbeat" 2>/dev/null; test -z "$watchdog" || kill_pid "$watchdog"' EXIT
pass=0 fail=0 skip=0 failed="" skipped="" deadline=0
# label:pattern, globbed rather than enumerated so a new NNN_engine-*.test or
@@ -217,7 +267,8 @@ echo "ran=$((pass + fail + skip)) pass=$pass fail=$fail skip=$skip" |
# datadir-ospath copies the unwrapped binary the automake build leaves in .libs,
# and needs the loader variable libtool picked, neither of which this job has;
# link-control-bytes names its fixtures with the raw control bytes the requests
# decode back to, which NTFS refuses.
# decode back to, which NTFS refuses;
# ftp-userpass starves the x64 runner until the step is lost, Win32 passing (#1038).
expected_skips="01_engine-footer-overflow.test
100_local-purge-longpath.test
158_local-link-control-bytes.test
@@ -228,6 +279,7 @@ expected_skips="01_engine-footer-overflow.test
152_engine-string-oom.test
153_local-proxytrack-quiet.test
215_engine-datadir-ospath.test
230_local-ftp-userpass.test
48_local-crange-memresume.test
71_local-crange-repaircache.test
79_local-proxytrack-webdav-mime.test

View File

@@ -0,0 +1,255 @@
# Off-box telemetry for the Windows suite step (#795): it spawns and kills
# nothing, so a wedge cannot disarm it, and reports as a commit status, the only
# channel that outlives a dead runner. Every status carries the same state.
param(
[string]$ProgressLog = '',
[int]$IntervalSeconds = 30,
[int]$PollSeconds = 5,
# Cannot outlive the step, whatever the caller forgets to kill.
[int]$MaxSeconds = 2700,
# Seam for the suite's own test, which points it at a sink it can count.
[string]$ApiBase = 'https://api.github.com',
[switch]$NoPost,
[switch]$SelfTest
)
$ErrorActionPreference = 'Stop'
# Windows PowerShell renders a progress bar per web call otherwise.
$ProgressPreference = 'SilentlyContinue'
# --- decisions, kept pure so -SelfTest can drive them by return value ---------
function Get-WatchdogAction {
param([int]$Now, [int]$Posted, [int]$Interval)
if (($Now - $Posted) -ge $Interval) { return 'post' }
return 'wait'
}
# Posts to skip after a rejected one: a fork PR's token is read-only for the run.
function Get-NextBackoff {
param([int]$Current)
if ($Current -lt 1) { return 1 }
return [Math]::Min($Current * 2, 32)
}
# The (skip, backoff) pair after an attempted post, $Ok being whether it landed:
# a rejection that later resolves has to leave nothing behind.
function Get-NextThrottle {
param([bool]$Ok, [int]$Backoff)
if ($Ok) { return @(0, 0) }
$b = Get-NextBackoff $Backoff
return @($b, $b)
}
# GitHub truncates a description at 140 chars, so the counters take the cut, not
# the fields a wedge is read for. $Static below zero is unknown.
function Format-WatchdogStatus {
param([int]$Elapsed, [int]$Static, [string]$InFlight, [string]$Counters)
$q = '?'
if ($Static -ge 0) { $q = [string]$Static }
$t = ($InFlight -replace '\s+', ' ').Trim()
if ($t.Length -gt 46) { $t = $t.Substring(0, 46) }
$s = 't={0}s q={1}s {2} | {3}' -f $Elapsed, $q, $t, $Counters
if ($s.Length -gt 140) { $s = $s.Substring(0, 140) }
return $s
}
# --- probes ------------------------------------------------------------------
# One try/catch per counter: a probe that fails costs its own field, not the loop.
# In-process only. A CIM query is richer, but its connect to a wedged WMI service
# is unbounded, and would hang the one reporter still standing.
function Get-WatchdogCounters {
$f = New-Object System.Collections.ArrayList
try {
$ps = @(Get-Process)
[void]$f.Add('p={0}' -f $ps.Count)
[void]$f.Add('h={0}' -f (($ps | Measure-Object -Property Handles -Sum).Sum))
} catch { [void]$f.Add('p=? h=?') }
try {
$drive = New-Object System.IO.DriveInfo($env:SystemDrive + '\')
[void]$f.Add('d={0}' -f [int]($drive.AvailableFreeSpace / 1MB))
} catch { [void]$f.Add('d=?') }
return ($f -join ' ')
}
# Ok separates "nothing moved" from "could not read it", which would otherwise report
# a wedge for an unreadable file. Share flags: the driver appends as we read.
function Get-ProgressTail {
param([string]$Path)
$r = @{ Ok = $false; Signature = ''; Line = '' }
if (-not $Path) { return $r }
try {
$share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete
$fs = New-Object System.IO.FileStream($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $share)
try {
$sr = New-Object System.IO.StreamReader($fs)
$text = $sr.ReadToEnd()
} finally { $fs.Dispose() }
$lines = @($text -split "`r?`n" | Where-Object { $_ -ne '' })
if ($lines.Count -gt 0) { $r.Line = $lines[-1] }
$r.Signature = '{0}|{1}' -f $text.Length, $r.Line
$r.Ok = $true
} catch { }
return $r
}
function Write-WatchdogLog {
param([string]$Message)
# A full disk is a state the sick runner reaches, and a log line lost to it
# must not take the loop reporting off-box with it.
try { Write-Host ('[watchdog {0:HH:mm:ss}] {1}' -f (Get-Date), $Message) } catch { }
}
# --- reporting ---------------------------------------------------------------
$script:Repo = $env:WATCHDOG_REPO
$script:Sha = $env:WATCHDOG_SHA
$script:Token = $env:WATCHDOG_TOKEN
$script:TargetUrl = $env:WATCHDOG_URL
$script:Context = $env:WATCHDOG_CONTEXT
if (-not $script:Context) { $script:Context = 'windows-suite-watchdog' }
# One state always, so nothing downstream can read a verdict out of telemetry:
# GitHub records the job's conclusion already. -NoPost is the test's hard stop,
# whatever the environment holds.
function Send-WatchdogStatus {
param([string]$Description)
if ($NoPost -or $SelfTest) { return $false }
if (-not $script:Token -or -not $script:Repo -or -not $script:Sha) { return $false }
$body = @{ state = 'success'; context = $script:Context; description = $Description }
if ($script:TargetUrl) { $body['target_url'] = $script:TargetUrl }
try {
Invoke-RestMethod -Method Post -TimeoutSec 20 `
-Uri ('{0}/repos/{1}/statuses/{2}' -f $ApiBase.TrimEnd('/'), $script:Repo, $script:Sha) `
-UserAgent 'httrack-windows-suite-watchdog' `
-Headers @{
Authorization = ('Bearer {0}' -f $script:Token)
Accept = 'application/vnd.github+json'
} -ContentType 'application/json' -Body ($body | ConvertTo-Json -Compress) | Out-Null
return $true
} catch {
Write-WatchdogLog ('status post failed: {0}' -f $_.Exception.Message)
return $false
}
}
# --- self-test ----------------------------------------------------------------
function Invoke-WatchdogSelfTest {
$bad = New-Object System.Collections.ArrayList
function Assert-That($cond, $what) { if (-not $cond) { [void]$bad.Add($what) } }
Assert-That ((Get-WatchdogAction 30 0 30) -eq 'post') 'a post due exactly on the interval was skipped'
Assert-That ((Get-WatchdogAction 29 0 30) -eq 'wait') 'posted ahead of the interval'
Assert-That ((Get-WatchdogAction 5000 4990 30) -eq 'wait') 'posted off cadence'
Assert-That ((Get-NextBackoff 0) -eq 1) 'the first rejection does not back off'
Assert-That ((Get-NextBackoff 1) -eq 2) 'the backoff does not grow'
Assert-That ((Get-NextBackoff 32) -eq 32) 'the backoff is not capped'
$ok = Get-NextThrottle $true 8
Assert-That ($ok[0] -eq 0 -and $ok[1] -eq 0) 'a landed post leaves the run throttled'
$ko = Get-NextThrottle $false 0
Assert-That ($ko[0] -eq 1 -and $ko[1] -eq 1) 'a first rejection skips nothing'
$ko = Get-NextThrottle $false 4
Assert-That ($ko[0] -eq 8 -and $ko[1] -eq 8) 'a repeat rejection does not widen the gap'
$long = '43_local-update-truncate-with-a-very-long-name-indeed.test'
$line = Format-WatchdogStatus 812 41 $long 'p=118 h=41230 d=13210'
Assert-That ($line.Length -le 140) ('status description is {0} characters' -f $line.Length)
Assert-That ($line -like 't=812s q=41s 43_local-update-truncate*') ('status leads with the wrong fields: {0}' -f $line)
Assert-That ($line -like '*d=13210') 'the counters did not survive a long test name'
# -match, not -like: '?' is a wildcard there, so q=0s would satisfy it too.
Assert-That ((Format-WatchdogStatus 8 -1 'x' 'y') -match '^t=8s q=\?s x \| y$') 'an unknown staticness reads as a number'
$clip = Format-WatchdogStatus 1 2 ('x' * 80) 'c'
Assert-That ($clip -match '^t=1s q=2s x{46} \| c$') ('the in-flight name was not clipped to 46: {0}' -f $clip)
$wide = Format-WatchdogStatus 1 2 ('x' * 300) ('y' * 300)
Assert-That ($wide.Length -le 140) ('an oversized status was not clipped: {0}' -f $wide.Length)
# Cut from the tail: the head carries the fields a wedge is read for.
Assert-That ($wide -like 't=1s q=2s x*') ('clipping dropped the leading fields: {0}' -f $wide)
$gone = Get-ProgressTail -Path ('no-such-progress-log-{0}.tmp' -f $PID)
Assert-That (-not $gone.Ok) 'an unreadable log reads as one that was read'
$f = New-Object System.IO.FileInfo([System.IO.Path]::GetTempFileName())
try {
[System.IO.File]::WriteAllText($f.FullName, "first`nRUN 42_probe.test at 7s`n")
$tail = Get-ProgressTail -Path $f.FullName
Assert-That ($tail.Ok) 'a readable log reads as unreadable'
Assert-That ($tail.Line -eq 'RUN 42_probe.test at 7s') ('the tail is not the last line: {0}' -f $tail.Line)
} finally { [System.IO.File]::Delete($f.FullName) }
# Space-separated key=value: the counters share the 140-char description with
# the fields a wedge is read for, and '?' from a failed probe is a value.
$c = Get-WatchdogCounters
Assert-That ($c -match '^[a-z]+=\S+( [a-z]+=\S+)*$') ('the counters are not key=value pairs: {0}' -f $c)
foreach ($k in 'p', 'h', 'd') {
Assert-That ($c -match ('(^| ){0}=' -f $k)) ('the counters dropped {0}=: {1}' -f $k, $c)
}
Assert-That ($c.Length -le 60) ('the counters take {0} of the 140 characters' -f $c.Length)
# Nothing else reads these: every other leg passes its own schedule.
Assert-That ($IntervalSeconds -eq 30) ('the default status cadence is {0}s' -f $IntervalSeconds)
Assert-That ($PollSeconds -eq 5) ('the default poll is {0}s' -f $PollSeconds)
Assert-That (-not (Send-WatchdogStatus 'self-test')) 'the self-test can reach the API'
if ($bad.Count -gt 0) {
foreach ($b in $bad) { Write-Host ('self-test FAIL: {0}' -f $b) }
exit 1
}
Write-Host 'watchdog self-test OK'
exit 0
}
if ($SelfTest) { Invoke-WatchdogSelfTest }
# --- main loop ----------------------------------------------------------------
# Windows PowerShell still defaults below TLS 1.2, which api.github.com refuses.
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$lastSig = ''
$movedAt = 0
# Negative, so the first tick posts: an early status is itself a datum.
$postedAt = -$IntervalSeconds
$backoff = 0
$skip = 0
# Guarded like the rest; the launcher waits for this exact line.
try { Write-Host 'watchdog ready' } catch { }
Write-WatchdogLog ('watching {0} every {1}s' -f $ProgressLog, $IntervalSeconds)
while ($sw.Elapsed.TotalSeconds -lt $MaxSeconds) {
# Measured, never accumulated: starvation is what makes a sleep overshoot.
$now = [int]$sw.Elapsed.TotalSeconds
try {
$tail = Get-ProgressTail -Path $ProgressLog
if ($tail.Ok -and $tail.Signature -ne $lastSig) {
$lastSig = $tail.Signature
$movedAt = $now
}
if ((Get-WatchdogAction $now $postedAt $IntervalSeconds) -eq 'post') {
$postedAt = $now
$static = -1
if ($tail.Ok) { $static = $now - $movedAt }
$desc = Format-WatchdogStatus $now $static $tail.Line (Get-WatchdogCounters)
# Logged whatever the backoff decides: it throttles the API, not the
# artifact, which is all a run whose token cannot post will leave.
Write-WatchdogLog $desc
if ($skip -gt 0) {
$skip--
} else {
$next = Get-NextThrottle (Send-WatchdogStatus $desc) $backoff
$skip = $next[0]
$backoff = $next[1]
}
}
} catch {
Write-WatchdogLog ('tick failed: {0}' -f $_.Exception.Message)
}
Start-Sleep -Seconds $PollSeconds
}
Write-WatchdogLog ('stopping after {0}s' -f [int]$sw.Elapsed.TotalSeconds)

View File

@@ -10,6 +10,8 @@ directory). Each line is "<path> <mode>...", path "*" matching everything:
empty open the data connection and send nothing
norest answer REST with 500, so the client re-fetches from scratch
nomdtm answer MDTM with 500, like a server predating RFC 3659
--require-pass answers USER with 331, the only way the client's PASS is sent.
"""
import argparse
@@ -25,12 +27,13 @@ def reply(conn, text):
class Session(threading.Thread):
def __init__(self, conn, root, mode_file, log):
def __init__(self, conn, root, mode_file, log, require_pass=False):
threading.Thread.__init__(self, daemon=True)
self.conn = conn
self.root = root
self.mode_file = mode_file
self.log = log
self.require_pass = require_pass
self.pasv = None
self.rest = 0
self.path = "/" # named by SIZE/RETR; REST carries no path
@@ -132,7 +135,9 @@ class Session(threading.Thread):
def dispatch(self, verb, arg):
conn = self.conn
if verb in ("USER", "PASS", "TYPE", "NOOP"):
if verb == "USER" and self.require_pass:
reply(conn, "331 password required") # the client sends PASS only on a 3xx
elif verb in ("USER", "PASS", "TYPE", "NOOP"):
reply(conn, "200 ok")
elif verb == "SYST":
reply(conn, "215 UNIX Type: L8")
@@ -209,6 +214,7 @@ def main():
ap.add_argument("--root", required=True)
ap.add_argument("--mode-file")
ap.add_argument("--log")
ap.add_argument("--require-pass", action="store_true")
args = ap.parse_args()
logfp = open(args.log, "a", encoding="utf-8") if args.log else None
@@ -231,7 +237,7 @@ def main():
root = os.path.abspath(args.root)
while True:
conn, _ = srv.accept()
Session(conn, root, args.mode_file, log).start()
Session(conn, root, args.mode_file, log, args.require_pass).start()
if __name__ == "__main__":

123
tests/install-manifest.txt Normal file
View File

@@ -0,0 +1,123 @@
@BINDIR@/htsserver
@BINDIR@/httrack
@BINDIR@/proxytrack
@BINDIR@/webhttrack
@DATADIR@/applications/WebHTTrack-Websites.desktop
@DATADIR@/applications/WebHTTrack.desktop
@DATADIR@/httrack/html
@DATADIR@/httrack/lang.def
@DATADIR@/httrack/lang.indexes
@DATADIR@/httrack/lang/Bulgarian.txt
@DATADIR@/httrack/lang/Castellano.txt
@DATADIR@/httrack/lang/Cesky.txt
@DATADIR@/httrack/lang/Chinese-BIG5.txt
@DATADIR@/httrack/lang/Chinese-Simplified.txt
@DATADIR@/httrack/lang/Croatian.txt
@DATADIR@/httrack/lang/Dansk.txt
@DATADIR@/httrack/lang/Deutsch.txt
@DATADIR@/httrack/lang/Eesti.txt
@DATADIR@/httrack/lang/English.txt
@DATADIR@/httrack/lang/Finnish.txt
@DATADIR@/httrack/lang/Francais.txt
@DATADIR@/httrack/lang/Greek.txt
@DATADIR@/httrack/lang/Italiano.txt
@DATADIR@/httrack/lang/Japanese.txt
@DATADIR@/httrack/lang/Macedonian.txt
@DATADIR@/httrack/lang/Magyar.txt
@DATADIR@/httrack/lang/Nederlands.txt
@DATADIR@/httrack/lang/Norsk.txt
@DATADIR@/httrack/lang/Polski.txt
@DATADIR@/httrack/lang/Portugues-Brasil.txt
@DATADIR@/httrack/lang/Portugues.txt
@DATADIR@/httrack/lang/Romanian.txt
@DATADIR@/httrack/lang/Russian.txt
@DATADIR@/httrack/lang/Slovak.txt
@DATADIR@/httrack/lang/Slovenian.txt
@DATADIR@/httrack/lang/Svenska.txt
@DATADIR@/httrack/lang/Turkish.txt
@DATADIR@/httrack/lang/Ukrainian.txt
@DATADIR@/httrack/lang/Uzbek.txt
@DATADIR@/httrack/libtest/callbacks-example-baselinks.c
@DATADIR@/httrack/libtest/callbacks-example-changecontent.c
@DATADIR@/httrack/libtest/callbacks-example-contentfilter.c
@DATADIR@/httrack/libtest/callbacks-example-displayheader.c
@DATADIR@/httrack/libtest/callbacks-example-filename.c
@DATADIR@/httrack/libtest/callbacks-example-filename2.c
@DATADIR@/httrack/libtest/callbacks-example-filenameiisbug.c
@DATADIR@/httrack/libtest/callbacks-example-listlinks.c
@DATADIR@/httrack/libtest/callbacks-example-log.c
@DATADIR@/httrack/libtest/callbacks-example-simple.c
@DATADIR@/httrack/libtest/example-main.c
@DATADIR@/httrack/libtest/example-main.h
@DATADIR@/httrack/libtest/readme.txt
@DATADIR@/httrack/templates/index-body.html
@DATADIR@/httrack/templates/index-footer.html
@DATADIR@/httrack/templates/index-header.html
@DATADIR@/httrack/templates/topindex-body.html
@DATADIR@/httrack/templates/topindex-bodycat.html
@DATADIR@/httrack/templates/topindex-footer.html
@DATADIR@/httrack/templates/topindex-header.html
@DATADIR@/icons/hicolor/128x128/apps/httrack.png
@DATADIR@/icons/hicolor/16x16/apps/httrack.png
@DATADIR@/icons/hicolor/256x256/apps/httrack.png
@DATADIR@/icons/hicolor/32x32/apps/httrack.png
@DATADIR@/icons/hicolor/48x48/apps/httrack.png
@DATADIR@/icons/hicolor/64x64/apps/httrack.png
@DATADIR@/icons/hicolor/scalable/apps/httrack.svg
@DATADIR@/metainfo/com.httrack.WebHTTrack.metainfo.xml
@DATADIR@/pixmaps/httrack.xpm
@DATADIR@/pixmaps/httrack16x16.xpm
@DATADIR@/pixmaps/httrack32x32.xpm
@DATADIR@/pixmaps/httrack48x48.xpm
@INCLUDEDIR@/httrack/config.h
@INCLUDEDIR@/httrack/htsarrays.h
@INCLUDEDIR@/httrack/htsbasenet.h
@INCLUDEDIR@/httrack/htsbauth.h
@INCLUDEDIR@/httrack/htsconfig.h
@INCLUDEDIR@/httrack/htsdefines.h
@INCLUDEDIR@/httrack/htsglobal.h
@INCLUDEDIR@/httrack/htsmodules.h
@INCLUDEDIR@/httrack/htsnet.h
@INCLUDEDIR@/httrack/htsopt.h
@INCLUDEDIR@/httrack/htssafe.h
@INCLUDEDIR@/httrack/htsstrings.h
@INCLUDEDIR@/httrack/htswrap.h
@INCLUDEDIR@/httrack/httrack-library.h
@LIBDIR@/httrack/libbaselinks.so
@LIBDIR@/httrack/libbaselinks.so.1
@LIBDIR@/httrack/libbaselinks.so.1.@revision@
@LIBDIR@/httrack/libchangecontent.so
@LIBDIR@/httrack/libchangecontent.so.1
@LIBDIR@/httrack/libchangecontent.so.1.@revision@
@LIBDIR@/httrack/libcontentfilter.so
@LIBDIR@/httrack/libcontentfilter.so.1
@LIBDIR@/httrack/libcontentfilter.so.1.@revision@
@LIBDIR@/httrack/libdisplayheader.so
@LIBDIR@/httrack/libdisplayheader.so.1
@LIBDIR@/httrack/libdisplayheader.so.1.@revision@
@LIBDIR@/httrack/libfilename.so
@LIBDIR@/httrack/libfilename.so.1
@LIBDIR@/httrack/libfilename.so.1.@revision@
@LIBDIR@/httrack/libfilename2.so
@LIBDIR@/httrack/libfilename2.so.1
@LIBDIR@/httrack/libfilename2.so.1.@revision@
@LIBDIR@/httrack/libfilenameiisbug.so
@LIBDIR@/httrack/libfilenameiisbug.so.1
@LIBDIR@/httrack/libfilenameiisbug.so.1.@revision@
@LIBDIR@/httrack/liblistlinks.so
@LIBDIR@/httrack/liblistlinks.so.1
@LIBDIR@/httrack/liblistlinks.so.1.@revision@
@LIBDIR@/httrack/liblog.so
@LIBDIR@/httrack/liblog.so.1
@LIBDIR@/httrack/liblog.so.1.@revision@
@LIBDIR@/httrack/libsimple.so
@LIBDIR@/httrack/libsimple.so.1
@LIBDIR@/httrack/libsimple.so.1.@revision@
@LIBDIR@/libhttrack.so
@LIBDIR@/libhttrack.so.3
@LIBDIR@/libhttrack.so.3.@revision@
@LIBDIR@/pkgconfig/libhttrack.pc
@MANDIR@/man1/htsserver.1
@MANDIR@/man1/httrack.1
@MANDIR@/man1/proxytrack.1
@MANDIR@/man1/webhttrack.1

View File

@@ -253,14 +253,12 @@ serverpid=$!
# Wait for the "PORT <n>" line (server prints it once bound). A cold Python
# start under a parallel `make check -jN` can lag past a second on a loaded Windows runner.
port=
for _ in $(seq 1 300); do
# Match anywhere: a startup warning merged via 2>&1 could precede the PORT line.
line=$(grep -m1 '^PORT ' "$serverlog" 2>/dev/null) && port="${line#PORT }" && break
kill -0 "$serverpid" 2>/dev/null || die "server exited early: $(cat "$serverlog")"
sleep 0.1
done
test -n "$port" || die "could not discover server port: $(cat "$serverlog")"
port=$(discover_server_port "$serverlog" "$serverpid") || {
# A server that died is a hard failure; only the deadline is the announce race
# 72 and 105 skip on, and they key on this exact wording.
test "$?" -ne 2 || die "server exited early"
die "could not discover server port"
}
debug "server listening on ${scheme}://127.0.0.1:${port}"
baseurl="${scheme}://127.0.0.1:${port}"

107
tests/png-colors.py Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Print a PNG's distinct colours as "count #rrggbb", commonest first.
Usage: png-colors.py FILE
Stdlib only: the suite cannot assume Pillow or ImageMagick on a build host.
"""
import struct
import sys
import zlib
CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
def chunks(blob):
off = 8
while off < len(blob):
(n,) = struct.unpack(">I", blob[off : off + 4])
yield blob[off + 4 : off + 8], blob[off + 8 : off + 8 + n]
off += 12 + n
def unfilter(raw, width, height, bpp, stride):
"""Undo the per-scanline filters; returns the concatenated raw scanlines."""
out, prev, off = bytearray(), bytearray(stride), 0
for _ in range(height):
ftype, line = raw[off], bytearray(raw[off + 1 : off + 1 + stride])
off += 1 + stride
for i in range(stride):
a = line[i - bpp] if i >= bpp else 0
b = prev[i]
c = prev[i - bpp] if i >= bpp else 0
if ftype == 1:
line[i] = (line[i] + a) & 0xFF
elif ftype == 2:
line[i] = (line[i] + b) & 0xFF
elif ftype == 3:
line[i] = (line[i] + (a + b) // 2) & 0xFF
elif ftype == 4:
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
line[i] = (
line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)
) & 0xFF
elif ftype != 0:
raise SystemExit("unknown filter %d" % ftype)
out += line
prev = line
return out
def samples(row, depth, count):
"""The first `count` samples of one unfiltered scanline."""
if depth == 8:
return list(row[:count])
if depth == 16:
return [row[i * 2] << 8 | row[i * 2 + 1] for i in range(count)]
per, mask = 8 // depth, (1 << depth) - 1
return [(row[i // per] >> (8 - depth * (i % per + 1))) & mask for i in range(count)]
def colors(path):
blob = open(path, "rb").read()
if blob[:8] != b"\x89PNG\r\n\x1a\n":
raise SystemExit("%s: not a PNG" % path)
plte, idat = b"", b""
for kind, data in chunks(blob):
if kind == b"IHDR":
width, height, depth, ctype, _, _, interlace = struct.unpack(
">IIBBBBB", data
)
elif kind == b"PLTE":
plte = data
elif kind == b"IDAT":
idat += data
if interlace:
raise SystemExit("%s: interlaced" % path)
nchan = CHANNELS[ctype]
stride = (width * nchan * depth + 7) // 8
bpp = max(1, nchan * depth // 8)
raw = unfilter(zlib.decompress(idat), width, height, bpp, stride)
tally = {}
for y in range(height):
row = raw[y * stride : (y + 1) * stride]
vals = samples(row, depth, width * nchan)
for x in range(width):
px = tuple(vals[x * nchan : (x + 1) * nchan])
if ctype == 3:
px = tuple(plte[px[0] * 3 : px[0] * 3 + 3])
elif ctype in (0, 4):
px = (px[0], px[0], px[0])
tally[px[:3]] = tally.get(px[:3], 0) + 1
return tally
def main():
if len(sys.argv) != 2:
raise SystemExit(__doc__)
tally = colors(sys.argv[1])
for px, n in sorted(tally.items(), key=lambda kv: (-kv[1], kv[0])):
print("%d #%02x%02x%02x" % ((n,) + px))
if __name__ == "__main__":
main()

View File

@@ -54,9 +54,8 @@ test -n "$had_m" || test -n "$windows" || set +m
# Poll, because bash cannot wait with a deadline and a watchdog subshell would
# have to signal across process groups, which MSYS cannot do. The interval is the
# latency this adds to every healthy test, so keep it small where fork is cheap;
# under MSYS fork costs tens of milliseconds, and a second is what the Windows
# suite already paid before this wrapper existed.
# latency this adds to every healthy test; the ceiling only matters where poll_wait
# has to fall back to a forked sleep, which under MSYS costs tens of milliseconds.
if test -n "$windows"; then
tick=1
else
@@ -80,6 +79,6 @@ while kill -0 "$pid" 2>/dev/null; do
dump_crawl_logs
exit 124
fi
sleep "$tick"
poll_wait "$tick"
done
wait "$pid"

View File

@@ -64,40 +64,117 @@ stage_install_exec() {
stage_install_target install-exec "$1" "$2"
}
IS_WINDOWS=
is_windows() {
case "$(uname -s)" in
MINGW* | MSYS* | CYGWIN*) return 0 ;;
*) return 1 ;;
esac
if test -z "$IS_WINDOWS"; then
case "$(uname -s)" in
MINGW* | MSYS* | CYGWIN*) IS_WINDOWS=yes ;;
*) IS_WINDOWS=no ;;
esac
fi
test "$IS_WINDOWS" = yes
}
# Open the timer fd poll_wait reads from: a fifo held open read-write, so there is
# always a writer and a read blocks to its own timeout instead of seeing EOF. fd 9
# is the harness's from here on; no test may hold it open across a poll.
POLL_STATE=
poll_open() {
local f
POLL_STATE=forked
test -z "${HTTRACK_POLL_SLEEP:-}" || return 0
# Not under MSYS: its fifos are emulated, and the leg whose stability #795 is
# about is no place to discover how its select() behaves. That tick is a whole
# second anyway, so there is little to win.
is_windows && return 0
# Unique: $$ is the same in every subshell, and the loser of a race on one name
# opens a path that is gone.
f=$(mktemp -u "${TMPDIR:-/tmp}/.httrack-poll.XXXXXX" 2>/dev/null) || return 0
mkfifo "$f" 2>/dev/null || return 0
# Braced: "exec 9<>x 2>/dev/null" alone would send this shell's stderr to
# /dev/null for good, exec applying its redirections to the shell itself.
# Still a fifo, or bash's <> created a regular file on a vanished path, which
# reads EOF at once and spins every deadline loop.
if { exec 9<>"$f"; } 2>/dev/null && test -p "$f"; then
POLL_STATE=fifo
else
exec 9<&-
fi
rm -f "$f" 2>/dev/null
return 0
}
# One poll tick of at most $1 seconds, forking nothing where bash can time out a
# read on that fd (a `sleep` per tick was 22% of every process the suite created,
# and costs milliseconds apiece under MSYS, #795). $1 is a ceiling: the fd tick
# returns every 0.1s whatever it is asked, and callers re-check their own deadline.
# HTTRACK_POLL_SLEEP=1 forces the forked tick back, re-read per tick so a test can
# starve the poll mid-run.
poll_wait() {
local secs=$1 rc=0
test -n "$POLL_STATE" || poll_open
if test "$POLL_STATE" = fifo && test -z "${HTTRACK_POLL_SLEEP:-}"; then
# bash 4.0 is where -t took a fraction; below it, hand a caller asking for
# one the sleep it asked for rather than rounding its tick up to a second.
if test "${BASH_VERSINFO[0]}" -ge 4; then
secs=0.1
else
case "$secs" in *.*)
sleep "$secs"
return 0
;;
esac
fi
read -t "$secs" -r -u 9 _ || rc=$?
test "$rc" -le 128 || return 0 # >128 is the timeout we asked for
# bash 4.0 is also where a timeout started reporting >128; 3.2 says 1, which is
# what a closed fd says everywhere. Ask the fd itself rather than the status,
# or macOS retires its timer on the first tick and forks for the whole run.
# `true`, not `:`: a redirection error on a POSIX special builtin exits the
# shell, which POSIXLY_CORRECT in the environment is enough to turn on.
if { true >&9; } 2>/dev/null; then return 0; fi
POLL_STATE=forked # not a timer any more, stop trusting it
exec 9<&-
fi
sleep "$1"
}
# On Windows MSYS can't signal a native python.exe, so kill_tree ends the whole
# tree (a bare kill -9 leaves children). "|| true" throughout: callers run under
# set -e and the reap makes wait return 143.
# tree (a bare kill -9 leaves children). Bounded because this runs from an EXIT
# trap, where a survivor would turn a passing test into a harness timeout.
stop_server() {
test -n "${1:-}" || return 0
kill "$1" 2>/dev/null || true
if is_windows; then kill_tree "$1"; fi
wait "$1" 2>/dev/null || true
reap_bounded "$1" || true
return 0
}
# Echo the port local-server.py announces on $1 (its log), $2 being its pid.
# Waits 30s: a cold Python start under a parallel `make check -jN` lags well
# past a second, and 5s was tight enough that macos-15 missed it on 15 tests.
# Matches anywhere, since a warning merged via 2>&1 can precede the line.
# Echo the port local-server.py announces on $1 (its log), $2 being its pid, or
# return 2 if the server died and 1 on the deadline: only the deadline is a race a
# caller may skip on. Matches anywhere, since a warning merged via 2>&1 can precede
# the line. A full minute of wall clock: a cold Python start under a parallel
# `make check -jN` lags well past a second, 5s had macos-15 missing it on 15 tests,
# and the count-of-ticks loop this replaced self-extended under load instead.
discover_server_port() {
local log=$1 pid=$2 line _i
for _i in $(seq 1 300); do
if line=$(grep -m1 '^PORT ' "$log" 2>/dev/null); then
printf '%s\n' "${line#PORT }"
return 0
local log=$1 pid=$2 line start=$SECONDS
while :; do
if test -r "$log"; then
# Read in the shell: a grep per tick is a process per tick (#795).
while read -r line; do
case "$line" in 'PORT '*)
printf '%s\n' "${line#PORT }"
return 0
;;
esac
done <"$log"
fi
kill -0 "$pid" 2>/dev/null || {
echo "server exited early: $(cat "$log" 2>/dev/null)" >&2
return 1
return 2
}
sleep 0.1
test "$((SECONDS - start))" -lt 60 || break
poll_wait 0.1
done
echo "could not discover server port: $(cat "$log" 2>/dev/null)" >&2
return 1
@@ -176,6 +253,20 @@ kill_tree() {
ENGINE_EXE_RE='^(lt-)?(httrack|proxytrack|htsserver|webhttrack)([.]exe)?$'
FIXTURE_SERVER_RE='^(local-server|proxy-https-server|proxy-connect-server|socks5-server|tls-stall-server)[.]py$'
# awk prologue for the matchers below: under qemu-user the kernel reports the
# binfmt interpreter as the command, so the name we match on lands one column
# right (Debian's hppa buildd emulates).
# shellcheck disable=SC2016 # awk fields, not shell expansions
AWK_PROC_NAMES='
function basen(s) { sub(/.*[\/\\]/, "", s); return s }
function qemushift( n) {
n = basen($6)
# qemu-img and friends take an image, not a program, and this list is fed to
# kill: shifting past them would read a disk path as the process name.
if (n ~ /^qemu-(img|nbd|io|ga|edid|keymap)$/) return 0
return n ~ /^qemu-[[:alnum:]_]+(-static)?$|^[[:alnum:]_]+-binfmt(-[[:upper:]]+)?$/ ? 1 : 0
}'
# Every process as "PID PPID PGID ELAPSED S COMMAND", header first. A Fedora
# build root has no procps, and an empty list reads as "nothing running" (#1021).
ps_snapshot() {
@@ -243,11 +334,13 @@ list_stray_processes() {
# Fields 6 and 7 are the command and its first argument (the interpreter
# and its script, for the Python fixtures).
ps_snapshot |
awk -v pg="$pgid" -v mode="$mode" -v eng="$ENGINE_EXE_RE" -v srv="$FIXTURE_SERVER_RE" '
awk -v pg="$pgid" -v mode="$mode" -v eng="$ENGINE_EXE_RE" -v srv="$FIXTURE_SERVER_RE" \
"$AWK_PROC_NAMES"'
NR == 1 { print; next }
{ ingroup = (pg > 0 && $3 == pg)
c = $6; sub(/.*[\/\\]/, "", c)
s = $7; sub(/.*[\/\\]/, "", s)
q = qemushift()
c = basen($(6 + q))
s = basen($(7 + q))
named = (c ~ eng || s ~ srv)
if (mode == "group" ? ingroup : \
mode == "named" ? named : (named && !ingroup)) print }' || true
@@ -289,8 +382,8 @@ list_engine_pids() {
local pgid=${1:-0}
test "$pgid" -gt 0 2>/dev/null || return 0
ps_snapshot |
awk -v pg="$pgid" -v eng="$ENGINE_EXE_RE" \
'NR > 1 && $3 == pg { c = $6; sub(/.*[\/\\]/, "", c); if (c ~ eng) print $1 }'
awk -v pg="$pgid" -v eng="$ENGINE_EXE_RE" "$AWK_PROC_NAMES"'
NR > 1 && $3 == pg { if (basen($(6 + qemushift())) ~ eng) print $1 }'
}
# Ask the wedged test's engine processes for a stack. What is obtainable differs
@@ -413,6 +506,9 @@ EOF
chmod +x "$dir/sleep" || return 1
PATH="$dir:$PATH"
export PATH
# Proved, not assumed: MSYS hands out a drive-letter TMPDIR and a PATH entry
# carrying a colon is read as two, so an unreachable shim starves nothing.
test "$(command -v sleep)" = "$dir/sleep" || return 1
}
# Skip when the next of $1 remaining steps, at 1.5x the $2 seconds the last one
@@ -439,7 +535,7 @@ reap_bounded() {
local pid=$1 start=$SECONDS
while kill -0 "$pid" 2>/dev/null; do
test "$((SECONDS - start))" -le "$REAP_GRACE" || return 1
sleep 1
poll_wait 1
done
wait "$pid" 2>/dev/null || true
return 0
@@ -466,7 +562,7 @@ run_with_timeout() {
reap_bounded "$pid" || true
return 124
fi
sleep 1
poll_wait 1
done
wait "$pid"
}
@@ -481,7 +577,7 @@ wait_bounded() {
reap_bounded "$pid" || true
return 124
fi
sleep 1
poll_wait 1
done
wait "$pid"
}

View File

@@ -259,3 +259,9 @@ TESTS += 216_engine-ftp-ctrlchars.test
TESTS += 221_local-ftp-ctrlchars.test
TESTS += 218_crash-nopie-frames.test
TESTS += 222_pkgconfig-consumer.test
TESTS += 227_watchdog-poll.test
TESTS += 228_icon-small-flat.test
TESTS += 226_watchdog-native.test
TESTS += 224_engine-ftp-cmdlen.test
TESTS += 230_local-ftp-userpass.test
TESTS += 225_install-manifest.test