37 Commits

Author SHA1 Message Date
Xavier Roche
a0622bcd04 Release 3.49.19
Version bump for 3.49.19. Most of what landed since 3.49.18 is FTP: an over-long user name in an `ftp://` URL logged in as a different account, a crawl ignored `--timeout` and `--max-time` and pinned a slot for five minutes, a dead host took two minutes to give up with no way to shorten it, and the engine freed the transfer slots while an FTP thread was still writing to one. Elsewhere, an unbounded panic message could smash the stack, a crawl that gave up mid-loop lost its WARC archive and its change report, and a resume the server refused could still lose the partial file.

`VERSION_INFO` goes 3:11:0 to 3:12:0, revision only: `httrackp` gained a tail field and `htsopt.h` added two `volatile` qualifiers, so no layout moved and nothing went away. Soname stays `libhttrack.so.3`, exports 167 to 168. Nothing under `debian/` landed since 3.49.18, so that changelog entry is the upstream overview plus the two build failures other distributions reported. `Standards-Version` 4.7.4 still matches debian-policy.
2026-08-08 22:12:35 +02:00
Xavier Roche
f5fbd1a82e The suite watchdogs give up the first time a poll tick cannot start a process (#1081)
* The suite watchdogs give up the first time a poll tick cannot start a process

ci_suite_heartbeat read its clock through a command substitution, so the
watchdog needed a fork to tell the time, and a sleep that returns without
waiting either killed it under the caller's errexit or turned
test-timeout.sh's wait loop into a fork storm. Both fail exactly when the
box is short of processes, which is when the watchdog is the only thing
still reporting.

The clock now assigns hb_time instead of printing, the heartbeat's tick is
guarded, and the wait loop gives up after ten ticks that never waited,
naming the test in the progress log the off-box watchdog reads.

References #1038.

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

* Review fixes: dump the crawl logs before TMPDIR goes, and pin the guards with mutants

The nofork exit skipped dump_crawl_logs, so the EXIT trap deleted the very
logs that would explain the hang. reap_bounded stays out: it polls on the
same broken tick.

250 now asserts the tenth tick by count, that the wedged child was killed,
and that a tick failing every other call is never read as a wedge. 171's
sleep stub returns 126 once, which is what catches the heartbeat guard
being removed.

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-08 21:22:21 +02:00
Xavier Roche
496e85a3ac --single-file writes marks it cannot read back (#1080)
* --single-file writes marks it cannot read back

sf_parse_mark refused a <len> above SF_MAX_REF (4096), but htsparse measures
the escaped bytes it appended: HTS_URLMAXSIZE*2 of savename through
escape_for_html_print_full plus as much query through escape_for_html_print.
A mark past the cap parses as nothing and survives into the page as literal
text, taking its reference with it. Derive the cap from the emitter, and have
singlefile_mark decline a span sf_parse_mark would not read back so the two
cannot drift apart. SF_MAX_REF keeps its value for the resolution buffers.

Also swap singlefile_may_mark's hand-rolled search for hts_memstr, clip the
unresolved-reference warning, and make the charset case in test 94 bite: the
server now answers /sfmark.html with a mark in its Content-Type charset.

Closes #1054
Closes #1069

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

* Check the span derivation at the emit site, and test what the review found vacuous

SINGLEFILE_MAX_SPAN moves to the header so htsparse can assert it against the
tempo and lien it is derived from: a resize there now breaks the build instead
of silently switching inlining off.

Test gaps the review named: the digit-loop saturation guard had no case (a
2^64+10 length wraps to 10 and eats the padding without it), the spans loop
only checked the reference was still present rather than that nothing but the
mark had gone, and nothing asserted the warning clip.

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-08 21:19:59 +02:00
Xavier Roche
d4b77e53dc Ctrl-C does not shorten an in-flight HTTP connect (#1079)
* Ctrl-C does not shorten an in-flight HTTP connect

The user stop only kept new links from being queued: a slot already
waiting for its connection stayed there until --timeout, so stopping a
crawl against an unreachable host took minutes.

back_wait() now ends every slot whose connection is not established yet
(name resolution, connect, TLS handshake) as soon as the stop flag is
set, the way #1071 did for FTP. Slots already receiving are left alone.

Closes #1073

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

* Cancel a stalled name resolution on stop, and harden the sweep's test

The HTTP resolver was still uncancellable: hts_dns_resolve_all() passed no
cancel flag, so a Ctrl-C during a resolve behind a black hole waited out
--timeout per host, the same stall the connect sweep fixes. Pass the
mirror's stop flag, which is what #1071 added the parameter for.

The backstop self-test now covers the states a sweep must not take (FTP,
chunked), runs a control pass with the mirror still running, checks the
socket was closed instead of only cleared, and repeats after re-arming, so
a one-shot or unconditional sweep fails it.

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-08 21:19:06 +02:00
Xavier Roche
507f3565e8 A crawl on a terminal with stdin at end of file toggles its display mode in a loop (#1078)
* Tell an stdin at end of file from a keypress

select() reporting stdin readable does not mean there is anything to read: at
end of file it stays ready forever, and check_stdin() sold that to the crawl
loop as an ENTER. The display then toggled on every pass, so a terminal run
with stdin closed alternated between the spinner and the full panel.

Peek a byte before answering yes, and drop the fflush(stdin) that would have
discarded it (undefined on an input stream anyway). The Windows branch reads
the console queue rather than stdin, so it has no end of file to confuse.

Closes #1072

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

* Model the ticket's terminal at EOF, not just a redirection

The EOF leg fed httrack /dev/null, so a fix that simply gave up off a
terminal would have passed it while leaving the reported bug alive. Add a
third crawl that types ^D into the pty, which is the case #1072 describes.

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>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:18:11 +02:00
Xavier Roche
4df1434b56 The ProxyTrack Windows skips outlived the reap that made them necessary (#1077)
* tests: the Windows engine reap named only httrack.exe

kill_tree's by-name fallback and reap_leftover_processes each carried their own
hand-written image list beside ENGINE_EXE_RE, and both had drifted: a proxytrack
orphan survived either one. Derive all three from a single ENGINE_EXES.

With the reap covering it, drop the stale #595 skip from the webdav-mime test.

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>

* tests: anchor the Windows image matcher, un-skip webdav-overflow

Review found a fourth hand-written engine list in dump_windows_stacks, and that
the tasklist greps were unanchored: notepad-httrack-notes.exe read as a leaked
engine. Both now go through ENGINE_IMAGE_RE, derived from ENGINE_EXES.

webdav-overflow joins webdav-mime on Windows. webdav-default and
proxytrack-quiet keep their skip, with the real reason: they read proxytrack's
console through a pty, and Windows has neither Python's pty nor os.fork.

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>

---------

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-08 21:17:24 +02:00
Xavier Roche
d85d91f8c6 Adding a test conflicts with every other test-adding PR (#1075)
* Adding a test conflicts with every other test-adding PR

tests/tests-list.mk was a single shared file every test-adding PR appended
to. merge=union kept that clean locally, but GitHub never reads
.gitattributes, so on the forge each pair of such PRs conflicted and every
merge re-conflicted the rest of the queue.

configure now globs tests/[0-9]*_*.test into TESTS_LIST and tests/Makefile.am
reads it back as TESTS = @TESTS_LIST@, the one form automake passes through
untouched. A make-level $(wildcard) cannot replace it: automake word-splits
TESTS at automake time and emits a broken rule per word.

check-tests-list.sh becomes check-test-names.sh, guarding the naming
convention the glob depends on rather than a list that no longer exists.

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

* Catch a build dir whose TESTS predates the test you just added

The glob moved the silent-skip hazard rather than removing it: TESTS_LIST is a
literal frozen into tests/Makefile by config.status, and nothing regenerates it
when a .test appears, so a new test was simply not run and every other test
still passed. 231_test-names.test now compares tests/ against the TESTS the
running Makefile holds, which turns that into a red test on the next make check
and covers the deletion case too.

The glob also dropped the old list's count oracle, so a test its author forgot
to git add left CI running a smaller suite, green. check-test-names.sh now
compares the glob against git ls-files, skipping that when nothing there is
tracked so tarball builds are unaffected.

It also missed a case-mistaken 01_Engine.TEST, and claimed a name with a space
in it was one make check runs, when configure space-joins the basenames and
make sees two words. Both are rejected now, with a control each.

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

* A git that cannot answer read as a clean tracked set

The tracked-set oracle swallowed git's exit status, so git missing from PATH or
a safe.directory refusal looked exactly like a tarball with nothing tracked:
over a checkout holding an untracked test the checker printed a clean line and
exited 0. It now probes whether git can answer at all and says on stderr when it
cannot, leaving the genuine empty-listing skip that tarball builds rely on
untouched. 231 stubs git out to prove the note fires.

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-08 17:05:06 +00:00
Xavier Roche
b16ffc7be2 The --single-file crawl assertions run on Windows but are reported as a skip (#1066)
* The --single-file crawl assertions run on Windows but are reported as a skip

94_local-single-file.test only reaches its exit 77 after every crawl assertion
has passed, so the Windows suite counts real coverage as a skip. Move the
htsserver block to 241_local-single-file-gui.test and drop 94 from
expected_skips.

Refresh two stale justifications while there: 48 and 71 were pinned on the now
closed #581, and 153 had none.

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

* Assert tokens, not substrings, in the single-file GUI and mirror checks

"--single-file" is a substring of "--single-file-max-size=5000", so the flag
assertion passed with the bare flag gone; the value match had no trailing
delimiter, so 50000 passed for 5000. Both mutants now kill the test.

94 carried three fail-open checks that this PR makes load-bearing on Windows: a
grep whose status was discarded, a mark sweep that read an unsearchable tree as
clean, and a --changes crawl writing to the first mirror's -O, which left its
page absent and every check on it vacuous.

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

* Keep grep's status in the mark sweep, and size the page the change report names

The file-count floor could not see what the sweep saw: it walked its own find
expression, so mirrored files at mode 000 counted 11, passed, and left grep's
status laundered by the pipe it was meant to guard. Keep grep's own exit status
instead, and run tr as a separate step.

The --changes check named an invariant it did not test. The report stats the
file it lists, so comparing its size for the page against the page on disk is
what proves expansion ran first; moving singlefile_process_mirror() after
hts_changes_close_opt() now fails it at 1037 bytes against 28705.

Also derive the crawl options from one array rather than retyping them at four
call sites, and match the max-size flag as a token: the right-hand anchor let a
template fusion through.

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

* Move the change-report probe out of a command substitution for bash 3.2

macOS ships bash 3.2, which ends $(...) at the ")" and runs the here-document
body as shell commands, so the probe failed with "import: command not found".
Write it to a file at statement level and run that.

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-08 16:29:59 +00:00
Xavier Roche
defd6735f3 A WARC abort can orphan a .tmp when teardown reopens the writer (#1068)
* A WARC abort can orphan a .tmp when teardown reopens the writer

warc_close_opt() and warc_abort_opt() left opt->state.warc NULL, which the
emit hook reads as "never opened". XH_uninit then finalizes the slots still
in flight, and back_finalize's warc_write_backtransaction lazily reopens the
archive. The reopened writer nobody closes leaves a .tmp behind, since the
archive it would replace is already in place.

Both now leave the existing WARC_DISABLED sentinel instead, which the hook
already honours: no writer, and none to open.

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

* Make test 242 tell the close case from the abort case

The size compare after teardown could not fire: a late reopen always writes
the temporary, never the archive, so the two sizes matched on every path. And
nothing pinned which run the archive held, so a close that stopped committing
its own run passed both assertions.

Each run now carries its own body, and the archive is read back: valid
framing, the run that had to survive, and neither of the other two. Abort
keeps run 1, close commits run 2.

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

* Say what the framing check covers, and convert warc_slurp's path

The WARC/1.1 compare reads the first record's version line, so it catches a
file that is no longer an archive, not a corrupt one; the late-body leg is an
append guard rather than a discriminator, since nothing can reach the archive
without losing the run that was there. Both said so in one line.

warc_slurp() also opened its path raw where every other selftest path goes
through fconv(), and the test now traps the signals on their own line.

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

* Note that copy_htsopt must not copy the embedded state

The rule was written only for sitemap_state and singlefile_state; a copy of
state would clear a WARC writer the live opt had already finished with.

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-08 15:52:40 +00:00
Xavier Roche
178f9156e5 An FTP crawl of a dead host takes two minutes to exit, and Ctrl-C cannot shorten it (#1071)
* An FTP crawl of a dead host takes two minutes to exit

The worker sat in a blocking connect(), so --timeout, --max-time and the
engine's stop flag were all bypassed and the kernel's own connect timeout
was the only bound. It now connects non-blocking and polls on the same
100ms tick wait_socket_receive uses, re-reading the stop flag and what is
left of --max-time each pass; the resolve goes through a new bounded entry
point taking an explicit deadline and a cancel flag.

The PASV data path also gated its connect on server_size > 0, which is
sizeof(SOCaddr) and so always true, rather than on the resolve result.

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

* Make the new tests able to tell the fix from an instant failure

The timing assertions had no lower bound, so ftp_connect's two
instant-failure returns passed both legs, and the log they matched is
wording the HTTP backend shares. Each leg now floors its elapsed time,
the interrupt leg proves the crawl was still parked before it signals,
and both anchor the FTP form of the message.

Split so Windows runs the half that needs no signal, poll a debug line
rather than sleeping toward the connect, and cover the cancellable
resolve through the DNS selftest's scripted black-hole backend, which
192.0.2.1 (a literal, resolved instantly) never reaches.

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

* Restore an unrelated test's file mode

A chmod glob for the two new tests caught 24_local-resume-overlap.test.

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

* Move the FTP timeout test off 244, which #1070 already claims

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

* Make the two stop flags volatile

The new 100ms poll loops re-read stop_ftp and state.stop with no lock, so
their correctness rests on the compiler not hoisting the load out of the
loop. select() and Sleep() are opaque enough today, but that is the shape
of the calls rather than a guarantee, and the code these loops replaced
sat in a blocking syscall instead.

hts_dns_resolve_all_bounded takes the flag by pointer, so its cancel
parameter becomes const volatile: passing &back->stop_ftp would otherwise
discard the qualifier.

Not an ABI break, though htsopt.h is installed: volatile changes no
offset, size or alignment, only the declared types.

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

---------

Signed-off-by: Xavier Roche <xroche@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:44:04 +00:00
Xavier Roche
ee5db0a9e6 -%v1 overwrites its own output and -%v2 paints escapes into a redirected log (#1070)
Salvages what stands on its own from #362; the progress bar itself is not here.

In `-%v1` each completed file printed a line ending with a CR and a clear-to-EOL, so every line overwrote the one before it. A finished crawl left the terminal holding a single line and no record of what had been fetched, and redirecting that output gave one unreadable line of escapes. Those lines end with a newline now, and the clear-to-EOL goes with them. That part is Matteo Fiorini's, credited in the trailer.

The rest is a defect #362 shared rather than fixed. An explicit `-%v2` kept painting cursor-addressed panels whatever stdout was, because the `isatty` probe in `htscoremain.c` only picks the default verbosity. That probe is also compiled out on Windows, where the spinner wrote its CRs straight into a redirected file. Both are gated on a terminal now, each where it is drawn rather than by rewriting the user's option. The CLI arms its panel in `htsshow_start()`, which the mid-crawl ENTER toggle re-enters, so the gate holds for the whole crawl. Off a terminal `-%v2` paints nothing; `-%v1` is the mode that gives a redirectable log.

`241_local-tty-output.test` crawls the local server three ways into a pipe. It requires that no escape and no CR survive, that each crawl actually ran, and that `-%v1` keeps one line per file. The controls repeat the crawls with stdout on a raw pty and require a repainting panel and a turning spinner, so a fix that merely silenced the display would fail instead of pass. Reverting either completion line, the panel gate or the terminal probe breaks the test, and so does deleting the display outright. One gap: the spinner gate cannot fail on POSIX, where `quiet` is already forced off a tty, so only the Windows leg exercises it.
2026-08-08 16:06:55 +02:00
Xavier Roche
edeada647c A crawl that gives up mid-loop loses its WARC archive and its change report (#1064)
`httpmirror()` has four exits that never reach the teardown at the end of the function: the three crawl-loop bailouts and the no-data rollback. None of them runs `warc_close_opt`, `hts_changes_close_opt` or `hts_sitemap_free`, so any crawl the engine gives up on loses all three. Tripping the `-#L` link limit is the easiest way to get there; a shell or GUI abort does it too.

The WARC is what this costs in practice. Segments are written under `.tmp` names and renamed only once the writer closes, so when a previous archive exists, a bailout strands the whole capture in a `warc-out.warc.gz.tmp` nothing will ever pick up. The previous archive stays in place as if the run had never happened, with a stale `.cdx` beside it.

All four exits now jump to one label, with `retcode` keeping the bailouts' -1 apart from the tail's 1. The rollback still calls `warc_abort_opt` first, which NULLs the writer and makes the shared `warc_close_opt` a no-op there. `hts_cache_reconcile` still runs after `XH_uninit`, which is what closes the cache files it renames.

The change report goes the other way. An abort never reaches `hts_changes_indexed()`, so any report it writes claims the cache is off and no deletions were found, and on the rollback path that false report replaces a true one describing the mirror the rollback just restored. So only a mirror that reached the end of the loop writes `hts-changes.json`. Every other exit frees the accumulator, which is the leak the issue actually named.

Test 240 poisons the archive and snapshots the report, aborts on the link limit, then re-runs against a dead server. Three separate mutants each kill a different assertion. It covers the `-#L` bailout only: the other live one needs the loop callback to refuse, which the CLI cannot drive. The third, a failing `hts_mirror_check_moved`, is unreachable today since every return in that function is 0, and it is routed with the others rather than left as the odd one out.

Also frees `primary` on the exits that leaked it. #1060 is unaffected: its window, a writer lazily reopened during `back_delete_all`, is unchanged.

Closes #1061
2026-08-08 09:27:46 +00:00
Xavier Roche
562f6790ec Two of the Android help screens have no anchor to deep-link at (#1065)
* Two of the Android help screens have no anchor to deep-link at

The Android app is getting per-screen contextual help (httrack-android#113)
and needs somewhere to link. Six of the eight screens already have a target,
since the wizard steps and the option panel kept their ids when the eighteen
GUI pages were folded into guide.html. The first-launch permission note and
the paragraph saying where mirrors land did not, so add an id to each.

Those ids are link API now: the app hardcodes them and an installed release
outlives whatever engine SHA it was built against. The new test resolves
every guide.html# reference in html/ against the file's actual ids, which
also covers the redirect stubs left by the page merge, and names the eight
the Android side depends on.

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

* BSD sed has no \| alternation, so macOS never strips the platform prefix

The scan left "droid/step-run" unstripped on any POSIX BRE sed, and no id
matches that, so the test failed everywhere but GNU.

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

* Drop the reinvented link scan; tools/doc-links.py already resolves them

The generic half duplicated tools/doc-links.py, which the documentation CI job
already runs over the whole doc set. What is left is the part it cannot know:
the eight anchors httrack-android hardcodes, each present once and on an
element the droid platform can see.

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-08 07:56:13 +00:00
Xavier Roche
cbb2257847 A rejected resume can still lose the file (#1057)
* A rejected resume can still lose the file

With --retries=0 an unusable Content-Range deleted the partial and never
re-fetched it: the restart-whole recovery was written as an error retry, so it
was both gated on the retry budget and charged to it. Restarting a transfer the
server refused to resume is not a retry of a failed transfer, so it no longer
spends the budget; refetch_whole, which only a previous restart sets, latches it
to one so an always-unusable server cannot loop.

#581 was already fixed by #655, but nothing held that fix in place. Tests 48 and
71 both pass with it disabled and neither reaches the restart path, so test 235
drives it with an LD_PRELOAD interposer that refuses the unlink the way Windows
refuses one on a file it still holds open.

The unlink in the memory-resume branch also ran while a FILE* on that file was
still open, which is what left the partial behind on Windows; it now closes
first.

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

* Register 235_local-resume-recovery in the Windows expected-skip set

The Windows suite pins its skip set, so a new test that skips there reds the
job at fail=0. Also drops the stale #581 note on 71, whose skip is now the
same MSYS signal limitation.

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

* Carry the whole-file restart latch across a link re-record

The free restart introduced by this branch is bounded only by
lien_url::refetch_whole, but the two sites that re-record a link while
preserving its retry count -- the case-alias redirect replay and the
412/416 re-get -- never copied that latch, and hts_record_link zeroes the
new entry. A server alternating an unusable 206 with a 301 to a
case-different alias of the same path therefore restarts for free at
every hop: 148 restarts and no retries consumed in 60s at both
--retries=0 and --retries=1, where the merge base terminates in one
second.

Copy the latch wherever retry is copied, and say so at the field's
declaration. Test 235 gains a leg driving that alternation; it also
asserts a later genuine failure still had its retry, which the previous
three legs could not see.

The unlink interposer now tells an open handle from a closed one through
/proc/self/fd instead of refusing by basename, so reverting the fclose in
the memory-resume branch turns the last leg red rather than leaving it
green.

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>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:36:01 +02:00
Xavier Roche
0e95c58865 Three unreachable defects from #1040, and a live stack overflow found beside one (#1056)
* Fix three unreachable defects in the array and bounds-check macros

TypedArrayEnsureRoom doubled the capacity until the requested room fit, so a
large enough request wrapped it to zero and the loop never ended; capa * width
could wrap too, into an allocation smaller than the capacity claimed. Growth
now saturates and asserts, and the macro uses the realloct/freet wrappers.

HTS_IS_CHAR_BUFFER cannot be made sound on MSVC, so its comment now names both
sizes that go blind (char[8] on Win64, char[4] on Win32), and the two
pointer-sized destinations in the tree are widened. HTS_CDLMAXSIZE keeps its
value; the *2 comment beside it went stale when 3.30.1 raised HTS_URLMAXSIZE.

Also fixes a live stack overflow found while auditing HTS_CDLMAXSIZE:
htscoremain.c formatted "Missing quote in %s" with a raw sprintf into a bare
HTS_CDLMAXSIZE buffer, overrunning it by 17 bytes on a max-length argument.

Closes #1040

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

* Keep the array overflow self-test's abort out of the optimizer's reach

Test 237's SIZE_MAX-room mode relied on realloc returning NULL, which nothing
observed: the array is dead after the call, so clang (and gcc from sid) drop
the allocation, assume it succeeded, and fold away the "== NULL" that aborts.
Printing the pointer keeps the allocation live. ASan meanwhile kills the
process itself on an over-large request rather than returning NULL, so that
one run asks for libc's behaviour instead.

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

* Bound the remaining raw sprintf panic messages in htscoremain.c

The "Unable to create log file %s" pair formats the whole -O path into a
char[HTS_CDLMAXSIZE + 256]. That path is not argv-gated: check_path() runs
expand_home() on it, which admits 2047 bytes, so
  HOME=<2000 chars> httrack -q -O '~/x' <url>
smashes ~800 bytes of stack (fortify aborts at htscoremain.c:2583).

The second "Missing quote in %s" site copies argv[na] into a +256 buffer
first, so its 17-byte prefix can overrun the same way even though the
argv gate keeps today's callers short of it.

Also fixes the HTS_CDLMAXSIZE comment (the gate rejects >= 1024, so 1023
is the longest accepted) and trims the comments added by this branch.

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-08 08:03:30 +02:00
Xavier Roche
8184b45696 Read the .arc bytes with od instead of piping into head (#1063)
`tail -c +N file | head -c 3` leaves tail writing into a pipe head has already
closed. Under this test's `set -o pipefail` that SIGPIPE becomes the script's
exit status, so 154 dies at 141 having printed nothing. GNU coreutils happens
to survive it, which is why the red only shows on the uutils leg. od seeks to
the offset itself and reads no more than it needs, so no second process is
left holding the other end.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:02:40 +02:00
Xavier Roche
62ca6e087a A repeated crawl can write a byte-identical .cdx, so the rerun test reds (#1046)
* A repeated crawl can write a byte-identical .cdx, so the rerun test reds

--archive-replaced-on-rerun compared bytes to decide the second pass had
rewritten each archive file. WARC segments carry a fresh record UUID, but the
.cdx holds no per-run entropy: only a second-granularity timestamp and
length/offset fields that move with the gzip member sizes. Two identical passes
can produce the same bytes, about 0.5% of runs, and the test read that as a
stale file.

Assert the replacement positively instead, reusing the poison the .wacz check
already used: every snapshotted file is overwritten with a marker between the
passes, and none of it may survive. Also make warc_cdx_flush() say something
when it leaves the previous pass's index beside a freshly swapped archive,
rather than returning silently on an open failure or an empty index.

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>

* Close the WARC writer on the rollback path, and stop warning when nothing was replaced

warc_open's own failure paths call warc_close with cdx_on already set, and
warc_commit returns early when there is no previous archive to protect, so a
failed archive open reached the new zero-count warning and logged a second,
false error line. Gate it on the writer having opened, and on the run not
having failed.

The "no data seems to have been transferred" branch in httrack_main returns
before the teardown that closes the writer, which is the one path where a stale
index can outlive its archive and the warning can never fire. Close it there
too. Closing it outright would be wrong: the temporary holds a warcinfo record,
so warc_commit would swap an almost empty archive over the previous one and
destroy it, measured as 2431 bytes and 2 index lines down to 1509 and 1. The
new entry point marks the run as one that must replace nothing, which drops the
temporaries and keeps the previous archive, while still writing the index for
an archive written in place.

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

* Tighten the --archive-replaced-on-rerun header

Cause before effect, and the entry fits in three lines again.

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

* Stop reading a straggler tick as a live reporter in 172

The stub reporter advances its marker with a forked mv, and that mv outlives
the kill -9 of its parent, so the marker can move once more after the wedge
branch has already ended the reporter. still_ticking calls it alive on the
first differing sample, which reds the suite (#1058). The two negative call
sites now wait for the marker to hold still instead, which a straggler only
rebaselines; the positive one keeps the old predicate.

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

* Renumber the WARC rollback test to 238

232 was taken by 232_online-gate-outoftree.test, which landed on master
in #1047 while this branch was open.

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

* Gate the empty-index warning on the archive this run actually touched

warc_cdx_flush() warned about a stale .cdx whenever a writer opened cleanly
and indexed nothing, but warc_commit() returns early when there is no previous
archive to protect, so a first run that saves nothing hit the warning with no
index to invalidate. Gate on protect_prev, which reaching the flush proves a
swap happened, and add the mirror case the old gate stayed silent on: a run
written in place and then abandoned, whose archive is clobbered while an index
beside it still describes what used to be there.

Report a write error ahead of fclose's echo of it, too: glibc drops the
unwritten buffer on a failed flush, so the "index is incomplete" arm only ever
fired when the total happened to land on a buffer boundary.

A warc-cdx-errors self-test drives all five outcomes, over /dev/full for the
two write failures.

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

* Warn about a stale index only when one is on disk

The empty-index gate keyed on protect_prev, which stands in for "this run
replaced the archive", but not for the index the message names: a run written
in place and abandoned, or one swapped over a previous archive, warned that
<base>.cdx "was not rewritten" whether or not that file had ever existed. It
now asks the disk. The self-test's two positive cases left no index behind, so
they passed on a message about a file that was not there; give them one, and
add the two negatives that pin the terms the old cases could not tell apart.

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-07 21:19:39 +00:00
Xavier Roche
7c6e5c3018 An FTP crawl ignores --timeout and --max-time, pinning a slot for five minutes (#1050)
* An FTP crawl ignores --timeout and --max-time, pinning a slot for five minutes

run_launch_ftp hardcoded a 300 second read timeout and never looked at
opt->timeout, so every control- and data-channel read on an FTP slot
inherited it: a server that accepted the connection and then went quiet
held the slot for five minutes. The timeout now comes from opt->timeout,
which defaults to 120 rather than 300.

--max-time could not end an in-flight FTP transfer either. That slot is
owned by the FTP thread, which back_wait cannot abort without racing it,
so each wait inside the thread is instead clipped to what is left of the
mirror deadline and the thread gives up on its own.

Closes #1039

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

* Pin the FTP data-channel wait and --timeout=0 in the tests

The two tests passed against a tree that hardcoded the body-transfer wait
back to 300, because one never reached RETR and the other read the outer
timeout rather than the value the wait used. Test 233 now runs a second,
answering server alongside the mute one: a stalled body transfer under
--timeout=5 with no --max-time, so only the data-channel wait can hold
it, and a trickled one under --timeout=0, which must complete rather than
expire at once.

Also trims the comments the review flagged, and documents what the two
FTP helpers do rather than only how opt is used.

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

* Drop the premature 3.49-19 release-notes block

history.txt carries a version block only when a release is cut, curated
from the tag diff; twelve releases show no feature PR opening one. Two
open PRs had each created 3.49-19 at the same anchor, and the file has no
merge=union, so the second to land would have conflicted.

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-07 21:05:07 +00:00
Xavier Roche
1878595043 The engine frees the transfer slots while an FTP thread is still writing to one (#1055)
* Wait for FTP threads before freeing the slots and options they still read

An FTP transfer runs in a detached thread holding a pointer into sback->lnk,
and nothing waited for it: back_delete_all() wiped the slot and back_free()
released the array while the thread was still writing through them. A mirror
limit makes it trivial to hit, since the engine stops the moment the cap trips.
Against a trickling server, "-M100000" segfaulted in run_launch_ftp writing
back->r.size.

httrack.c drained threads before hts_free_opt(), but hts_main() and htsweb.c
did not, so opt had the same exposure.

htsftp.c now keeps a mutex-guarded list of live workers, registered before the
thread is spawned and released only after the worker's last store to the slot.
ftp_stop_workers() cancels them and waits, and runs at both lifetime
boundaries: back_delete_all() for the slot array, hts_free_opt() for opt.

The wait has to be bounded rather than merely patient, so wait_socket_receive()
and get_ftp_line() now take the slot and leave their poll loop as soon as
stop_ftp is raised. Previously the flag was only read between operations, so a
silent server held teardown for the whole read timeout. A failed thread spawn is
also handled now; it used to leave a slot nobody would ever reap.

Test 236 crawls a trickling server under a size cap, then a stalled one, and
requires teardown well inside the read timeout. Both fail on master, the first
on the segfault and the second by taking 300 seconds.

Closes #1051

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

* Budget the trickle crawl's wall clock, and trim the review comments

The trickle half of test 236 asserted only that the crawl survived, which a
drain that waits for the transfer instead of cancelling it also satisfies: it
exits 0 with the whole file fetched. Measured at 103s against 3s for a real
cancel, so the pass now carries a 30s budget too. Also records why the crash is
reproducible at all, since it rests on the backlog array being large enough for
free() to unmap.

Comment cleanup: one issue citation instead of five, a one-fact contract for
ftp_stop_workers, and a one-line test header. The registration-timing note keeps
the invariant and drops the issue number. The history entry said --max-size
could not stop an FTP download, which reads as the cap never firing; it fired,
then crashed or took the full read timeout to tear down.

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>

* tests: fail 236 on a sanitizer report, not only on a signal

Both crawl cases only asserted "not killed by a signal", which a sanitized
build never satisfies: ASan catches the #1051 use-after-free and exits 1
unless the job also sets abort_on_error, so the regression test passed on a
buggy binary. Capture the crawl's stderr and reject any ASan/UBSan/MSan
report; leaks at exit stay out of scope. Vacuous on a normal build, where the
signal check still fires.

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

* Drop the premature 3.49-19 release-notes block

history.txt carries a version block only when a release is cut, curated
from the tag diff; twelve releases show no feature PR opening one. Two
open PRs had each created 3.49-19 at the same anchor, and the file has no
merge=union, so the second to land would have conflicted.

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-07 22:06:53 +02:00
Xavier Roche
057648e335 --single-file maintains a second HTML parser instead of reusing htsparse (#1049)
`--single-file` shipped with its own HTML and CSS scanner in `htssinglefile.c`, about 1140 lines doing what `htsparse.c` already does. This replaces it. htsparse now appends an unguessable per-run mark to each reference it saves that the pass may inline, and the end-of-mirror pass becomes a substitution over those marks: no HTML and no CSS is parsed there at all. The scanner goes, and with it the private attribute table that had already drifted from `hts_detect[]` twice. `htssinglefile.c` goes from 1155 to 863 lines; `htsparse.c` gains 31.

The mark is `#!<16-hex secret>.<class>.<len>`. It is a fragment, so a mirror an interrupted run left marked still browses. The secret is CSPRNG bits drawn once per run and never written to disk, which is what makes the mark unforgeable: a site cannot spell one, so no sanitiser has to keep hostile bytes away from it. `<len>` is the length of the reference preceding the mark, so the pass never has to guess where a reference begins. `<class>` is the context htsparse saw, checked against the resolved type so a mismatch fails loudly instead of inlining whatever the walk found.

Behaviour worth knowing about. A fragment comes back byte-identical rather than re-escaped, since the mark covers the reference alone and what follows it is document text nothing touches; the query moved inside the marked span, because glued to base64 it corrupts the payload. Expanding a mark needs the secret, but recognising one well enough to delete it does not, so the end-of-mirror sweep strips by shape. That clears what an interrupted earlier run abandoned, and costs a page any mark-shaped text of its own. A `<link>` carrying no `rel` is no longer inlined, which is what the old scanner did.

`htsserver` links libhttrack but cannot see a hidden symbol inside it, so the CSPRNG that #1011 added privately to `htsweb.c` and this branch added to `htstools.c` now share `htsrandom.c`.

Closes #749
2026-08-07 18:25:28 +00:00
Xavier Roche
7a7206d7f1 Update Turkish translation from issue #306 contribution (#1048)
* Update Turkish translation from issue #306 contribution

Value-merges the epiusu/httrack fork's Turkish.txt into our own: our
key set and order are kept as-is, but each key's value is replaced
with the fork's translation where the fork has that key. 227 of our
508 values changed; the fork's 44 missing keys (translated against an
older English.txt) keep their existing value, and the fork's one key
absent from our English.txt is dropped.

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

* Catch a value left as raw UTF-8 under a declared single-byte charset

A single-byte charset maps all 256 byte values, so the existing iconv
decode can never fail on one and cannot see a translation pasted in as
UTF-8 without transcoding. Only the C1-control heuristic stood in the
way, and a UTF-8 sequence whose bytes are all >= 0xA0 decodes to
ordinary printable characters under Latin-1/-9, sailing straight past
it.

Scan the raw bytes instead: a whole line that is itself valid
multi-byte UTF-8 under a file declaring a single-byte charset is the
leak. Scoped to those charsets because the premise fails for BIG5,
gb2312 and shift-jis, whose genuine text matches the same shape.

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

* Two Polish translations were stored as UTF-8 in a windows-1250 file

The Keep-Alive option label and its tooltip carry raw UTF-8 bytes in a
file that declares windows-1250, so the GUI and htsserver render them
as mojibake ("połączenia"). Transcode just those two values; the rest
of the file was always windows-1250.

Found by the raw-UTF-8 check added in the previous commit.

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

* The lang charset check uses grep -P, which macOS does not have

The raw-UTF-8 scan reached for a PCRE lookahead, so BSD grep exits with
"invalid option -- P" and the loop that consumes its output sees nothing.
The per-file scan then passes everything, and the negative control fires
instead: "the charset check passed a deliberately mislabelled file".

Express the same grammar as a POSIX ERE over raw bytes and let awk apply
it, which also folds the per-line length probe into the one pass. Checked
against the PCRE on all 30 lang files plus both probes, under gawk and
mawk: identical line sets.

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: Ümit Solmaz <usncb@yandex.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:22:29 +00:00
Xavier Roche
e4df3b387c Out-of-tree builds skip the online crawl tests with a misleading reason (#1047)
* tests: resolve online-gate helper scripts against the script's own directory

The seven online crawl tests invoked check-network.sh and crawl-test.sh by bare
relative name, which the shell resolves against the caller's cwd. make check
runs from the build tree's tests/, so an out-of-tree build finds no copy: the
gate failed to open, the || branch reported "skipping online unit tests", and
the tests skipped whatever --enable-online-unit-tests asked for.

check-network.sh had the same defect in its own auto-probe call to
crawl-test.sh. Resolve all of them against $(dirname "$0"), the idiom
local-crawl.sh already uses. 232 pins the skip reason from an unrelated cwd and
sweeps the tree for the same form. The option's help string advertised
default=yes; the code has always defaulted to no.

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

* tests: keep 232's comment clear of 226's credential-scrub gate

226 flags any .test naming the Windows suite driver without the scrub line,
comment or not, so the exemption note tripped it.

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

* tests: tighten 232's sweep, which is the only check below the gate

With the flag off check-network.sh exits before a test reaches its own
crawl-test.sh line, so the runtime probe cannot see that class at all and the
static sweep carries it alone. Four spellings of #1016 were getting through: a
leading ./, a quoted bare name, eval, and a variable holding the bare name.

Quoting and ./ change nothing about what the shell opens, so the shape
exemption is gone and the two call sites that mean the cwd are named instead. A
variable operand is resolved back to its assignment, and eval sites are pinned
as a set rather than passed over. Each form is planted as a fixture, with
anchored and commented fixtures for the other direction.

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

* tests: 172 never checked that it ran its own staged suite copy

Pointing it at the srcdir copy passed, dropping the reap_leftover_processes
neuter it appends to the staged testlib.sh and leaving the test to reap sibling
engines under "make check -j", a flake whose symptom points nowhere near its
cause. test-timeout.sh sources testlib.sh from the staging directory too, so a
plain marker file would still be written under the mutant; record the sourcing
script instead and require the driver.

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-07 16:38:48 +00:00
Xavier Roche
58ddc5a6b1 An --enable-fuzzers build cannot link proxytrack (#1045)
* Link the sancov runtime into every program under --enable-fuzzers

--enable-fuzzers appends -fsanitize=fuzzer-no-link to DEFAULT_CFLAGS, so
every object in the tree carries sancov instrumentation, but only fuzz/
linked a runtime defining those hooks. src/proxytrack, the first program
linked, failed on __sancov_lowest_stack and __sanitizer_cov_trace_*.

Append the flag to DEFAULT_LDFLAGS in the same branch, and add a CI leg
that builds the bare configuration: the existing fuzz job links ASan,
whose runtime happens to define the same hooks, so it could not see this.

Closes #1030

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

* Tighten the two new comments

Both opened with setup instead of the point, and the configure.ac one
restated its neighbour two lines above.

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-07 17:39:46 +02:00
Xavier Roche
fb01ad6f65 A resurrected test registration stops make check with no test results (#1044)
* The union merge driver on tests-list.mk resurrects deleted registrations

merge=union on tests/tests-list.mk only applies to a local merge: GitHub
does not run the driver when it computes mergeability, so both appending
PRs go CONFLICTING anyway, and a conflicted PR runs no checks. Where it
does apply it never removes a line, so a rebase past a deletion restores
the old registration, and make check then exits 2 with no test results.

Drop the attribute and check the pairing instead, in the lint job (a
stale registration kills make check before it can report) and in the
suite (an unregistered file is simply never run).

Closes #1037

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

* Keep the union, and make the registration it resurrects loud

Review of the first commit: the attribute is right on 75 of the 76 test
add/remove events since it landed, and dropping it would not have helped
the conflicted-PR half of #1037, since GitHub never ran the driver. So
keep it, pin the file to LF, and let the checker cover its one failure
mode.

Give 231 four broken fixtures: every branch of the checker is silent on
a healthy tree, so a checker that had stopped comparing passed the old
assertion just as well. Pin LC_ALL=C, since BSD comm need not share the
collation GNU sort used.

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

* Count a directory or dangling symlink named NN_x.test as absent

Set equality held for both, so a registration pointing at one passed the
checker while make check would still choke on it.

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-07 15:57:09 +02:00
Xavier Roche
1a211555d0 3.49.18 fails to build on Ubuntu: two tests assume GNU coreutils (#1043)
* 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>

* 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>

* 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>

* Re-trigger CI after the Actions outage

The 2026-08-06 Actions incident throttled webhooks, so the previous push never created a workflow run on this head.

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-07 00:37:43 +00: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
Xavier Roche
3426ea35ea Release 3.49.18 (#1024)
* Release 3.49.18

Source-only release so packagers can pick up the test-suite build fixes:
3.49.17-1 was FTBFS on armhf, powerpc, hppa and loong64, and the suite
failed on a Fedora build root with no ps. The engine is untouched, so
VERSION_INFO moves by revision alone.

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

* Wrap the 3.49.18 changelog entry under 80 columns

lintian's debian-changelog-line-too-long fires on two lines of the new
entry, and the CI gate runs --fail-on=error,warning. The sid perl
transition that had been masking this gate has cleared, so it is live
again.

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

* Sharpen the 3.49-18 release notes

The #1021 line named the symptom and implied the remedy was a louder
failure; the fix is a /proc fallback, so the diagnostics work. Add the
internal catch-all the 3.49-17 block carries, so the cross-arch matrix
appears to the upstream audience and not only in debian/changelog.

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-05 21:43:50 +00:00
Xavier Roche
0274e016af Four architectures cannot build the altstack test shim, and nothing before the buildd sees it (#1023)
* Fix the four 3.49.17 buildd failures and cross-compile for those arches in CI

The LD_PRELOAD altstack shim did not build on Debian's 32-bit time64
architectures, and 183 read a frame count no unwinder there can produce.

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>

* Install the cross libc, and keep a trace-less report a failure

Review found the frame floor took a report naming no frame at all for a
weak unwinder, and the cross jobs had no target headers.

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>

* Give the cross jobs a libssl-free configure and assert what they built

No ports architecture has a cross libssl, and an empty TESTS= would have
passed having built nothing.

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>

* Add deb-src in place: a second stanza collides on the keyring spelling

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>

* Extract zlib unpatched: Debian's arch patches need Debian's rules

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>

* Turn off zlib's s390x vector CRC: the .dfsg repack drops its sources

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>

* Drop zlib's s390x vx object too, not just its define

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>

* Trim the raw_mmap comment

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>

* Keep the raw_mmap comment inside clang-format's width

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>

---------

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-05 18:43:51 +00:00
Xavier Roche
22fcf9ffff The suite watchdog dumps nothing on a host without ps (#1022)
* The suite watchdog dumps nothing on a host without ps

Fedora's build root ships no procps, so every process list in the hang
diagnostics came back empty: the guard still fired, but named no process
and could not signal the wedged engine for a stack. Read /proc directly
when ps is missing, and say so when neither source is available.

Closes #1021

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

* Harden the no-ps fallback and its test

Review follow-ups: a ps that exits 0 while listing nothing (hidepid, a
locked-down container) now falls back too, whitespace inside an argv can
no longer shift the columns the consumers match on, a failed /proc open
stops leaking to stderr, and a bogus CLK_TCK can no longer abort the
shell. The test leg pins the header line, the column order and the group
filter, and drives request_engine_backtraces itself.

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

* kill -0 succeeds on a zombie, so the SIGABRT check failed in a container

The fake engine is reparented once its subshell exits, and a container's
pid 1 does not always reap it, so the signalled process stays visible to
kill -0. Read its state from /proc instead: gone or Z both prove the
signal landed.

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-05 18:16:14 +00:00
136 changed files with 8118 additions and 2085 deletions

3
.gitattributes vendored
View File

@@ -6,6 +6,3 @@
# bash die on $'\r' on every line of them.
*.test text eol=lf
*.sh text eol=lf
# Scoped to this file so a union can never touch build logic.
tests/tests-list.mk merge=union

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,
@@ -510,6 +571,33 @@ jobs:
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: bash fuzz/run-fuzzers.sh fuzz check
# Catches #1030: the job above links ASan, whose runtime already defines the
# sancov hooks the instrumentation calls, hiding a program left without one.
fuzz-no-sanitizer:
name: build (fuzzers, no sanitizer runtime, clang)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
submodules: recursive
- name: Install build dependencies
run: |
set -euo pipefail
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
- name: Configure (fuzzers, static)
run: |
set -euo pipefail
autoreconf -fi
./configure CC=clang --enable-fuzzers --disable-shared
- name: Build
run: make -j"$(nproc)"
# Optional-dependency build: compile and test with HTTPS/OpenSSL disabled --
# the configuration users on minimal systems build, and one libssl is not even
# installed here so configure cannot silently re-enable it. The matrix above
@@ -715,7 +803,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
@@ -725,6 +814,11 @@ jobs:
- name: shfmt
run: shfmt -d -i 4 $SHELL_SCRIPTS
# A misnamed test falls outside the glob "make check" runs, so it goes
# missing without failing anything.
- name: test file names
run: bash tests/check-test-names.sh
# MSBuild rejects a malformed .vcxproj with a bare MSB4025 and no build, so
# catch it here in seconds rather than on a Windows runner minutes in.
- name: XML well-formedness (MSBuild project files)
@@ -742,6 +836,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,

116
.github/workflows/cross-arch.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
# Cross-compiles for the Debian architectures no GitHub runner exists for.
# 3.49.17 failed on four of them at once and every failure was a compile or
# assemble error that x86-64 and arm64 cannot produce, so the buildd was the
# first thing to see the code.
name: Cross-arch
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
# Least privilege: the workflow only needs to read the repo.
permissions:
contents: read
# Cancel superseded runs on the same branch or PR.
concurrency:
group: cross-arch-${{ github.ref }}
cancel-in-progress: true
jobs:
cross:
name: cross (${{ matrix.arch }})
runs-on: ubuntu-24.04
# sid, not stable: the point is to predict what the sid buildds will say.
container: debian:sid
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# arch selects the hardening flags, triplet is the toolchain prefix.
# The first four are 3.49.17's failures.
- { arch: armhf, triplet: arm-linux-gnueabihf }
- { arch: powerpc, triplet: powerpc-linux-gnu }
- { arch: hppa, triplet: hppa-linux-gnu }
- { arch: loong64, triplet: loongarch64-linux-gnu }
- { arch: sh4, triplet: sh4-linux-gnu }
- { arch: m68k, triplet: m68k-linux-gnu }
- { arch: sparc64, triplet: sparc64-linux-gnu }
- { arch: riscv64, triplet: riscv64-linux-gnu }
- { arch: s390x, triplet: s390x-linux-gnu }
env:
DEB_HOST_ARCH: ${{ matrix.arch }}
TRIPLET: ${{ matrix.triplet }}
ZPREFIX: /tmp/zlib-${{ matrix.arch }}
steps:
- name: Install the toolchain
run: |
set -euo pipefail
# deb-src, so zlib below comes from the archive and not a pinned URL.
# In place: a second stanza has to repeat Signed-By, and apt rejects
# the same source carrying two spellings of the keyring.
sed -i 's/^Types: deb$/Types: deb deb-src/' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y --no-install-recommends \
build-essential autoconf automake libtool autoconf-archive \
dpkg-dev git ca-certificates \
"gcc-$TRIPLET" "libc6-dev-$DEB_HOST_ARCH-cross"
- uses: actions/checkout@v7
with:
submodules: recursive
- name: Cross-build zlib
run: |
set -euo pipefail
# Mandatory, and no ports arch has a zlib1g-dev:<arch> to
# multiarch-install. -fPIC because libhttrack.so links it.
mkdir -p /tmp/zsrc && cd /tmp/zsrc
# Unpatched: Debian's arch-specific patches expect its own rules to
# build them (s390x's vector CRC pulls a header they add), and we only
# need something to link -lz against.
apt-get source --download-only zlib1g
dpkg-source --skip-patches -x ./*.dsc src
cd src
CHOST="$TRIPLET" CC="$TRIPLET-gcc" CFLAGS="-O2 -fPIC" \
./configure --prefix="$ZPREFIX" --static
# configure detects s390x's vector CRC, whose sources live in the
# contrib/ the .dfsg repack drops: drop the define and the object it
# would build. Nothing here needs a fast CRC.
sed -i -E 's/(-DHAVE_S390X_VX|crc32_vx\.l?o)//g' Makefile
make -j"$(nproc)"
make install
- name: Configure
run: |
set -euo pipefail
autoreconf -fi
mkdir -p /tmp/bld && cd /tmp/bld
# The buildd's own per-arch flags: -D_FILE_OFFSET_BITS=64 is the shim
# hazard, and -fstack-clash-protection is not offered everywhere.
eval "$(dpkg-buildflags --export=sh)"
# A cross AC_TRY_RUN answers "cross", which would swap in the bundled
# snprintf that no buildd ever builds. Every glibc passes these.
# --disable-https: no ports arch has a cross libssl, so the TLS paths
# are the one part of the tree this matrix does not compile.
"$GITHUB_WORKSPACE/configure" --host="$TRIPLET" \
--build="$(dpkg-architecture -qDEB_BUILD_GNU_TYPE)" --disable-https \
ac_cv_have_working_snprintf=yes ac_cv_have_working_vsnprintf=yes \
CPPFLAGS="${CPPFLAGS:-} -I$ZPREFIX/include" \
LDFLAGS="${LDFLAGS:-} -L$ZPREFIX/lib"
- name: Build
run: make -C /tmp/bld -j"$(nproc)"
- name: Build the test artifacts
# Nothing runs here, but the shims are where three of the four 3.49.17
# failures were: they only build under `check`. Asserted, because an
# empty TESTS= would otherwise pass having built nothing.
run: |
set -euo pipefail
make -C /tmp/bld -j"$(nproc)" check TESTS=
test -f /tmp/bld/tests/.libs/libaltstackprobe.so

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

@@ -19,8 +19,12 @@ the operational checklist: toolchain, invariants, and how to ship a change.
(`request_queue_size`) so macOS/BSD don't drop connections under a parallel
`-c16` bigcrawl the way Python's default backlog of 5 did.
Or run `sh build.sh` to do bootstrap + configure + make in one shot.
- A `tests/NN_*.test` runs only if listed in `tests/tests-list.mk`; an
unregistered file is silently skipped.
- `configure` globs `TESTS` from `tests/[0-9]*_*.test`, so a new test needs no
registration, but an existing build dir keeps the list it was configured with:
`231_test-names.test` goes red until you reconfigure. It also compares the glob
against `git ls-files`, so a test you forgot to `git add` fails there instead of
quietly shrinking CI's suite. Name one outside the pattern and it never runs;
`tests/check-test-names.sh` (also a CI lint) rejects that.
- `make check` prepends the build's `src/` to `PATH`, but a hand-run `.test` does
not — an installed `/usr/bin/httrack` then shadows your build. Run via `make
check`, or `PATH="<bld>/src:$PATH"` for a manual run.
@@ -38,6 +42,10 @@ the operational checklist: toolchain, invariants, and how to ship a change.
match that is not on the last line is exposed. Capture the reply, assert the
status line it must carry (an empty, truncated or redirected one is
marker-free too), then match with a here-string: `grep -q M <<<"$reply"`.
`head -c N` and `head -n N` end the same way, and there the reds are
platform-specific: GNU `tail | head -c 3` survives, the uutils coreutils leg
turns the same line into exit 141 with no output at all. Let the reader seek
instead: `od -An -c -j <skip> -N <len> file`.
## Hard invariants
- **Generated autotools files are NOT in git.** `configure`, every

View File

@@ -1,6 +1,6 @@
AC_PREREQ([2.71])
AC_INIT([httrack], [3.49.17], [roche+packaging@httrack.com], [httrack], [http://www.httrack.com/])
AC_INIT([httrack], [3.49.19], [roche+packaging@httrack.com], [httrack], [http://www.httrack.com/])
AC_COPYRIGHT([
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) 1998-2015 Xavier Roche and other contributors
@@ -29,6 +29,11 @@ AC_CONFIG_SRCDIR(src/httrack.c)
AC_CONFIG_MACRO_DIR([m4])
AC_CONFIG_HEADERS(config.h)
AM_INIT_AUTOMAKE([subdir-objects])
# 3:12:0: revision-only bump. httrackp gained a tail field (singlefile_state) and
# htsopt.h added volatile qualifiers; no layout moved, nothing went away.
# 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.
# 3:8:0: revision-only bump, no ABI change.
@@ -37,7 +42,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:9:0"
VERSION_INFO="3:12:0"
AM_MAINTAINER_MODE
AC_USE_SYSTEM_EXTENSIONS
@@ -392,7 +397,10 @@ AC_CHECK_TYPE(sa_family_t, [], [AC_DEFINE([sa_family_t], [uint16_t], [sa_family_
AX_CHECK_ALIGNED_ACCESS_REQUIRED
# check for various headers
AC_CHECK_HEADERS([execinfo.h sys/ioctl.h])
AC_CHECK_HEADERS([execinfo.h sys/ioctl.h sys/random.h])
## CSPRNG for the --single-file mark; /dev/urandom is the fallback
AC_CHECK_FUNCS([getrandom])
### zlib (mandatory)
CHECK_ZLIB()
@@ -547,7 +555,7 @@ AC_DEFINE(HAVE_STRNLEN, 1,[Check for strnlen])], AC_MSG_RESULT([not found]))
## Online unit tests
AC_MSG_CHECKING(whether to enable online unit tests)
AC_ARG_ENABLE([online-unit-tests],
[AS_HELP_STRING([--enable-online-unit-tests=@<:@yes/no/auto@:>@],[Enable online-unit-tests @<:@default=yes@:>@])],
[AS_HELP_STRING([--enable-online-unit-tests=@<:@yes/no/auto@:>@],[Enable online-unit-tests @<:@default=no@:>@])],
[
case "${enableval}" in
no|yes|auto)
@@ -589,6 +597,10 @@ if test x"$fuzzers" = x"yes"; then
AX_CHECK_COMPILE_FLAG([-fsanitize=fuzzer-no-link],
[DEFAULT_CFLAGS="$DEFAULT_CFLAGS -fsanitize=fuzzer-no-link"],
[AC_MSG_ERROR([--enable-fuzzers requires libFuzzer support (clang)])])
# Ordinary programs are instrumented too, so they need that runtime at link.
AX_CHECK_LINK_FLAG([-fsanitize=fuzzer-no-link],
[DEFAULT_LDFLAGS="$DEFAULT_LDFLAGS -fsanitize=fuzzer-no-link"],
[AC_MSG_ERROR([--enable-fuzzers requires libFuzzer support (clang)])])
# clang's static sanitizer runtimes clash with -Wl,--no-undefined on the .so.
if test x"$enable_shared" != x"no"; then
AC_MSG_ERROR([--enable-fuzzers requires --disable-shared])
@@ -596,6 +608,20 @@ if test x"$fuzzers" = x"yes"; then
fi
AM_CONDITIONAL([FUZZERS], [test x"$fuzzers" = x"yes"])
# tests/Makefile.am takes its TESTS from here instead of a checked-in list,
# which made every pair of test-adding PRs conflict on the forge (#844).
AC_MSG_CHECKING([for test scripts])
TESTS_LIST=
hts_ntests=0
for hts_t in "$srcdir"/tests/[[0-9]]*_*.test; do
test -f "$hts_t" || continue
TESTS_LIST="$TESTS_LIST `echo "$hts_t" | sed 's|.*/||'`"
hts_ntests=`expr $hts_ntests + 1`
done
AC_SUBST([TESTS_LIST])
AC_MSG_RESULT([$hts_ntests])
test "$hts_ntests" -gt 0 || AC_MSG_ERROR([no test script matched tests/[[0-9]]*_*.test])
# Final output
AC_CONFIG_FILES([
Makefile

22
debian/changelog vendored
View File

@@ -1,3 +1,25 @@
httrack (3.49.19-1) unstable; urgency=medium
* New upstream release: mostly FTP fixes, plus stack-overflow, mirror
interruption and --update data-loss fixes; full list in history.txt. Two
of them matter to the build: the test suite no longer assumes GNU
coreutils, and the AppStream metainfo no longer declares a non-stock icon
name that appstream-util rejects.
-- Xavier Roche <xavier@debian.org> Sat, 08 Aug 2026 21:29:56 +0200
httrack (3.49.18-1) unstable; urgency=medium
* New upstream release, fixing the 3.49.17-1 build failures on armhf,
powerpc, hppa and loong64: the test suite's mmap interposer did not
compile on the first three, and the alternate-stack test insisted on a
longer backtrace than loong64's unwinder produces. Nothing in the engine
changed. Upstream now cross-builds the test shims for the ports
architectures on every push, so the next such break surfaces before the
buildds do.
-- Xavier Roche <xavier@debian.org> Wed, 05 Aug 2026 23:00:24 +0200
httrack (3.49.17-1) unstable; urgency=medium
* New upstream release: security fixes in the FTP and HTTP request paths and

View File

@@ -58,7 +58,9 @@ EXTRA_DIST = README.md run-fuzzers.sh \
corpus/singlefile/img-src.html corpus/singlefile/link-rel.html \
corpus/singlefile/style-block.html corpus/singlefile/style-attr.html \
corpus/singlefile/srcset.html corpus/singlefile/rawtext.html \
corpus/singlefile/malformed.html corpus/singlefile/many-attrs.html \
corpus/singlefile/malformed.html \
corpus/singlefile/mark-at-eof.html corpus/singlefile/mark-only.html \
corpus/singlefile/mark-degenerate.html corpus/singlefile/traversal.html \
corpus/sitemap/urlset.xml corpus/sitemap/sitemapindex.xml \
corpus/sitemap/truncated.xml corpus/sitemap/urlset.xml.gz \
corpus/arc/roundtrip.arc corpus/arc/truncated.arc \

View File

@@ -1,3 +1,4 @@
<img src="a.png">
<img src=big.png alt=over-cap>
<img src="../escape.png"><img src="/abs.png"><img src="data:,x">
<img src="a.png">
<img src=big.png alt=over-cap>
<img src="../escape.png"><img src="/abs.png">
<img src="data:,x">

View File

@@ -1,4 +1,3 @@
<link rel="stylesheet" href="s.css">
<link rel=icon href=a.png>
<link rel="next" href="p2.html">
<link rel="preload" href="j.js">
<link rel="stylesheet" href="s.css">
<link rel="icon" href="a.png">
<link rel="canonical" href="other.html">

View File

@@ -1,5 +1,3 @@
<img src="unterminated.png
<div style="background:url(a.png">
<style>@import url(
<!-- unterminated comment
<a href=
<p a=1 b=2 c=3 d=4 e=5 f=6 g=7 h=8 i=9 j=10 title="> <img src=a.png>">x</p>
<!--><img src="a.png">
<img src="a.png" >

View File

@@ -1 +0,0 @@
<img src="a.png" a0="v" a1="v" a2="v" a3="v" a4="v" a5="v" a6="v" a7="v" a8="v" a9="v" a10="v" a11="v" a12="v" a13="v" a14="v" a15="v" a16="v" a17="v" a18="v" a19="v" a20="v" a21="v" a22="v" a23="v" a24="v" a25="v" a26="v" a27="v" a28="v" a29="v" a30="v" a31="v" a32="v" a33="v" a34="v" a35="v" a36="v" a37="v" a38="v" a39="v" a40="v" a41="v" a42="v" a43="v" a44="v" a45="v" a46="v" a47="v" a48="v" a49="v" a50="v" a51="v" a52="v" a53="v" a54="v" a55="v" a56="v" a57="v" a58="v" a59="v" a60="v" a61="v" a62="v" a63="v" a64="v" a65="v" a66="v" a67="v" a68="v" a69="v">

View File

@@ -0,0 +1 @@
a.png

View File

@@ -0,0 +1,5 @@

==
""
a.png#!htsinlin
#!htsinlin

View File

@@ -0,0 +1 @@


View File

@@ -1,4 +1,3 @@
<script>var s="</scripting>"; if(a</b) x=1;</script>
<script src="j.js"></script>
<textarea></textareas></textarea>
<title></titles></title>
<script src="j.js"></script>
<script>var u="a.png";</script>
<textarea>a.png</textarea>

View File

@@ -1,2 +1 @@
<img srcset="a.png 1x, big.png 2x, a.png 100w">
<source srcset="a.png,, a.png 2x," src="a.png">
<img srcset="a.png 1x, big.png 2x, a.png 480w">

View File

@@ -1,2 +1,2 @@
<div style="background:url(a.png);list-style:url('a.png')"></div>
<p style='background:url("a.png")'>x</p>
<div style="background:url(a.png)"></div>
<div style='background:url("a.png")'></div>

View File

@@ -1,5 +1,4 @@
<style>@import "s.css";
@import url(sub/b.css);
div{background:url(a.png)}
/* url(a.png) */ p:after{content:"url(a.png)"}
<style>@import "s.css";
@import url(sub/b.css);
div{background:url(a.png)}
</style>

View File

@@ -0,0 +1,4 @@
<img src="../../../../etc/passwd">
<img src="a.png#frag?q=1">
<img src="sub/../a.png">
<img src="./a.png">

View File

@@ -60,6 +60,10 @@ static void sf_cleanup(void) {
(void) remove(sf_root);
}
/* One httrackp for the whole run: the mark secret lives on it, so a fresh one
per input would make every mark in the corpus unrecognisable. */
static httrackp *sf_opt = NULL;
/* A missing asset would silently reduce the target to its parser half. */
static void sf_write(const char *name, const char *data, size_t len) {
char path[700];
@@ -76,6 +80,48 @@ static void sf_text(const char *name, const char *data) {
sf_write(name, data, strlen(data));
}
/* Append ref plus a real mark for it. The secret is drawn per httrackp, so a
fixture cannot spell one; every mark the target sees is built here. */
static void sf_marked(String *out, const char *ref, char cls) {
char mark[SINGLEFILE_MARK_MAX];
StringCat(*out, ref);
StringCat(*out,
singlefile_mark(sf_opt, mark, sizeof(mark), cls, strlen(ref)));
}
/* \001<ref>\002 in an input becomes <ref> plus its mark, which is the only
way a corpus file can reach the mark parser at all. */
static void sf_expand_input(const char *in, size_t len, String *out) {
size_t i, start = 0;
StringClear(*out);
for (i = 0; i < len; i++) {
if (in[i] == '\001') {
start = StringLength(*out);
} else if (in[i] == '\002') {
char mark[SINGLEFILE_MARK_MAX];
StringCat(*out, singlefile_mark(sf_opt, mark, sizeof(mark),
SINGLEFILE_CLASS_ANY,
StringLength(*out) - start));
} else {
StringAddchar(*out, in[i]);
}
}
}
static void sf_css(const char *name, const char *pre, const char *ref,
const char *post) {
String body = STRING_EMPTY;
StringCopy(body, pre);
sf_marked(&body, ref, SINGLEFILE_CLASS_ANY);
StringCat(body, post);
sf_write(name, StringBuff(body), StringLength(body));
StringFree(body);
}
static void sf_init(void) {
static const char png[] = "\x89PNG\r\n\x1a\n";
static const char big[4096] = "\x89PNG";
@@ -83,6 +129,9 @@ static void sf_init(void) {
char path[700];
hts_init();
sf_opt = hts_create_opt();
sf_opt->log = sf_opt->errlog = NULL;
sf_opt->single_file_max_size = FUZZ_SF_CAP;
snprintf(sf_root, sizeof(sf_root), "%s/httrack-fuzz-sf-XXXXXX",
tmp != NULL && tmp[0] != '\0' ? tmp : "/tmp");
if (mkdtemp(sf_root) == NULL)
@@ -95,37 +144,50 @@ static void sf_init(void) {
sf_write("a.png", png, sizeof(png) - 1);
sf_write("big.png", big, sizeof(big));
sf_text("j.js", "var x=1;\n");
/* @import plus a url(), so an inlined stylesheet recurses and its own
relative reference is rebased. */
sf_text("s.css", "@import url(sub/b.css);\ndiv{background:url(a.png)}\n");
sf_text("sub/b.css", "p{background:url(../a.png)}\n");
/* Marked, so an inlined stylesheet recurses into its own marks and its
un-inlinable reference is rebased; unmarked assets leave the target as a
bare scan that reaches nothing. */
{
String css = STRING_EMPTY;
StringCopy(css, "@import url(");
sf_marked(&css, "sub/b.css", SINGLEFILE_CLASS_CSS);
StringCat(css, ");\ndiv{background:url(");
sf_marked(&css, "a.png", SINGLEFILE_CLASS_ANY);
StringCat(css, ")}\np{background:url(");
sf_marked(&css, "big.png", SINGLEFILE_CLASS_ANY);
StringCat(css, ")}\n");
sf_write("s.css", StringBuff(css), StringLength(css));
StringFree(css);
}
sf_css("sub/b.css", "p{background:url(", "../a.png", ")}\n");
}
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
static int inited = 0;
String out = STRING_EMPTY;
httrackp *opt;
/* Exact-length, unterminated: the rewriter is span-based, so ASan bounds a
read past html_len instead of it landing on a terminator. */
char *html = malloct(size != 0 ? size : 1);
String in = STRING_EMPTY;
char *html;
size_t html_len;
if (!inited) {
sf_init();
inited = 1;
}
memcpy(html, data, size);
opt = hts_create_opt();
opt->log = opt->errlog = NULL;
opt->single_file_max_size = FUZZ_SF_CAP;
sf_expand_input((const char *) data, size, &in);
html_len = StringLength(in);
/* Exact-length, unterminated: the rewriter is span-based, so ASan bounds a
read past html_len instead of it landing on a terminator. */
html = malloct(html_len != 0 ? html_len : 1);
memcpy(html, StringBuff(in), html_len);
StringFree(in);
StringClear(out);
(void) singlefile_rewrite_html(opt, sf_root, sf_page, html, size,
(void) singlefile_rewrite_html(sf_opt, sf_root, sf_page, html, html_len,
SINGLEFILE_MAX_PAGE_SIZE, &out);
StringFree(out);
freet(html);
hts_free_opt(opt);
return 0;
}

View File

@@ -4,6 +4,33 @@ HTTrack Website Copier release history:
This file lists all changes and fixes that have been made for HTTrack
3.49-19
+ Fixed: an over-long user name in an ftp:// URL was truncated at the credential boundary, so the login went to a different account (#1032)
+ Fixed: an over-long FTP path or host name aborted the process instead of failing that one link (#1019)
+ Fixed: an FTP crawl ignored --timeout and --max-time, so a server that went quiet held a slot for five minutes (#1039)
+ Fixed: an FTP crawl of a dead host took two minutes to give up, and no signal could shorten it (#1059)
+ Fixed: the engine released the transfer slots while an FTP thread was still writing to one, crashing a mirror that hit its size or time limit (#1051)
+ Fixed: Ctrl-C did not shorten a connection or a name lookup already in flight, so ending a crawl against an unreachable host took minutes (#1073)
+ Fixed: a crawl that gave up mid-loop, on the link limit or an abort, lost its WARC archive and its change report, and a WARC written past teardown was stranded in a .tmp file (#1061, #1060)
+ Fixed: a crawl that rolled back for want of data still replaced the previous WARC archive, stranding its temporary and never writing its index; a stale or unwritable index is now reported rather than left silently in place (#1053)
+ Fixed: a resume the server answered with an unusable Content-Range deleted the partial file and never fetched it again; the restart no longer spends the retry budget, and is latched to one per link (#581, #1052)
+ Fixed: a long argument to -n or -O smashed the stack through an unbounded panic message, and the growable arrays could wrap their capacity to zero (#1040)
+ Fixed: --single-file could write a reference mark longer than it reads back, leaving it in the delivered page as visible text (#1054, #1069)
+ Fixed: on a terminal with no input left, a crawl toggled between the spinner and the full panel on every pass (#1072)
+ Fixed: -%v1 ended each line with a carriage return, so the lines overwrote each other and a redirected log was unreadable; -%v2 now paints its panel only on a terminal
+ Fixed: the installed socket headers did not compile under -std=c99 without _POSIX_C_SOURCE (#1001)
+ Fixed: the 16 px application icon rendered every stem as grey (#938)
+ Fixed: the desktop files installed outside the data directory, and the AppStream metainfo declared an icon name that is not a stock one
+ Fixed: the build failed on distributions whose coreutils are not GNU's (#1042)
+ Changed: --single-file drops its own HTML and CSS scanner and reuses the crawler's parser, so it follows the same references the mirror does (#749)
+ Changed: updated Turkish translation, and repaired two mangled Polish strings (#306)
+ Changed: multiple internal hardening, build, test and CI improvements
3.49-18
+ Fixed: the 3.49.17 package build failed on four architectures: the test suite's mmap interposer did not compile on armhf, powerpc or hppa, and the alternate-stack test rejected loong64's shorter backtrace (#1023)
+ Fixed: the test suite failed on a host with no ps command, such as a Fedora build root; its hang diagnostics now read /proc directly (#1021)
+ Changed: multiple internal test and CI improvements, including a cross-compile matrix covering the Debian ports architectures
3.49-17
+ New: a pkg-config file, libhttrack.pc, ships with the development headers (#1018)
+ Fixed: a crawled page could read the WebHTTrack session id and drive the control panel; the id is now unguessable and cross-origin commands are refused (#877)

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,

View File

@@ -58,6 +58,9 @@
<main id="main">
<!-- Section ids are link API: the redirect stubs and the Android app's contextual help
hardcode them, and an app release outlives its engine pin. -->
<h1>The HTTrack interface, step by step</h1>
<p class="lede">HTTrack copies a website to your disk so you can read it offline. The same
@@ -88,7 +91,7 @@ itself down once those pings stop.</p>
<p>Each option also lists its command-line equivalent, so anything you set here can later be
scripted. The <a href="cmdguide.html">command-line guide</a> covers that side.</p>
<div class="note" data-for="droid">
<div class="note" data-for="droid" id="droid-first-launch">
<p><b>First launch.</b> Android asks for permission to store mirrors on your device. Without
it the app cannot save anything, so tap <b>Allow</b>.</p>
<figure>
@@ -287,7 +290,7 @@ usual causes.</p>
<p data-for="win web">The files sit under the base path you chose, in a folder named after the
project. Opening <code>index.html</code> there browses the mirror without HTTrack.</p>
<p data-for="droid">Mirrors are written to
<p data-for="droid" id="droid-storage-location">Mirrors are written to
<code>/storage/emulated/0/HTTrack/Websites</code>, in a folder named after the project. That is
shared storage, so a file manager or a USB cable can reach it, and opening
<code>index.html</code> there browses the mirror without the app.</p>

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>
@@ -51,6 +50,27 @@
<content_rating type="oars-1.1"/>
<!-- Newest first; tests/01_engine-version-macros.test enforces it. -->
<releases>
<release version="3.49.19" date="2026-08-08">
<description>
<ul>
<li>Stopping a mirror now takes effect at once, instead of waiting for connections that were still being opened</li>
<li>A mirror that stops early keeps its web archive and its list of changes</li>
<li>A partly downloaded file the server refuses to resume is fetched again rather than lost</li>
<li>An FTP site that stops responding no longer blocks a download for five minutes</li>
<li>A very long web address or FTP path no longer crashes the mirror</li>
<li>Stopping a mirror while an FTP file is downloading no longer crashes</li>
<li>An FTP address carrying an over-long user name could sign in as a different account</li>
<li>Updated Turkish translation</li>
</ul>
</description>
</release>
<release version="3.49.18" date="2026-08-05">
<description>
<ul>
<li>The program itself is unchanged: this release only fixes building HTTrack from source on some Linux distributions and processors</li>
</ul>
</description>
</release>
<release version="3.49.17" date="2026-08-05">
<description>
<ul>

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

@@ -539,9 +539,9 @@ U
TimeOut(s)
Przekroczenie(a) czasu oczekiwania
Persistent connections (Keep-Alive)
Podtrzymywane połączenia (Keep-Alive)
Podtrzymywane po³¹czenia (Keep-Alive)
Reduce connection time and type lookup time using persistent connections
Zmniejsz czas połączenia i czas sprawdzania typu dzięki użyciu podtrzymywanych połączeń
Zmniejsz czas po³¹czenia i czas sprawdzania typu dziêki u¿yciu podtrzymywanych po³¹czeñ
Retries
Ponowienia
Size limit

File diff suppressed because it is too large Load Diff

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),
@@ -55,11 +56,12 @@ htsserver_LDFLAGS = $(AM_LDFLAGS) $(LDFLAGS_PIE) @RPATH_ORIGIN_LDFLAGS@
lib_LTLIBRARIES = libhttrack.la
htsserver_SOURCES = htsserver.c htsserver.h htsweb.c htsweb.h htsstats.h \
htsrandom.c htsrandom.h \
htscmdline.c htscmdline.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
@@ -77,10 +79,10 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \
htscache_selftest.c htsdns_selftest.c htsselftest.c \
htscatchurl.c htsfilters.c htsftp.c htshash.c coucal/coucal.c \
htscmdline.c htshelp.c htslib.c htsurlport.c htscoremain.c \
htsname.c htsrobots.c htstools.c htswizard.c \
htsname.c htsrobots.c htstools.c htsrandom.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 \
@@ -91,7 +93,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \
htshelp.h htsindex.h htslib.h htsurlport.h htsmd5.h \
htsmodules.h htsname.h htsnet.h htssniff.h \
htsopt.h htsrobots.h htsthread.h \
htscrashtest.h htstools.h htswizard.h htswrap.h htscodec.h htswarc.h htschanges.h htssinglefile.h htssitemap.h htsproxy.h htszlib.h \
htscrashtest.h htstools.h htsrandom.h htswizard.h htswrap.h htscodec.h htswarc.h htschanges.h htssinglefile.h htssitemap.h htsproxy.h htszlib.h \
htsstrings.h htsarrays.h httrack-library.h \
htscharset.h punycode.h htsencoding.h \
htsentities.h htsentities.sh htsbasiccharsets.sh htscodepages.h \

View File

@@ -106,18 +106,26 @@ static HTS_UNUSED void hts_record_assert_memory_failed(const size_t size) {
/**
* Ensure at least 'ROOM' elements can be put in the remaining space.
* After a call to this macro, TypedArrayRoom(A) is guaranteed to be at
* least equal to 'ROOM'.
* least equal to 'ROOM'. Aborts if the total would not fit a size_t.
**/
#define TypedArrayEnsureRoom(A, ROOM) \
do { \
const size_t room_ = (ROOM); \
while (TypedArrayRoom(A) < room_) { \
TypedArrayCapa(A) = TypedArrayCapa(A) < 16 ? 16 : TypedArrayCapa(A) * 2; \
/* Largest element count whose byte size still fits a size_t. */ \
const size_t maxCapa_ = (size_t) -1 / TypedArrayWidth(A); \
const size_t minCapa_ = maxCapa_ < 16 ? maxCapa_ : 16; \
size_t capa_ = TypedArrayCapa(A); \
assertf(room_ <= maxCapa_ - TypedArraySize(A)); \
/* Saturating: a plain capa_*2 wraps to 0 and the loop never ends. */ \
while (capa_ - TypedArraySize(A) < room_) { \
capa_ = capa_ < minCapa_ ? minCapa_ \
: capa_ > maxCapa_ / 2 ? maxCapa_ \
: capa_ * 2; \
} \
TypedArrayPtr(A) = \
realloc(TypedArrayPtr(A), TypedArrayCapa(A) * TypedArrayWidth(A)); \
TypedArrayCapa(A) = capa_; \
TypedArrayPtr(A) = realloct(TypedArrayPtr(A), capa_ * TypedArrayWidth(A)); \
if (TypedArrayPtr(A) == NULL) { \
hts_record_assert_memory_failed(TypedArrayCapa(A) * TypedArrayWidth(A)); \
hts_record_assert_memory_failed(capa_ *TypedArrayWidth(A)); \
} \
} while (0)
@@ -154,8 +162,7 @@ static HTS_UNUSED void hts_record_assert_memory_failed(const size_t size) {
do { \
if (TypedArrayPtr(A) != NULL) { \
TypedArrayCapa(A) = TypedArraySize(A) = 0; \
free(TypedArrayPtr(A)); \
TypedArrayPtr(A) = NULL; \
freet(TypedArrayPtr(A)); \
} \
} while (0)

View File

@@ -60,8 +60,6 @@ Please visit our Website: http://www.httrack.com
#define HTS_FTRUNCATE(fp, sz) ftruncate(fileno(fp), (sz))
#endif
#define VT_CLREOL "\33[K"
/* Subdirectory holding a mirrored file's temporaries, beside it. url_savename()
maps '~' to '_', so no URL can ever be mirrored inside it (#774, #842). */
#define HTS_TMPDIR "~hts-tmp"
@@ -174,20 +172,6 @@ int back_connect_fallback_due(int addr_index, int addr_count, int elapsed,
return elapsed >= deadline;
}
/* Pending-connect result for a non-blocking socket reported ready by select():
0 = connected, >0 = the connect errno (refused, unreachable, ...), -1 if the
probe itself failed. A failed connect is reported ready as well (writable on
posix, exception set on winsock), so this is how success is told from failure
without blocking. */
static int connect_socket_error(T_SOC soc) {
int soerr = 0;
socklen_t len = (socklen_t) sizeof(soerr);
if (getsockopt(soc, SOL_SOCKET, SO_ERROR, (char *) &soerr, &len) != 0)
return -1;
return soerr;
}
/* Retry a stuck/failed connecting slot against its next resolved address.
Closes the current socket and starts a non-blocking connect to the next
candidate, leaving the slot in STATUS_CONNECTING. Returns 1 if a new connect
@@ -225,6 +209,9 @@ void back_delete_all(httrackp * opt, cache_back * cache, struct_back * sback) {
if (sback != NULL) {
int i;
/* An FTP worker writes through its slot until it returns, so nothing here
may wipe or free one under it. */
ftp_stop_workers();
// delete live slots
for(i = 0; i < sback->count; i++) {
back_delete(opt, cache, sback, i);
@@ -2784,6 +2771,37 @@ void back_clean(httrackp * opt, cache_back * cache, struct_back * sback) {
}
}
/* Slot waiting for a connection to come up: nothing requested on it yet. */
static hts_boolean back_is_preconnect(const int status) {
return status == STATUS_WAIT_DNS || status == STATUS_CONNECTING ||
status == STATUS_SSL_WAIT_HANDSHAKE;
}
/* Drop the slots still waiting to connect: nothing to finish there, and left
alone they hold the drain for --timeout (#1073). Receiving slots stay. */
static void back_stop_preconnect(httrackp *opt, struct_back *sback) {
lien_back *const back = sback->lnk;
int aborted = 0;
int i;
for (i = 0; i < sback->count; i++) {
if (!back_is_preconnect(back[i].status))
continue;
if (back[i].r.soc != INVALID_SOCKET)
deletehttp(&back[i].r);
back[i].r.soc = INVALID_SOCKET;
/* fatal, as back_add() reports it: a stop must not schedule a retry */
back[i].r.statuscode = STATUSCODE_INVALID;
strcpybuff(back[i].r.msg, "mirror stopped by user");
back_set_finished(sback, i);
aborted++;
}
if (aborted > 0)
hts_log_print(opt, LOG_WARNING,
"Mirror stopped by user, %d pending connection(s) aborted",
aborted);
}
// attente (gestion des buffers des sockets)
void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
TStamp stat_timestart) {
@@ -2813,6 +2831,9 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
back_clean(opt, cache, sback);
#endif
if (opt->state.stop)
back_stop_preconnect(opt, sback);
/* Time/size limit exceeded past grace: abort in-flight transfers so no wait
loop starves (#481, #77). FTP slots stay, their thread owns the socket. */
if (!back_checkmirror(opt)) {
@@ -4427,6 +4448,9 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
// Content-Length that would overflow the add or the
// (size_t) cast is dropped and refetched instead.
if (back[i].r.totalsize > INT32_MAX - alloc_mem) {
/* Windows refuses to unlink a file still open */
fclose(fp);
fp = NULL;
url_savename_refname_remove(opt, back[i].url_adr,
back[i].url_fil);
UNLINK(back[i].url_sav);
@@ -4457,7 +4481,8 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
strcpybuff(back[i].r.msg,
"No memory for partial file");
}
fclose(fp);
if (fp != NULL)
fclose(fp);
} else { // open failed
back[i].status = STATUS_READY; // terminé (voir plus loin)
back_set_finished(sback, i);
@@ -4594,11 +4619,11 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
if (opt->verbosedisplay == HTS_VERBOSE_SIMPLE) {
if (back[i].status == STATUS_READY) {
if (back[i].r.statuscode == HTTP_OK)
printf("* %s%s (" LLintP " bytes) - OK" VT_CLREOL "\r",
back[i].url_adr, back[i].url_fil, (LLint) back[i].r.size);
printf("* %s%s (" LLintP " bytes) - OK\n", back[i].url_adr,
back[i].url_fil, (LLint) back[i].r.size);
else
printf("* %s%s (" LLintP " bytes) - %d" VT_CLREOL "\r",
back[i].url_adr, back[i].url_fil, (LLint) back[i].r.size,
printf("* %s%s (" LLintP " bytes) - %d\n", back[i].url_adr,
back[i].url_fil, (LLint) back[i].r.size,
back[i].r.statuscode);
fflush(stdout);
}

View File

@@ -87,8 +87,8 @@ HTS_INLINE int back_exist(struct_back * sback, httrackp * opt, const char *adr,
const char *fil, const char *sav);
int back_nsoc(const struct_back * sback);
int back_nsoc_overall(const struct_back * sback);
/* refetch_whole: force a whole-file GET, ignoring any partial/temp-ref resume
(set when a prior 206 was rejected as unusable, #581). */
/* refetch_whole: force a whole-file GET, ignoring any partial/temp-ref resume;
also the caller's latch marking this link's one free restart spent (#581). */
int back_add(struct_back *sback, httrackp *opt, cache_back *cache,
const char *adr, const char *fil, const char *save,
const char *referer_adr, const char *referer_fil, int test,

View File

@@ -542,6 +542,9 @@ int httpmirror(char *url1, httrackp * opt) {
t_cookie BIGSTK cookie; // gestion des cookies
int ptr; // pointeur actuel sur les liens
int retcode = 1; // return code for the single exit; a bailout sets -1
hts_boolean rollback = HTS_FALSE; // set to roll back the cache at cleanup
hts_boolean completed = HTS_FALSE; // set once the crawl loop reaches its end
//
int numero_passe = 0; // deux passes pour html puis images
@@ -939,6 +942,7 @@ int httpmirror(char *url1, httrackp * opt) {
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_html_utf8), "index.html")),
"", "", NULL)) {
freet(primary);
XH_extuninit; // désallocation mémoire & buffers
return 0;
}
@@ -1114,6 +1118,7 @@ int httpmirror(char *url1, httrackp * opt) {
/* Info for wrappers */
hts_log_print(opt, LOG_INFO, "engine: start");
if (!RUN_CALLBACK0(opt, start)) {
freet(primary);
XH_extuninit;
return 1;
}
@@ -1278,9 +1283,8 @@ int httpmirror(char *url1, httrackp * opt) {
/* Parse */
switch (hts_mirror_wait_for_next_file(&str, &stre)) {
case -1:
XH_uninit;
return -1;
break;
retcode = -1;
goto cleanup;
case 2:
// Jump to 'continue'
// This is one of the very very rare cases where goto
@@ -1616,8 +1620,8 @@ int httpmirror(char *url1, httrackp * opt) {
const int nlinks = opt->lien_tot;
if (hts_mirror_check_moved(&str, &stre) != 0) {
XH_uninit;
return -1;
retcode = -1;
goto cleanup;
}
/* A redirect re-queues the target as a fresh link; without carrying
the marking over, a moved sitemap is fetched and then ignored. */
@@ -1805,8 +1809,8 @@ int httpmirror(char *url1, httrackp * opt) {
/* Parse */
if (htsparse(&str, &stre) != 0) {
XH_uninit;
return -1;
retcode = -1;
goto cleanup;
}
}
}
@@ -2084,10 +2088,11 @@ int httpmirror(char *url1, httrackp * opt) {
) {
hts_log_print(opt, LOG_NOTICE,
"No data seems to have been transferred during this session! : restoring previous one!");
XH_uninit;
hts_cache_reconcile(opt, CACHE_RECONCILE_ROLLBACK);
/* this run replaces nothing, so its archive must not be committed */
warc_abort_opt(opt);
opt->state.exit_xh = 2; /* interrupted (no connection detected) */
return 1;
rollback = HTS_TRUE;
goto cleanup;
}
// info text
if (cache.txt) {
@@ -2287,14 +2292,27 @@ int httpmirror(char *url1, httrackp * opt) {
// ending
usercommand(opt, 0, NULL, NULL, NULL, NULL);
warc_close_opt(opt);
hts_changes_close_opt(opt);
completed = HTS_TRUE;
cleanup:
/* single exit: every bailout jumps here, so the closes below always run */
warc_close_opt(opt); /* a no-op once warc_abort_opt() has run */
/* An abort never ran hts_changes_indexed(), so its report would be false;
free it rather than overwrite the previous run's true one. */
if (completed)
hts_changes_close_opt(opt);
else
hts_changes_free_opt(opt);
hts_sitemap_free(opt);
// désallocation mémoire & buffers
freet(primary); /* NULL once the first link adopted it */
XH_uninit;
/* reconcile renames the cache files XH_uninit just closed, so it runs last */
if (rollback)
hts_cache_reconcile(opt, CACHE_RECONCILE_ROLLBACK);
return 1; // OK
return retcode;
}
// version 2 pour le reste
@@ -3393,6 +3411,7 @@ int read_stdin(char *s, int max) {
#ifdef _WIN32
int check_stdin(void) {
/* The console queue, not stdin: no end of file to mistake for a keystroke. */
return (_kbhit());
}
#else
@@ -3408,11 +3427,18 @@ int check_flot(T_SOC s) {
return FD_ISSET(s, &fds);
}
int check_stdin(void) {
int c;
fflush(stdout);
fflush(stdin);
if (check_flot(0))
return 1;
return 0;
if (!check_flot(0))
return 0;
/* Readable is not data: an stdin at EOF stays readable forever and reads as a
keypress (#1072). clearerr: a terminal can get input again after a ^D. */
clearerr(stdin);
if ((c = fgetc(stdin)) == EOF)
return 0;
ungetc(c, stdin);
return 1;
}
#endif
#endif

View File

@@ -261,7 +261,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
#ifdef _WIN32
#else
/* Terminal is a tty, may ask questions and display funny information */
if (isatty(1)) {
if (hts_stdout_isterminal()) {
opt->quiet = 0;
opt->verbosedisplay = HTS_VERBOSE_SIMPLE;
}
@@ -448,9 +448,10 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
strcpybuff(tempo, argv[na] + 1);
if (hts_lastchar(tempo) != '"') {
char BIGSTK s[HTS_CDLMAXSIZE];
/* +256 holds the prefix around a max-length argument. */
char BIGSTK s[HTS_CDLMAXSIZE + 256];
sprintf(s, "Missing quote in %s", argv[na]);
snprintf(s, sizeof(s), "Missing quote in %s", argv[na]);
HTS_PANIC_PRINTF(s);
htsmain_free();
return -1;
@@ -979,7 +980,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
if (hts_lastchar(tempo) != '"') {
char s[HTS_CDLMAXSIZE + 256];
sprintf(s, "Missing quote in %s", argv[na]);
snprintf(s, sizeof(s), "Missing quote in %s", argv[na]);
HTS_PANIC_PRINTF(s);
htsmain_free();
return -1;
@@ -1334,6 +1335,16 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
opt->mimehtml = 0;
com++;
}
if (opt->mimehtml && opt->single_file) {
HTS_PANIC_PRINTF(
"-%M and --single-file are two ways to make one "
"self-contained file, so pick one: MIME (-%M) carries "
"text parts without the base64 tax and stores a shared "
"asset once; single-file HTML opens anywhere by "
"double-click.");
htsmain_free();
return -1;
}
break;
case 'k':
opt->nokeepalive = 0;
@@ -1945,6 +1956,16 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
com++;
}
}
if (opt->single_file && opt->mimehtml) {
HTS_PANIC_PRINTF(
"-%M and --single-file are two ways to make one "
"self-contained file, so pick one: MIME (-%M) carries "
"text parts without the base64 tax and stores a shared "
"asset once; single-file HTML opens anywhere by "
"double-click.");
htsmain_free();
return -1;
}
break;
case 'm': // sitemap / sitemap-url: seed the crawl from sitemaps
if (*(com + 1) == 'u') { // --sitemap-url URL: explicit sitemap
@@ -2554,20 +2575,21 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
else
opt->errlog = opt->log;
if (opt->log == NULL) {
/* path_log is not argv-gated: ~ expansion admits 2047 bytes */
char s[HTS_CDLMAXSIZE + 256];
sprintf(s, "Unable to create log file %s",
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log),
"hts-log.txt"));
snprintf(s, sizeof(s), "Unable to create log file %s",
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-log.txt"));
HTS_PANIC_PRINTF(s);
htsmain_free();
return -1;
} else if (opt->errlog == NULL) {
char s[HTS_CDLMAXSIZE + 256];
sprintf(s, "Unable to create log file %s",
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log),
"hts-err.txt"));
snprintf(s, sizeof(s), "Unable to create log file %s",
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-err.txt"));
HTS_PANIC_PRINTF(s);
htsmain_free();
return -1;

View File

@@ -102,6 +102,13 @@ static mock_host mock_hosts[] = {
{"nodns.test", EAI_NONAME, 0, {{0}}, 0},
/* resolves, but only well after --timeout: the #606 wedge */
{"slow.test", 0, 1, {{AF_INET, {127, 0, 0, 9}}}, 0, MOCK_SLOW_MS},
/* a second one, so a cancelled resolve cannot cache an answer the next
case then reads instead of asking for */
{"slow2.test", 0, 1, {{AF_INET, {127, 0, 0, 10}}}, 0, MOCK_SLOW_MS},
/* two more for the cached entry point, checked with the mirror running and
then stopped: neither case may read the other's cached answer */
{"slow3.test", 0, 1, {{AF_INET, {127, 0, 0, 11}}}, 0, MOCK_SLOW_MS},
{"slow4.test", 0, 1, {{AF_INET, {127, 0, 0, 12}}}, 0, MOCK_SLOW_MS},
};
/* Serializes mock_host bookkeeping: a timed-out resolve is abandoned, so its
@@ -546,10 +553,57 @@ int dns_timeout_selftests(httrackp *opt) {
CHECK(count == 0);
CHECK(mock_read_calls("slow.test") == 2); /* re-resolved, not cached */
/* Both resolves were abandoned mid-backend; wait for their workers to leave
it before returning. The backend stays installed: an abandoned worker
still reads it (to free its addrinfo) after the last call returns. */
mock_wait_finished(2);
/* A raised cancel flag ends the wait where no deadline would, which is what
lets a stopped FTP worker leave a black-holed resolver (#1059). */
{
hts_boolean cancel = HTS_TRUE;
start = mtime_local();
count = hts_dns_resolve_all_bounded(opt, "slow.test", addrs, HTS_MAXADDRNUM,
0, &cancel, &err);
elapsed = mtime_local() - start;
CHECK(count == 0);
CHECK(elapsed < MOCK_SLOW_MS / 2);
}
/* And an unbounded resolve nobody cancels still waits for its answer: a
timeout of 0 must not be read back as a deadline of "now". */
{
hts_boolean cancel = HTS_FALSE;
start = mtime_local();
count = hts_dns_resolve_all_bounded(opt, "slow2.test", addrs,
HTS_MAXADDRNUM, 0, &cancel, &err);
elapsed = mtime_local() - start;
CHECK(count == 1);
CHECK(elapsed >= MOCK_SLOW_MS / 2);
}
/* The cached entry point takes the mirror's own stop flag as its cancel, so
a ^C ends a resolve nothing else would bound (#1073). Unbounded here, to
tell the stop apart from a deadline. */
opt->timeout = 0;
{
/* control first: with the mirror running, the same call still waits */
start = mtime_local();
count = hts_dns_resolve_all(opt, "slow3.test", addrs, HTS_MAXADDRNUM, &err);
elapsed = mtime_local() - start;
CHECK(count == 1);
CHECK(elapsed >= MOCK_SLOW_MS / 2);
opt->state.stop = 1; /* the flag, not hts_request_stop's log line */
start = mtime_local();
count = hts_dns_resolve_all(opt, "slow4.test", addrs, HTS_MAXADDRNUM, &err);
elapsed = mtime_local() - start;
opt->state.stop = 0;
CHECK(count == 0);
CHECK(elapsed < MOCK_SLOW_MS / 2);
}
/* Four of the six resolves were abandoned mid-backend; wait for their
workers to leave it before returning. The backend stays installed: an
abandoned worker still reads it (to free its addrinfo). */
mock_wait_finished(6);
return failures;
}

View File

@@ -40,6 +40,9 @@ Please visit our Website: http://www.httrack.com
#include "htscore.h"
#include "htsthread.h"
#include <limits.h>
#ifdef _WIN32
#else
//inet_ntoa
@@ -60,12 +63,55 @@ Please visit our Website: http://www.httrack.com
#if USE_BEGINTHREAD
/* Live FTP workers. Each writes through its backlog slot and reads opt for its
whole run, so neither may be freed while the list is not empty (#1051).
Registration happens at spawn, not at thread entry: a worker that has not
been scheduled yet must already hold the list against a teardown. */
static FTPDownloadStruct *ftp_workers = NULL;
static htsmutex ftp_workers_mutex = HTSMUTEX_INIT;
static void ftp_worker_register(FTPDownloadStruct *worker) {
hts_mutexlock(&ftp_workers_mutex);
worker->pNext = ftp_workers;
ftp_workers = worker;
hts_mutexrelease(&ftp_workers_mutex);
}
static void ftp_worker_unregister(FTPDownloadStruct *worker) {
FTPDownloadStruct **prev;
hts_mutexlock(&ftp_workers_mutex);
for (prev = &ftp_workers; *prev != NULL; prev = &(*prev)->pNext) {
if (*prev == worker) {
*prev = worker->pNext;
break;
}
}
hts_mutexrelease(&ftp_workers_mutex);
}
void ftp_stop_workers(void) {
int wait;
do {
FTPDownloadStruct *worker;
hts_mutexlock(&ftp_workers_mutex);
/* Idempotent, and re-raised each pass so a worker registered meanwhile is
not missed. A registered worker's slot is still live by construction:
back_delete_all() drains before back_free(). */
for (worker = ftp_workers; worker != NULL; worker = worker->pNext)
worker->pBack->stop_ftp = HTS_TRUE;
wait = ftp_workers != NULL;
hts_mutexrelease(&ftp_workers_mutex);
if (wait)
Sleep(100);
} while (wait);
}
void back_launch_ftp(void *pP) {
FTPDownloadStruct *pStruct = (FTPDownloadStruct *) pP;
if (pStruct == NULL)
return;
if (pStruct == NULL) {
#if FTP_DEBUG
printf("[ftp error: no args]\n");
@@ -84,11 +130,15 @@ void back_launch_ftp(void *pP) {
// prêt
pStruct->pBack->status = STATUS_FTP_READY;
/* Delete structure */
free(pP);
/* Uninitialize */
hts_uninit();
/* The status store above was this worker's last read of the slot and of opt;
deregistering lets the engine free both. */
ftp_worker_unregister(pStruct);
/* Delete structure */
free(pP);
return;
}
@@ -98,7 +148,16 @@ void launch_ftp(FTPDownloadStruct * params) {
#if FTP_DEBUG
printf("[Launching main ftp thread]\n");
#endif
hts_newthread(back_launch_ftp, (void *) params);
ftp_worker_register(params);
if (hts_newthread(back_launch_ftp, (void *) params) != 0) {
/* Nobody would ever reap a slot left in STATUS_FTP_TRANSFER, and
ftp_stop_workers() would never return. */
ftp_worker_unregister(params);
strcpybuff(params->pBack->r.msg, "Unable to launch FTP thread");
params->pBack->r.statuscode = STATUSCODE_INVALID;
params->pBack->status = STATUS_FTP_READY;
free(params);
}
}
#else
@@ -122,42 +181,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. */
@@ -205,13 +270,76 @@ static hts_boolean ftp_may_resume(httrackp *opt, const lien_back *back,
return HTS_TRUE;
}
/* Clip a wait to what is left of --max-time: back_wait() can't abort this slot
the way it aborts an HTTP one. */
static int ftp_wait_left(const httrackp *opt, int timeout) {
if (opt != NULL && opt->maxtime > 0) {
const TStamp left =
(TStamp) opt->maxtime - (time_local() - HTS_STAT.stat_timestart);
if (left < (TStamp) timeout)
timeout = left > 0 ? (int) left : 0;
}
return timeout;
}
/* Connect honoring the stop flag, --timeout and --max-time: a blocking
connect() to a black hole sits for the kernel's own two minutes (#1059). */
static hts_boolean ftp_connect(lien_back *back, T_SOC soc, SOCaddr *server,
int timeout, const httrackp *opt) {
const TStamp started = time_local();
timeout = ftp_wait_left(opt, timeout);
if (!socket_set_nonblocking(soc, HTS_TRUE))
return HTS_FALSE;
if (connect(soc, &SOCaddr_sockaddr(*server), SOCaddr_size(*server)) != 0) {
#ifdef _WIN32
if (WSAGetLastError() != WSAEWOULDBLOCK)
#else
if (errno != EINPROGRESS && errno != EINTR)
#endif
return HTS_FALSE;
/* state.stop counts here, unlike in the waits below: a mirror the user
stopped has nothing to finish on a connection never established. */
while (check_socket_connect(soc) == 0) {
if (back->stop_ftp || (opt != NULL && opt->state.stop) ||
(int) (time_local() - started) >= timeout)
return HTS_FALSE;
Sleep(100);
}
if (connect_socket_error(soc) != 0)
return HTS_FALSE;
}
/* the rest of the session reads and writes this socket blocking */
return socket_set_nonblocking(soc, HTS_FALSE);
}
/* Resolve, likewise: the shared resolver bounds itself by opt->timeout alone
and takes no stop flag. */
static SOCaddr *ftp_dns_resolve(httrackp *opt, lien_back *back,
const char *host, SOCaddr *addr, int timeout,
const char **error) {
const int left = ftp_wait_left(opt, timeout);
SOCaddr_clear(*addr);
if (left <= 0) {
if (error != NULL)
*error = "mirror deadline reached";
return NULL;
}
if (hts_dns_resolve_all_bounded(opt, host, addr, 1, left, &back->stop_ftp,
error) > 0)
return SOCaddr_is_valid(*addr) ? addr : NULL;
return NULL;
}
// la véritable fonction une fois lancées les routines thread/fork
int run_launch_ftp(FTPDownloadStruct * pStruct) {
lien_back *back = pStruct->pBack;
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
@@ -220,7 +348,9 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
char BIGSTK adr_ip[1024];
char *adr, *real_adr;
char BIGSTK ftp_path[CATBUFF_SIZE]; // the decoded URL path, screened once
int timeout = 300; // timeout
/* opt->timeout == 0 means unlimited; use INT_MAX so 0 doesn't make the read
return instantly. */
const int timeout = opt->timeout > 0 ? opt->timeout : INT_MAX;
int timeout_onfly = 8; // attente réponse supplémentaire
int transfer_list = 0; // directory
int rest_understood = 0; // rest command understood
@@ -234,8 +364,6 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
//
line_retr[0] = adr_ip[0] = ftp_path[0] = '\0';
timeout = 300;
// effacer
strcpybuff(back->r.msg, "");
back->r.statuscode = 0;
@@ -251,7 +379,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 +394,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 +436,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,13 +454,20 @@ 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");
if (hts_dns_resolve2(opt, _adr, &server, &error) == NULL) {
if (ftp_dns_resolve(opt, back, _adr, &server, timeout, &error) == NULL) {
htsblk_failf(&back->r, "Unable to get server's address: %s", error);
back->r.statuscode = STATUSCODE_NON_FATAL;
_HALT_FTP return 0;
@@ -341,34 +487,33 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
SOCaddr_initport(server, port);
// connexion (bloquante, on est en thread)
strcpybuff(back->info, "connect");
hts_log_print(opt, LOG_DEBUG, "FTP: connecting to %s:%d", _adr, port);
if (connect(soc_ctl, &SOCaddr_sockaddr(server), SOCaddr_size(server)) != 0) {
if (!ftp_connect(back, soc_ctl, &server, timeout, opt)) {
strcpybuff(back->r.msg, "Unable to connect to the server");
back->r.statuscode = STATUSCODE_INVALID;
_HALT_FTP return 0;
#ifdef _WIN32
}
#else
}
#endif
_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
// --USER--
get_ftp_line(soc_ctl, line, sizeof(line), timeout); // en tête
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt); // en tête
_CHECK_HALT_FTP;
if (line[0] == '2') { // ok, connecté
strcpybuff(back->info, "login: user");
snprintf(line, sizeof(line), "USER %s", user);
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if ((line[0] == '3') || (line[0] == '2')) {
// --PASS--
@@ -376,12 +521,12 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
strcpybuff(back->info, "login: pass");
snprintf(line, sizeof(line), "PASS %s", pass);
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
}
if (line[0] == '2') { // ok
send_line(soc_ctl, "TYPE I");
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if (line[0] == '2') {
// ok
@@ -414,7 +559,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
strcpybuff(back->info, "pasv");
snprintf(line, sizeof(line), "PASV");
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
} else { /* ipv6 */
line[0] = '\0';
}
@@ -476,7 +621,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
strcpybuff(back->info, "pasv");
snprintf(line, sizeof(line), "EPSV");
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if (line[0] == '2') { /* got it */
char *a;
@@ -514,13 +659,12 @@ 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);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if (line[0] == '2') { // SIZE compris, ALORS tester REST (sinon pas tester: cf probleme des txt.gz decompresses a la volee)
char *szstr = strchr(line, ' ');
@@ -537,22 +681,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(back, soc_ctl, line, sizeof(line), timeout, opt);
_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
@@ -564,13 +710,13 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
snprintf(line, sizeof(line), "REST " LLintP,
(LLint) back->range_req_size);
send_line(soc_ctl, line);
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if ((line[0] == '3') || (line[0] == '2')) { // ok
rest_understood = 1;
} // else never mind
}
} // sinon tant pis
} // sinon tant pis
}
}
#if FTP_PASV
@@ -584,7 +730,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
// Ok, se connecter
if (port_pasv) {
SOCaddr server;
int server_size = sizeof(server);
hts_boolean resolved;
const char *error = "unknown error";
// effacer structure
@@ -595,9 +741,11 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
// résoudre
if (adr_ip[0]) {
hts_dns_resolve2(opt, adr_ip, &server, &error);
resolved = ftp_dns_resolve(opt, back, adr_ip, &server, timeout,
&error) != NULL;
} else {
SOCaddr_copy_SOCaddr(server, server_data);
resolved = HTS_TRUE;
}
// infos
@@ -605,17 +753,19 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
#if FTP_DEBUG
printf("Data: Connecting to %s:%d...\n", adr_ip, port_pasv);
#endif
if (server_size > 0) {
if (resolved) {
// socket
soc_dat = (T_SOC) socket(SOCaddr_sinfamily(server), SOCK_STREAM, 0);
if (soc_dat != INVALID_SOCKET) {
// structure: connexion au domaine internet, port 80 (ou autre)
SOCaddr_initport(server, port_pasv);
if (connect(soc_dat, &SOCaddr_sockaddr(server), SOCaddr_size(server)) == 0) {
if (ftp_connect(back, soc_dat, &server, timeout, opt)) {
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);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if (line[0] == '1') {
// OK
@@ -636,7 +786,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
//
strcpybuff(back->r.msg, "Unable to connect");
back->r.statuscode = STATUSCODE_INVALID;
} // sinon on est prêts
} // sinon on est prêts
} else {
strcpybuff(back->r.msg, "Unable to create a socket");
back->r.statuscode = STATUSCODE_INVALID;
@@ -645,7 +795,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
htsblk_failf(&back->r, "Unable to resolve IP %s: %s", adr_ip,
error);
back->r.statuscode = STATUSCODE_INVALID;
} // sinon on est prêts
} // sinon on est prêts
} else {
htsblk_failf(&back->r, "PASV incorrect: %s", linejmp(line));
back->r.statuscode = STATUSCODE_INVALID;
@@ -656,13 +806,15 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
if ((soc_servdat = get_datasocket(line, sizeof(line))) != INVALID_SOCKET) {
_CHECK_HALT_FTP;
send_line(soc_ctl, line); // envoi du RETR
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_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);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
_CHECK_HALT_FTP;
if (line[0] == '1') {
//T_SOC soc_dat;
@@ -717,14 +869,17 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
while((len > 0) && (!stop_ftp(back))) {
// attendre les données
len = 1; // pas d'erreur pour le moment
switch (wait_socket_receive(soc_dat, timeout)) {
switch (wait_socket_receive(back, soc_dat, timeout, opt)) {
case -1:
strcpybuff(back->r.msg, "FTP read error");
back->r.statuscode = STATUSCODE_INVALID;
len = 0; // fin
break;
case 0:
htsblk_failf(&back->r, "Time out (%d)", timeout);
/* stop_ftp() names the real reason when it is the stop that
cut the wait short. */
if (!stop_ftp(back))
htsblk_failf(&back->r, "Time out (%d)", timeout);
back->r.statuscode = STATUSCODE_INVALID;
len = 0; // fin
break;
@@ -784,9 +939,9 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
// 226 Transfer complete?
if (back->r.statuscode != -1) {
if (wait_socket_receive(soc_ctl, timeout_onfly) > 0) {
if (wait_socket_receive(back, soc_ctl, timeout_onfly, opt) > 0) {
// récupérer 226 transfer complete
get_ftp_line(soc_ctl, line, sizeof(line), timeout);
get_ftp_line(back, soc_ctl, line, sizeof(line), timeout, opt);
if (line[0] == '2') { // OK
strcpybuff(back->r.msg, "OK");
back->r.statuscode = HTTP_OK;
@@ -809,7 +964,7 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
_CHECK_HALT_FTP;
strcpybuff(back->info, "quit");
send_line(soc_ctl, "QUIT"); // bye bye
get_ftp_line(soc_ctl, NULL, 0, timeout);
get_ftp_line(back, soc_ctl, NULL, 0, timeout, opt);
#ifdef _WIN32
closesocket(soc_ctl);
#else
@@ -922,7 +1077,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 +1098,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");
@@ -962,7 +1121,8 @@ int send_line(T_SOC soc, const char *data) {
#endif
}
int get_ftp_line(T_SOC soc, char *ptrline, size_t line_size, int timeout) {
int get_ftp_line(lien_back *back, T_SOC soc, char *ptrline, size_t line_size,
int timeout, const httrackp *opt) {
char BIGSTK data[1024];
int i, ok, multiline;
@@ -978,7 +1138,7 @@ int get_ftp_line(T_SOC soc, char *ptrline, size_t line_size, int timeout) {
char b;
// vérifier données
switch (wait_socket_receive(soc, timeout)) {
switch (wait_socket_receive(back, soc, timeout, opt)) {
case -1: // erreur de lecture
if (ptrline)
snprintf(ptrline, line_size, "500 *read error");
@@ -1090,17 +1250,21 @@ int check_socket_connect(T_SOC soc) {
}
// attendre des données
int wait_socket_receive(T_SOC soc, int timeout) {
// attendre les données
int wait_socket_receive(lien_back *back, T_SOC soc, int timeout,
const httrackp *opt) {
TStamp ltime = time_local();
int r;
timeout = ftp_wait_left(opt, timeout);
#if FTP_DEBUG
printf("\x0dWaiting for data ");
fflush(stdout);
#endif
while((!(r = check_socket(soc)))
&& (((int) ((TStamp) (time_local() - ltime))) < timeout)) {
/* A stop raised by the engine ends the wait on the next 100ms tick instead of
after the full timeout, so teardown does not sit on a silent server. */
while ((!(r = check_socket(soc))) && (back == NULL || !back->stop_ftp) &&
(((int) ((TStamp) (time_local() - ltime))) < timeout)) {
Sleep(100);
#if FTP_DEBUG
printf(".");

View File

@@ -55,13 +55,20 @@ typedef struct FTPDownloadStruct FTPDownloadStruct;
struct FTPDownloadStruct {
lien_back *pBack;
httrackp *pOpt;
FTPDownloadStruct *pNext; /* live-worker list, owned by htsftp.c */
};
/* 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);
/* Cancel every live FTP worker and block until each stops touching its backlog
slot and opt. Call before freeing either. */
void ftp_stop_workers(void);
#else
void launch_ftp(FTPDownloadStruct * params, char *path, char *exec);
int back_launch_ftp(FTPDownloadStruct * params);
@@ -69,23 +76,43 @@ int back_launch_ftp(FTPDownloadStruct * params);
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);
/* Read one control-channel reply into line[line_size]. Returns 0 on error.
Optional back ends the wait on a stop, opt clips it to --max-time. */
int get_ftp_line(lien_back *back, T_SOC soc, char *line, size_t line_size,
int timeout, const httrackp *opt);
/* 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);
int check_socket(T_SOC soc);
int check_socket_connect(T_SOC soc);
int wait_socket_receive(T_SOC soc, int timeout);
/* Wait up to timeout seconds for soc to become readable. Optional back ends the
wait on a stop, opt clips it to --max-time. */
int wait_socket_receive(lien_back *back, T_SOC soc, int timeout,
const httrackp *opt);
#endif
#endif

View File

@@ -43,8 +43,8 @@ Please visit our Website: http://www.httrack.com
configure.ac, decoupled from these). VERSION is the display form, VERSIONID
the dotted numeric form, AFF_VERSION the short form shown in footers,
LIB_VERSION the data/cache format generation. */
#define HTTRACK_VERSION "3.49-17"
#define HTTRACK_VERSIONID "3.49.17"
#define HTTRACK_VERSION "3.49-19"
#define HTTRACK_VERSIONID "3.49.19"
#define HTTRACK_AFF_VERSION "3.x"
#define HTTRACK_LIB_VERSION "2.0"
@@ -212,8 +212,8 @@ Please visit our Website: http://www.httrack.com
/* Maximum URL length, in bytes. Callers size URL/path string buffers to this;
anything longer is rejected. */
#define HTS_URLMAXSIZE 1024
/* Maximum command-line argument length, in bytes (kept >= HTS_URLMAXSIZE*2 so
an addr+path pair always fits). */
/* Command-line argument cap, in bytes: an argument this long or longer is
rejected. A buffer holding a message built around one adds +256. */
#define HTS_CDLMAXSIZE 1024
/* MIME-type buffer contract (htsblk.contenttype/charset/contentencoding); holds
the longest registered MIME type, the Office OOXML ones reaching 73 chars */
@@ -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

View File

@@ -47,6 +47,7 @@ Please visit our Website: http://www.httrack.com
#include "htsbauth.h"
#include "htsthread.h"
#include "htsback.h"
#include "htsftp.h"
#include "htswrap.h"
#include "htsmd5.h"
#include "htsmodules.h"
@@ -2121,6 +2122,30 @@ htsblk http_test(httrackp * opt, const char *adr, const char *fil, char *loc) {
return retour;
}
hts_boolean socket_set_nonblocking(T_SOC soc, hts_boolean nonblocking) {
#ifdef _WIN32
unsigned long p = nonblocking ? 1 : 0;
return ioctlsocket(soc, FIONBIO, &p) == 0 ? HTS_TRUE : HTS_FALSE;
#else
const int flags = fcntl(soc, F_GETFL, 0);
const int wanted = nonblocking ? (flags | O_NONBLOCK) : (flags & ~O_NONBLOCK);
if (flags == -1 || fcntl(soc, F_SETFL, wanted) == -1)
return HTS_FALSE;
return HTS_TRUE;
#endif
}
int connect_socket_error(T_SOC soc) {
int soerr = 0;
socklen_t len = (socklen_t) sizeof(soerr);
if (getsockopt(soc, SOL_SOCKET, SO_ERROR, (char *) &soerr, &len) != 0)
return -1;
return soerr;
}
// Crée un lien (http) vers une adresse internet iadr
// retour: structure (adresse, taille, message si erreur (si !adr))
// peut ouvrir avec des connect() non bloquants: waitconnect=0/1
@@ -2269,24 +2294,17 @@ T_SOC newhttp_addr(httrackp *opt, const char *_iadr, htsblk *retour, int port,
// connexion non bloquante?
if (!waitconnect) {
if (!socket_set_nonblocking(soc, HTS_TRUE)) {
#ifdef _WIN32
unsigned long p = 1; // non bloquant
if (ioctlsocket(soc, FIONBIO, &p)) {
const int last_errno = WSAGetLastError();
#else
const int last_errno = errno;
#endif
snprintf(retour->msg, sizeof(retour->msg),
"Non-blocking socket failed: %s", strerror(last_errno));
deletesoc(soc);
return INVALID_SOCKET;
}
#else
const int flags = fcntl(soc, F_GETFL, 0);
if (flags == -1 || fcntl(soc, F_SETFL, flags | O_NONBLOCK) == -1) {
snprintf(retour->msg, sizeof(retour->msg),
"Non-blocking socket failed: %s", strerror(errno));
deletesoc(soc);
return INVALID_SOCKET;
}
#endif
}
// Connexion au serveur lui même
#if HDEBUG
@@ -5184,20 +5202,20 @@ static void dns_resolve_thread(void *arg) {
dns_job_release(job);
}
/* Resolve hostname on a worker thread, giving up after timeout seconds.
Returns the address count, or -1 on timeout -- distinct from 0 ("does not
resolve"), which is a real answer and gets negative-cached. */
static int hts_dns_resolve_nocache_list_bounded(const char *hostname,
SOCaddr *const out,
const int max,
const int timeout,
const char **error) {
/* Resolve hostname on a worker thread, giving up after timeout seconds or as
soon as *cancel (optional) is raised. Returns the address count, or -1 on
timeout -- distinct from 0 ("does not resolve"), which is a real answer and
gets negative-cached. */
static int hts_dns_resolve_nocache_list_bounded(
const char *hostname, SOCaddr *const out, const int max, const int timeout,
const volatile hts_boolean *cancel, const char **error) {
dns_resolve_job *job;
TStamp deadline;
int count = -1;
int poll_ms = 1;
if (timeout <= 0) /* no bound asked for (--timeout 0) */
/* no bound asked for (--timeout 0), and nobody to cut it short either */
if (timeout <= 0 && cancel == NULL)
return hts_dns_resolve_nocache_list(hostname, out, max, error);
job = calloct(1, sizeof(*job));
@@ -5211,7 +5229,8 @@ static int hts_dns_resolve_nocache_list_bounded(const char *hostname,
return hts_dns_resolve_nocache_list(hostname, out, max, error);
}
deadline = mtime_local() + (TStamp) timeout * 1000;
/* timeout <= 0 got here only for a cancellable resolve: no deadline then */
deadline = timeout > 0 ? mtime_local() + (TStamp) timeout * 1000 : 0;
for (;;) {
hts_boolean done;
@@ -5224,7 +5243,8 @@ static int hts_dns_resolve_nocache_list_bounded(const char *hostname,
*error = job->error;
}
hts_mutexrelease(&job->lock);
if (done || mtime_local() >= deadline)
if (done || (cancel != NULL && *cancel) ||
(deadline != 0 && mtime_local() >= deadline))
break;
Sleep(poll_ms);
if (poll_ms < 50) /* short first polls keep a fast resolve fast */
@@ -5236,6 +5256,17 @@ static int hts_dns_resolve_nocache_list_bounded(const char *hostname,
int hts_dns_resolve_all(httrackp *opt, const char *iadr, SOCaddr *out, int max,
const char **error) {
assertf(opt != NULL);
/* the mirror's stop flag cancels: a resolve behind a black hole otherwise
holds a stopped crawl for the whole --timeout (#1073) */
return hts_dns_resolve_all_bounded(opt, iadr, out, max, opt->timeout,
&opt->state.stop, error);
}
int hts_dns_resolve_all_bounded(httrackp *opt, const char *iadr, SOCaddr *out,
int max, int timeout,
const volatile hts_boolean *cancel,
const char **error) {
char BIGSTK host[HTS_URLMAXSIZE * 2];
SOCaddr resolved[HTS_MAXADDRNUM];
coucal cache;
@@ -5274,7 +5305,7 @@ int hts_dns_resolve_all(httrackp *opt, const char *iadr, SOCaddr *out, int max,
/* Resolve with no lock held: getaddrinfo can block for a long time, and
state.lock also gates the stop request (#606). */
count = hts_dns_resolve_nocache_list_bounded(host, resolved, HTS_MAXADDRNUM,
opt->timeout, error);
timeout, cancel, error);
#if HTS_WIDE_DEBUG
DEBUG_W("gethostbyname done\n");
@@ -6055,6 +6086,7 @@ HTSEXT_API httrackp *hts_create_opt(void) {
opt->changes_state = NULL;
opt->single_file = HTS_FALSE;
opt->single_file_max_size = SINGLEFILE_DEFAULT_MAX_SIZE;
opt->singlefile_state = NULL;
StringCopy(opt->why_url, "");
opt->pause_min_ms = 0;
opt->pause_max_ms = 0;
@@ -6126,6 +6158,9 @@ HTSEXT_API size_t hts_sizeof_opt(void) {
HTSEXT_API void hts_free_opt(httrackp * opt) {
if (opt != NULL) {
/* An FTP worker reads opt for its whole run, and not every caller drains
its threads the way httrack.c does before getting here. */
ftp_stop_workers();
/* Alocated callbacks */
@@ -6209,6 +6244,7 @@ HTSEXT_API void hts_free_opt(httrackp * opt) {
StringFree(opt->warc_file);
StringFree(opt->sitemap_url);
hts_sitemap_free(opt); /* backstop: httpmirror's early-return paths */
singlefile_free(opt);
hts_changes_free_opt(opt);

View File

@@ -214,6 +214,16 @@ int http_sendhead(httrackp * opt, t_cookie * cookie, int mode, const char *xsend
int http_cookie_header(t_cookie *cookie, const char *domain, const char *path,
char *dst, size_t dst_size);
/* Switch soc between blocking and non-blocking mode; false on failure, with
errno (WSAGetLastError() on Windows) set. */
hts_boolean socket_set_nonblocking(T_SOC soc, hts_boolean nonblocking);
/* Pending-connect result for a non-blocking socket reported ready by select():
0 = connected, >0 = the connect errno (refused, unreachable, ...), -1 if the
probe itself failed. A failed connect is reported ready as well (writable on
posix, exception set on winsock), so this is how success is told from failure
without blocking. */
int connect_socket_error(T_SOC soc);
T_SOC newhttp(httrackp * opt, const char *iadr, htsblk * retour, int port,
int waitconnect);
/* Like newhttp(), but connect to the addr_index-th resolved address of the host
@@ -269,9 +279,16 @@ LLint http_xfread1(htsblk * r, int bufl);
resolver order), returning the count (0 = does not resolve, negative-cached).
Resolves once per host; later calls read the DNS cache. Must hold no lock
(brackets opt->state.lock itself, never across the resolve). A miss resolves
on a worker thread bounded by opt->timeout; a timeout reports 0, uncached. */
on a worker thread bounded by opt->timeout and cut short by a mirror stop;
either reports 0, uncached. */
int hts_dns_resolve_all(httrackp *opt, const char *iadr, SOCaddr *out, int max,
const char **error);
/* Like hts_dns_resolve_all(), with the wait bounded by timeout seconds (<= 0:
unbounded) and cut short as soon as *cancel, if given, is raised. */
int hts_dns_resolve_all_bounded(httrackp *opt, const char *iadr, SOCaddr *out,
int max, int timeout,
const volatile hts_boolean *cancel,
const char **error);
HTS_INLINE SOCaddr *hts_dns_resolve2(httrackp *opt, const char *iadr,
SOCaddr *const addr, const char **error);
HTS_INLINE SOCaddr* hts_dns_resolve(httrackp * opt, const char *iadr,

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

@@ -222,7 +222,8 @@ typedef struct htsoptstate htsoptstate;
struct htsoptstate {
htsmutex lock; /**< guards this state block */
/* */
int stop; /**< set to request the mirror to stop */
/** set to request the mirror to stop; volatile: polled without a lock */
volatile int stop;
int exit_xh;
int back_add_stats;
/* */
@@ -256,7 +257,8 @@ struct htsoptstate {
unsigned int debug_state;
unsigned int tmpnameid; /**< counter for temporary file names */
int is_ended; /**< mirror has finished */
void *warc; /**< open WARC writer (warc_writer*), or NULL */
void *warc; /**< WARC writer (warc_writer*), or NULL, or the WARC_DISABLED
sentinel (htswarc.c) */
};
/* Library handles */
@@ -526,6 +528,7 @@ struct httrackp {
// store library handles
htslibhandles libHandles; /**< loaded external module handles */
//
/* Live state, not options: copy_htsopt must leave it alone. */
htsoptstate state; /**< embedded live engine state */
String strip_query; /**< query keys to drop when deduping URLs (-strip-query);
appended at the tail to keep field offsets stable */
@@ -564,6 +567,7 @@ struct httrackp {
rather than in htsoptstate because that struct is embedded by value, so
growing it would shift every httrackp field declared after it. */
void *sitemap_state; /**< hts_sitemap_state*, or NULL. Tail: ABI */
void *singlefile_state; /**< hts_singlefile_state*, or NULL. Tail: ABI */
};
/* Running statistics for a mirror. */
@@ -723,7 +727,8 @@ struct lien_url {
int testmode; /**< test only: send just a HEAD */
hts_boolean
refetch_whole; /**< force a whole-file GET, ignoring any partial/temp-ref
resume, so a rejected 206 can't loop (#581) */
resume (#581); doubles as the latch spending this link's one
free restart, so copy it wherever retry is copied (#1052) */
};
/* A file being fetched in the background. */
@@ -767,7 +772,7 @@ struct lien_back {
LLint chunk_blocksize; /**< data size declared by the chunk */
LLint compressed_size; /**< compressed size (stats only) */
char info[256]; /**< status text, e.g. for FTP */
int stop_ftp; /**< stop flag for FTP */
volatile int stop_ftp; /**< stop flag for FTP, polled without a lock */
int finalized; /**< finalized (memory optimization) */
int early_add; /**< was added before the link heap saw it */
#if DEBUG_CHECKINT

View File

@@ -59,6 +59,7 @@ Please visit our Website: http://www.httrack.com
// parser
#include "htsparse.h"
#include "htssinglefile.h"
#include "htsback.h"
// arrays
@@ -398,6 +399,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
FILE *fp = NULL; // fichier écrit localement
const char *html = r->adr; // pointeur (on parcours)
const char *lastsaved; // adresse du dernier octet sauvé + 1
hts_boolean sf_may_mark = HTS_FALSE; // --single-file: mark this one?
hts_log_print(opt, LOG_DEBUG, "scanning file %s%s (%s)..", urladr(), urlfil(),
savename());
@@ -413,6 +415,10 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
r->adr[i] = ' ';
}
}
/* Only chance can put this run's secret in a fetched document. */
if (opt->single_file) {
sf_may_mark = singlefile_may_mark(opt, r->adr, (size_t) r->size);
}
}
// Indexing!
@@ -1831,6 +1837,12 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
int quoteinscript = 0;
int noquote = 0;
const char *tag_attr_start = html;
/* Kept because the value scan below clears intag on an unquoted
value ending at '>', and --single-file must still know the tag.
inscript_locked marks a whole CSS or JS file, the one context
that legitimately has no tag. */
const char *const sf_tag = intag_start_valid ? intag_name : NULL;
const int sf_tagless_body = inscript_locked;
// si nofollow ou un stop a été déclenché, réécrire tous les liens en externe
if ((nofollow)
@@ -3100,6 +3112,19 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
if ((opt->getmode & HTS_GETMODE_HTML) && (ptr > 0)) {
/* --single-file: tag the reference for the
end-of-mirror pass. A fragment, so a mirror left
marked by an interrupted run still browses. */
/* A NULL tag reads as "a CSS or JS body", so a tag
we cannot name must not reach the classifier. */
const char sf_class =
(opt->single_file && sf_may_mark && !in_media &&
p_type == 0 && !p_searchMETAURL &&
(sf_tag != NULL || sf_tagless_body))
? singlefile_ref_class(sf_tag, tag_attr_start)
: 0;
const size_t sf_start = TypedArraySize(output_buffer);
// écrire le lien modifié, relatif
// Note: escape all chars, even >127 (no UTF)
HT_ADD_HTMLESCAPED_FULL(tempo);
@@ -3113,6 +3138,28 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
HT_ADD_HTMLESCAPED(a);
}
}
/* Measured on the appended bytes, not tempo: the
escape can change the length. The query sits inside
the span so inlining drops it. */
if (sf_class != 0) {
char sf_mark[SINGLEFILE_MARK_MAX];
/* SINGLEFILE_MAX_SPAN is derived from these two
buffers, so a resize must move it: divide by zero
here rather than silently stop marking. */
enum {
sf_span_fits =
1 /
(sizeof(tempo) * HTS_HTMLESCAPE_FULL_MAXEXP +
sizeof(lien) * HTS_HTMLESCAPE_MAXEXP <=
SINGLEFILE_MAX_SPAN)
};
HT_ADD(singlefile_mark(
opt, sf_mark, sizeof(sf_mark), sf_class,
TypedArraySize(output_buffer) - sf_start));
}
}
lastsaved = eadr - 1; // dernier écrit+1 (enfin euh apres on fait un ++ alors hein)
} else {
@@ -3622,6 +3669,9 @@ int hts_mirror_check_moved(htsmoduleStruct * str,
heap_top()->pass2 =
max(heap(ptr)->pass2, numero_passe);
heap_top()->retry = heap(ptr)->retry;
// same transfer under another name: carry the restart latch
// too, else a 206/alias alternation restarts for free
heap_top()->refetch_whole = heap(ptr)->refetch_whole;
heap_top()->premier = heap(ptr)->premier;
heap_top()->precedent = heap(ptr)->precedent;
} else { // oups erreur, plus de mémoire!!
@@ -3728,6 +3778,7 @@ int hts_mirror_check_moved(htsmoduleStruct * str,
heap_top()->depth = heap(ptr)->depth;
heap_top()->pass2 = max(heap(ptr)->pass2, numero_passe);
heap_top()->retry = heap(ptr)->retry;
heap_top()->refetch_whole = heap(ptr)->refetch_whole;
heap_top()->premier = heap(ptr)->premier;
heap_top()->precedent = ptr;
error = 1;
@@ -3802,8 +3853,15 @@ int hts_mirror_check_moved(htsmoduleStruct * str,
break;
}
/* Restarting a resume the server refused is not a retry of a failed
transfer: free, but latched to one per link so an always-unusable
server cannot loop (#1052). */
const hts_boolean restart_whole =
r->refetch_wholefile && !heap(ptr)->refetch_whole;
if (strcmp(heap(ptr)->fil, "/primary") != 0) { // no primary (internal page 0)
if ((heap(ptr)->retry <= 0) || (!can_retry)) { // retry épuisés (ou retry impossible)
// give up: no free restart left, and no budget (or nothing to retry)
if (!restart_whole && ((heap(ptr)->retry <= 0) || (!can_retry))) {
if ((opt->retry > 0) && (can_retry)) {
hts_log_print(opt, LOG_ERROR,
"\"%s\" (%d) after %d retries at link %s%s (from %s%s)",
@@ -3853,23 +3911,32 @@ int hts_mirror_check_moved(htsmoduleStruct * str,
}
}
} else { // retry!!
hts_log_print(opt, LOG_NOTICE,
"Retry after error %d (%s) at link %s%s (from %s%s)",
r->statuscode, r->msg, urladr(), urlfil(),
heap(heap(ptr)->precedent)->adr,
heap(heap(ptr)->precedent)->fil);
// redemander fichier
} else { // retry, or a refused-resume restart
if (restart_whole) {
hts_log_print(opt, LOG_NOTICE,
"Restarting whole file after error %d (%s) at link "
"%s%s (from %s%s)",
r->statuscode, r->msg, urladr(), urlfil(),
heap(heap(ptr)->precedent)->adr,
heap(heap(ptr)->precedent)->fil);
} else {
hts_log_print(opt, LOG_NOTICE,
"Retry after error %d (%s) at link %s%s (from %s%s)",
r->statuscode, r->msg, urladr(), urlfil(),
heap(heap(ptr)->precedent)->adr,
heap(heap(ptr)->precedent)->fil);
}
// re-request the file
if (hts_record_link(opt, urladr(), urlfil(), savename(), "", "", codebase)) {
heap_top()->testmode = heap(ptr)->testmode; // mode test?
heap_top()->link_import = 0; // pas mode import
heap_top()->depth = heap(ptr)->depth;
heap_top()->pass2 = max(heap(ptr)->pass2, numero_passe);
heap_top()->retry = heap(ptr)->retry - 1; // moins 1 retry!
heap_top()->retry =
restart_whole ? heap(ptr)->retry : heap(ptr)->retry - 1;
heap_top()->premier = heap(ptr)->premier;
heap_top()->precedent = heap(ptr)->precedent;
// a rejected resume (unusable 206) must refetch whole, no Range
// (#581)
// refetch whole with no Range, and latch out a second free restart
heap_top()->refetch_whole = r->refetch_wholefile;
} else { // oups erreur, plus de mémoire!!
return 0;
@@ -4333,7 +4400,8 @@ int hts_mirror_wait_for_next_file(htsmoduleStruct * str,
}
if (!opt->verbosedisplay) {
if (!opt->quiet) {
/* The spinner overwrites itself; nothing to overwrite off a terminal. */
if (!opt->quiet && hts_stdout_isterminal()) {
static int roll = 0; /* static: ok */
roll = (roll + 1) % 4;
@@ -4343,10 +4411,10 @@ int hts_mirror_wait_for_next_file(htsmoduleStruct * str,
} else if (opt->verbosedisplay == HTS_VERBOSE_SIMPLE) {
if (b >= 0) {
if (back[b].r.statuscode == HTTP_OK)
printf("%d/%d: %s%s (" LLintP " bytes) - OK\33[K\r", ptr, opt->lien_tot,
printf("%d/%d: %s%s (" LLintP " bytes) - OK\n", ptr, opt->lien_tot,
back[b].url_adr, back[b].url_fil, (LLint) back[b].r.size);
else
printf("%d/%d: %s%s (" LLintP " bytes) - %d\33[K\r", ptr, opt->lien_tot,
printf("%d/%d: %s%s (" LLintP " bytes) - %d\n", ptr, opt->lien_tot,
back[b].url_adr, back[b].url_fil, (LLint) back[b].r.size,
back[b].r.statuscode);
} else {

101
src/htsrandom.c Normal file
View File

@@ -0,0 +1,101 @@
/* ------------------------------------------------------------ */
/*
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
*/
/* ------------------------------------------------------------ */
/* System CSPRNG. See htsrandom.h. */
/* ------------------------------------------------------------ */
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "htsrandom.h"
#ifdef _WIN32
#include <windows.h>
#else
#include <errno.h>
#include <stdio.h>
#ifdef HAVE_SYS_RANDOM_H
#include <sys/random.h>
#endif
#endif
#ifdef _WIN32
/* RtlGenRandom, resolved at runtime so no import library is needed. */
typedef BOOLEAN(WINAPI *hts_rtlgenrandom_t)(PVOID buffer, ULONG length);
#endif
hts_boolean hts_random_bytes(void *buf, size_t len) {
#ifdef _WIN32
hts_boolean ok = HTS_FALSE;
HMODULE dll = LoadLibraryA("advapi32.dll");
if (dll != NULL) {
hts_rtlgenrandom_t gen =
(hts_rtlgenrandom_t) GetProcAddress(dll, "SystemFunction036");
/* the ULONG cast must not truncate; callers ask for a few dozen bytes */
if (gen != NULL && len <= 0x10000 && gen(buf, (ULONG) len)) {
ok = HTS_TRUE;
}
FreeLibrary(dll);
}
return ok;
#else
unsigned char *const p = (unsigned char *) buf;
size_t got = 0;
FILE *fp;
#ifdef HAVE_GETRANDOM
while (got < len) {
const ssize_t n = getrandom(p + got, len - got, 0);
if (n < 0) {
if (errno == EINTR)
continue;
break; /* pre-3.17 kernel or a seccomp filter: try /dev/urandom */
}
got += (size_t) n;
}
if (got == len)
return HTS_TRUE;
#endif
fp = fopen("/dev/urandom", "rb");
if (fp == NULL)
return HTS_FALSE;
while (got < len) {
const size_t n = fread(p + got, 1, len - got, fp);
if (n == 0) /* a short read is a hard failure, not partial credit */
break;
got += n;
}
fclose(fp);
return got == len ? HTS_TRUE : HTS_FALSE;
#endif
}

52
src/htsrandom.h 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
*/
/* ------------------------------------------------------------ */
/* System CSPRNG. Its own file because htsserver links libhttrack but cannot
see a hidden symbol inside it, and both need this one. */
/* ------------------------------------------------------------ */
#ifndef HTS_RANDOM_DEFH
#define HTS_RANDOM_DEFH
#include "htsglobal.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Fill buf with len cryptographically strong random bytes. HTS_FALSE when no
such source is available: the caller must fail, never fall back to rand(). */
hts_boolean hts_random_bytes(void *buf, size_t len);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -112,7 +112,8 @@ static HTS_UNUSED void abortf_(const char *exp, const char *file, int line) {
#define HTS_IS_CHAR_BUFFER(VAR) \
(__builtin_types_compatible_p(__typeof__(VAR), char[]))
#else
/* Note: a bit lame as char[8] won't be seen. */
/* An array of exactly pointer size reads as a pointer and loses its bound:
keep buff() destinations off char[8] (Win64) and char[4] (Win32). */
#define HTS_IS_CHAR_BUFFER(VAR) (sizeof(VAR) != sizeof(char *))
#endif
#define HTS_IS_NOT_CHAR_BUFFER(VAR) (!HTS_IS_CHAR_BUFFER(VAR))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ Please visit our Website: http://www.httrack.com
#ifndef HTS_SINGLEFILE_DEFH
#define HTS_SINGLEFILE_DEFH
#include "htscore.h" /* HTS_HTMLESCAPE_*_MAXEXP */
#include "htsopt.h"
#include "htsstrings.h"
@@ -55,18 +56,74 @@ extern "C" {
so a few hundred bytes of hostile CSS can otherwise ask for gigabytes. */
#define SINGLEFILE_MAX_PAGE_SIZE (64 * 1024 * 1024)
/* Rewrite every HTML page the mirror produced. No-op unless opt->single_file;
call once the tree is final, after the update purge. */
/* The mark htsparse appends to a saved reference the pass may inline:
#!<16-hex secret>.<class>.<len>
A fragment, so a mirror left marked by an interrupted run still browses.
The secret is 64 CSPRNG bits drawn once per run and never written to disk,
which is what makes the mark unforgeable: a site cannot spell one, so no
sanitiser has to keep hostile bytes away from it. <len> is the byte length
of the reference text immediately preceding the mark, so the pass never has
to guess where that reference starts. <class> is the context htsparse saw,
checked against the resolved type so a mismatch fails loudly. */
#define SINGLEFILE_MARK_INTRO "#!"
#define SINGLEFILE_SECRET_HEX 16
/* Bytes before the ".<class>.<len>" tail. */
#define SINGLEFILE_INTRO_LEN \
(sizeof(SINGLEFILE_MARK_INTRO) - 1 + SINGLEFILE_SECRET_HEX)
/* <class>: what the referencing context expects. */
#define SINGLEFILE_CLASS_ANY '-'
#define SINGLEFILE_CLASS_CSS 'c'
#define SINGLEFILE_CLASS_JS 'j'
/* Enough for the intro, the secret, both separators and a 20-digit length. */
#define SINGLEFILE_MARK_MAX 64
/* Longest <len> a mark may carry, which is what htsparse can append for one
reference: HTS_URLMAXSIZE*2 of savename through escape_for_html_print_full,
plus as much query through escape_for_html_print. The emit site asserts the
derivation against its own buffers. */
#define SINGLEFILE_MAX_SPAN \
(HTS_URLMAXSIZE * 2 * (HTS_HTMLESCAPE_FULL_MAXEXP + HTS_HTMLESCAPE_MAXEXP))
/* Release this run's secret. */
void singlefile_free(httrackp *opt);
/* "#!<secret>" for this run, or NULL if no CSPRNG was available (in which case
nothing may be marked). Draws the secret on first use. */
const char *singlefile_intro(httrackp *opt);
/* HTS_FALSE if [body,len) already contains this run's intro, in which case the
document must not be marked: RFC 2046 gives the generator the same duty for
a MIME boundary. Only a 2^-64 coincidence can trip it, since the intro is
not something fetched content can spell. */
hts_boolean singlefile_may_mark(httrackp *opt, const char *body, size_t len);
/* Write the mark for a reference of reflen bytes into buf; returns buf, left
empty for a reflen the pass could not read back. */
const char *singlefile_mark(httrackp *opt, char *buf, size_t bufsize, char cls,
size_t reflen);
/* The class a reference in this context may inline as, or 0 to leave it alone.
tag_name points just past the '<' of the enclosing start tag, or NULL when
there is none (inside a stylesheet or a script); attr at the attribute name.
Everything htsparse detects is inlinable unless it names a page. */
char singlefile_ref_class(const char *tag_name, const char *attr);
/* Rewrite every HTML page the mirror produced, then strip the marks left in
the assets. No-op unless opt->single_file; call once the tree is final,
after the update purge. */
void singlefile_process_mirror(httrackp *opt);
/* Rewrite one HTML document held in memory, appending the result to out.
root is the mirror directory that references may not escape; page_path is
the document's own path under it (both UTF-8, '/' or native separators).
page_budget caps the total inlined bytes, since nested @import fans out
/* Expand the marks in the document held in memory, appending the result to
out. root is the mirror directory that references may not escape; page_path
is the document's own path under it (both UTF-8, '/' or native separators).
page_budget caps the total inlined bytes, since a nested @import fans out
multiplicatively; the mirror pass passes SINGLEFILE_MAX_PAGE_SIZE.
Returns HTS_TRUE if at least one reference was replaced; out may still
differ from the input when that is HTS_FALSE, since a style or srcset value
is re-serialized in place. */
Returns HTS_TRUE if at least one reference was replaced. */
hts_boolean singlefile_rewrite_html(httrackp *opt, const char *root,
const char *page_path, const char *html,
size_t html_len, LLint page_budget,

View File

@@ -42,6 +42,7 @@ Please visit our Website: http://www.httrack.com
#include "htscharset.h"
#ifdef _WIN32
#include "windows.h"
#include <io.h> /* _isatty */
#else
#include <dirent.h>
#ifdef HAVE_UNISTD_H
@@ -1509,3 +1510,11 @@ hts_boolean hts_rename_over_aside_selftest(httrackp *opt, const char *src,
fconv(cdst, sizeof(cdst), dst);
return rename_over_aside(opt, csrc, cdst);
}
hts_boolean hts_stdout_isterminal(void) {
#ifdef _WIN32
return _isatty(_fileno(stdout)) ? HTS_TRUE : HTS_FALSE;
#else
return isatty(fileno(stdout)) ? HTS_TRUE : HTS_FALSE;
#endif
}

View File

@@ -149,6 +149,10 @@ hts_boolean hts_rename_over(httrackp *opt, const char *src, const char *dst);
hts_boolean hts_rename_over_aside_selftest(httrackp *opt, const char *src,
const char *dst);
/* True when stdout is a real terminal. Gate any VT escape or CR overwrite on
it: a redirected log keeps that noise forever. */
hts_boolean hts_stdout_isterminal(void);
#endif
#endif

View File

@@ -55,7 +55,8 @@ Please visit our Website: http://www.httrack.com
#include <openssl/rand.h>
#endif
/* opt->state.warc value meaning "open failed once, do not retry". */
/* opt->state.warc value meaning "no writer, and do not open one": the open
failed, or the session is done with the archive. */
#define WARC_DISABLED ((void *) ~(uintptr_t) 0)
/* Suffix of the in-progress archive when a previous one must survive it. */
@@ -851,17 +852,39 @@ static void warc_cdx_flush(warc_writer *w) {
FILE *f;
char catbuff[CATBUFF_SIZE];
size_t i;
if (!w->cdx_on || w->cdx_path == NULL || w->cdx_count == 0)
int werr, cerr;
if (!w->cdx_on || w->cdx_path == NULL)
return;
if (w->cdx_count == 0) {
/* Stale only if an index is still on disk and this run wrote an archive
over what it describes: opened covers the swap and the in-place run. */
if (w->opened && fsize_utf8(w->cdx_path) > 0)
hts_log_print(w->opt, LOG_ERROR,
"WARC: no record was indexed, %s was not rewritten",
w->cdx_path);
return;
}
qsort(w->cdx_lines, w->cdx_count, sizeof(char *), cdx_cmp);
f = FOPEN(fconv(catbuff, sizeof(catbuff), w->cdx_path), "wb");
if (f == NULL)
if (f == NULL) {
hts_log_print(w->opt, LOG_ERROR | LOG_ERRNO,
"WARC: could not write the index %s", w->cdx_path);
return;
}
for (i = 0; i < w->cdx_count; i++) {
fputs(w->cdx_lines[i], f);
fputc('\n', f);
}
fclose(f);
werr = ferror(f) != 0;
cerr = fclose(f) != 0;
/* a write that already failed says more than fclose echoing it, and only
fclose's errno is still fresh */
if (werr)
hts_log_print(w->opt, LOG_ERROR, "WARC: the index %s is incomplete",
w->cdx_path);
else if (cerr)
hts_log_print(w->opt, LOG_ERROR | LOG_ERRNO,
"WARC: could not write the index %s", w->cdx_path);
}
/* ---- WACZ pages + packaging (--wacz) ---- */
@@ -1669,7 +1692,19 @@ void warc_close_opt(httrackp *opt) {
if (opt->state.warc != NULL && opt->state.warc != WARC_DISABLED) {
warc_close((warc_writer *) opt->state.warc);
}
opt->state.warc = NULL;
/* final: teardown still finalizes slots, and a reopen there would orphan a
.tmp nobody closes (#1060) */
opt->state.warc = WARC_DISABLED;
}
void warc_abort_opt(httrackp *opt) {
if (opt->state.warc != NULL && opt->state.warc != WARC_DISABLED) {
warc_writer *const w = (warc_writer *) opt->state.warc;
/* the session is rolling back, so this run must replace nothing */
w->failed = HTS_TRUE;
warc_close(w);
}
opt->state.warc = WARC_DISABLED;
}
/* ---- one transaction ---- */

View File

@@ -80,13 +80,19 @@ void warc_move_request(htsblk *src, htsblk *dst);
void warc_adopt_rawspool(htsblk *r, const char *tmpfile_path);
/* Emit the request + response (or revisit) records for one finished
transaction. Lazily opens the writer into opt->state.warc; a no-op (logged
once) if the archive cannot be created. */
transaction. Lazily opens the writer into opt->state.warc; a no-op if the
archive cannot be created (logged once), and a silent one once the session
has closed or abandoned it. */
void warc_write_backtransaction(httrackp *opt, lien_back *back);
/* Close and free the writer held in opt->state.warc, if any. */
/* Close and free the writer held in opt->state.warc, if any. Both this and
warc_abort_opt() are final: a later transaction reopens nothing. */
void warc_close_opt(httrackp *opt);
/* Same, but discards this run's archive: keeps the previous one and drops
the temporaries. For a rolled-back session that transferred nothing. */
void warc_abort_opt(httrackp *opt);
/* --- Direct writer API (used by the hooks above and the self-test). --- */
/* Create the archive at path (auto-named when path is WARC_AUTONAME), writing

View File

@@ -67,6 +67,7 @@ Please visit our Website: http://www.httrack.com
#include "htsurlport.h"
#include "htsweb.h"
#include "htscharset.h"
#include "htsrandom.h"
#if USE_BEGINTHREAD==0
#error fatal: no threads support
@@ -101,50 +102,6 @@ static void htsweb_sig_brpipe(int code) {
/* ignore */
}
#ifdef _WIN32
/* RtlGenRandom, resolved at runtime so no import library is needed. */
typedef BOOLEAN(WINAPI *hts_rtlgenrandom_t)(PVOID buffer, ULONG length);
#endif
/* Fill buffer with system entropy; HTS_FALSE if no source answered. There is
deliberately no weak fallback: the caller mints a secret with it. */
static hts_boolean hts_random_bytes(void *buffer, size_t size) {
#ifdef _WIN32
hts_boolean ok = HTS_FALSE;
HMODULE dll = LoadLibraryA("advapi32.dll");
if (dll != NULL) {
hts_rtlgenrandom_t gen =
(hts_rtlgenrandom_t) GetProcAddress(dll, "SystemFunction036");
/* the ULONG cast must not truncate; callers ask for a few dozen bytes */
if (gen != NULL && size <= 0x10000 && gen(buffer, (ULONG) size)) {
ok = HTS_TRUE;
}
FreeLibrary(dll);
}
return ok;
#else
unsigned char *dst = (unsigned char *) buffer;
size_t done = 0;
FILE *fp = fopen("/dev/urandom", "rb");
if (fp == NULL) {
return HTS_FALSE;
}
while (done < size) {
const size_t n = fread(dst + done, 1, size - done, fp);
if (n == 0) { /* short read is a hard failure, not partial credit */
break;
}
done += n;
}
fclose(fp);
return done == size ? HTS_TRUE : HTS_FALSE;
#endif
}
/* Threads that never return; no wait may count on them draining. */
static int nonjoinable_threads = 0;
@@ -716,7 +673,7 @@ int __cdecl htsshow_loop(t_hts_callbackarg * carg, httrackp * opt, lien_back * b
strcpybuff(StatsBuffer[index].state, "search");
ok = 1;
} else if (back[i].status == STATUS_FTP_TRANSFER) { // ohh le beau ftp
char proto[] = "ftp";
char proto[8] = "ftp";
if (back[i].url_adr[0]) {
char *ep = strchr(back[i].url_adr, ':');

View File

@@ -61,11 +61,6 @@ Please visit our Website: http://www.httrack.com
assertf((*opt->filters.filptr) < opt->maxfilter); \
} while (0)
typedef struct htspair_t {
const char *tag;
const char *attr;
} htspair_t;
/* "embedded" */
htspair_t hts_detect_embed[] = {
{"img", "src"},
@@ -133,11 +128,13 @@ int hts_acceptlink(httrackp * opt, int ptr,
return forbidden_url;
}
static int cmp_token(const char *tag, const char *cmp) {
hts_boolean hts_cmp_tag_token(const char *tag, const char *cmp) {
int p;
return (strncasecmp(tag, cmp, (p = (int) strlen(cmp))) == 0
&& !isalnum((unsigned char) tag[p]));
return (tag != NULL && strncasecmp(tag, cmp, (p = (int) strlen(cmp))) == 0 &&
!isalnum((unsigned char) tag[p]))
? HTS_TRUE
: HTS_FALSE;
}
/* TRUE if (tag, attribute) matches an embedded-asset pair in the table */
@@ -145,7 +142,8 @@ static hts_boolean is_embed_pair(const htspair_t *table, const char *tag,
const char *attribute) {
int i;
for (i = 0; table[i].tag != NULL; i++) {
if (cmp_token(tag, table[i].tag) && cmp_token(attribute, table[i].attr))
if (hts_cmp_tag_token(tag, table[i].tag) &&
hts_cmp_tag_token(attribute, table[i].attr))
return HTS_TRUE;
}
return HTS_FALSE;

View File

@@ -56,6 +56,19 @@ hts_boolean hts_robots_forbids(httrackp *opt, const char *adr, const char *fil,
hts_boolean filters_decided,
hts_boolean filters_refused);
/* A (tag, attribute) pair naming a reference kind. */
#ifndef HTS_DEF_DEFSTRUCT_htspair_t
#define HTS_DEF_DEFSTRUCT_htspair_t
typedef struct htspair_t {
const char *tag;
const char *attr;
} htspair_t;
#endif
/* HTS_TRUE if tag starts with the whole token cmp; NULL tag never matches. */
hts_boolean hts_cmp_tag_token(const char *tag, const char *cmp);
int hts_acceptlink(httrackp * opt, int ptr,
const char *adr, const char *fil,
const char *tag, const char *attribute,

View File

@@ -73,6 +73,9 @@ static int linput(FILE * fp, char *s, int max);
#ifdef HAVE_SYS_IOCTL_H
#include <sys/ioctl.h>
#endif
#ifdef _WIN32
#include <io.h> /* _isatty */
#endif
#include <ctype.h>
/* END specific definitions */
@@ -190,6 +193,15 @@ static void vt_home(void) {
static int term_cols = 80;
static int term_rows = 24;
/* The library's hts_stdout_isterminal() is hidden by -fvisibility=hidden. */
static hts_boolean stdout_isterminal(void) {
#ifdef _WIN32
return _isatty(_fileno(stdout)) ? HTS_TRUE : HTS_FALSE;
#else
return isatty(fileno(stdout)) ? HTS_TRUE : HTS_FALSE;
#endif
}
/* Returns HTS_TRUE if the terminal size changed since the last call. */
static hts_boolean vt_size_refresh(void) {
int cols = 0;
@@ -327,7 +339,8 @@ static void __cdecl htsshow_uninit(t_hts_callbackarg * carg) {
}
static int __cdecl htsshow_start(t_hts_callbackarg * carg, httrackp * opt) {
use_show = 0;
if (opt->verbosedisplay == HTS_VERBOSE_FULL) {
/* Cursor addressing needs a screen; the ENTER toggle re-enters here. */
if (opt->verbosedisplay == HTS_VERBOSE_FULL && stdout_isterminal()) {
use_show = 1;
(void) vt_size_refresh();
vt_clear();

View File

@@ -126,10 +126,12 @@
<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" />
<ClCompile Include="htsproxy.c" />
<ClCompile Include="htsrandom.c" />
<ClCompile Include="htsrobots.c" />
<ClCompile Include="htsselftest.c" />
<ClCompile Include="htssinglefile.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

@@ -17,8 +17,8 @@
#endif
VS_VERSION_INFO VERSIONINFO
FILEVERSION 3, 49, 17, 0
PRODUCTVERSION 3, 49, 17, 0
FILEVERSION 3, 49, 19, 0
PRODUCTVERSION 3, 49, 19, 0
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
@@ -35,12 +35,12 @@ BEGIN
BEGIN
VALUE "CompanyName", "Xavier Roche"
VALUE "FileDescription", VER_FILE_DESCRIPTION
VALUE "FileVersion", "3.49.17"
VALUE "FileVersion", "3.49.19"
VALUE "InternalName", VER_ORIGINAL_FILENAME
VALUE "LegalCopyright", "Copyright (C) 1998-2026 Xavier Roche and other contributors. GNU GPL v3 or later."
VALUE "OriginalFilename", VER_ORIGINAL_FILENAME
VALUE "ProductName", "HTTrack Website Copier"
VALUE "ProductVersion", "3.49-17"
VALUE "ProductVersion", "3.49-19"
END
END
BLOCK "VarFileInfo"

View File

@@ -97,6 +97,7 @@
<ItemGroup>
<ClCompile Include="htsserver.c" />
<ClCompile Include="htsweb.c" />
<ClCompile Include="htsrandom.c" />
<ClCompile Include="htscmdline.c" />
<ClCompile Include="htsurlport.c" />
</ItemGroup>

View File

@@ -97,6 +97,10 @@ run_only "$tmp/q-bad" '"foo'
refused "dangling-quote argument not refused cleanly"
run_only "$tmp/q-lone" '"'
refused "lone-quote argument not refused cleanly"
# A dangling quote on an argument at the cap must not overflow the "Missing
# quote in %s" panic buffer: the prefix plus a 1023-byte argument overran it.
run_only "$tmp/q-long" "\"$(nchars 1022)"
refused "near-cap dangling-quote argument overflowed the panic buffer"
# --pause (#185): valid MIN[:MAX] accepted; malformed, reversed, over-range and
# non-finite values refused cleanly. NaN defeats naive `<`/`>` checks (it

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

@@ -0,0 +1,18 @@
#!/bin/bash
#
# An index that cannot be written, or that would be left empty next to a
# rewritten archive, must say so; a first run with nothing to index must not.
set -euo pipefail
httrack_bin=$(cd "$(dirname "$(command -v httrack)")" && pwd)/httrack
scratch=$(mktemp -d)
trap 'set +e; rm -rf "$scratch"' EXIT
out=$("$httrack_bin" -O /dev/null -#test=warc-cdx-errors "$scratch/")
echo "$out"
case "$out" in
*": OK") ;;
*) exit 1 ;;
esac

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 ----------------------
@@ -65,17 +68,20 @@ grep -q 'slow but healthy' "$out" || fail "healthy test output lost"
# The killed tree must really be gone, or the next test inherits its ports.
sleep 1
! grep -q "$tmp/90_wedged.test" <<<"$(ps -A -o args 2>/dev/null)" ||
! grep -q "$tmp/90_wedged.test" <<<"$(ps_snapshot)" ||
fail "the wedged test survived the guard"
# --- 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.
@@ -139,6 +145,142 @@ HTTRACK_TEST_TIMEOUT=600 bash "$driver" "$tmp/94_pacer.test" >"$out" 2>&1 || rc=
test "$rc" -eq 0 || fail "a 60s step under a 600s budget reported $rc, want 0"
grep -q 'the pacer let it through' "$out" || fail "the pacer skipped a test that fits"
# --- the process lists survive a host with no ps ----------------------------
# Fedora's build root ships no procps, and the guard then named nothing (#1021).
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"
# 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" -c "$hold" &
echo $! >"$tmp/fake.pid"
)
# Its own group, to prove the filter keeps an outsider out.
(
set -m
"$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; }
read -r fppid pgid _ <<<"$(procfields "$fake")"
read -r _ opgid _ <<<"$(procfields "$outside")"
test "$fppid" != "$pgid" || fail "the fake shares ppid and pgid ($pgid)"
test "$opgid" != "$pgid" || fail "the outsider landed in the group under test"
(
PATH="$tmp/nops:$PATH"
! ps -A >/dev/null 2>&1 || fail "the ps shim did not take"
snap=$(list_stray_processes "$pgid" group)
# Line 1 is the header every consumer drops; a row there would be lost.
case "$(head -n1 <<<"$snap")" in
[0-9]*) fail "the no-ps listing has no header: $snap" ;;
esac
! grep -qE "^$outside " <<<"$snap" ||
fail "no-ps group list reported pid $outside, in another group"
read -r _ rppid rpgid _ <<<"$(grep -E "^$fake " <<<"$snap")"
test "$rppid $rpgid" = "$fppid $pgid" ||
fail "no-ps row for $fake reads ppid/pgid '$rppid $rpgid', want '$fppid $pgid'"
engines=$(list_engine_pids "$pgid")
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"
# 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; }
grep -q 'no process list' <<<"$(list_stray_processes "$pgid" group)" ||
fail "a host with no ps and no /proc produced no notice"
)
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

@@ -3,6 +3,8 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash crawl-test.sh --errors 0 --files 5 httrack http://ut.httrack.com/simple/basic.html
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
bash "$testdir/crawl-test.sh" --errors 0 --files 5 httrack http://ut.httrack.com/simple/basic.html

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

@@ -3,9 +3,11 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash crawl-test.sh --errors 0 --files 3 \
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
bash "$testdir/crawl-test.sh" --errors 0 --files 3 \
--found ut.httrack.com/cookies/third.html \
--found ut.httrack.com/cookies/second.html \
--found ut.httrack.com/cookies/entrance.html \

View File

@@ -3,10 +3,12 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
# unicode tests
bash crawl-test.sh \
bash "$testdir/crawl-test.sh" \
--errors 1 --files 5 \
--found 'café.ut.httrack.com/unicode-links/café3860.html' \
--found 'café.ut.httrack.com/unicode-links/café30f4.html' \
@@ -16,7 +18,7 @@ bash crawl-test.sh \
'+*.ut.httrack.com/*' --robots=0
# unicode tests (bogus links)
bash crawl-test.sh \
bash "$testdir/crawl-test.sh" \
--errors 0 --files 1 \
--found 'ut.httrack.com/unicode-links/idna_bogus.html' \
httrack 'http://ut.httrack.com/unicode-links/idna_bogus.html' \

View File

@@ -3,10 +3,12 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
# unicode tests
bash crawl-test.sh \
bash "$testdir/crawl-test.sh" \
--errors 1 --files 10 \
--found ut.httrack.com/unicode-links/caf%a91bce.html \
--found ut.httrack.com/unicode-links/café30f4.html \
@@ -21,7 +23,7 @@ bash crawl-test.sh \
--found ut.httrack.com/unicode-links/utf8.html \
httrack http://ut.httrack.com/unicode-links/utf8.html
bash crawl-test.sh \
bash "$testdir/crawl-test.sh" \
--errors 2 --files 9 \
--found ut.httrack.com/unicode-links/café3860.html \
--found ut.httrack.com/unicode-links/café9fa8.html \
@@ -36,7 +38,7 @@ bash crawl-test.sh \
--found ut.httrack.com/unicode-links/default.html \
httrack http://ut.httrack.com/unicode-links/default.html
bash crawl-test.sh \
bash "$testdir/crawl-test.sh" \
--errors 2 --files 9 \
--found ut.httrack.com/unicode-links/caf%a9ae52.html \
--found ut.httrack.com/unicode-links/caf%a9bf59.html \
@@ -51,7 +53,7 @@ bash crawl-test.sh \
--found ut.httrack.com/unicode-links/iso88591.html \
httrack http://ut.httrack.com/unicode-links/iso88591.html
bash crawl-test.sh \
bash "$testdir/crawl-test.sh" \
--errors 4 --files 9 \
--found ut.httrack.com/unicode-links/caf%a8%a6c72a.html \
--found ut.httrack.com/unicode-links/caf%a9bf59.html \

View File

@@ -3,10 +3,12 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
# http://code.google.com/p/httrack/issues/detail?id=42&can=1
# we expect 2 errors only because other links are too longs (to be modified if suitable)
bash crawl-test.sh --errors 2 --files 1 \
bash "$testdir/crawl-test.sh" --errors 2 --files 1 \
--found ut.httrack.com/overflow/longquerywithaccents.html \
httrack http://ut.httrack.com/overflow/longquerywithaccents.php

View File

@@ -3,10 +3,12 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
# http://code.google.com/p/httrack/issues/detail?id=4&can=1
bash crawl-test.sh --errors 0 --files 4 \
bash "$testdir/crawl-test.sh" --errors 0 --files 4 \
--found ut.httrack.com/parsing/back5e1f.gif \
--found ut.httrack.com/parsing/events.html \
--found ut.httrack.com/parsing/fade230f4.gif \
@@ -14,21 +16,21 @@ bash crawl-test.sh --errors 0 --files 4 \
httrack http://ut.httrack.com/parsing/events.html
# http://code.google.com/p/httrack/issues/detail?id=2&can=1
bash crawl-test.sh --errors 0 --files 3 \
bash "$testdir/crawl-test.sh" --errors 0 --files 3 \
--found ut.httrack.com/parsing/background-image.css \
--found ut.httrack.com/parsing/background-image.html \
--found ut.httrack.com/parsing/fade.gif \
httrack http://ut.httrack.com/parsing/background-image.html
# javascript parsing
bash crawl-test.sh --errors 0 --files 3 \
bash "$testdir/crawl-test.sh" --errors 0 --files 3 \
--found ut.httrack.com/parsing/back.gif \
--found ut.httrack.com/parsing/fade.gif \
--found ut.httrack.com/parsing/javascript.html \
httrack http://ut.httrack.com/parsing/javascript.html
# handling of + before query string
bash crawl-test.sh --errors 0 --files 6 \
bash "$testdir/crawl-test.sh" --errors 0 --files 6 \
--found ut.httrack.com/parsing/escaping.html \
--found "ut.httrack.com/parsing/foo bar30f4.html" \
--found "ut.httrack.com/parsing/foo bar5e1f.html" \
@@ -39,7 +41,7 @@ bash crawl-test.sh --errors 0 --files 6 \
# handling of # encoded in filename
# see http://code.google.com/p/httrack/issues/detail?id=25
bash crawl-test.sh --errors 2 --files 4 \
bash "$testdir/crawl-test.sh" --errors 2 --files 4 \
--found "ut.httrack.com/parsing/escaping2.html" \
--found "ut.httrack.com/parsing/++foo++bar++plus++.html" \
--found "ut.httrack.com/parsing/foo#bar#.html" \

View File

@@ -18,9 +18,10 @@ command -v curl >/dev/null 2>&1 || {
echo "curl missing, skipping"
exit 77
}
# MSYS cannot reap a native listener, and the orphan wedges the suite (#595).
# A leak on a fully buffered stdout shows only through the pty below, and Windows
# builds neither Python's pty module nor os.fork.
if is_windows; then
echo "windows: cannot reap a backgrounded proxytrack, skipping"
echo "windows: python has no pty, so a buffered leak would be invisible, skipping"
exit 77
fi

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

@@ -3,11 +3,13 @@
set -euo pipefail
bash check-network.sh || ! echo "skipping online unit tests" || exit 77
testdir=$(cd "$(dirname "$0")" && pwd)
bash "$testdir/check-network.sh" || ! echo "skipping online unit tests" || exit 77
if test "${HTTPS_SUPPORT:-}" == "no"; then
echo "no https support compiled, skipping"
exit 77
fi
bash crawl-test.sh --errors 0 --files 5 httrack https://ut.httrack.com/simple/basic.html
bash "$testdir/crawl-test.sh" --errors 0 --files 5 httrack https://ut.httrack.com/simple/basic.html

View File

@@ -18,11 +18,6 @@ command -v curl >/dev/null 2>&1 || {
echo "curl missing, skipping"
exit 77
}
# MSYS cannot reap a native listener, and the orphan wedges the suite (#595).
if is_windows; then
echo "windows: cannot reap a backgrounded proxytrack, skipping"
exit 77
fi
dir=$(mktemp -d)
ptpid=
@@ -49,8 +44,10 @@ alen=$(($(wc -c <"$dir/hdr") + $(wc -c <"$dir/body")))
cat "$dir/hdr" "$dir/body"
} >"$dir/in.arc"
# $() strips a trailing LF, not the CR Windows python's print can put before it.
freeport() {
"$python" -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'
"$python" -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' |
tr -d '\r'
}
proxyport=$(freeport)
icpport=$(freeport)
@@ -84,7 +81,7 @@ propfind() {
# Amplification, not raw length, clears the old 1024-byte reserve: 900 '&'
# become 4500 bytes, written twice, all under the 1024-byte request-line cap.
amps=$("$python" -c "print('&' * 900)")
amps=$("$python" -c "print('&' * 900)" | tr -d '\r')
resp=$(propfind "$amps") || {
echo "FAIL: proxytrack died on an escapexml-amplified PROPFIND"
cat "$dir/pt.log"
@@ -104,7 +101,7 @@ test "$got" -eq 1800 || {
}
# Unescaped, so the bound is shown to be on the written length, not on '&'.
plain=$("$python" -c "print('a' * 900)")
plain=$("$python" -c "print('a' * 900)" | tr -d '\r')
resp=$(propfind "$plain") || {
echo "FAIL: proxytrack died on a 900-byte plain PROPFIND path"
cat "$dir/pt.log"

View File

@@ -19,9 +19,11 @@ command -v curl >/dev/null 2>&1 || {
echo "curl missing, skipping"
exit 77
}
# MSYS cannot reap a native listener, and the orphan wedges the suite (#595).
# A leak on a fully buffered stdout shows only through the pty below, and Windows
# builds neither Python's pty module nor os.fork; proxytrack also skips the PID=
# line, which request_log() cuts the banner off at.
if is_windows; then
echo "windows: cannot reap a backgrounded proxytrack, skipping"
echo "windows: python has no pty, so a buffered leak would be invisible, skipping"
exit 77
fi
@@ -126,8 +128,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

@@ -58,11 +58,13 @@ test "$(convert "$dir/legacy.arc" "$dir/c.arc")" = 1 ||
# a third would put the record out of its reach again.
hdrlen=$(head -1 "$dir/a.arc" | wc -c)
declared=$(head -1 "$dir/a.arc" | awk '{print $NF}')
sep=$(tail -c +$((hdrlen + declared + 1)) "$dir/a.arc" | head -c 3 | od -An -c | tr -d ' ')
# od seeks for itself: piping the archive into `head -c` leaves the writer with
# a closed pipe, and under pipefail that SIGPIPE is the test's exit status.
sep=$(od -An -c -j $((hdrlen + declared)) -N 3 "$dir/a.arc" | tr -d ' ')
test "$sep" = '\n\nh' || fail "version block ends on '$sep', expected two newlines"
# The block itself must not end on one either: counting the blank line and then
# writing a second one round-trips, but leaves the length a byte too long.
inner=$(tail -c +$((hdrlen + declared - 1)) "$dir/a.arc" | head -c 2 | od -An -c | tr -d ' ')
inner=$(od -An -c -j $((hdrlen + declared - 2)) -N 2 "$dir/a.arc" | tr -d ' ')
test "$inner" != '\n\n' || fail "declared length still counts the blank line"
# An empty archive is not a corrupt one...

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"
@@ -20,9 +24,15 @@ fail() {
}
progress="$tmp/progress"
kill_tree() { echo "KILL $1 at $(hb_now)"; }
kill_tree() {
hb_now
echo "KILL $1 at $hb_time"
}
# Stubbed too, or the legs below would send a real SIGKILL to pid 4242.
kill_pid() { echo "DIRECT $1 at $(hb_now)"; }
kill_pid() {
hb_now
echo "DIRECT $1 at $hb_time"
}
# Twelve lines, against a bound of eight.
list_stray_processes() {
printf '%s\n' "$*" >"$tmp/strayargs"
@@ -61,10 +71,21 @@ sleep() {
printf 'RUN %ss%%a%%b.test\r at %ss\n' "$vnow" "$vnow" >>"$progress"
echo "$vnow" >"$tmp/lastwrite"
fi
# One tick reports what a sleep that cannot exec reports. Unguarded, errexit
# ends the watchdog here and every assertion below goes unanswered.
test "$ticks" -ne 3 || return 126
return 0
}
# A clock read through a command substitution runs one subshell deeper than the
# watchdog itself, and that is a fork it must not need. Marker, not fail: an exit
# here would only end that subshell. hb_depth is set at each call site below.
hb_now() {
test "$BASH_SUBSHELL" -eq "$hb_depth" || : >"$tmp/forked"
hb_time=$vnow
}
hb_now() { echo "$vnow"; }
printf 'RUN 98_earlier.test at 0s\n' >"$progress"
hb_depth=$BASH_SUBSHELL
ci_suite_heartbeat 720 360 "$progress" 900 4242 >"$tmp/out" 2>&1
# Timed by the stub, not by the annotation. The kill is due one staticness window
@@ -130,6 +151,7 @@ awk '/^::(notice|error) title=/ { if (prev != "") bad = 1 } { prev = $0 } END {
vnow=9000
ticks=0
printf 'RUN 97_dead.test at 0s\n' >"$progress"
hb_depth=$BASH_SUBSHELL
ci_suite_heartbeat 960 360 "$progress" 900 4242 >"$tmp/late" 2>&1
killed=$(sed -n 's/^KILL 4242 at \([0-9]*\)$/\1/p' "$tmp/late")
test -n "$killed" || fail "a suite static from the start was never killed"
@@ -138,7 +160,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,18 +169,24 @@ 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"
exit 9
}
hb_depth=$BASH_SUBSHELL
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")"
test ! -e "$tmp/forked" || fail "the clock was read through a subshell, a fork a starved box cannot spare"
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,36 +43,109 @@ 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; }
EOF
# Records who sourced this copy, so assert_staged can tell the staged driver from
# the srcdir one. test-timeout.sh sources it too, hence the caller and not a bare
# marker file.
cat >>"$run/testlib.sh" <<EOF
printf '%s\n' "\${BASH_SOURCE[1]##*/}" >>"$tmp/sourced-by"
EOF
: >"$tmp/sourced-by"
# The suite under test has to be the staged copy: only it sources the testlib.sh
# neutered above, and the srcdir one would reap sibling engines under "make check -j".
assert_staged() {
grep -qx 'ci-windows-suite.sh' "$tmp/sourced-by" ||
fail "the srcdir driver ran, not the staged copy: $(sort -u "$tmp/sourced-by" | tr '\n' ' ')"
: >"$tmp/sourced-by"
}
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
}
# Not still_ticking negated: the marker's mv outlives the kill -9 of its parent,
# and one differing sample would read that straggler as a live reporter (#1058).
reporter_stopped() {
local a b _try
for _try in 1 2 3 4 5; do
a=$(cat "$wdmark" 2>/dev/null || true)
sleep 3
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=$?
assert_staged
# 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.
test "$rc" -eq 1 || fail "stub suite exited $rc, want 1 from the pass floor"
@@ -77,6 +154,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).
reporter_stopped || 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"
@@ -92,6 +183,7 @@ rm -f "$run/13_zlib-pass.test"
rc=0
out=$(RUNNER_TEMP="$tmp" GITHUB_STEP_SUMMARY="$tmp/summary" \
bash ./ci-windows-suite.sh "$bin" 2>&1) || rc=$?
assert_staged
test "$rc" -eq 1 || fail "an empty category exited $rc, want 1"
grep -q '::error::test category zlib matched no tests' <<<"$out" ||
fail "the empty category was not named: $out"
@@ -105,9 +197,40 @@ rm -f "$run/00_runnable.test"
rc=0
out=$(RUNNER_TEMP="$tmp" GITHUB_STEP_SUMMARY="$tmp/summary" \
bash ./ci-windows-suite.sh "$bin" 2>&1) || rc=$?
assert_staged
test "$rc" -eq 1 || fail "an empty single-test category exited $rc, want 1"
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")"
reporter_stopped || 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

@@ -63,11 +63,24 @@ grep -q "^Caught signal 11$" <<<"$worker" || fail "-#c=threadstack: no 'Caught s
# worker's, which is what had no alternate stack.
worker_frames=$(frame_count "$worker")
plain_frames=$(frame_count "$plain")
test "$worker_frames" -ge 100 ||
fail "-#c=threadstack: $worker_frames frames, expected a runaway recursion"
test "$plain_frames" -lt 100 ||
fail "-#c=segv: $plain_frames frames, the threshold no longer discriminates"
# Naming no frame at all is the unwinder failing outright, not a weak one, and
# the floor below would take it for loong64. hts_print_backtrace() says which.
if grep -q "No stack trace available" <<<"$worker"; then
fail "-#c=threadstack: the report carries no stack trace at all"
fi
# armhf and loong64 cannot unwind past the frame that faulted on the guard page
# (180 skips on the same floor); the release trace below still judges the fix.
if [ "$worker_frames" -ge 1 ] && [ "$worker_frames" -lt 6 ]; then
echo "-#c=threadstack: $worker_frames frames, too few for this unwinder to" \
"show the recursion" >&2
elif [ "$worker_frames" -lt 100 ]; then
fail "-#c=threadstack: $worker_frames frames, expected a runaway recursion"
fi
# Everything above only proves the stack gets installed: the crashing worker
# never returns, so the release hook never runs there. -#test=threadwait spawns
# workers that do return, and the syscall trace says what each did with its

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"

82
tests/231_test-names.test Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
#
# The tree's own names, the build dir's TESTS against them, and one control per
# way either check can go blind.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
checker="${testdir}/check-test-names.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
work=$(mktemp -d "${TMPDIR:-/tmp}/test_names.XXXXXX") || fail "no tmpdir"
trap 'set +e; rm -rf "$work"' EXIT
bash "$checker" "$testdir" || fail "the tree's own test names do not check out"
## the build dir runs the TESTS configure froze into it, not what tests/ holds
#
# Nothing regenerates that list when a .test appears, so without this the new
# test is simply never run and every other test still passes.
mk="${abs_top_builddir:-}/tests/Makefile"
if [ -z "${abs_top_builddir:-}" ]; then
echo "note: abs_top_builddir unset, skipping the stale-TESTS check (run via make check)" >&2
elif [ ! -r "$mk" ]; then
fail "no Makefile at $mk"
else
want=$(cd "$testdir" && printf '%s\n' [0-9]*_*.test | sort)
got=$(sed -n 's/^TESTS = //p' "$mk" | tr ' ' '\n' | sed '/^$/d' | sort)
test -n "$got" || fail "no TESTS assignment in $mk"
if [ "$want" != "$got" ]; then
fail "this build dir's TESTS predates tests/; re-run configure.
never run: $(comm -23 <(printf '%s\n' "$want") <(printf '%s\n' "$got") | tr '\n' ' ')
gone from tests/: $(comm -13 <(printf '%s\n' "$want") <(printf '%s\n' "$got") | tr '\n' ' ')"
fi
fi
## the checker still sees each way it can break
#
# It is silent on a clean tree, so one that had stopped looking would pass the
# assertion above just as well.
control() {
local name=$1 want=$2 mutate=$3 dir="${work}/$1" out=""
mkdir -p "$dir"
: >"${dir}/00_kept.test"
(cd "$dir" && eval "$mutate")
if out=$(bash "$checker" "$dir" 2>&1); then
fail "$name: the checker accepted a name make check would not run"
fi
grep -q "$want" <<<"$out" || fail "$name: wrong diagnostic: $out"
}
control misnamed 'outside the' ': >ghost.test'
control noprefix 'outside the' ': >engine-thing.test'
control uppercase 'outside the' ': >01_Engine.TEST'
control whitespace 'whitespace in the name' ': >"01_a b.test"'
control directory 'not a regular file' 'mkdir 99_dir.test'
control dangling 'not a regular file' 'ln -s nowhere 99_dangling.test'
control empty 'no test matched' 'rm 00_kept.test'
# A git that answers with an error must report that the oracle did not run, not
# read as "nothing tracked". Stubbed rather than dropped from PATH, which the
# checker's other tools need.
mkdir -p "$work/stub"
printf '#!/bin/sh\nexit 1\n' >"$work/stub/git"
chmod +x "$work/stub/git"
out=$(PATH="$work/stub:$PATH" bash "$checker" "$testdir" 2>&1) ||
fail "the checker failed outright with git stubbed out: $out"
grep -q 'skipping the tracked-set check' <<<"$out" ||
fail "a git that cannot answer read as a clean tracked set: $out"
if command -v git >/dev/null 2>&1; then
control untracked 'untracked' 'git init -q . && git add 00_kept.test && : >99_ghost.test'
control gone 'tracked but absent' 'git init -q . && : >99_extra.test && git add . && rm 99_extra.test'
else
echo "note: no git, skipping the tracked-set controls" >&2
fi
echo "OK: tests/, the build dir's TESTS and the checker's blind spots all line up"

View File

@@ -0,0 +1,172 @@
#!/bin/bash
#
# The online crawl gates must resolve their helper scripts against the script's
# own directory: an out-of-tree build tree holds no copy, so a bare relative
# path fails to open and the tests skip claiming the network is unavailable
# (#1016). Everything here runs from an unrelated directory with the flag off,
# so it never touches the network.
set -euo pipefail
# 226 asks this of every test naming the Windows suite driver, as the sweep's
# allowlist below does.
unset WATCHDOG_TOKEN WATCHDOG_REPO WATCHDOG_SHA WATCHDOG_URL
testdir=$(cd "$(dirname "$0")" && pwd)
work=$(mktemp -d)
cleanup() {
rm -rf "$work"
}
trap 'set +e; cleanup' EXIT
trap 'set +e; cleanup; exit 1' HUP INT QUIT TERM
fail() {
echo "FAIL: $*" >&2
exit 1
}
## the gated tests must skip for the reason the flag gave, not because the gate
## script could not be opened
# Discovered rather than listed, so a new online test is covered too. Matches the
# gate line itself, not the mentions of it in comments.
gated=$(cd "$testdir" && command grep -lE '^[[:space:]]*[^#[:space:]].*check-network\.sh.*exit 77' -- *.test | sort)
count=$(printf '%s\n' "$gated" | command grep -c . || true)
# Floor: the seven of #1016. A silently empty list would pass every check below.
test "$count" -ge 7 || fail "found only $count tests gating on check-network.sh, expected at least 7"
for t in $gated; do
out=$(cd "$work" && ONLINE_UNIT_TESTS=no bash "$testdir/$t" 2>&1) && rc=0 || rc=$?
test "$rc" -eq 77 || fail "$t exited $rc from an out-of-tree cwd, expected 77 (skip)"
# Only check-network.sh emits this, and only once it has actually run. The
# "skipping online unit tests" banner prints on the broken path too, so
# matching that instead would assert nothing.
command grep -q 'online tests are disabled' <<<"$out" ||
fail "$t skipped without the gate's reason, got: $out"
done
## the flag still decides, from an out-of-tree cwd
out=$(cd "$work" && ONLINE_UNIT_TESTS=no bash "$testdir/check-network.sh" 2>&1) && rc=0 || rc=$?
test "$rc" -eq 1 || fail "check-network.sh exited $rc with the flag off, expected 1"
command grep -q 'online tests are disabled' <<<"$out" || fail "check-network.sh gave no reason: $out"
out=$(cd "$work" && ONLINE_UNIT_TESTS=yes bash "$testdir/check-network.sh" 2>&1) && rc=0 || rc=$?
test "$rc" -eq 0 || fail "check-network.sh exited $rc with the flag on, expected 0: $out"
## no helper anywhere in tests/ is invoked through the caller's cwd
#
# The gate above cannot see this class: with the flag off check-network.sh exits
# before a test's own crawl-test.sh line is ever reached, so the sweep is the
# only thing standing between #1016 and a rerun of it.
# An invocation is safe when its operand resolves the same from any cwd: an
# absolute path, or one built from a variable holding a directory. Quotes and a
# leading "./" change nothing about what the shell opens, so neither is exempt.
invoke='(^|[[:space:];&|(`])(bash|sh|source|\.)[[:space:]]+'
quote='["'"'"']?'
literal_re="$invoke$quote"'(\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.sh'
var_re="$invoke$quote"'\$\{?[A-Za-z_][A-Za-z0-9_]*\}?'"$quote"'([[:space:]]|$)'
# Anchored at a command position, or the word inside these failure messages
# would match and this file would pin itself.
eval_re='(^|[;&|(`])[[:space:]]*eval[[:space:]]'
# The only two call sites that mean the cwd: each cd's into a directory it has
# just filled with copies, and the copy is the target. Named rather than matched
# by shape, because a shape exemption cannot tell them from #1016 in disguise.
allowed='172_ci-windows-driver.test ci-windows-suite.sh
ci-windows-suite.sh test-timeout.sh'
# Matching lines of the given files as file:line:text, comments dropped:
# local-crawl.sh documents its own usage with a bare call.
code_lines() {
local re=$1
shift
command grep -HnE "$re" -- "$@" | command grep -vE '^[^:]*:[0-9]+:[[:space:]]*#' || true
}
# Prints every invocation resolved against the caller's cwd, and every one whose
# operand it cannot resolve. Silence means the files are clean.
sweep() {
local hit file text script var val
while IFS= read -r hit; do
test -n "$hit" || continue
file=${hit%%:*}
text=${hit#*:}
text=${text#*:}
script=$(command grep -oE '[A-Za-z0-9_][A-Za-z0-9_./-]*\.sh' <<<"$text" | head -1)
command grep -qxF "${file##*/} $script" <<<"$allowed" || echo "cwd-relative: $hit"
done < <(code_lines "$literal_re" "$@")
# A variable operand is only as good as what it holds, so resolve it: an
# assignment that is not itself anchored, or that cannot be found in the
# file at all, is reported rather than assumed innocent.
while IFS= read -r hit; do
test -n "$hit" || continue
file=${hit%%:*}
text=${hit#*:}
text=${text#*:}
var=$(command grep -oE "$var_re" <<<"$text" | head -1 |
sed -E 's/.*\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?.*/\1/')
val=$(command grep -hE "^[[:space:]]*$var=" "$file" | head -1)
val=${val#*=}
val=${val#[\"\']}
case $val in
/* | \$*) ;;
*) echo "unresolved \$$var: $hit" ;;
esac
done < <(code_lines "$var_re" "$@")
}
# eval hides its operand from any static reading, so the set of them is pinned
# instead: a new one fails here and gets a human's eye rather than passing unseen.
scan_eval() {
code_lines "$eval_re" "$@" |
sed -E 's#^\./##; s/^([^:]*):[0-9]+:[[:space:]]*(.*[[:space:]])?eval[[:space:]]+/\1 /'
}
# Controls. Each evasion is a printf format here, so the forms under test reach
# the fixtures without appearing in this file as invocations the sweep would see.
name=crawl-test.sh
driver=ci-windows-suite.sh
ev=$work/evasions
mkdir -p "$ev"
printf '#!/bin/bash\nbash %s --errors 0\n' "$name" >"$ev/bare.sh"
printf '#!/bin/bash\nbash ./%s --errors 0\n' "$name" >"$ev/dotslash.sh"
printf '#!/bin/bash\nbash "%s" --errors 0\n' "$name" >"$ev/quoted.sh"
# shellcheck disable=SC2016 # the fixture's text, not this shell's
printf '#!/bin/bash\nS=%s\nbash $S --errors 0\n' "$name" >"$ev/indirect.sh"
# shellcheck disable=SC2016 # the fixture's text, not this shell's
printf '#!/bin/bash\nbash ./%s "$bin"\n' "$driver" >"$ev/impostor.sh"
# shellcheck disable=SC2016 # the fixture's text, not this shell's
printf '#!/bin/bash\nG="$testdir/%s"\nbash $G --errors 0\n' "$name" >"$ev/anchored.sh"
printf '#!/bin/bash\n# bash %s --errors 0\n' "$name" >"$ev/commented.sh"
for f in bare dotslash quoted indirect impostor; do
test -n "$(sweep "$ev/$f.sh")" || fail "the sweep let $f through: $(tail -n +2 "$ev/$f.sh")"
done
# The other half of the control: a rule that flags everything proves nothing.
for f in anchored commented; do
out=$(sweep "$ev/$f.sh")
test -z "$out" || fail "the sweep flagged $f.sh, which is correct code: $out"
done
printf '#!/bin/bash\neval "bash %s"\n' "$name" >"$ev/eval.sh"
test -n "$(scan_eval "$ev/eval.sh")" || fail "the eval scan missed a planted eval"
hits=$(cd "$testdir" && sweep ./*.test ./*.sh)
test -z "$hits" || fail "helper scripts reachable only from the right cwd:
$hits"
# Pinned by operand, since none of these builds a command naming a helper today.
# shellcheck disable=SC2016 # the pinned operands, quoted as they appear
reviewed='145_webhttrack-datadir.test "${line}"
231_test-names.test "$mutate")
61_webhttrack-locale.test "${block}"
61_webhttrack-locale.test "${funcs}"'
got=$(cd "$testdir" && scan_eval ./*.test ./*.sh | sort)
test "$got" = "$(sort <<<"$reviewed")" || fail "the set of eval sites changed; each hides its operand from the sweep and needs reading:
$got"
echo "OK: $count gated tests skip with the flag's reason, and tests/ is free of cwd-relative helper calls"

View File

@@ -0,0 +1,127 @@
#!/bin/bash
#
# Exercises --timeout on the FTP control and data channels, with two bounds no single
# hardcoded constant satisfies, plus --timeout=0 meaning unlimited rather than instant.
set -euo pipefail
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
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_ftptmo.XXXXXX")
mutepid=
livepid=
cleanup() {
stop_server "$mutepid"
stop_server "$livepid"
rm -rf "$tmpdir"
}
trap 'set +e; cleanup' EXIT
trap cleanup HUP INT QUIT PIPE TERM
root="${tmpdir}/root"
cmds="${tmpdir}/cmds"
mkdir -p "$root"
printf 'body\n' >"${root}/f.txt"
# One holds the data connection open and says nothing; the other feeds it in
# chunks 0.2s apart, so a zero-length wait cannot ride the gaps out.
awk 'BEGIN { s = sprintf("%0999d", 0); for (i = 0; i < 100; i++) print s }' \
>"${root}/stall.bin"
awk 'BEGIN { s = sprintf("%0999d", 0); for (i = 0; i < 60; i++) print s }' \
>"${root}/trick.bin"
printf '/stall.bin stall\n/trick.bin trickle\n' >"${tmpdir}/modes"
mutelog="${tmpdir}/mute.out"
"$python" "$server" --root "$(nativepath "$root")" --mute >"$mutelog" 2>&1 &
mutepid=$!
muteport=$(discover_server_port "$mutelog" "$mutepid") || exit 1
livelog="${tmpdir}/live.out"
"$python" "$server" --root "$(nativepath "$root")" \
--mode-file "$(nativepath "${tmpdir}/modes")" \
--log "$(nativepath "$cmds")" >"$livelog" 2>&1 &
livepid=$!
liveport=$(discover_server_port "$livelog" "$livepid") || exit 1
fail() {
echo "FAIL: $*" >&2
exit 1
}
ok() { echo "OK: $*"; }
# No --max-time anywhere below, so --timeout is the only thing that can end a run.
crawl() {
local tag=$1 to=$2 cap=$3 url=$4
local out="${tmpdir}/out${tag}" rc=0
mkdir -p "$out"
run_with_timeout "$cap" httrack "$url" -O "$out" --quiet \
--disable-security-limits --robots=0 --timeout="$to" -c1 \
>"${tmpdir}/log${tag}" 2>&1 || rc=$?
# Only the cap and a signal are the crawl's own failure. A nonzero exit against
# a deliberately broken server is not, and the assertions below judge the rest.
test "$rc" -ne 124 || fail "the ${tag} run was still going at ${cap}s"
test "$rc" -lt 128 || fail "the ${tag} run died on signal $((rc - 128))"
}
# Every case names the timeout it saw, since htsftp.c prints the value actually in
# force: that kills a hardcoded constant more directly than a timing bound does.
logged() {
grep -aq "$2" "${tmpdir}/out${1}/hts-log.txt" ||
fail "no '$2' in the log: $(grep -a Error "${tmpdir}/out${1}/hts-log.txt" || true)"
}
# --- control channel: a server that connects and never greets -----------------
# The engine retries once, so a run costs about twice the value asked for.
start=$SECONDS
crawl 4 4 60 "ftp://127.0.0.1:${muteport}/f.txt"
short=$((SECONDS - start))
logged 4 'read timeout (4)'
test "$short" -lt 18 || fail "--timeout=4 took ${short}s: the greeting read ignores it"
ok "a 4s timeout ends the greeting read in ${short}s"
# The floor here sits above the ceiling above, so no one constant satisfies both.
start=$SECONDS
crawl 20 20 120 "ftp://127.0.0.1:${muteport}/f.txt"
long=$((SECONDS - start))
logged 20 'read timeout (20)'
test "$long" -ge 20 ||
fail "--timeout=20 gave up after ${long}s, short of the value asked for"
test "$long" -lt 70 || fail "--timeout=20 took ${long}s"
ok "a 20s timeout waits ${long}s: the wait tracks the value, not a constant"
# --- data channel: logged in, mid-body, and the server goes quiet -------------
# The control channel is answered here, so only the body-transfer wait can hang.
: >"$cmds"
start=$SECONDS
crawl stall 5 60 "ftp://127.0.0.1:${liveport}/stall.bin"
stalled=$((SECONDS - start))
sent=$(cat "$cmds")
grep -q '^RETR ' <<<"$sent" || fail "the body transfer never started: ${sent}"
logged stall 'Time out (5)'
test "$stalled" -lt 40 ||
fail "a stalled body transfer took ${stalled}s under --timeout=5"
ok "a 5s timeout ends the body-transfer wait in ${stalled}s"
# --- --timeout=0 is unlimited, not zero --------------------------------------
: >"$cmds"
start=$SECONDS
crawl zero 0 90 "ftp://127.0.0.1:${liveport}/trick.bin"
zeroed=$((SECONDS - start))
got="${tmpdir}/outzero/127.0.0.1_${liveport}/trick.bin"
test -f "$got" || fail "--timeout=0 fetched nothing; mirror holds $(find \
"${tmpdir}/outzero" -type f -not -path '*hts-cache*')"
want=$(wc -c <"${root}/trick.bin")
have=$(wc -c <"$got")
test "$have" -eq "$want" ||
fail "--timeout=0 gave up mid-transfer: ${have} of ${want} bytes"
ok "--timeout=0 rides out a ${zeroed}s trickle and keeps all ${want} bytes"

View File

@@ -0,0 +1,74 @@
#!/bin/bash
#
# Exercises --max-time against an in-flight FTP transfer; --timeout is set far above
# the cap so only the mirror deadline can end the run.
set -euo pipefail
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
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_ftpmax.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"
# Big enough that the served prefix leaves the transfer open and mid-body.
awk 'BEGIN { s = sprintf("%0999d", 0); for (i = 0; i < 100; i++) print s }' \
>"${root}/f.bin"
printf '* stall\n' >"${tmpdir}/modes"
serverlog="${tmpdir}/server.out"
"$python" "$server" --root "$(nativepath "$root")" \
--mode-file "$(nativepath "${tmpdir}/modes")" \
--log "$(nativepath "$cmds")" >"$serverlog" 2>&1 &
serverpid=$!
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
fail() {
echo "FAIL: $*" >&2
exit 1
}
ok() { echo "OK: $*"; }
start=$SECONDS
run_with_timeout 90 httrack "ftp://127.0.0.1:${port}/f.bin" -O "$out" --quiet \
--disable-security-limits --robots=0 --timeout=200 --max-time=20 -c1 \
>"${tmpdir}/log" 2>&1 || fail "the run did not finish in $((SECONDS - start))s"
elapsed=$((SECONDS - start))
log="${out}/hts-log.txt"
# Confirms the transfer had started; otherwise the wait under test never runs.
sent=$(cat "$cmds")
grep -q '^RETR ' <<<"$sent" ||
fail "the transfer never started, so nothing was in flight: ${sent}"
ok "the crawl was inside the body transfer when the cap hit"
test "$elapsed" -ge 15 ||
fail "the run ended after ${elapsed}s, before --max-time could be reached"
test "$elapsed" -lt 60 || fail "--max-time=20 left the run going for ${elapsed}s"
ok "--max-time ends the in-flight transfer in ${elapsed}s"
grep -aq 'More than 20 seconds passed' "$log" ||
fail "the mirror deadline is not what ended the run: $(grep -a Error "$log" || true)"
# Proves the run really did carry a 200s timeout it could have waited out.
grep -aq 'Time out (200)' "$log" ||
fail "--timeout=200 was not the timeout in force: $(grep -a Error "$log" || true)"
ok "the deadline beat a --timeout ten times its size"

View File

@@ -0,0 +1,252 @@
#!/bin/bash
# A resume the server answers with an unusable Content-Range must drop the
# partial and re-fetch the whole file, once, without spending the retry budget.
set -e
set -u
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
server=$(nativepath "${testdir}/local-server.py")
root=$(nativepath "${testdir}/server-root")
python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77
# Pass 1 has to be interrupted mid-download to leave the temp-ref the resume
# needs, and MSYS cannot signal a native exe.
if is_windows; then
echo "Windows: the pass-1 interrupt needs a signal MSYS cannot deliver, skipping"
exit 77
fi
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_resume.XXXXXX") || exit 1
serverpid=
crawlpid=
cleanup() {
set +e
test -n "$crawlpid" && kill -9 "$crawlpid" 2>/dev/null
stop_server "$serverpid"
rm -rf "$tmpdir"
}
trap 'set +e; cleanup' EXIT
trap 'set +e; cleanup; exit 1' HUP INT QUIT PIPE TERM
serverlog="${tmpdir}/server.log"
: >"$serverlog"
# stdin off the terminal: run_with_timeout toggles job control, and a background
# job that touches the tty is stopped with SIGTTIN.
"$python" "$server" --root "$root" >"$serverlog" 2>&1 </dev/null &
serverpid=$!
port=$(discover_server_port "$serverlog" "$serverpid") || exit 1
base="http://127.0.0.1:${port}"
which httrack >/dev/null || {
echo "could not find httrack"
exit 1
}
# Read from the server's own definitions: two routes happening to share a body
# length must not let one leg assert the other's. Bodies come back as a length.
server_const() { # server_const <name>
"$python" -c 'import re, sys
src = open(sys.argv[1]).read()
v = eval(re.search(r"^\s*%s = (.+)$" % sys.argv[2], src, re.M).group(1))
print(v if isinstance(v, int) else len(v))' "$server" "$1"
}
resume_full=$(server_const RESUME206_BODY)
crange_full=$(server_const CRANGE206_BODY)
have=$(server_const RESUME206_HAVE) # what pass 1 leaves on disk
# Crawl until the temp-ref appears, then interrupt: that is the state a resume
# needs (a partial plus the ref naming it).
interrupt_pass1() { # interrupt_pass1 <out> <route> <extra httrack args...>
local out=$1 route=$2
shift 2
local refdir="${out}/hts-cache/ref"
httrack -O "$out" --quiet --disable-security-limits --robots=0 \
--timeout=30 -c1 "$@" "${base}/${route}/index.html" \
>"${out}.log1" 2>&1 &
crawlpid=$!
local _
for _ in $(seq 1 300); do
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" && break
kill -0 "$crawlpid" 2>/dev/null || break
sleep 0.1
done
sleep 0.3
kill -TERM "$crawlpid" 2>/dev/null
wait "$crawlpid" 2>/dev/null || true
crawlpid=
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" || {
echo "FAIL: no temp-ref survived pass 1; cannot drive the resume"
exit 1
}
}
blob_size() { # blob_size <out>
local blob
blob=$(find "$1" -name blob.bin 2>/dev/null | head -1)
test -n "$blob" || {
echo 0
return
}
wc -c <"$blob"
}
# --- #1052: --retries=0 must still recover -----------------------------------
# The restart is the same transfer asked for differently, so it must not be
# charged to the retry budget. Before the fix this deleted the file.
printf '[--retries=0 recovers the whole file] ..\t'
out="${tmpdir}/retry0"
mkdir "$out"
interrupt_pass1 "$out" resume206 --retries=0
test "$(blob_size "$out")" -eq "$have" || {
echo "FAIL: pass 1 left $(blob_size "$out") bytes, expected ${have}"
exit 1
}
rc=0
run_with_timeout 120 httrack -O "$out" --quiet --disable-security-limits \
--robots=0 --timeout=30 -c1 --retries=0 --continue \
"${base}/resume206/index.html" >"${out}.log2" 2>&1 || rc=$?
test "$rc" -ne 124 || {
echo "FAIL: the crawl never terminated"
exit 1
}
log="${out}/hts-log.txt"
# Without these the recovery could not have been exercised at all.
grep -q 'Unusable partial-content range' <"$log" || {
echo "FAIL: the resume was never rejected; the test proved nothing"
exit 1
}
grep -q 'Restarting whole file' <"$log" || {
echo "FAIL: the restart did not run outside the retry budget"
exit 1
}
got=$(blob_size "$out")
test "$got" -eq "$resume_full" || {
echo "FAIL: blob.bin is ${got} bytes, expected ${resume_full}"
exit 1
}
echo "OK (${got} bytes)"
# --- the latch bounds an always-unusable server ------------------------------
# This server answers every request unusably, with or without a Range, so an
# ungated restart would requeue forever. Exactly one is free; the rest fall
# back to the retry budget, which --retries=0 empties immediately.
printf '[always-unusable server terminates] ..\t'
out="${tmpdir}/loop"
mkdir "$out"
interrupt_pass1 "$out" resume206loop --retries=0
rc=0
run_with_timeout 120 httrack -O "$out" --quiet --disable-security-limits \
--robots=0 --timeout=30 -c1 --retries=0 --continue \
"${base}/resume206loop/index.html" >"${out}.log2" 2>&1 || rc=$?
test "$rc" -ne 124 || {
echo "FAIL: the crawl never terminated; the restart is unbounded"
exit 1
}
grep -q 'Unusable partial-content range' <"${out}/hts-log.txt" || {
echo "FAIL: the resume was never rejected; the latch was not exercised"
exit 1
}
restarts=$(grep -c 'Restarting whole file' <"${out}/hts-log.txt" || true)
test "$restarts" -eq 1 || {
echo "FAIL: ${restarts} free restarts, expected exactly 1 (latch)"
exit 1
}
echo "OK (1 free restart, then bounded)"
# --- the latch rides a re-record, and the budget survives the restart --------
# A redirect to a case-different alias re-records the link under a new heap
# entry; a latch left behind there buys a free restart every hop, so this
# server (206, redirect, 206, ...) never ends. --retries=1 also shows the free
# restart left the budget for the genuine failure that follows.
printf '[alias redirect cannot reset the latch] ..\t'
out="${tmpdir}/alias"
mkdir "$out"
rc=0
run_with_timeout 120 httrack -O "$out" --quiet --disable-security-limits \
--robots=0 --timeout=30 -c1 --retries=1 \
"${base}/alias206/index.html" >"${out}.log1" 2>&1 || rc=$?
test "$rc" -ne 124 || {
echo "FAIL: the crawl never terminated; the alias hop reset the latch"
exit 1
}
log="${out}/hts-log.txt"
# Without the hop the alternation was never driven and the rest proves nothing.
grep -q 'Warning moved treated' <"$log" || {
echo "FAIL: no alias hop; the re-record path was never reached"
exit 1
}
restarts=$(grep -c 'Restarting whole file' <"$log" || true)
test "$restarts" -eq 1 || {
echo "FAIL: ${restarts} free restarts across the alias hops, expected 1"
exit 1
}
retries=$(grep -c 'Retry after error' <"$log" || true)
test "$retries" -eq 1 || {
echo "FAIL: ${retries} retries, expected 1; the free restart spent the budget"
exit 1
}
echo "OK (1 free restart, budget intact)"
# --- #581: the refetch must not depend on the removal succeeding -------------
# Windows refuses to unlink a file it still holds open, so the partial outlives
# the restart. LD_PRELOAD is the only way to reach that from POSIX, and the
# interposer needs /proc to tell an open handle from a closed one.
if [ "$(uname -s)" != "Linux" ] || [ ! -d /proc/self/fd ]; then
echo "resume-recovery: unlink interposition needs Linux + /proc, skipping the last leg"
exit 0
fi
# A --disable-shared build has nothing to preload; anything else missing is a
# build problem, not a skip, or this leg would pass vacuously.
if [ ! -r "${UNLINKFAIL_LA:-}" ]; then
echo "resume-recovery: ${UNLINKFAIL_LA:-\$UNLINKFAIL_LA} was not built" >&2
exit 1
fi
if grep -q "^dlname=''" "$UNLINKFAIL_LA"; then
echo "resume-recovery: static-only build, skipping the interposed leg"
exit 0
fi
if [ ! -r "${UNLINKFAIL_LIB:-}" ]; then
echo "resume-recovery: ${UNLINKFAIL_LIB:-\$UNLINKFAIL_LIB} was not built" >&2
exit 1
fi
printf '[partial is closed before it is unlinked] ..\t'
out="${tmpdir}/nounlink"
mkdir "$out"
interrupt_pass1 "$out" crange206mem --retries=1
attempts="${tmpdir}/attempts.txt"
: >"$attempts"
# An LD_PRELOAD library loads ahead of the executable's own libasan, which ASan
# refuses by default. The shim allocates nothing, so the ordering is harmless.
export ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}verify_asan_link_order=0"
export UNLINKFAIL_MATCH=blob.bin UNLINKFAIL_LOG="$attempts" LD_PRELOAD="$UNLINKFAIL_LIB"
rc=0
run_with_timeout 120 httrack -O "$out" --quiet --disable-security-limits \
--robots=0 --timeout=30 -c1 --retries=1 --continue \
"${base}/crange206mem/index.html" >"${out}.log2" 2>&1 || rc=$?
unset LD_PRELOAD
test "$rc" -ne 124 || {
echo "FAIL: the crawl never terminated"
exit 1
}
# The shim silently matching nothing would make this leg a copy of test 48.
seen=$(wc -l <"$attempts")
test "$seen" -ge 1 || {
echo "FAIL: the interposer saw no unlink of the partial; it never fired"
exit 1
}
held=$(grep -c '^open ' <"$attempts" || true)
test "$held" -eq 0 || {
echo "FAIL: ${held} of ${seen} unlink(s) came while the handle was open"
exit 1
}
got=$(blob_size "$out")
test "$got" -eq "$crange_full" || {
echo "FAIL: blob.bin is ${got} bytes, expected ${crange_full} (refetch lost)"
exit 1
}
echo "OK (${got} bytes, ${seen} unlink(s), none on an open handle)"

Some files were not shown because too many files have changed in this diff Show More