Compare commits

...

21 Commits

Author SHA1 Message Date
Xavier Roche
5e39f2d4d2 Merge origin/master into fix-630-pathlog-utf8
Resolve tests/Makefile.am: keep 66 (#632), 67 (#633) and this branch's 69.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-18 08:18:26 +02:00
Xavier Roche
db19089448 A path-ceiling truncation drops the .delayed marker and loses the file
HTTrack gives a file a temporary `.delayed` placeholder name while it can't resolve the file type yet, then renames the placeholder once the type is known. The problem is that url_savename appends that marker before it enforces the 236-char path ceiling, and the ceiling cuts the tail of the last path segment, which is exactly where the marker lives. Once the trailing `.delayed` is gone the name no longer matches `IS_DELAYED_EXT`, so back_delayed_rename bails out ("nothing bound to the placeholder name") and the downloaded file is never moved to its final name. It is reachable through #133-style deep paths whose final segment is a long hashed filename.

The fix reserves the trailing `.<id>.delayed` in the last-segment copy loop and trims the head of the segment instead, so the result still fits under the ceiling. The hex-id scan stops at its dot separator, so a wholly-hex hashed base is never mistaken for the collision tag and pulled into the marker.

Test 67 drives the naming path over a deep, over-long delayed URL and checks that the marker survives the cut. It fails on master and passes with the fix.

Closes #623

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 08:17:44 +02:00
Xavier Roche
eb9e353f0b Fix SIGPIPE flake in 01_engine-xfread under pipefail (#637)
The test matched engine output with `echo "$out" | grep -q PATTERN`. Under
`set -o pipefail`, grep -q exits the moment it matches, so echo takes a SIGPIPE
writing the rest and the pipeline reports failure even though the pattern was
found. The `|| { echo FAIL; exit 1; }` guard then fired on a passing case,
turning it into an intermittent, output-size-dependent failure (seen on the
Debian buildd leg of #632).

Match with here-strings instead, dropping the pipe and the race entirely.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 08:05:23 +02:00
Xavier Roche
0480f28fcc Merge origin/master into fix-630-pathlog-utf8
Resolve tests/Makefile.am: keep 66_engine-port80-strip (#632) alongside this
branch's test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-18 08:03:19 +02:00
Xavier Roche
8a8fd7f9c0 Stripping a default :80 with leading zeros mangled the host (#632)
The link-rewrite step that drops a redundant :80 read the port with a
hand-rolled digit accumulator, then skipped a hardcoded 3 chars (":80")
when the value equaled 80. Any longer spelling that still evaluates to
80 lost only its first 3 chars and glued the rest onto the host:
http://127.0.0.1:080/x became 127.0.0.10/x, :0080 became 127.0.0.180.
The accumulator was also unchecked, so :4294967376 wrapped to 80 and
took the same path (#614 shape).

Extract the strip into hts_strip_default_port() and parse the matched
digits with the range-checked hts_parse_url_port(): a wrapped or
out-of-range value no longer aliases 80, and a genuine default is
dropped by its full matched length instead of 3. Covered by the
"stripport" engine self-test (tests/66_engine-port80-strip.test).

Closes #627

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 07:54:27 +02:00
Xavier Roche
b946356576 Route the log block through the UTF-8 file wrappers so a non-ASCII -O keeps hts-log.txt out of a mangled twin
A single -O sets both path_html and path_log (htscoremain.c:411), so
`httrack -O café url` sends the logs through path_log. Since argv is UTF-8,
path_log holds UTF-8 bytes, but the two-file log branch created its directory
and opened hts-log.txt/hts-err.txt with the ANSI structcheck()/fopen(), which on
Windows read those bytes as the codepage and dropped the logs into a second,
mangled caf<mojibake>/ directory next to the mirror. #628 fixed the same class
for the mirror root; this routes the log block through structcheck_utf8()/FOPEN()
/UNLINK()/fexist_utf8() so the logs land under café/ with the mirror. On a UTF-8
filesystem the wrappers are the same calls, so only Windows changes.

The cache half of #630 (hts-cache still opened ANSI, via minizip's own fopen and
an ANSI opendir) is a larger, separate conversion and is left for a follow-up.

A local-crawl test mirrors into a single non-ASCII -O and asserts the audits
read hts-log.txt from that directory; it bites on the Windows CI leg where the
encoding differs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-18 07:36:14 +02:00
Xavier Roche
1c1c0b1267 Release 3.49.13 (#631)
Bump the version in configure.ac, htsglobal.h and the Windows version
resource, and move VERSION_INFO to 3:5:0. The installed struct layouts and
the exported symbol set are unchanged since 3.49.12, so the soname stays
libhttrack.so.3 and no Debian package rename is needed.

The release is a large one: SOCKS5 and CONNECT proxy support, brotli and
zstd content decoding, a family of --update data-loss fixes, 2 GB+ file
handling on Windows and 32-bit builds, and the Windows toolchain port to
VS2022 with its own CI test suite.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 07:26:15 +02:00
Xavier Roche
fe685d4117 The listen ports and the ftp:// port take a port sscanf never range-checked (#626)
* Range-check the listen and ftp:// ports, not just the crawled-URL one

The --port listen port, proxytrack's host:port arguments and an ftp:// URL
port all took their value from sscanf("%d"), which range-checks nothing and
is undefined past INT_MAX (glibc wraps). "--port 4295009395" listened on
42099, and proxytrack, which had no range check at all, listened on port 80
for ":65616".

Reuse hts_parse_url_port rather than range-check after the fact. It moves to
its own file: htsserver cannot see a hidden libhttrack symbol and proxytrack
does not link the library at all, so neither could reach it in htslib.c.

htsweb's --port and proxytrack's arguments are operator input for a listen
socket, so both refuse a bad value at startup through the error path each
already had, rather than falling back to a default: silently listening
somewhere the operator did not name is the bug, and a default would keep the
same wrong behavior. The ftp:// port is a URL port and follows the
crawled-URL policy, refusing the link.

--port 0 (which asked for the auto-pick) and 65535 (refused by an off-by-one
">= 65535") both now mean what they say.

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

* Wire htsurlport into the MSVC projects and make test 64 portable

The new htsurlport.c was added to the autotools targets but not the MSVC
projects, so libhttrack.dll, proxytrack.exe and webhttrack.exe failed to
link on Windows with hts_parse_url_port unresolved. Add it to the three
.vcxproj that compile a caller, mirroring the autotools split.

Test 64 drove the listen servers with timeout(1), which is absent on macOS,
so every assertion tripped over a missing command. Source testlib.sh and use
its run_with_timeout instead, the same helper the other blocking-server
tests use. The htsserver and proxytrack listeners block once a port is
accepted, so both need the bound regardless.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 02:01:54 +02:00
Xavier Roche
f12d5e7434 Non-ASCII -O path is double-encoded on Windows now that argv is UTF-8 (#628)
Since argv became UTF-8 through hts_argv_utf8(), opt->path_html is already
UTF-8, but hts_main_internal() still re-encoded it with
hts_convertStringSystemToUTF8(), reinterpreting the bytes as the ANSI codepage.
path_html_utf8 prefixes every saved file, so a non-ASCII -O path sent the whole
mirror into a double-encoded directory. Collapse the Windows branch to the
plain UTF-8 copy the POSIX side already used, and create the mirror root with
structcheck_utf8() so the pre-created directory matches where files are written.
On a UTF-8 filesystem both resolve to the same mkdir, so only Windows changes.
This fixes the CLI and the WinHTTrack GUI, which both hand the engine UTF-8 argv.

A local-crawl test mirrors into a non-ASCII -O directory and asserts the files
land there; it runs on the Windows CI leg where the bug is reachable.

Two adjacent gaps stay open with their own issues: a single-directory -O still
leaves a mangled path_log twin holding logs and cache (#630), and webhttrack
never converts its argv to UTF-8 (#629).

Closes #621

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:57:03 +02:00
Xavier Roche
e01e1b34de An empty $HOME sends webhttrack's base path and httrack.ini to the root (#625)
PR #619 fixed hts_gethome() for an exported-but-empty $HOME, where getenv()
returns "" rather than NULL and the old NULL-only check let "~/foo" collapse
into the absolute "/foo". The same shape lives in two static clones that the
fix missed; htsserver's is live.

With HOME= exported, htsserver renders /websites as the default base path,
reads /.httrack.ini on the first request of every session, and writes
/.httrack.ini when a mirror starts. Running as root that last one lands a file
at the filesystem root; otherwise it silently fails on EACCES.

The clones cannot just call hts_gethome(): it is hidden by -fvisibility=hidden
(nm reports it as a local "t" in libhttrack.so), so htsserver cannot reach it
even though it links the library, and proxytrack does not link libhttrack at
all. Exporting it would grow the public ABI for no other caller, so each copy
keeps its own body. proxytrack's is dead today and fixed anyway: this bug got a
second round precisely because one copy kept the old shape.

Test 63 drives htsserver and reads the base path back out of the rendered UI,
since a static in htsserver.h is out of reach of the -#test registry. It pins a
real HOME too, or the empty case would also pass against a gethomedir() that
ignored the environment and always answered ".".

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:24:38 +02:00
Xavier Roche
a67eb57def Top-index gifs land in a mangled directory for non-ASCII Windows projects (#624)
hts_buildtopindex() works in the system charset, but verif_backblue(), which
writes backblue.gif and fade.gif, expects UTF-8, and every other caller already
hands it the UTF-8 path. Only this one passed a system-charset path, so on
Windows the gifs were written into a mangled twin directory instead of the
project. Convert at the call site, guarded by _WIN32 since the converter is
Windows-only and the system charset is UTF-8 everywhere else.

Adds a topindex self-test (driven from the offline suite, so it runs on the
Windows leg where the bug is reachable) that asserts the gifs land in the
project directory. It failed on both Windows legs before the fix.

Sibling #216, the mojibake project name in the generated index, is a separate
leak in the same function and is not addressed here.

Closes #217
Refs #216

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:39:11 +02:00
Xavier Roche
6634d48c21 An out-of-range port in a crawled URL silently connects to a different port (#620)
* Refuse a URL whose port is outside 1..65535

newhttp_addr() parsed a crawled URL's port with sscanf("%d") and validated it
with "i != -1", then cast it to unsigned short. An out-of-range port therefore
folded into 0..65535 and httrack connected there: http://host:105579/ mirrored
off port 40043, storing the result under host_105579 while the bytes came from
somewhere else. A value past INT_MAX is also UB, and wraps on glibc.

What carries the weight is that a user's port exclusion filter stops describing
the port httrack opens, since the filter sees the literal spelling and the
connect uses the folded one.

Parse it with hts_parse_url_port() instead, factored out of #610's
parse_proxy_port(), and refuse the link through newhttp_addr()'s existing
"msg + INVALID_SOCKET" path. A proxy port can fall back to the scheme default;
a URL port cannot, because the URL named one and named a nonsense one.

socks5_handshake_stream() had the same shape, range-checking only after the
sscanf had already wrapped a huge value into a plausible port; it now shares
the parser and its existing socks5_fail() rejection.

Covered by the dns self-test, which drives newhttp_addr() through the mocked
resolver: *addr_count separates a refused port (0, no resolve) from one merely
truncated or defaulted (2), so a "fall back to 80" fix would not pass.

Closes #614

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

* Keep accepting an empty "host:" port, which means the default

WHATWG and curl both treat an empty port as valid and meaning the scheme
default, and httrack's URL parser hands "http://host:/x" to newhttp_addr() as
adr="host:", so refusing it as an unparseable port would drop a legal link that
master crawls today.

Guard the parse on a non-empty port text, and move the case to the accepted
side of the self-test. Removing the guard fails that test, so the case is
covered rather than merely asserted.

The SOCKS5 origin path already refused an empty port before this series and
still does; that predates #614 and is left alone.

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

* Cover the SOCKS5 origin port in the self-test, and trim comments

Reverting htsproxy.c alone to the wrap-then-check version passed the whole
suite, so nothing pinned that half of the fix and a later refactor could have
dropped it silently. The scripted SOCKS5 harness now asserts a bad origin port
is refused with no byte sent.

65616 would not have tested anything: it fits an int, so the old range check
already caught it. The case the old check could not see is 4294967376, which
overflowed the sscanf("%d") into a plausible 80 and passed; the trailing-junk
and signed spellings sscanf also accepted are covered alongside it. Against
master's htsproxy.c the new assertion fails.

Also note at the SOCKS5 call site that an empty "host:" stays refused there
while the direct path accepts it, so the asymmetry reads as intended rather
than as an oversight, and halve two comments.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:24:42 +02:00
Xavier Roche
a2c75b5316 SOCKS5 test server never checked the RFC 1929 version byte (#622)
The SOCKS5 test server read the sub-negotiation version byte and dropped it. That byte must be 0x01, RFC 1929's own version, not the 0x05 of the SOCKS5 greeting. `htsproxy.c` gets it right and even comments the trap, but since the server never looked, a client that regressed to 0x05 would have kept `52_local-socks5.test` green forever while every real proxy rejected the handshake.

The server now rejects a mismatch the way a real proxy does, and the test names it, so a bad version byte reads as itself rather than as the vaguer "origin not downloaded".

This turned up while checking the proxy client against implementations we didn't write (microsocks, squid, tinyproxy, tor) over the live site. The client is correct on every leg, but this test could not have told us that. Verified by mutation: with `htsproxy.c` patched to send 0x05, master's test passes and the fixed one fails.

Signed-off-by: Xavier Roche <xroche@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:03 +02:00
Xavier Roche
6aabb3ba09 ~/ in the -O base path has never been expanded (#619)
* Restore ~/ expansion in the -O base path

expand_home() has never fired since 660b569 (3.41.2) refactored it from char*
to String: the correct str[0] == '~' became StringSub(*str, 1) == '~', but the
body still skips a single leading character. "~/foo"[1] is '/', so the guard
never matches, while any path whose *second* char is '~' silently lost its
first character and gained $HOME ("a~/x" -> "/home/smith~/x").

Index from 0 and gate on a following '/' so ~user/ is left alone rather than
mangled into $HOME + "user/" -- resolving it needs getpwnam, which this does
not do. An empty $HOME now falls back to '.' like an unset one, so ~/foo can
no longer expand to the absolute /foo, and an oversized $HOME leaves the path
untouched instead of aborting inside strcatbuff.

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

* Pin the expandhome test to a $HOME that MSYS leaves alone

The Win32 leg spawns a native httrack.exe, and MSYS rewrites a POSIX absolute
$HOME into a Windows path on the way in, so the engine read
C:\Program Files\Git\home\smith and the pinned /home/smith never matched. A
$HOME with no leading '/' is passed through verbatim; expand_home only
concatenates, so the sentinel exercises the same code.

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

* Restore the clang-format run over the gate condition

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

* Ask the engine for $HOME rather than pinning it in the expandhome test

MSYS rewrites $HOME into a cwd-relative Windows path before the native
httrack.exe reads it (HTSHOME became D:\a\httrack\httrack\tests\HTSHOME), so
no pinned value survives the Win32 leg. Take the expansion of a bare '~' as
the reference instead, and assert it moved off '~' so the derived cases cannot
go vacuous. The ~user/ and a~/ negatives are literals and stay exact.

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

* Pin what the expandhome test could not distinguish

A hts_gethome() that ignored $HOME and always returned the fallback passed
the whole suite: the empty-HOME cases expect ./foo, which a constant "."
satisfies. That is the same silent no-op the fix exists to remove. Also cover
the oversized-$HOME bail, which had no coverage at all.

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

* Keep the oversized-$HOME case inside Windows' environment limit

70000 chars cannot be set as an environment variable on Windows, so the case
failed the MSVC legs, and $(seq 1 70000) flooded the -x log. The buffer is
2 * HTS_URLMAXSIZE, so 4096 clears it and still kills the no-guard mutant.

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

* Run the oversized-$HOME case only where HOME survives the shell

MSYS rewrites HOME into a path of its own, so the engine never saw the long
value: it expanded ~/foo against the build directory instead of bailing. Gate
the case on the engine reporting the HOME we set; it still runs, and still
fails without the guard, on every platform that passes HOME through.

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

---------

Signed-off-by: Xavier Roche <xroche@gmail.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:19:52 +02:00
Xavier Roche
088f0711b8 Three lang/ msgids never resolve, and nothing checks the msgid pairing (#617)
* Fix three broken msgids in lang/ and assert the pairing in a test

lang/*.txt are consecutive line pairs, an English msgid then its translation,
and nothing validates that shape. Finnish.txt had ended with a blank line since
2012, which was harmless while it was the last line; #588 appended new strings
after it, so the blank became an interior line and knocked every pair past it
out of phase. All 14 strings #588 added to Finnish, plus the CONNECT proxy one
from 938a873, were keyed off a translation instead of an msgid and rendered as
English. Drop the blank.

Italiano and Portugues-Brasil each carry one msgid that matches nothing in
English.txt ("ISO 9660" for "ISO9660", "Relative URL" for "Relative URI"), so
those two entries never resolved either.

The test is the point: it rebuilds the msgid set from English.txt and checks
every file pairs against it, byte-wise, since each file is in its own legacy
charset. It also checks lang.def's English values, which are the join key into
the msgids. It fails on all three defects before this change.

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

* Renumber the lang integrity test to 62

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

* Note the test's physical-line assumption

The engine continues a msgid ending in \ onto the next line; the test does
not. Latent today: no lang line ends in one.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 07:44:19 +02:00
Xavier Roche
ce481efeca webhttrack ignores LC_ALL/LC_MESSAGES and picks the wrong Chinese and Portuguese (#616)
* Fix webhttrack picking the wrong language from the locale

The launcher resolved LC_MESSAGES/LC_ALL/LANG into HTSLANG, then immediately
overwrote it with a value recomputed from $LANG alone, so LC_ALL and
LC_MESSAGES were ignored: a user with only LC_ALL=fr_FR.UTF-8 set got English.
That same line cut the region off with "cut -f1 -d'_'", leaving the zh_tw and
pt_br entries of lang.indexes unreachable, so Traditional Chinese fell back to
Simplified and Brazilian Portuguese to European Portuguese.

Resolve the locale in POSIX precedence order and look the tag up region-first,
falling back to the bare language. Also fix Slovenian's ISO 639-1 code, which
was "si" (Sinhala) in both lang.indexes and Slovenian.txt: sl_SI users got
English, and the served pages claimed lang="si".

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

* Trim the locale test's comments

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

* Anchor the locale test's lift so it cannot silently stop testing

The lift ended at the first blank line, so a blank line grown inside the
locale block would truncate it: reinstating the old LANG-only bug below one
left the test passing 14/14 while the launcher returned English. Anchor on
the next section instead, and pin exported-empty as unset, which lang_of
cannot express.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 07:24:05 +02:00
Xavier Roche
7b68d7dc96 A crawl killed by the Windows test watchdog leaves nothing to diagnose it with (#612)
* Salvage a killed crawl's logs into the Windows test artifact

A test the suite watchdog kills at 600s left nothing to diagnose it with.
local-crawl.sh sends the crawl's output to its tmpdir and hts-log.txt lives
there too, but the artifact only uploads tests/*.log, so the killed test's
entry was a single truncated line. The hard kill also skips local-crawl.sh's
cleanup trap, so the tmpdir survives on the runner and is then discarded.

Dump those logs into the test's own .log on a timeout, and clear the tmpdir so
a later timeout cannot re-report it. hts-log.txt is where "More than N seconds
passed.. giving up" lands, which is the question #605 asks of a wedge.

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

* Never let the log salvage's cleanup fail the test

dump_crawl_logs runs on the timeout path, where the caller is already
handling a failure: rm -rf can fail on Windows while the killed httrack
still holds a file, and under set -e that would abort the branch whose
whole job is to salvage the evidence.

Renumber the test to 60: #611 adds a 59 too.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 07:06:10 +02:00
Xavier Roche
a47b7bee4d Do not mistake -%i and -@i for the -i continue flag (#618)
* Do not mistake -%i and -@i for the -i continue flag

The pass that counts URLs also detects -i (continue), and for anything
cmdl_opt() calls an option it looked for a bare 'i' with strchr(). That runs
after optalias_check() has expanded the long aliases, so --build-top-index
(-%i) and --protocol N (-@iN) matched too: they wiped the URL list and left
the run on the usage screen, exit 255. The neighbouring 'q' test had the same
flaw, with -%q silently forcing quiet mode.

Look for the flag in plain short-option clusters only, so -i, -iC1, -iC2 and
--continue keep forcing continue mode while prefixed options stay distinct.

Closes #615

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

* Walk the option cluster the way the parser does

The helper tested the second character only, which disagreed with the parser
in both directions: it dropped continue mode for -%qi and -%Mi, where the
trailing i does reach the main set, and it still mistook the i of -q%i for
-i, leaving that spelling on the usage screen.

Walk the cluster instead, letting %, &, @ and # take the letter after them
into another set wherever they sit. & matters because the -& to -% rewrite
only fires at position 0, so -q&i reaches the parser as-is.

None of this is reachable through a documented option or an alias, which all
place the prefix first; it keeps the helper honest against hand-written
clusters.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:46:56 +02:00
Xavier Roche
7be61d3111 Synchronous DNS resolution can wedge a crawl past --max-time (#613)
* Bound DNS resolution so a slow resolver cannot outlast --max-time

host_wait() is a stub returning 1, so a STATUS_WAIT_DNS slot resolves
synchronously through hts_dns_resolve_all() -> getaddrinfo() on the engine
thread. Nothing bounded that call, and it ran holding opt->state.lock, so a
black-hole resolver stalled the crawl past --max-time and --timeout and blocked
hts_request_stop()/hts_has_stopped() with it. resolv.conf caps getaddrinfo on
POSIX and hides this most of the time; Windows has no equivalent.

Resolve a cache miss on a worker thread and wait at most opt->timeout for it,
with the lock held only for the cache lookup and the store. A timed-out resolve
is abandoned rather than cancelled (getaddrinfo has no portable cancellation),
so the job is refcounted and the worker touches nothing but it. A timeout is
reported as "does not resolve" but never cached, so the host is retried instead
of written off for the rest of the crawl.

This bounds the wedge; it does not make resolution asynchronous. The engine
thread still stalls other slots for up to --timeout per resolve, which only
driving STATUS_WAIT_DNS from the poll loop would fix.

New -#test=dnstimeout self-test drives a mock resolver that blocks for 3s past
a 1s --timeout; all five of its checks fail on master.

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

* Derive the DNS timeout assertion from the spec, and join the mock workers

The self-test's ceiling came from the mock's sleep (MOCK_SLOW_MS - 500), not
from opt->timeout: a resolve using a hardcoded 2400ms deadline that ignored
opt->timeout entirely still passed it. Derive the bound from opt->timeout so
the assertion tests the contract instead of the fixture.

The scaffolding also raced its own abandoned workers. mock_host.calls was read
while a worker was still writing it, and tearing the backend down at the end
raced the worker's freeaddrinfo read, ordered only by a sleep. Serialize the
counters under a mutex, wait for the backend calls to actually return instead
of guessing, and leave the backend installed, since an abandoned worker still
reads it. Drops the drain sleep: 6.0s -> 4.0s.

Also: --timeout now bounds resolution, so say so in the help text (the man page
is generated from it), and factor the three address-copy loops into a helper.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:43:47 +02:00
Xavier Roche
3cc49b79c3 Stalled TLS handshake ignores --timeout (#611)
* Reap a stalled TLS handshake with the per-slot --timeout

back_wait only runs its per-slot timeout check when the local gestion_timeout
flag is armed. The CONNECTING, WAIT_DNS and receiving handlers arm it, but the
STATUS_SSL_WAIT_HANDSHAKE handler did not, so a peer that completes the TCP
connect and never speaks TLS left SSL_connect returning WANT_READ until
--max-time fired, ignoring --timeout entirely.

Arm the flag in the handshake handler and start a fresh timeout window when the
slot enters the handshake, so it is measured from there rather than from the
connect. The generic check already reaps any status > 0 slot once armed; give it
a distinct message instead of the generic "Receive Time Out".

Test 59 crawls a server that accepts the connect and stays silent: it ends in
--timeout seconds with the fix, and hangs until the kill guard without it.

Closes #607

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

* Cover the handshake timeout window, and share the ephemeral-bind helper

Test 59 only bounded the crawl from above, so an engine that reaped every
handshake instantly passed it, and nothing exercised the timeout_refresh at the
handshake entry: on loopback the connect is instant, so the handshake's own
window is indistinguishable from the connect's.

Add a floor to the first case, and a second one behind a proxy that takes 4s to
answer CONNECT. The handshake must still get its full --timeout=5 from there
(~9s); sharing the connect's clock reaps at ~5s. Dropping the refresh now fails
the test, as does collapsing the window to zero.

The stall server grows a proxy mode for that, and takes its listening socket
from a new proxytestlib bind_ephemeral(), which replaces the same boilerplate
in the socks5 and proxy servers.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:39:11 +02:00
Xavier Roche
959deb0afb An out-of-range -P proxy port parses to a garbage port instead of being rejected (#610)
* Reject an out-of-range -P proxy port instead of wrapping it

hts_parse_proxy read the port with sscanf(a, "%d", &p), which is signed
overflow UB past INT_MAX. glibc wraps rather than fails, so a garbage
port got through: -P 'http://host:99999999999999' parsed to port
276447231, while 'http://host:4294967296' happened to fail and fall back
to the default. 65536 was accepted as-is.

Parse with strtol and range-check to *DIGIT in 1..65535 (RFC 3986); an
out-of-range or malformed port now falls back to proxy_default_port,
exactly as an unparsable one already did.

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

* Attribute the proxy port range to TCP, not RFC 3986

RFC 3986 defines port as *DIGIT with no range; the 1..65535 bound comes from
TCP's 16-bit port field.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:37:58 +02:00
61 changed files with 1825 additions and 237 deletions

View File

@@ -158,13 +158,21 @@ jobs:
pass=0 fail=0 skip=0 failed="" skipped=""
for t in 00_runnable.test 01_engine-*.test 01_zlib-*.test \
*_local-*.test 13_crawl_proxy_https.test 58_watchdog.test; do
*_local-*.test 13_crawl_proxy_https.test 58_watchdog.test \
60_crawl-log-salvage.test; do
rc=0
run_with_timeout 600 bash "$t" >"$t.log" 2>&1 || rc=$?
case "$rc" in
0) pass=$((pass + 1)); echo "PASS $t" ;;
77) skip=$((skip + 1)) skipped="$skipped $t"; echo "SKIP $t" ;;
124) fail=$((fail + 1)) failed="$failed $t"; echo "FAIL $t (timed out, tree killed)" ;;
124)
fail=$((fail + 1)) failed="$failed $t"
echo "FAIL $t (timed out, tree killed)"
# Re-running a wedge traced would just hang again: salvage the
# killed crawl's own logs into the artifact instead.
dump_crawl_logs >>"$t.log"
tail -n 25 "$t.log" | sed 's/^/ /'
;;
*)
fail=$((fail + 1)) failed="$failed $t"
echo "FAIL $t (exit $rc)"

View File

@@ -1,6 +1,6 @@
AC_PREREQ([2.71])
AC_INIT([httrack], [3.49.12], [roche+packaging@httrack.com], [httrack], [http://www.httrack.com/])
AC_INIT([httrack], [3.49.13], [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,11 +29,13 @@ AC_CONFIG_SRCDIR(src/httrack.c)
AC_CONFIG_MACRO_DIR([m4])
AC_CONFIG_HEADERS(config.h)
AM_INIT_AUTOMAKE([subdir-objects])
# 3:4:0: 3.49.12 tail-appends one field (why_url) to httrackp and otherwise only
# deletes dead comments; no struct layout or exported signature broke vs 3.49.11,
# so it stays soname .so.3; bump revision.
# 3:5:0: 3.49.13 leaves every installed struct layout and the exported symbol set
# untouched vs 3.49.12; the 2 GB (_stat64) and UTF-8 export widenings are _WIN32-only,
# and INTsys widened on POSIX (#600) but sits in no installed struct. Stays soname
# .so.3; bump revision. x32 alone changes layout (LLint was 4 bytes there, #524), and
# is not a release architecture.
# (3:0:0 was the htsblk mime-buffer widening, the ABI break that moved .so.2 -> .so.3.)
VERSION_INFO="3:4:0"
VERSION_INFO="3:5:0"
AM_MAINTAINER_MODE
AC_USE_SYSTEM_EXTENSIONS

15
debian/changelog vendored
View File

@@ -1,3 +1,18 @@
httrack (3.49.13-1) unstable; urgency=medium
* New upstream release: SOCKS5 and CONNECT proxy support, brotli and zstd
content decoding, --update data-loss fixes, and hardening of several
network-facing parsers; full list in history.txt.
* Add libbrotli-dev and libzstd-dev to Build-Depends for the new content
codings.
* libhttrack3 and libhttrack-dev no longer ship libhtsjava.so.3*: the
obsolete Java-applet parser was removed upstream. It was a dlopen plugin
with no reverse dependencies, so this is a plain file drop on upgrade;
libhttrack.so.3 itself is unchanged and needs no rename.
* Drop the htsjava attribution from debian/copyright.
-- Xavier Roche <xavier@debian.org> Fri, 17 Jul 2026 10:49:52 +0200
httrack (3.49.12-1) unstable; urgency=medium
* New upstream release: security and crawl-correctness fixes (remote stack

View File

@@ -4,6 +4,31 @@ HTTrack Website Copier release history:
This file lists all changes and fixes that have been made for HTTrack
3.49-13
+ New: SOCKS5 proxy support, with scheme-aware -P URLs (socks5://, socks5h://, connect://) and plain HTTP tunneled through a CONNECT-only proxy (#563, #564)
+ New: decode brotli and zstd content codings, advertised over TLS only as browsers do (#556)
+ New: webhttrack exposes the engine options added since 3.49-2, among them --cookies-file, --pause and --strip-query (#587)
+ Fixed: files of 2 GB or more were mishandled on Windows and on every 32-bit build (#569)
+ Fixed: --update destroyed a good local copy when the re-fetch returned an HTTP error, was aborted by -M/-E, failed to decode, or came in short (#176, #521, #557, #562)
+ Fixed: a self-redirect cookie wall was dropped instead of being re-fetched with the cookie (#15)
+ Fixed: a stalled TLS handshake ignored --timeout, and synchronous DNS resolution could wedge a crawl past --max-time (#607, #613)
+ Fixed: -M metered saved bytes rather than received volume, and overshot its cap under a slow server (#77, #520)
+ Fixed: several network-facing overflows and denial-of-service paths in the Content-Range, chunked-transfer, cookie, filter and ProxyTrack cache parsers
+ Fixed: a failed connect did not fall back to the next address on Windows (#579)
+ Fixed: -P took an out-of-range port as a garbage port, and scanned past an IPv6 literal's closing bracket (#598, #602)
+ Fixed: reject a port outside 1..65535 wherever one is parsed (a crawled URL, the htsserver and proxytrack listen arguments, an ftp:// URL), instead of letting a bare sscanf wrap a huge value into a plausible port and silently use it (#614, #626)
+ Fixed: a configured proxy still resolved and dialed the origin itself (#592)
+ Fixed: ~/ in the -O base path was never expanded (#270)
+ Fixed: a non-ASCII -O output path was double-encoded on Windows once argv became UTF-8 (#621)
+ Fixed: files under a non-ASCII project path were saved to a mangled directory on Windows (#217)
+ Fixed: --build-top-index (-%i) and --protocol (-@i) were taken for the -i continue flag, wiping the URL list and exiting on the usage screen (#615)
+ Fixed: webhttrack ignored LC_ALL/LC_MESSAGES and picked the wrong Chinese and Portuguese (#95)
+ Fixed: webhttrack wrote its base path and httrack.ini to the filesystem root when $HOME was empty (#625)
+ Fixed: crawls on a non-default port were slowed by a per-request pre-resolve (#181)
+ Changed: Windows builds moved to Visual Studio 2022 and OpenSSL 3.x, the VS2008 project files are retired, and the binaries carry a version resource
+ Changed: removed the obsolete Java-applet (.class) parser and the dead SWF module remnants
+ Changed: multiple internal hardening, test and CI improvements (Windows and macOS crawl suites, HTML-parser fuzzing, parallel make check)
3.49-12
+ New: --why explains which filter rule accepts or rejects a given URL, then exits (#505)
+ Fixed: links carrying raw UTF-8 bytes were fetched double-encoded and 404'd (#516)

View File

@@ -23,7 +23,7 @@ pt:7
ro:25
ru:8
sk:20
si:24
sl:24
sv:17
tr:10
uk:22

View File

@@ -930,7 +930,6 @@ Server terminated
Palvelin lopetettu
A fatal error has occurred during this mirror
Tällä peilillä tapahtui vakava virhe
Proxy type:
Välityspalvelimen tyyppi:
Proxy protocol. HTTP: standard proxy. HTTP (CONNECT tunnel): sends every request through a CONNECT tunnel, for CONNECT-only proxies like Tor's HTTPTunnelPort. SOCKS5: default port 1080.

View File

@@ -374,7 +374,7 @@ Create error logging and report files
Crea file di log per segnalare errori e informazioni
Generate DOS 8-3 filenames ONLY
Genera solo nomi di file in formato 8.3
Generate ISO 9660 filenames ONLY for CDROM medias
Generate ISO9660 filenames ONLY for CDROM medias
Genera nomi di file in formato ISO9660 per i CDROM
Do not create HTML error pages
Non generare pagine d'errore HTML

View File

@@ -918,7 +918,7 @@ normal\nextended\ndebug
normal\nextendido\ncorrigir
Download web site(s)\nDownload web site(s) + questions\nGet individual files\nDownload all sites in pages (multiple mirror)\nTest links in pages (bookmark test)\n* Continue interrupted download\n* Update existing download
Copiar site(s) da Web\nCopiar site(s) interativos da Web (perguntas)\nReceber arquivos específicos\nCopiar todas as páginas do site (alternação múltipla)\nTestar links nas páginas (testar indicador)\n* Retomar download interrompido\n* Atualizar download existente
Relative URL / Absolute URL (default)\nAbsolute URL / Absolute URL\nAbsolute URI / Absolute URL\nOriginal URL / Original URL
Relative URI / Absolute URL (default)\nAbsolute URL / Absolute URL\nAbsolute URI / Absolute URL\nOriginal URL / Original URL
URI Relativa / URL Absoluta (padrão)\nURL Absoluta / URL Absoluta\nURI Absoluta / URL Absoluta\nURL Original / URL Original
Open Source offline browser
Abrir origem offline no navegador

View File

@@ -3,7 +3,7 @@ Slovenian
LANGUAGE_FILE
Slovenian
LANGUAGE_ISO
si
sl
LANGUAGE_AUTHOR
Jadran Rudec,iur.\r\njrudec@email.si \r\n
LANGUAGE_CHARSET

View File

@@ -3,7 +3,7 @@
.\"
.\" This file is generated by man/makeman.sh; do not edit by hand.
.\" SPDX-License-Identifier: GPL-3.0-or-later
.TH httrack 1 "14 July 2026" "httrack website copier"
.TH httrack 1 "16 July 2026" "httrack website copier"
.SH NAME
httrack \- offline browser : copy websites to a local directory
.SH SYNOPSIS
@@ -165,7 +165,7 @@ pause transfer if N bytes reached, and wait until lock file is deleted (\-\-max\
.IP \-cN
number of multiple connections (*c8) (\-\-sockets[=N])
.IP \-TN
timeout, number of seconds after a non\-responding link is shutdown (\-\-timeout[=N])
timeout, number of seconds after a non\-responding link is shutdown; also bounds host name resolution (\-\-timeout[=N])
.IP \-RN
number of retries, in case of timeout or non\-fatal errors (*R1) (\-\-retries[=N])
.IP \-JN

View File

@@ -46,9 +46,11 @@ htsserver_LDFLAGS = $(AM_LDFLAGS) $(LDFLAGS_PIE)
lib_LTLIBRARIES = libhttrack.la
htsserver_SOURCES = htsserver.c htsserver.h htsweb.c htsweb.h
htsserver_SOURCES = htsserver.c htsserver.h htsweb.c htsweb.h \
htsurlport.c htsurlport.h
proxytrack_SOURCES = proxy/main.c \
proxy/proxytrack.c proxy/store.c \
htsurlport.c htsurlport.h \
coucal/coucal.c htsmd5.c md5.c \
minizip/ioapi.c minizip/mztools.c minizip/unzip.c minizip/zip.c
@@ -58,7 +60,7 @@ whttrackrun_SCRIPTS = webhttrack
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 \
htshelp.c htslib.c htscoremain.c \
htshelp.c htslib.c htsurlport.c htscoremain.c \
htsname.c htsrobots.c htstools.c htswizard.c \
htsalias.c htsthread.c htsindex.c htsbauth.c \
htsmd5.c htscodec.c htsproxy.c htszlib.c htswrap.c htsconcat.c \
@@ -69,7 +71,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \
htsbasenet.h htsbauth.h htscache.h htscache_selftest.h htsdns_selftest.h htsselftest.h htscatchurl.h \
htsconfig.h htscore.h htsparse.h htscoremain.h htsdefines.h \
htsfilters.h htsftp.h htsglobal.h htshash.h coucal/coucal.h \
htshelp.h htsindex.h htslib.h htsmd5.h \
htshelp.h htsindex.h htslib.h htsurlport.h htsmd5.h \
htsmodules.h htsname.h htsnet.h htssniff.h \
htsopt.h htsrobots.h htsthread.h \
htstools.h htswizard.h htswrap.h htscodec.h htsproxy.h htszlib.h \

View File

@@ -567,24 +567,29 @@ int optinclude_file(const char *name, int *argc, char **argv, char *x_argvblk,
return 0;
}
/* Get home directory, '.' if failed */
/* Get home directory, '.' if unset or empty */
/* example: /home/smith */
const char *hts_gethome(void) {
const char *home = getenv("HOME");
if (home)
return home;
else
return ".";
/* An empty $HOME would expand ~/foo into the absolute /foo */
return strnotempty(home) ? home : ".";
}
/* Convert ~/foo into /home/smith/foo */
/* Convert ~/foo into /home/smith/foo (~user/ left alone: no getpwnam here) */
void expand_home(String * str) {
if (StringSub(*str, 1) == '~') {
if (StringNotEmpty(*str) && StringSub(*str, 0) == '~' &&
(StringLength(*str) == 1 || StringSub(*str, 1) == '/')) {
char BIGSTK tempo[HTS_URLMAXSIZE * 2];
const char *const home = hts_gethome();
const size_t homelen = strlen(home);
const size_t taillen = StringLength(*str) - 1;
strcpybuff(tempo, hts_gethome());
strcatbuff(tempo, StringBuff(*str) + 1);
StringCopy(*str, tempo);
/* Leave untouched rather than abort() in strcatbuff on a huge $HOME */
if (taillen < sizeof(tempo) && homelen < sizeof(tempo) - taillen) {
strcpybuff(tempo, home);
strcatbuff(tempo, StringBuff(*str) + 1);
StringCopy(*str, tempo);
}
}
}

View File

@@ -2816,6 +2816,9 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
if (SSL_set_fd(back[i].r.ssl_con, (int) back[i].r.soc) == 1) {
SSL_set_connect_state(back[i].r.ssl_con);
back[i].status = STATUS_SSL_WAIT_HANDSHAKE; /* handshake wait */
// the handshake gets its own timeout window, as connect does
if (back[i].timeout > 0)
back[i].timeout_refresh = time_local();
} else
back[i].r.statuscode = STATUSCODE_SSL_HANDSHAKE;
} else
@@ -2875,6 +2878,11 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
}
#if HTS_USEOPENSSL
else if (back[i].status == STATUS_SSL_WAIT_HANDSHAKE) { // wait for SSL handshake
// a peer that never speaks TLS must be reaped by --timeout too (#607)
if (!gestion_timeout)
if (back[i].timeout > 0)
gestion_timeout = 1;
/* SSL mode */
if (back[i].r.ssl) {
int conn_code;
@@ -4265,6 +4273,8 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
strcpybuff(back[i].r.msg, "Connect Time Out");
else if (back[i].status == STATUS_WAIT_DNS)
strcpybuff(back[i].r.msg, "DNS Time Out");
else if (back[i].status == STATUS_SSL_WAIT_HANDSHAKE)
strcpybuff(back[i].r.msg, "SSL/TLS Handshake Time Out");
else
strcpybuff(back[i].r.msg, "Receive Time Out");
back[i].status = STATUS_READY; // terminé

View File

@@ -113,6 +113,7 @@ HTSEXT_API int hts_main(int argc, char **argv) {
}
static int hts_main_internal(int argc, char **argv, httrackp * opt);
static hts_boolean cmdl_shortopt_has(const char *s, char c);
// Main, récupère les paramètres et appelle le robot
HTSEXT_API int hts_main2(int argc, char **argv, httrackp * opt) {
@@ -304,12 +305,12 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
hts_get_version_info(opt));
return 0;
} else {
if (strncmp(tmp_argv[0], "--", 2)) { /* pas */
if ((strchr(tmp_argv[0], 'q') != NULL))
opt->quiet = 1; // ne pas poser de questions! (nohup par exemple)
if ((strchr(tmp_argv[0], 'i') != NULL)) { // doit.log!
argv_url = -1; /* forcer */
opt->quiet = 1;
if (strncmp(tmp_argv[0], "--", 2)) { /* not a long option */
if (cmdl_shortopt_has(tmp_argv[0], 'q'))
opt->quiet = HTS_TRUE; // never ask questions (nohup)
if (cmdl_shortopt_has(tmp_argv[0], 'i')) { // doit.log!
argv_url = -1;
opt->quiet = HTS_TRUE;
}
} else if (strcmp(tmp_argv[0] + 2, "quiet") == 0) {
opt->quiet = 1; // ne pas poser de questions! (nohup par exemple)
@@ -424,25 +425,10 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
} // for
// Convert path to UTF-8
#ifdef _WIN32
{
char *const path =
hts_convertStringSystemToUTF8(StringBuff(opt->path_html),
(int) StringLength(opt->path_html));
if (path != NULL) {
StringCopy(opt->path_html_utf8, path);
free(path);
} else {
StringCopyN(opt->path_html_utf8, StringBuff(opt->path_html),
StringLength(opt->path_html));
}
}
#else
// Assume UTF-8 filesystem.
// path_html is already UTF-8 (argv is UTF-8 on Windows via
// hts_argv_utf8), so no re-encoding.
StringCopyN(opt->path_html_utf8, StringBuff(opt->path_html),
StringLength(opt->path_html));
#endif
/* if doit.log exists, or if new URL(s) defined,
then DO NOT load standard config files */
@@ -2382,30 +2368,28 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
opt->errlog = stderr;
} else if (httrack_logmode >= 2) {
// deux fichiers log
structcheck(StringBuff(opt->path_log));
if (fexist
(fconcat
(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "hts-log.txt")))
remove(fconcat
(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "hts-log.txt"));
if (fexist
(fconcat
(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "hts-err.txt")))
remove(fconcat
(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "hts-err.txt"));
// path_log holds UTF-8 bytes (argv is UTF-8): the ANSI file calls would
// read them as the codepage and drop the logs into a mangled twin (#630).
structcheck_utf8(StringBuff(opt->path_log));
if (fexist_utf8(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-log.txt")))
UNLINK(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-log.txt"));
if (fexist_utf8(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-err.txt")))
UNLINK(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-err.txt"));
/* Check FS directory structure created */
structcheck(StringBuff(opt->path_log));
structcheck_utf8(StringBuff(opt->path_log));
opt->log =
fopen(fconcat
(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "hts-log.txt"),
"w");
opt->log = FOPEN(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-log.txt"),
"w");
if (httrack_logmode == 2)
opt->errlog =
fopen(fconcat
(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "hts-err.txt"),
"w");
opt->errlog = FOPEN(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-err.txt"),
"w");
else
opt->errlog = opt->log;
if (opt->log == NULL) {
@@ -2485,9 +2469,12 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
}
} */
// vérifier existence de la structure
structcheck(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_html), "/"));
structcheck(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log), "/"));
// vérifier existence de la structure (path_html/path_log are UTF-8, use
// the UTF-8 mkdir path)
structcheck_utf8(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_html), "/"));
structcheck_utf8(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "/"));
// reprise/update
if (opt->cache) {
@@ -2807,6 +2794,32 @@ int check_path(String * s, char *defaultname) {
return return_value;
}
/* Does the short-option cluster s carry c from the main option set (-i, -iC2,
-%Mi)? Walked as the parser does below: %, &, @ and # each take the letter
after them into another set, so the i of -%i is not the main-set -i. */
static hts_boolean cmdl_shortopt_has(const char *s, char c) {
const char *com;
if (s[0] != '-' || s[1] == '-')
return HTS_FALSE;
for (com = s + 1; *com != '\0'; com++) {
switch (*com) {
case '%':
case '&':
case '@':
case '#':
if (*(com + 1) != '\0')
com++; /* skip the other set's letter */
break;
default:
if (*com == c)
return HTS_TRUE;
break;
}
}
return HTS_FALSE;
}
// détermine si l'argument est une option
int cmdl_opt(char *s) {
if (s[0] == '-') { // c'est peut être une option

View File

@@ -63,9 +63,14 @@ typedef struct mock_host {
int gai_err; /* non-zero: getaddrinfo returns this */
int naddr;
mock_addr addr[6];
int calls; /* times the backend resolved this host */
int calls; /* times the backend resolved this host */
int slow_ms; /* non-zero: block this long, as a black-hole resolver would */
} mock_host;
/* Long enough to outlast the 1s --timeout the bounded resolve is checked
against, short enough to keep the self-test quick. */
#define MOCK_SLOW_MS 3000
static mock_host mock_hosts[] = {
{"v4only.test", 0, 1, {{AF_INET, {1, 2, 3, 4}}}, 0},
{"v6only.test", 0, 1, {{AF_INET6, {0x20, 0x01, 0x0d, 0xb8, [15] = 1}}}, 0},
@@ -95,8 +100,15 @@ static mock_host mock_hosts[] = {
{AF_INET, {10, 0, 0, 6}}},
0},
{"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},
};
/* Serializes mock_host bookkeeping: a timed-out resolve is abandoned, so its
worker is still inside the backend while the test reads the counters. */
static htsmutex mock_lock = HTSMUTEX_INIT;
static int mock_finished = 0; /* backend calls that have returned */
static mock_host *mock_find(const char *name) {
for (size_t i = 0; i < sizeof(mock_hosts) / sizeof(mock_hosts[0]); i++) {
if (strcmp(mock_hosts[i].name, name) == 0)
@@ -106,8 +118,34 @@ static mock_host *mock_find(const char *name) {
}
static void mock_reset_calls(void) {
hts_mutexlock(&mock_lock);
for (size_t i = 0; i < sizeof(mock_hosts) / sizeof(mock_hosts[0]); i++)
mock_hosts[i].calls = 0;
mock_finished = 0;
hts_mutexrelease(&mock_lock);
}
static int mock_read_calls(const char *name) {
int calls;
hts_mutexlock(&mock_lock);
calls = mock_find(name)->calls;
hts_mutexrelease(&mock_lock);
return calls;
}
/* Wait for n backend calls to return, ordering their writes against ours. */
static void mock_wait_finished(int n) {
for (;;) {
hts_boolean done;
hts_mutexlock(&mock_lock);
done = (mock_finished >= n) ? HTS_TRUE : HTS_FALSE;
hts_mutexrelease(&mock_lock);
if (done)
break;
Sleep(10);
}
}
/* Build one addrinfo node owning its sockaddr (freed by mock_freeaddrinfo). */
@@ -133,10 +171,10 @@ static struct addrinfo *mock_mkai(const mock_addr *a) {
return ai;
}
static int HTS_RESOLVER_CALL mock_getaddrinfo(const char *node,
const char *service,
const struct addrinfo *hints,
struct addrinfo **res) {
static int HTS_RESOLVER_CALL mock_getaddrinfo_(const char *node,
const char *service,
const struct addrinfo *hints,
struct addrinfo **res) {
mock_host *const h = mock_find(node);
const int want = (hints != NULL) ? hints->ai_family : PF_UNSPEC;
struct addrinfo *head = NULL, *tail = NULL;
@@ -145,7 +183,11 @@ static int HTS_RESOLVER_CALL mock_getaddrinfo(const char *node,
*res = NULL;
if (h == NULL)
return EAI_NONAME;
hts_mutexlock(&mock_lock);
h->calls++; /* a real backend hit; a cached host skips this */
hts_mutexrelease(&mock_lock);
if (h->slow_ms != 0)
Sleep(h->slow_ms);
if (h->gai_err != 0)
return h->gai_err;
for (int i = 0; i < h->naddr; i++) {
@@ -165,6 +207,18 @@ static int HTS_RESOLVER_CALL mock_getaddrinfo(const char *node,
return 0;
}
static int HTS_RESOLVER_CALL mock_getaddrinfo(const char *node,
const char *service,
const struct addrinfo *hints,
struct addrinfo **res) {
const int ret = mock_getaddrinfo_(node, service, hints, res);
hts_mutexlock(&mock_lock);
mock_finished++;
hts_mutexrelease(&mock_lock);
return ret;
}
static void HTS_RESOLVER_CALL mock_freeaddrinfo(struct addrinfo *res) {
while (res != NULL) {
struct addrinfo *const next = res->ai_next;
@@ -348,6 +402,50 @@ int dns_selftests(httrackp *opt) {
deletesoc(s);
}
/* A URL port outside 1..65535 must refuse the link, not fold into range and
connect elsewhere (#614). *addr_count discriminates: 0 only if refused
before the resolve, still 2 for one merely truncated or defaulted. */
{
/* an empty "dual.test:" means the default port (WHATWG, curl): keep it */
static const char *const good[] = {"dual.test:1", "dual.test:80",
"dual.test:8080", "dual.test:65535",
"dual.test:080", "dual.test:"};
/* 65616 and 4294967376 are load-bearing: both wrap to a plausible 80 */
static const char *const bad[] = {
"dual.test:0", "dual.test:65536", "dual.test:65616",
"dual.test:99999", "dual.test:2147483648", "dual.test:4294967296",
"dual.test:4294967376", "dual.test:-1", "dual.test:-23437",
"dual.test:80x", "dual.test:+80", "dual.test: 80",
"dual.test:0x50"};
size_t k;
for (k = 0; k < sizeof(good) / sizeof(good[0]); k++) {
htsblk r;
int count = -1;
T_SOC s;
hts_init_htsblk(&r);
s = newhttp_addr(opt, good[k], &r, -1, 0, 0, &count);
CHECK(count == 2); /* accepted: reached the resolve */
if (s != INVALID_SOCKET)
deletesoc(s);
}
for (k = 0; k < sizeof(bad) / sizeof(bad[0]); k++) {
htsblk r;
int count = -1;
T_SOC s;
hts_init_htsblk(&r);
s = newhttp_addr(opt, bad[k], &r, -1, 0, 0, &count);
CHECK(s == INVALID_SOCKET);
CHECK(count == 0); /* refused before resolving, not a failed connect */
CHECK(strstr(r.msg, "Invalid port") != NULL);
if (s != INVALID_SOCKET)
deletesoc(s);
}
}
/* Connect-fallback decision (consumer of the multi-address list): when a
stuck connect should abandon the current address for the next one. */
{
@@ -370,6 +468,91 @@ int dns_selftests(httrackp *opt) {
return failures;
}
/* Probes how long acquiring opt->state.lock takes while a resolve is in
flight. hts_has_stopped() takes that same lock and mutates nothing, and the
API promises it stays callable from another thread during a mirror. */
typedef struct lock_probe {
httrackp *opt;
htsmutex lock;
hts_boolean done;
TStamp blocked_ms;
} lock_probe;
static void lock_probe_thread(void *arg) {
lock_probe *const p = (lock_probe *) arg;
TStamp start;
Sleep(MOCK_SLOW_MS / 10); /* let the resolve get under way first */
start = mtime_local();
(void) hts_has_stopped(p->opt);
hts_mutexlock(&p->lock);
p->blocked_ms = mtime_local() - start;
p->done = HTS_TRUE;
hts_mutexrelease(&p->lock);
}
int dns_timeout_selftests(httrackp *opt) {
SOCaddr addrs[HTS_MAXADDRNUM];
const char *err = NULL;
lock_probe probe;
TStamp start, elapsed;
int count;
failures = 0;
hts_dns_set_resolver_backend(&mock_backend);
IPV6_resolver = 0;
mock_reset_calls();
opt->timeout = 1; /* the bound under test */
memset(&probe, 0, sizeof(probe));
probe.opt = opt;
probe.lock = HTSMUTEX_INIT;
hts_mutexinit(&probe.lock);
CHECK(hts_newthread(lock_probe_thread, &probe) == 0);
start = mtime_local();
count = hts_dns_resolve_all(opt, "slow.test", addrs, HTS_MAXADDRNUM, &err);
elapsed = mtime_local() - start;
/* the resolve returns on opt->timeout, not when the resolver deigns to
answer: this is what lets --max-time and --timeout fire (#606). The bound
is derived from opt->timeout, never from the mock's sleep, or a resolve
that ignored opt->timeout would still pass under the mock. */
CHECK(elapsed < (TStamp) opt->timeout * 1000 + 500);
CHECK(count == 0); /* a timeout is reported as "does not resolve" */
/* state.lock is not held across the resolve; a concurrent stop query, which
the mirror API promises stays live, is not blocked behind it */
for (;;) {
hts_boolean done;
TStamp blocked;
hts_mutexlock(&probe.lock);
done = probe.done;
blocked = probe.blocked_ms;
hts_mutexrelease(&probe.lock);
if (done) {
CHECK(blocked < 500);
break;
}
Sleep(20);
}
hts_mutexfree(&probe.lock);
/* a timeout is not an answer, so it must not be negative-cached: the host is
resolved again rather than written off for the rest of the crawl */
CHECK(mock_read_calls("slow.test") == 1);
count = hts_dns_resolve_all(opt, "slow.test", addrs, HTS_MAXADDRNUM, &err);
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);
return failures;
}
#else
int dns_selftests(httrackp *opt) {

View File

@@ -46,6 +46,12 @@ typedef struct httrackp httrackp;
Returns the number of failed checks (0 == success). */
int dns_selftests(httrackp *opt);
/* Drive a deliberately slow (mock) resolver, asserting that a resolve is
bounded by opt->timeout, does not hold opt->state.lock while it runs, and
does not cache a timeout as an answer (#606). Takes a few seconds.
Returns the number of failed checks (0 == success). */
int dns_timeout_selftests(httrackp *opt);
#endif
#endif

View File

@@ -248,7 +248,13 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
// port
a = strchr(adr, ':'); // port
if (a) {
sscanf(a + 1, "%d", &port);
// folding a nonsense port into 1..65535 fetches one the link never named;
// an empty "host:" just means the default (#614)
if (a[1] != '\0' && !hts_parse_url_port(a + 1, &port)) {
snprintf(back->r.msg, sizeof(back->r.msg), "Invalid port: %s", a + 1);
back->r.statuscode = STATUSCODE_INVALID; // permanent, unlike a DNS miss
_HALT_FTP return 0;
}
strncatbuff(_adr, adr, (int) (a - adr));
} else
strcpybuff(_adr, adr);

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-12"
#define HTTRACK_VERSIONID "3.49.12"
#define HTTRACK_VERSION "3.49-13"
#define HTTRACK_VERSIONID "3.49.13"
#define HTTRACK_AFF_VERSION "3.x"
#define HTTRACK_LIB_VERSION "2.0"

View File

@@ -496,8 +496,8 @@ void help(const char *app, int more) {
infomsg("");
infomsg("Flow control:");
infomsg(" cN number of multiple connections (*c8)");
infomsg
(" TN timeout, number of seconds after a non-responding link is shutdown");
infomsg(" TN timeout, number of seconds after a non-responding link is"
" shutdown; also bounds host name resolution");
infomsg
(" RN number of retries, in case of timeout or non-fatal errors (*R1)");
infomsg

View File

@@ -65,6 +65,7 @@ Please visit our Website: http://www.httrack.com
#endif /* _WIN32 */
#include <stdarg.h>
#include <ctype.h>
#include <string.h>
#include <time.h>
#include <stdarg.h>
@@ -2166,15 +2167,18 @@ T_SOC newhttp_addr(httrackp *opt, const char *_iadr, htsblk *retour, int port,
#endif
if (a != NULL) {
int i = -1;
iadr2[0] = '\0';
sscanf(a + 1, "%d", &i);
if (i != -1) {
port = (unsigned short int) i;
// folding a nonsense port into 0..65535 crawls one neither the link nor
// a port filter named; an empty "host:" just means the default (#614)
if (a[1] != '\0' && !hts_parse_url_port(a + 1, &port)) {
if (retour != NULL) {
snprintf(retour->msg, sizeof(retour->msg), "Invalid port: %s",
a + 1);
}
return INVALID_SOCKET;
}
// adresse véritable (sans :xx)
iadr2[0] = '\0';
// the address itself, without the ":port"
strncatbuff(iadr2, iadr, (int) (a - iadr));
resolve_host = iadr2;
}
@@ -3747,6 +3751,16 @@ static int proxy_default_port(const char *arg) {
return hts_proxy_is_socks(arg) ? 1080 : 8080;
}
// port "a" of -P argument "arg": digits fitting TCP's 1..65535, else the scheme
// default. Not sscanf("%d"): past INT_MAX it wraps to a garbage port (#602)
static int parse_proxy_port(const char *a, const char *arg) {
int port;
if (!hts_parse_url_port(a, &port))
return proxy_default_port(arg);
return port;
}
void hts_parse_proxy(const char *arg, char *name, size_t name_size, int *port) {
const char *authority = strstr(arg, "://");
const char *a;
@@ -3762,10 +3776,7 @@ void hts_parse_proxy(const char *arg, char *name, size_t name_size, int *port) {
while (a > authority && a[-1] != ':' && a[-1] != '@' && a[-1] != ']')
a--;
if (a > authority && a[-1] == ':') {
int p = -1;
sscanf(a, "%d", &p);
*port = (p > 0) ? p : proxy_default_port(arg);
*port = parse_proxy_port(a, arg);
namelen = (size_t) (a - 1 - arg);
} else {
*port = proxy_default_port(arg);
@@ -5084,50 +5095,164 @@ HTSEXT_API int check_hostname_dns(const char *const hostname) {
return hts_dns_resolve_nocache(hostname, &buffer) != NULL;
}
// Needs locking
// Internal DNS cache. Fill out[0..count-1] with up to max addresses for _iadr,
// resolving (and caching the full list) on a miss. Returns the count.
static int hts_dns_resolve_list_(httrackp *opt, const char *_iadr,
SOCaddr *const out, const int max,
const char **error) {
char BIGSTK iadr[HTS_URLMAXSIZE * 2];
coucal cache = hts_cache(opt); // le cache dns
/* A resolve in flight. Refcounted: a timed-out resolve is abandoned, not
cancelled, so the last of caller/worker to leave frees the job. */
typedef struct dns_resolve_job {
htsmutex lock;
int refcount;
hts_boolean done;
char *hostname;
SOCaddr addr[HTS_MAXADDRNUM];
int count;
const char *error;
} dns_resolve_job;
/* Copy the first min(count, max) addresses of src into dest. */
static void dns_copy_addrs(SOCaddr *dest, SOCaddr *src, int count, int max) {
int i;
for (i = 0; i < count && i < max; i++)
SOCaddr_copy_SOCaddr(dest[i], src[i]);
}
static void dns_job_release(dns_resolve_job *job) {
hts_boolean last;
hts_mutexlock(&job->lock);
last = (--job->refcount == 0) ? HTS_TRUE : HTS_FALSE;
hts_mutexrelease(&job->lock);
if (last) {
hts_mutexfree(&job->lock);
freet(job->hostname);
freet(job);
}
}
/* Outlives a timed-out resolve, so it writes only the job: never opt (freed
before the thread wait at exit) nor the DNS cache. */
static void dns_resolve_thread(void *arg) {
dns_resolve_job *const job = (dns_resolve_job *) arg;
SOCaddr resolved[HTS_MAXADDRNUM];
const char *error = NULL;
const int count = hts_dns_resolve_nocache_list(job->hostname, resolved,
HTS_MAXADDRNUM, &error);
hts_mutexlock(&job->lock);
dns_copy_addrs(job->addr, resolved, count, HTS_MAXADDRNUM);
job->count = count;
job->error = error;
job->done = HTS_TRUE; /* published last: gates the caller's read of addr[] */
hts_mutexrelease(&job->lock);
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) {
dns_resolve_job *job;
TStamp deadline;
int count = -1;
int poll_ms = 1;
if (timeout <= 0) /* no bound asked for (--timeout 0) */
return hts_dns_resolve_nocache_list(hostname, out, max, error);
job = calloct(1, sizeof(*job));
assertf(job != NULL);
hts_mutexinit(&job->lock);
job->hostname = strdupt(hostname);
job->refcount = 2; /* this caller + the worker */
if (hts_newthread(dns_resolve_thread, job) != 0) {
job->refcount = 1; /* no worker: fall back to resolving inline */
dns_job_release(job);
return hts_dns_resolve_nocache_list(hostname, out, max, error);
}
deadline = mtime_local() + (TStamp) timeout * 1000;
for (;;) {
hts_boolean done;
hts_mutexlock(&job->lock);
done = job->done;
if (done) {
count = job->count;
dns_copy_addrs(out, job->addr, count, max);
if (error != NULL)
*error = job->error;
}
hts_mutexrelease(&job->lock);
if (done || mtime_local() >= deadline)
break;
Sleep(poll_ms);
if (poll_ms < 50) /* short first polls keep a fast resolve fast */
poll_ms *= 2;
}
dns_job_release(job);
return count;
}
int hts_dns_resolve_all(httrackp *opt, const char *iadr, SOCaddr *out, int max,
const char **error) {
char BIGSTK host[HTS_URLMAXSIZE * 2];
SOCaddr resolved[HTS_MAXADDRNUM];
coucal cache;
int count, i;
assertf(opt != NULL);
assertf(_iadr != NULL);
assertf(out != NULL);
if (!strnotempty(iadr) || max <= 0) {
return 0;
}
strcpybuff(iadr, jump_identification_const(_iadr));
// couper éventuel :
/* cache key and resolver input: identification and any ":port" stripped */
strcpybuff(host, jump_identification_const(iadr));
{
char *a;
if ((a = jump_toport(iadr)))
if ((a = jump_toport(host)))
*a = '\0';
}
/* get IP from the dns cache */
count = hts_ghbn_all(cache, iadr, out, max);
hts_mutexlock(&opt->state.lock);
cache = hts_cache(opt);
#if HTS_INET6 != 0
hts_resolver_check_env(); /* settle the backend before a worker reads it */
#endif
count = hts_ghbn_all(cache, host, out, max);
hts_mutexrelease(&opt->state.lock);
if (count >= 0) { // cache hit (0 == negative-cached)
return count;
} else { // non présent dans le cache dns, tester
SOCaddr resolved[HTS_MAXADDRNUM];
t_dnscache *record;
int i;
}
#if DEBUGDNS
printf("resolving (not cached) %s\n", iadr);
printf("resolving (not cached) %s\n", host);
#endif
count = hts_dns_resolve_nocache_list(iadr, resolved, HTS_MAXADDRNUM, error);
/* 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);
#if HTS_WIDE_DEBUG
DEBUG_W("gethostbyname done\n");
DEBUG_W("gethostbyname done\n");
#endif
/* attempt to store new entry (coucal owns it and dups the host key) */
record = malloct(sizeof(t_dnscache));
if (count < 0) { /* timed out: no answer to cache, and none to report */
if (error != NULL)
*error = "host name resolution timed out";
return 0;
}
hts_mutexlock(&opt->state.lock);
{ /* store the full list (coucal owns the record and dups the host key; a
concurrent resolve of the same host replaces, and frees, this one) */
t_dnscache *const record = malloct(sizeof(t_dnscache));
if (record != NULL) {
memset(record, 0, sizeof(*record));
record->host_count = count;
@@ -5137,28 +5262,13 @@ static int hts_dns_resolve_list_(httrackp *opt, const char *_iadr,
memcpy(record->host_addr[i], &SOCaddr_sockaddr(resolved[i]),
record->host_length[i]);
}
coucal_add_pvoid(cache, iadr, record);
coucal_add_pvoid(cache, host, record);
}
/* copy result to caller (cache store may have failed; result still valid)
*/
for (i = 0; i < count && i < max; i++) {
SOCaddr_copy_SOCaddr(out[i], resolved[i]);
}
return count;
} // retour hp du cache
}
int hts_dns_resolve_all(httrackp *opt, const char *iadr, SOCaddr *out, int max,
const char **error) {
int count;
if (!strnotempty(iadr) || max <= 0) {
return 0;
}
hts_mutexlock(&opt->state.lock);
count = hts_dns_resolve_list_(opt, iadr, out, max, error);
hts_mutexrelease(&opt->state.lock);
/* copy result to caller (cache store may have failed; result still valid) */
dns_copy_addrs(out, resolved, count, max);
return count;
}

View File

@@ -61,6 +61,7 @@ typedef struct lien_adrfilsave lien_adrfilsave;
/* définitions globales */
#include "htsglobal.h"
#include "htsurlport.h"
/* basic net definitions */
#include "htsbase.h"
@@ -222,7 +223,8 @@ LLint http_xfread1(htsblk * r, int bufl);
/* Cached resolver: fill out[0..count-1] with up to max addresses for iadr (in
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). */
(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. */
int hts_dns_resolve_all(httrackp *opt, const char *iadr, SOCaddr *out, int max,
const char **error);
HTS_INLINE SOCaddr *hts_dns_resolve2(httrackp *opt, const char *iadr,

View File

@@ -1532,8 +1532,30 @@ int url_savename(lien_adrfilsave *const afs,
// last segment
wsave[j++] = '/';
#define MAX_UTF8_SEQ_CHARS 4
for(i = lastSeg; wsave[i] != '\0' && j < maxLen; i++) {
wsave[j++] = wsave[i];
{
// #623: the ".delayed" placeholder marker sits at the tail; cutting
// through it drops IS_DELAYED_EXT, so the file is never renamed to its
// final name. Reserve the trailing ".<id>.delayed" across the cut.
size_t markStart = wsaveLen;
if (IS_DELAYED_EXT(afs->save)) {
const size_t extDot = wsaveLen - strlen("." DELAYED_EXT);
size_t p = extDot; /* walk back over a dot-separated ".<hexid>" tag */
while (p > lastSeg && ((wsave[p - 1] >= '0' && wsave[p - 1] <= '9') ||
(wsave[p - 1] >= 'a' && wsave[p - 1] <= 'f')))
p--;
// keep the tag only if truly ".<hexid>.delayed"; else the bare marker
// (a wholly-hex base, e.g. a hashed #133 name, must not be absorbed)
markStart = (p > lastSeg && p < extDot && wsave[p - 1] == '.')
? p - 1
: extDot;
}
// head, bounded so the marker still fits, then the marker itself
for (i = lastSeg; i < markStart && j + (wsaveLen - markStart) < maxLen;
i++)
wsave[j++] = wsave[i];
for (i = markStart; i < wsaveLen && j < maxLen; i++)
wsave[j++] = wsave[i];
}
// terminating \0
wsave[j++] = '\0';

View File

@@ -315,6 +315,43 @@ static void escape_url_parens(char *const s, const size_t size) {
strlcpybuff(s, buff, size);
}
/* Strip a default ":80" from lien's authority in place. Any spelling that
range-parses to 80 (":80", ":080") is dropped by its matched length, not a
hardcoded 3 chars; a value that merely wraps to 80 as an int (#614) stays. */
void hts_strip_default_port(char *lien, size_t size) {
char *a;
if (!link_has_authority(lien))
return;
a = strstr(lien, "//"); // "//" authority
if (a)
a += 2;
else
a = lien;
a = jump_toport(a);
if (a) { // port present
char *b = a + 1;
char saved;
int port;
hts_boolean is_default;
while (isdigit((unsigned char) *b))
b++;
saved = *b;
*b = '\0';
is_default = hts_parse_url_port(a + 1, &port) && port == 80;
*b = saved;
if (is_default) { // default port, strip it
char BIGSTK tempo[HTS_URLMAXSIZE * 2];
tempo[0] = '\0';
strncatbuff(tempo, lien, a - lien);
strcatbuff(tempo, b); // skip the whole matched :port
strlcpybuff(lien, tempo, size);
}
}
}
/* Main parser */
int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
char catbuff[CATBUFF_SIZE];
@@ -2138,38 +2175,8 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
} while((b != a) && (b));
}
}
// éliminer les éventuels :80 (port par défaut!)
if (link_has_authority(lien)) {
char *a;
a = strstr(lien, "//"); // "//" authority
if (a)
a += 2;
else
a = lien;
a = jump_toport(a);
if (a) { // port
int port = 0;
int defport = 80;
char *b = a + 1;
#if HTS_USEOPENSSL
#endif
while(isdigit((unsigned char) *b)) {
port *= 10;
port += (int) (*b - '0');
b++;
}
if (port == defport) { // port 80, default - c'est débile
char BIGSTK tempo[HTS_URLMAXSIZE * 2];
tempo[0] = '\0';
strncatbuff(tempo, lien, a - lien);
strcatbuff(tempo, a + 3); // sauter :80
strcpybuff(lien, tempo);
}
}
}
// drop a default :80 port from the authority
hts_strip_default_port(lien, sizeof(lien));
// filtrer les parazites (mailto & cie)
/*
if (strfield(lien,"mailto:")) { // ne pas traiter

View File

@@ -106,6 +106,10 @@ struct htsmoduleStructExtended {
*/
int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre);
/* Strip a default ":80" (any spelling) from an absolute link's authority, in
place into a buffer of the given size. */
void hts_strip_default_port(char *lien, size_t size);
/*
Check for 301,302.. errors ("moved") and handle them; re-isuue requests, make
rediretc file, handle filters considerations..

View File

@@ -491,14 +491,11 @@ static int socks5_handshake_stream(httrackp *opt, socks5_stream *st,
if ((unsigned char) host[i] < ' ')
return socks5_fail(msg, msgsize, "SOCKS5: invalid origin host");
}
if (portsep != NULL) {
int p = -1;
sscanf(portsep + 1, "%d", &p);
if (p <= 0 || p > 65535)
return socks5_fail(msg, msgsize, "SOCKS5: invalid origin port");
port = p;
}
// the old range check ran after sscanf("%d") had wrapped a huge value into a
// plausible port (#614). An empty "host:" stays refused here, unlike the
// direct path, as it was before #614.
if (portsep != NULL && !hts_parse_url_port(portsep + 1, &port))
return socks5_fail(msg, msgsize, "SOCKS5: invalid origin port");
if (link_has_authorization(proxy_name)) {
if (!socks5_credentials(opt, proxy_name, user, sizeof(user), &userlen, pass,
sizeof(pass), &passlen, msg, msgsize))

View File

@@ -45,6 +45,7 @@ Please visit our Website: http://www.httrack.com
#include "htscore.h"
#include "htsdefines.h"
#include "htslib.h"
#include "htsalias.h"
#include "htsparse.h"
#include "htscache.h"
#include "htscache_selftest.h"
@@ -825,6 +826,21 @@ static int st_simplify(httrackp *opt, int argc, char **argv) {
return 0;
}
static int st_expandhome(httrackp *opt, int argc, char **argv) {
String path = STRING_EMPTY;
(void) opt;
if (argc < 1) {
fprintf(stderr, "expandhome: needs a path\n");
return 1;
}
StringCopy(path, argv[0]);
expand_home(&path);
printf("expanded=%s\n", StringBuff(path));
StringFree(path);
return 0;
}
static int st_mime(httrackp *opt, int argc, char **argv) {
char mime[256];
@@ -1453,6 +1469,25 @@ static int st_socks5(httrackp *opt, int argc, char **argv) {
assertf(socks5_handshake_scripted(opt, "origin.test:8443", proxy, &io) == 1);
assertf(memcmp(io.sent + io.sent_len - 2, "\x20\xfb", 2) == 0);
/* a bad origin port is refused before any byte goes out (#614). 4294967376 is
the case the old range check could not see: it overflowed the sscanf("%d")
into a plausible 80 and passed. 65616 would not prove anything here, since
it fits an int and the old check already caught it. */
{
static const char *const bad[] = {"origin.test:4294967376",
"origin.test:80x", "origin.test:+80",
"origin.test: 80", "origin.test:8.0"};
size_t k;
for (k = 0; k < sizeof(bad) / sizeof(bad[0]); k++) {
len = socks5_reply(script, 0x01, v4, sizeof(v4));
io.reply = script;
io.reply_len = len;
assertf(socks5_handshake_scripted(opt, bad[k], proxy, &io) == 0);
assertf(io.sent_len == 0);
}
}
/* credentials: split on the first colon of the escaped userinfo, so %3a stays
inside the username and a colon in the password is not a delimiter */
{
@@ -1554,6 +1589,41 @@ static int st_identabs(httrackp *opt, int argc, char **argv) {
return 0;
}
/* Default-port strip (#627): a genuine 80 (any spelling) is removed by its
matched length, host preserved; a non-80 port or one that only wraps to 80 as
a 32-bit int (#614) is left intact. Guards the old bug where ":080"/":0080"
dropped a hardcoded 3 chars and glued the leftover digits onto the host. */
static int st_stripport(httrackp *opt, int argc, char **argv) {
static const struct {
const char *in, *out;
} cases[] = {
{"http://127.0.0.1:80/x", "http://127.0.0.1/x"},
{"http://127.0.0.1:080/x", "http://127.0.0.1/x"},
{"http://127.0.0.1:0080/x", "http://127.0.0.1/x"},
{"http://127.0.0.1:80", "http://127.0.0.1"},
{"http://127.0.0.1:0081/x", "http://127.0.0.1:0081/x"},
{"http://127.0.0.1:81/x", "http://127.0.0.1:81/x"},
{"http://127.0.0.1:8080/x", "http://127.0.0.1:8080/x"},
{"http://127.0.0.1:4294967376/x", "http://127.0.0.1:4294967376/x"},
{"http://127.0.0.1/x", "http://127.0.0.1/x"},
};
size_t k;
(void) opt;
(void) argc;
(void) argv;
for (k = 0; k < sizeof(cases) / sizeof(cases[0]); k++) {
char BIGSTK buff[HTS_URLMAXSIZE * 2];
strcpybuff(buff, cases[k].in);
hts_strip_default_port(buff, sizeof(buff));
assertf(strcmp(buff, cases[k].out) == 0);
}
printf("stripport self-test OK\n");
return 0;
}
/* Extra args are key=value: adr= cdispo= statuscode= status= strip= urlhack=
no-www= no-slash= no-query= n83= type=, plus repeatable prior=adr|fil|sav
registering an already-crawled link (dedup/collision paths). */
@@ -2109,6 +2179,15 @@ static int st_dns(httrackp *opt, int argc, char **argv) {
return err;
}
static int st_dnstimeout(httrackp *opt, int argc, char **argv) {
const int err = dns_timeout_selftests(opt);
(void) argc;
(void) argv;
printf("dns-timeout-selftest: %s\n", err ? "FAIL" : "OK");
return err;
}
static int st_cookies(httrackp *opt, int argc, char **argv) {
static t_cookie cookie;
char hdr[1024];
@@ -2433,6 +2512,45 @@ static int st_makeindex(httrackp *opt, int argc, char **argv) {
return 0;
}
// hts_buildtopindex takes a system-charset path but verif_backblue() below it
// expects utf-8, so on Windows a non-ASCII project dir gets the gifs written to
// a mangled twin (issues #216/#217). argv[0] is a writable dir.
static int st_topindex(httrackp *opt, int argc, char **argv) {
char projdir[HTS_URLMAXSIZE];
char path[HTS_URLMAXSIZE + 16]; /* projdir plus a basename */
#ifdef _WIN32
/* the GUI hands hts_buildtopindex an ANSI path; mimic it. CP1252 'cafe' */
static const char *const projName = "caf\xE9";
#else
/* POSIX system charset is UTF-8 */
static const char *const projName = "caf\xC3\xA9";
#endif
assertf(argc >= 1);
snprintf(projdir, sizeof(projdir), "%s/%s", argv[0], projName);
/* structcheck(), not the utf-8 MKDIR family: same charset as buildtopindex */
snprintf(path, sizeof(path), "%s/", projdir);
assertf(structcheck(path) == 0);
/* returns 0 here: the dir holds no sub-project, only the gifs matter */
(void) hts_buildtopindex(opt, projdir, "");
/* the gifs must land in the project dir itself, not in a mangled sibling */
snprintf(path, sizeof(path), "%s/backblue.gif", projdir);
assertf(fexist(path));
/* raw unlink/rmdir: UNLINK is utf-8 on Windows, these paths aren't */
unlink(path);
snprintf(path, sizeof(path), "%s/fade.gif", projdir);
unlink(path);
snprintf(path, sizeof(path), "%s/index.html", projdir);
unlink(path);
rmdir(projdir);
printf("topindex self-test OK\n");
return 0;
}
/* Each inplace_escape_*() must equal escape_*() on a copy. */
static int st_inplace_escape(httrackp *opt, int argc, char **argv) {
/* >255 bytes forces the helper's malloct path, not the stack buffer */
@@ -3070,6 +3188,7 @@ static const struct selftest_entry {
{"filterbounds", "", "matcher length/work caps reject hostile patterns",
st_filterbounds},
{"simplify", "<path>", "collapse ./ and ../ in a path", st_simplify},
{"expandhome", "<path>", "expand a leading ~/ into $HOME", st_expandhome},
{"stripquery", "", "--strip-query pattern/key stripping self-test",
st_stripquery},
{"urlhack", "", "-%u url-hack sub-flag (www/slash/query) self-test",
@@ -3105,6 +3224,8 @@ static const struct selftest_entry {
st_socks5},
{"identabs", "", "ident_url_absolute one-byte fil[] overflow self-test",
st_identabs},
{"stripport", "", "default :80 port strip preserves host (#627)",
st_stripport},
{"header", "<raw-header-line> ...", "response header-line parsing",
st_header},
{"headerlong", "[header-name:]",
@@ -3133,10 +3254,15 @@ static const struct selftest_entry {
{"cache-corrupt", "<dir>", "cache read-side corruption self-test",
st_cache_corrupt},
{"dns", "", "DNS resolver/cache self-test", st_dns},
{"dnstimeout", "", "a slow DNS resolve is bounded and holds no lock",
st_dnstimeout},
{"cookies", "", "cookie request-header self-test", st_cookies},
{"useragent", "", "default User-Agent self-test", st_useragent},
{"makeindex", "[dir]", "hts_finish_makeindex footer/refresh self-test",
st_makeindex},
{"topindex", "[dir]",
"hts_buildtopindex charset handling of a non-ASCII project dir",
st_topindex},
{"inplace-escape", "", "inplace_escape_* vs escape_* equivalence self-test",
st_inplace_escape},
{"escape-room", "", "HT_ADD_HTMLESCAPED* reservation-factor self-test",

View File

@@ -164,13 +164,12 @@ HTS_UNUSED static int linputsoc_t(T_SOC soc, char *s, int max, int timeout) {
return -1;
}
/* Same contract as hts_gethome(), which is hidden and out of reach from here */
static const char *gethomedir(void) {
const char *home = getenv("HOME");
if (home)
return home;
else
return ".";
/* An empty $HOME would put the base path and httrack.ini at the root */
return strnotempty(home) ? home : ".";
}
static int linput_cpp(FILE * fp, char *s, int max) {
int rlen = 0;

View File

@@ -896,7 +896,20 @@ HTSEXT_API int hts_buildtopindex(httrackp * opt, const char *path,
if (fpo) {
find_handle h;
verif_backblue(opt, concat(catbuff, sizeof(catbuff), rpath, "/")); // générer gif
// générer gif. verif_backblue() is utf-8, but our path is the system
// charset, so on Windows convert it or the gifs land in a mangled twin
// dir (#217). Elsewhere the system charset is already utf-8.
#ifdef _WIN32
{
const char *const base = concat(catbuff, sizeof(catbuff), rpath, "/");
char *const base_utf8 =
hts_convertStringSystemToUTF8(base, strlen(base));
verif_backblue(opt, base_utf8 != NULL ? base_utf8 : base);
free(base_utf8);
}
#else
verif_backblue(opt, concat(catbuff, sizeof(catbuff), rpath, "/"));
#endif
// Header
hts_template_format(fpo, toptemplate_header,
"<!-- Mirror and index made by HTTrack Website Copier/"

50
src/htsurlport.c Normal file
View File

@@ -0,0 +1,50 @@
/* ------------------------------------------------------------ */
/*
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) 1998 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: TCP port parser, shared by the engine, htsserver and */
/* proxytrack */
/* Author: Xavier Roche */
/* ------------------------------------------------------------ */
#include "htsurlport.h"
#include <ctype.h>
#include <stdlib.h>
hts_boolean hts_parse_url_port(const char *a, int *port) {
char *end;
long p;
if (!isdigit((unsigned char) *a)) // strtol would eat a sign or leading space
return HTS_FALSE;
p = strtol(a, &end, 10);
if (*end != '\0' || p < 1 || p > 65535) // ERANGE lands out of range too
return HTS_FALSE;
*port = (int) p;
return HTS_TRUE;
}

45
src/htsurlport.h Normal file
View File

@@ -0,0 +1,45 @@
/* ------------------------------------------------------------ */
/*
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) 1998 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: TCP port parser, shared by the engine, htsserver and */
/* proxytrack */
/* Author: Xavier Roche */
/* ------------------------------------------------------------ */
#ifndef HTSURLPORT_DEFH
#define HTSURLPORT_DEFH
#include "htsglobal.h"
/* Parse the port text "a" (after the ':', up to the end of the string): TRUE
and *port set for a bare decimal in 1..65535, else FALSE and *port left
alone. Not sscanf("%d"), which range-checks nothing and wraps past INT_MAX.
Its own file so proxytrack, which does not link the library, can share it. */
hts_boolean hts_parse_url_port(const char *a, int *port);
#endif

View File

@@ -63,6 +63,7 @@ Please visit our Website: http://www.httrack.com
#include "md5.c"
#include "htsserver.h"
#include "htsurlport.h"
#include "htsweb.h"
#if USE_BEGINTHREAD==0
@@ -257,8 +258,10 @@ int main(int argc, char *argv[]) {
/* set commandline keys */
for(i = 2; i < argc; i += 2) {
if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
if (sscanf(argv[i + 1], "%d", &defaultPort) != 1 || defaultPort < 0
|| defaultPort >= 65535) {
// the range check ran after sscanf("%d") had wrapped a huge value into a
// plausible port, and listened there (#614). 0 (was the auto-pick) and
// 65535 (was refused, off by one) now both mean what they say.
if (!hts_parse_url_port(argv[i + 1], &defaultPort)) {
fprintf(stderr, "couldn't set the port number to %s\n", argv[i + 1]);
return -1;
}

View File

@@ -121,6 +121,7 @@
<ClCompile Include="htshelp.c" />
<ClCompile Include="htsindex.c" />
<ClCompile Include="htslib.c" />
<ClCompile Include="htsurlport.c" />
<ClCompile Include="htsmd5.c" />
<ClCompile Include="htsmodules.c" />
<ClCompile Include="htsname.c" />

View File

@@ -47,7 +47,9 @@ static void sig_brpipe(int code) {
}
#endif
static int scanHostPort(const char *str, char *host, int *port) {
// split a "host:port" listen argument; FALSE sends the caller to the usage
// screen. The port was unchecked, so a huge one wrapped into range (#614).
static hts_boolean scanHostPort(const char *str, char *host, int *port) {
char *pos = strrchr(str, ':');
if (pos != NULL) {
@@ -56,12 +58,10 @@ static int scanHostPort(const char *str, char *host, int *port) {
if (n < 256) {
host[0] = '\0';
strncat(host, str, n);
if (sscanf(pos + 1, "%d", port) == 1) {
return 1;
}
return hts_parse_url_port(pos + 1, port);
}
}
return 0;
return HTS_FALSE;
}
int main(int argc, char *argv[]) {

View File

@@ -107,13 +107,12 @@ HTS_UNUSED static void proxytrack_print_log(const char *severity, const char *fo
"<!-- _-._.--._._-._.--._._-._.--._._-._.--._._-._.--._. -->\r\n" \
"<!-- End Disable IE Friendly HTTP Error Messages -->\r\n"
/* Same contract as hts_gethome(): proxytrack does not link libhttrack */
HTS_UNUSED static const char *gethomedir(void) {
const char *home = getenv("HOME");
if (home)
return home;
else
return ".";
/* An empty $HOME would resolve a relative path against the root */
return strnotempty(home) ? home : ".";
}
HTS_UNUSED static int linput(FILE * fp, char *s, int max) {

View File

@@ -99,6 +99,7 @@
<ClCompile Include="proxy\main.c" />
<ClCompile Include="proxy\proxytrack.c" />
<ClCompile Include="proxy\store.c" />
<ClCompile Include="htsurlport.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, 12, 0
PRODUCTVERSION 3, 49, 12, 0
FILEVERSION 3, 49, 13, 0
PRODUCTVERSION 3, 49, 13, 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.12"
VALUE "FileVersion", "3.49.13"
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-12"
VALUE "ProductVersion", "3.49-13"
END
END
BLOCK "VarFileInfo"

View File

@@ -33,6 +33,19 @@ function log {
return 0
}
# Map a POSIX locale ("zh_TW.UTF-8@euro") to its lang.indexes number, English (1) if unknown
function lang_index {
local locale=$1 indexes=$2 tag n t
tag=$(echo "${locale}" | cut -f1 -d'.' | cut -f1 -d'@' | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z_')
# a few languages carry a region-specific entry (zh_tw, pt_br); else use the bare language
for t in "${tag}" "${tag%%_*}"; do
n=$(grep -E "^${t}:" "${indexes}" | cut -f2 -d':')
test -n "${n}" && break
done
test -n "${n}" || n=1
echo "${n}"
}
function launch_browser {
log "Launching $1"
browser=$1
@@ -59,13 +72,11 @@ test -f "${DISTPATH}/lang.indexes" || ! log "Could not find ${DISTPATH}/lang.ind
test -d "${DISTPATH}/lang" || ! log "Could not find ${DISTPATH}/lang" || exit 1
test -d "${DISTPATH}/html" || ! log "Could not find ${DISTPATH}/html" || exit 1
# Locale
HTSLANG="${LC_MESSAGES}"
! test -n "${HTSLANG}" && HTSLANG="${LC_ALL}"
# Locale: POSIX precedence, LC_ALL overrides LC_MESSAGES overrides LANG
HTSLANG="${LC_ALL}"
! test -n "${HTSLANG}" && HTSLANG="${LC_MESSAGES}"
! test -n "${HTSLANG}" && HTSLANG="${LANG}"
HTSLANG="$(echo "$LANG" | cut -f1 -d'.' | cut -f1 -d'_')"
LANGN=$(grep -E "^${HTSLANG}:" "${DISTPATH}/lang.indexes" | cut -f2 -d':')
! test -n "${LANGN}" && LANGN=1
LANGN=$(lang_index "${HTSLANG}" "${DISTPATH}/lang.indexes")
# Find the browser
# note: not all systems have sensible-browser or www-browser alternative

View File

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

View File

@@ -54,6 +54,13 @@ refused() {
! echo "FAIL: $1 (exit $RC)" || exit 1
}
# assert continue mode was entered: it drops the URL list, so with no cache to
# resume the run ends on the usage screen rather than on any other error
continued() {
{ test "$RC" -ne 0 && grep -q 'usage:' "$1/.log"; } ||
! echo "FAIL: $2 (exit $RC)" || exit 1
}
# a value past the old 126/256 caps but within the cap is accepted, on both the
# short and long form of each option
long=$(nchars 900)
@@ -102,4 +109,50 @@ for bad in nan nan:5 5:nan inf 10:5 99999; do
refused "#185: invalid --pause '$bad' not refused cleanly"
done
# An option is not -i (continue) merely because its name contains an 'i' (#615).
# These used to wipe the URL given before them and exit on the usage screen.
run "$tmp/ord-bti" --build-top-index
accepted "$tmp/ord-bti" "#615: --build-top-index after the URL wiped the URL list"
run "$tmp/ord-bti-s" "-%i"
accepted "$tmp/ord-bti-s" "#615: -%i after the URL wiped the URL list"
run "$tmp/ord-proto" --protocol 2
accepted "$tmp/ord-proto" "#615: --protocol after the URL wiped the URL list"
run "$tmp/ord-proto-s" "-@i2"
accepted "$tmp/ord-proto-s" "#615: -@i2 after the URL wiped the URL list"
# %, &, @ and # take the letter after them into another option set, wherever
# they sit in the cluster, so the i of -q%i is not the main-set -i either.
run "$tmp/ord-qpi" "-q%i"
accepted "$tmp/ord-qpi" "#615: -q%i after the URL wiped the URL list"
run "$tmp/ord-qai" "-q@i"
accepted "$tmp/ord-qai" "#615: -q@i after the URL wiped the URL list"
# -%q only forces quiet mode, which nothing here can see: a run whose stdout is
# not a tty is quiet from the start (htscoremain.c:174). Just check it crawls.
run "$tmp/ord-iqs" "-%q"
accepted "$tmp/ord-iqs" "#615: -%q after the URL broke the crawl"
# The real -i still forces continue mode and drops the URL list, in every form:
# a fix that merely stopped looking for 'i' would pass the checks above.
run "$tmp/cont-s" -i
continued "$tmp/cont-s" "#615: -i after the URL no longer forces continue mode"
run "$tmp/cont-c" -iC2
continued "$tmp/cont-c" "#615: -iC2 after the URL no longer forces continue mode"
run "$tmp/cont-l" --continue
continued "$tmp/cont-l" "#615: --continue after the URL no longer forces continue mode"
run "$tmp/cont-u" --update
continued "$tmp/cont-u" "#615: --update after the URL no longer forces continue mode"
# ...including where another set's letter precedes it in the cluster.
run "$tmp/cont-pq" "-%qi"
continued "$tmp/cont-pq" "#615: -%qi after the URL no longer forces continue mode"
run "$tmp/cont-pm" "-%Mi"
continued "$tmp/cont-pm" "#615: -%Mi after the URL no longer forces continue mode"
# Placed before the URL these always worked; they must keep working.
run_only "$tmp/pre-bti" "-%i" "file://$tmp/index.html"
accepted "$tmp/pre-bti" "#615: -%i before the URL broke the crawl"
run_only "$tmp/pre-proto" "-@i2" "file://$tmp/index.html"
accepted "$tmp/pre-proto" "#615: -@i2 before the URL broke the crawl"
exit 0

View File

@@ -0,0 +1,15 @@
#!/bin/bash
#
set -euo pipefail
# A resolve against a deliberately slow mock getaddrinfo (no network) must
# return on --timeout, must not hold opt->state.lock meanwhile, and must not
# cache the timeout as an answer (#606).
# 'run' is an ignored placeholder argument.
out=$(httrack -#test=dnstimeout run)
test "$out" = "dns-timeout-selftest: OK" || {
echo "expected 'dns-timeout-selftest: OK', got: $out" >&2
exit 1
}

View File

@@ -0,0 +1,53 @@
#!/bin/bash
#
# SC2088: the literal, unexpanded '~' is the input under test.
# shellcheck disable=SC2088
set -euo pipefail
# ~ expansion for the -O base path (expand_home). $HOME is pinned so the cases
# cannot depend on the caller's, but MSYS rewrites it into a Windows path
# before the native httrack.exe reads it: ask the engine what it saw rather
# than hardcoding the value.
ask() {
HOME=HTSHOME httrack -O /dev/null -#test=expandhome "$1"
}
exp() {
test "$(ask "$1")" == "expanded=$2" || exit 1
}
home=$(ask '~')
home=${home#expanded=}
# or every case below is vacuous: '~' means expansion never fired, '.' means it
# fell back to the default without ever reading $HOME
test "$home" != '~' || exit 1
test "$home" != '.' || exit 1
exp '~' "$home"
exp '~/foo' "$home/foo"
exp '~/foo/bar/#' "$home/foo/bar/#"
exp '~/' "$home/"
exp '~/../x' "$home/../x"
# ~user/ needs getpwnam: left alone rather than mangled into $HOME + "user/"
exp '~smith/foo' '~smith/foo'
exp '~root' '~root'
# only a leading '~' expands; anything else is a literal path component
exp 'a~/x' 'a~/x'
exp './~/x' './~/x'
exp 'foo~bar' 'foo~bar'
# an unset or empty $HOME must not turn ~/foo into the absolute /foo
test "$(env -u HOME httrack -O /dev/null -#test=expandhome '~/foo')" == "expanded=./foo" || exit 1
test "$(HOME='' httrack -O /dev/null -#test=expandhome '~/foo')" == "expanded=./foo" || exit 1
# A $HOME past the 2 * HTS_URLMAXSIZE buffer is left alone: strcatbuff aborts
# rather than truncates. Only meaningful where the engine sees the value we set:
# MSYS rewrites HOME into a path of its own choosing, long or not.
if test "$home" = HTSHOME; then
long=$(printf '%04096d' 0 | tr '0' 'A')
test "$(HOME="$long" httrack -O /dev/null -#test=expandhome '~/foo')" == "expanded=~/foo" || exit 1
fi

View File

@@ -71,3 +71,23 @@ p 'http://a]b:c@host' 'name=http://a]b:c@host port=8080 host=host kind=http'
# a lone ':' must not underflow the backward scan
p ':' 'name= port=8080 host= kind=http'
# a port is *DIGIT in 1..65535, else the scheme default: sscanf("%d") used to
# wrap past INT_MAX and hand back a garbage port (#602)
p 'http://host:65535' 'name=http://host port=65535 host=host kind=http'
p 'http://host:1' 'name=http://host port=1 host=host kind=http'
p 'http://host:65536' 'name=http://host port=8080 host=host kind=http'
p 'http://host:2147483648' 'name=http://host port=8080 host=host kind=http'
p 'http://host:4294967296' 'name=http://host port=8080 host=host kind=http'
p 'http://host:99999999999999' 'name=http://host port=8080 host=host kind=http'
# discriminating: this one wrapped to a plausible 80, so range-checking the
# sscanf result instead of rejecting the overflow still passes everything above
p 'http://host:4294967376' 'name=http://host port=8080 host=host kind=http'
p 'http://host:999999999999999999999999999999' 'name=http://host port=8080 host=host kind=http'
p 'http://host:0' 'name=http://host port=8080 host=host kind=http'
p 'http://host:-1' 'name=http://host port=8080 host=host kind=http'
p 'http://host:80x' 'name=http://host port=8080 host=host kind=http'
p 'http://host: 80' 'name=http://host port=8080 host=host kind=http'
p 'http://host:' 'name=http://host port=8080 host=host kind=http'
# the fallback is the scheme's own default, not a hardcoded 8080
p 'socks5://host:99999999999999' 'name=socks5://host port=1080 host=host kind=socks'

12
tests/01_engine-topindex.test Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
#
set -euo pipefail
# hts_buildtopindex takes a system-charset path but verif_backblue below it
# expects utf-8, mangling a non-ASCII project dir on Windows (#216, #217).
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
httrack -O /dev/null -#test=topindex "$dir" run |
grep -q "topindex self-test OK"

View File

@@ -9,21 +9,23 @@ set -euo pipefail
# drives both refusal paths and the accept path.
out="$(httrack -O /dev/null -#test=xfread-limit)"
# Match with here-strings, not `echo | grep -q`: under pipefail the early grep
# exit SIGPIPEs echo and fails the pipeline even when the pattern matched.
for case in bylen bygrow; do
echo "$out" | grep -q "${case}: refused=1 adr=null msg=In-memory content too large" || {
grep -q "${case}: refused=1 adr=null msg=In-memory content too large" <<<"$out" || {
echo "FAIL ${case}: $out"
exit 1
}
done
# Exactly INT32_MAX must be refused too (the reallocs add 1).
echo "$out" | grep -q 'boundary: msg=In-memory content too large' || {
grep -q 'boundary: msg=In-memory content too large' <<<"$out" || {
echo "FAIL boundary: $out"
exit 1
}
# The guard must NOT fire for a legitimate small size.
if echo "$out" | grep -q 'accept: msg=In-memory content too large'; then
if grep -q 'accept: msg=In-memory content too large' <<<"$out"; then
echo "FAIL accept (guard fired on a legit size): $out"
exit 1
fi

View File

@@ -165,6 +165,13 @@ auth="$tmpdir/auth"
start_server "$auth" ok
run_crawl "$auth/out" "http://${host}:${http_port}/" \
"socks5://user:secret@127.0.0.1:${socks_port}"
# name a rejected version byte here: the crawl check below only knows the
# handshake failed, not why
grep -q "^AUTHVER-BAD" "$auth/socks.log" && {
echo "FAIL: sub-negotiation version was not RFC 1929's 0x01" >&2
cat "$auth/socks.log" >&2
exit 1
}
grep -rq "ORIGIN-PAGE-563" "$auth/out" || {
echo "FAIL: origin not downloaded through the authenticated socks proxy" >&2
cat "$auth/out.log" "$auth/socks.log" >&2

View File

@@ -0,0 +1,95 @@
#!/bin/bash
#
# Issue #607: a peer that accepts the TCP connect but never speaks TLS leaves the
# slot stuck in the handshake. The per-slot --timeout must reap it; before the
# fix only --max-time did, so these crawls ran until the kill guard.
set -euo pipefail
: "${top_srcdir:=..}"
# shellcheck source=tests/testlib.sh
. "$top_srcdir/tests/testlib.sh"
if test "${HTTPS_SUPPORT:-}" == "no"; then
echo "no https support compiled, skipping"
exit 77
fi
python=$(find_python) || {
echo "python3 missing, skipping"
exit 77
}
server=$(nativepath "$top_srcdir/tests/tls-stall-server.py")
tmpdir=$(mktemp -d)
serverpid=
cleanup() {
stop_server "$serverpid"
rm -rf "$tmpdir"
return 0
}
trap cleanup EXIT
# start_stall_server <tag> <mode-args...>: sets $port from the announced one.
start_stall_server() {
local tag="$1"
shift
"$python" "$server" "$@" >"$tmpdir/$tag.out" 2>"$tmpdir/$tag.err" &
serverpid=$!
port=
for _ in $(seq 1 50); do
line=$(head -n1 "$tmpdir/$tag.out" 2>/dev/null || true)
if test "${line%% *}" == "PORT"; then
port="${line#PORT }"
return 0
fi
kill -0 "$serverpid" 2>/dev/null || {
echo "server exited early: $(cat "$tmpdir/$tag.err")"
exit 1
}
sleep 0.1
done
echo "could not discover server port"
exit 1
}
# no -E/--max-time on purpose: --timeout is the only thing that can end these.
# The kill guard stands in for the hang, so a wall time near it means no reap.
crawl_wall() {
local out="$tmpdir/$1"
shift
local start
start=$(date +%s)
run_with_timeout 60 httrack -O "$out" -c1 --robots=0 --retries=0 --quiet -Z \
"$@" >>"$tmpdir/log" 2>&1 || true
echo $(($(date +%s) - start))
}
# 1. handshake stalled from the first byte: reaped at --timeout, not before.
start_stall_server direct direct
wall=$(crawl_wall crawl1 "https://127.0.0.1:$port/" --timeout=5)
if test "$wall" -ge 30 || test "$wall" -lt 3; then
echo "FAIL: stalled handshake reaped after ${wall}s, expected about 5s" >&2
cat "$tmpdir/log" >&2
exit 1
fi
grep -q 'Handshake Time Out' "$tmpdir/crawl1/hts-log.txt" || {
echo "FAIL: crawl ended in ${wall}s but not on a handshake timeout" >&2
cat "$tmpdir/crawl1/hts-log.txt" >&2
exit 1
}
echo "OK: stalled TLS handshake reaped by --timeout after ${wall}s"
# 2. the handshake window is its own, not what the connect left over: a proxy
# that takes 4s to answer CONNECT must still leave the full --timeout=5 for the
# handshake (~9s total). Sharing the connect's clock would reap at ~5s.
stop_server "$serverpid"
start_stall_server proxy proxy 4
wall=$(crawl_wall crawl2 "https://127.0.0.1:443/" -P "127.0.0.1:$port" --timeout=5)
if test "$wall" -ge 20 || test "$wall" -lt 7; then
echo "FAIL: handshake after a slow connect reaped at ${wall}s, expected about 9s" >&2
cat "$tmpdir/log" >&2
exit 1
fi
echo "OK: handshake keeps its own timeout window after a slow connect (${wall}s)"

View File

@@ -0,0 +1,46 @@
#!/bin/bash
#
# Unit-tests dump_crawl_logs (testlib.sh): the killed test's hts-log.txt must
# reach the uploaded logs, or a Windows wedge past --max-time stays
# undiagnosable (#605).
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/httrack_salv.XXXXXX")
export TMPDIR
trap 'rm -rf "$TMPDIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
verdict="More than 120 seconds passed.. giving up"
# Vacuity control: with nothing left behind the assertions below cannot pass on
# ambient output.
test -z "$(dump_crawl_logs)" || fail "dumped something with no crawl tmpdir"
# A killed local-crawl.sh leaves exactly this shape.
d="${TMPDIR}/httrack_local.probe"
mkdir -p "$d/crawl"
echo "$verdict" >"$d/crawl/hts-log.txt"
echo "httrack stdout marker" >"$d/log"
# Append to an unterminated line, as a killed test's own log always ends.
art="${TMPDIR}/artifact.log"
printf '[running httrack ...] ..\t' >"$art"
dump_crawl_logs >>"$art"
grep -qF "$verdict" "$art" || fail "hts-log.txt verdict not salvaged"
grep -qF "httrack stdout marker" "$art" || fail "httrack stdout not salvaged"
grep -q '^--- .*hts-log.txt' "$art" || fail "dump glued its header onto the unterminated line"
# Cleared, so the next timing-out test cannot re-report this crawl as its own.
test ! -d "$d" || fail "salvaged tmpdir left behind"
test -z "$(dump_crawl_logs)" || fail "salvaged crawl reported twice"
echo "crawl log salvage OK"

View File

@@ -0,0 +1,91 @@
#!/bin/bash
#
# webhttrack must map the caller's locale to the right lang.indexes number.
# Guards: LC_ALL/LC_MESSAGES were ignored, and zh_tw/pt_br were unreachable.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
distdir=$(cd "${testdir}/.." && pwd)
script="${distdir}/src/webhttrack"
fail() {
echo "FAIL: $*" >&2
exit 1
}
test -r "${script}" || fail "no ${script}"
test -r "${distdir}/lang.indexes" || fail "no ${distdir}/lang.indexes"
# Lift the locale code out of the launcher, so this tests the shipped logic itself.
funcs=$(sed -n '/^function lang_index/,/^}/p' "${script}")
# End on the next section, not a blank line: a blank line grown inside the block
# would truncate the lift and silently stop testing everything below it.
grep -q '^# Find the browser' "${script}" || fail "locale block terminator moved in ${script}"
block=$(sed -n '/^# Locale/,/^# Find the browser/p' "${script}" | sed '$d')
echo "${block}" | grep -q 'LANGN=.*lang_index' || fail "could not lift the whole locale block from ${script}"
# Run the lifted block against whatever locale vars the caller exported.
run_block() {
# shellcheck disable=SC2034 # read by the eval'd locale block
DISTPATH="${distdir}"
# the launcher runs under neither -e ("! test -n x && y" returns 1) nor -u
set +eu
eval "${funcs}"
eval "${block}"
# shellcheck disable=SC2153 # LANGN is set by the eval'd block, not a LANG typo
echo "${LANGN}"
}
# Resolve LANG/LC_ALL/LC_MESSAGES (empty = unset) to the language number.
lang_of() {
(
unset LANG LC_ALL LC_MESSAGES
# only the value matters here; hide bash's setlocale gripe when the locale is not installed
# shellcheck disable=SC2030 # confining the locale to this subshell is the point
{
if test -n "$1"; then export LANG="$1"; fi
if test -n "$2"; then export LC_ALL="$2"; fi
if test -n "$3"; then export LC_MESSAGES="$3"; fi
} 2>/dev/null
run_block
)
}
check() {
local want=$1 got desc
shift
desc="LANG='$1' LC_ALL='$2' LC_MESSAGES='$3'"
got=$(lang_of "$1" "$2" "$3")
test "${got}" = "${want}" || fail "${desc}: want ${want}, got ${got}"
echo "ok: ${desc} -> ${got}"
}
# want LANG LC_ALL LC_MESSAGES
check 2 "fr_FR.UTF-8" "" ""
check 14 "zh_TW.UTF-8" "" "" # region-qualified: Chinese-BIG5, not Simplified (13)
check 12 "pt_BR.UTF-8" "" "" # region-qualified: Portugues-Brasil, not Portugues (7)
check 13 "zh_CN.UTF-8" "" "" # unlisted region falls back to the bare language
check 24 "sl_SI.UTF-8" "" "" # Slovenian is ISO 639-1 "sl"
check 4 "" "de_DE.UTF-8" "" # LC_ALL alone
check 4 "" "" "de_DE.UTF-8" # LC_MESSAGES alone
check 4 "fr_FR.UTF-8" "de_DE.UTF-8" "" # LC_ALL wins over LANG
check 4 "fr_FR.UTF-8" "" "de_DE.UTF-8" # LC_MESSAGES wins over LANG
check 2 "de_DE.UTF-8" "fr_FR.UTF-8" "de_DE.UTF-8" # LC_ALL wins over LC_MESSAGES
check 2 "fr_FR@euro" "" "" # @modifier without a charset
check 1 "C" "" "" # C, unknown and unset all mean English
check 1 "xx_YY.UTF-8" "" ""
check 1 "" "" ""
# Exported but empty is unset (POSIX), so it must not mask LANG. lang_of cannot
# express this: it exports nothing for an empty argument.
got=$(
unset LANG LC_ALL LC_MESSAGES
# shellcheck disable=SC2031 # confining the locale to this subshell is the point
{ export LANG="fr_FR.UTF-8" LC_ALL="" LC_MESSAGES=""; } 2>/dev/null
run_block
)
test "${got}" = 2 || fail "empty LC_ALL/LC_MESSAGES must not mask LANG: want 2, got ${got}"
echo "ok: LANG='fr_FR.UTF-8' with LC_ALL='' exported -> ${got}"
echo "PASS"

View File

@@ -0,0 +1,90 @@
#!/bin/bash
#
# lang/*.txt are line pairs: an English msgid, then its translation. A stray
# blank or a drifted msgid silently unhooks every translation that follows,
# rather than failing, so assert the pairing and the join against English.txt.
# Pairs are physical lines here; a msgid ending in \ continues onto the next one
# for the engine (linput_cpp) but not for us. None do today.
set -euo pipefail
langdir="${top_srcdir:-..}/lang"
def="${top_srcdir:-..}/lang.def"
eng="$langdir/English.txt"
for f in "$eng" "$def"; do
[ -f "$f" ] || {
echo "cannot find $f"
exit 1
}
done
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
keys="$tmp/english.keys"
# Byte-wise: each file is in its own declared legacy charset, lang.def is CRLF.
LC_ALL=C awk 'NR%2==1 { sub(/\r$/, ""); print }' "$eng" >"$keys"
# An empty parse would make every check below pass vacuously.
nkeys=$(wc -l <"$keys")
if [ "$nkeys" -lt 400 ]; then
echo "only $nkeys msgids parsed from $eng; the parse is broken"
exit 1
fi
nfiles=0
fail=0
for f in "$langdir"/*.txt; do
nfiles=$((nfiles + 1))
LC_ALL=C awk -v keys="$keys" -v name="${f##*/}" '
BEGIN { while ((getline k < keys) > 0) known[k] = 1 }
{ sub(/\r$/, "") }
NR % 2 == 1 {
if ($0 == "")
printf "%s:%d: blank line where an English msgid belongs\n", name, NR
else if (!($0 in known))
printf "%s:%d: msgid absent from English.txt: %s\n", name, NR, $0
else
next
bad++
}
END {
if (NR % 2) {
printf "%s: odd line count (%d): msgid/translation pairing is broken\n", name, NR
bad++
}
exit(bad > 0)
}
' "$f" || fail=1
done
if [ "$nfiles" -lt 25 ]; then
echo "only $nfiles language files found in $langdir"
exit 1
fi
# lang.def maps each LANG_* macro to the English string the GUI compiles in,
# which is the lookup key into every lang/*.txt: no msgid, no translations.
LC_ALL=C awk -v keys="$keys" '
BEGIN { while ((getline k < keys) > 0) known[k] = 1 }
{ sub(/\r$/, "") }
NR % 2 == 1 { macro = $0; next }
macro ~ /^(LANG_|LISTDEF_)/ {
n++
if (!($0 in known)) {
printf "lang.def:%d: %s maps to \"%s\", which is not a msgid in English.txt\n", NR, macro, $0
bad++
}
}
END {
if (n < 400) {
printf "lang.def: only %d LANG_ entries parsed; the parse is broken\n", n
bad++
}
exit(bad > 0)
}
' "$def" || fail=1
[ "$fail" -eq 0 ] || exit 1
echo "$nfiles language files consistent with $nkeys English msgids"

View File

@@ -0,0 +1,88 @@
#!/bin/bash
#
# htsserver's $HOME lookup, through the web UI it actually renders. An exported
# but empty $HOME must not resolve the default base path against the root.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
distdir=${top_srcdir:-$(cd "${testdir}/.." && pwd)}
distdir=$(cd "${distdir}" && pwd)
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v htsserver >/dev/null || fail "no htsserver in PATH"
# Mirror local-crawl.sh's skip-with-77: the Debian buildd chroot has no python3.
command -v python3 >/dev/null || {
echo "python3 not found; skipping" >&2
exit 77
}
srv=
cleanup() {
test -z "${srv}" || kill -9 "${srv}" 2>/dev/null || true
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
log=$(mktemp)
trap 'cleanup; rm -f "${log}"' EXIT
# Ask htsserver for its rendered default base path, under the given $HOME
# ("-" = leave $HOME unset).
pathfor() {
local homeval=$1 port url got
port=$(python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()')
: >"${log}"
(
# htsserver ignores SIGTERM/SIGTTOU handling from the harness shell
trap '' TERM TTOU
if test "${homeval}" = -; then unset HOME; else export HOME="${homeval}"; fi
exec htsserver "${distdir}/" --port "${port}" >"${log}" 2>&1
) &
srv=$!
for _ in $(seq 1 40); do
url=$(sed -n 's/^URL=//p' "${log}" 2>/dev/null) && test -n "${url}" && break
kill -0 "${srv}" 2>/dev/null || break
sleep 0.25
done
test -n "${url:-}" || fail "htsserver did not come up: $(cat "${log}")"
# The UI is served ISO-8859-1, so keep python out of text mode.
got=$(python3 -c 'import re, sys, urllib.request
body = urllib.request.urlopen(sys.argv[1] + "server/step2.html", timeout=20).read()
m = re.search(br"name=\"path\" value=\"([^\"]*)\"", body)
print(m.group(1).decode("latin-1") if m else "")' "${url}")
kill -9 "${srv}" 2>/dev/null || true
wait "${srv}" 2>/dev/null || true
srv=
test -n "${got}" || fail "no path field in the rendered step2.html"
echo "${got}"
}
check() {
local desc=$1 homeval=$2 want=$3 got
got=$(pathfor "${homeval}")
test "${got}" = "${want}" || fail "${desc}: want ${want}, got ${got}"
echo "ok: ${desc} -> ${got}"
}
# A real HOME must reach the page, or the two cases below are vacuous: they
# also pass when gethomedir() ignores the environment and always answers ".".
check 'a set HOME is used' /nonexistent/hts-home /nonexistent/hts-home/websites
# Exported but empty is the regression: getenv() returns "", not NULL, so a
# bare NULL check let the base path collapse onto /websites.
check 'an empty HOME' '' ./websites
check 'an unset HOME' - ./websites
echo "PASS"

13
tests/64_local-intl-outdir.test Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
#
# A non-ASCII -O output dir must receive the mirror, not a double-encoded twin
# (#621). argv is UTF-8 on Windows, so path_html was re-encoded as if it were
# the ANSI codepage, prefixing every saved file with a mangled directory. Only
# path_html is the non-ASCII "café"; path_log stays ASCII so the harness reads
# hts-log.txt/hts-cache normally and this isolates the -O encoding path.
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" --html-subdir 'café' --errors 0 --files 5 \
--found 'simple/basic.html' \
httrack 'BASEURL/simple/basic.html'

127
tests/65_port-siblings.test Normal file
View File

@@ -0,0 +1,127 @@
#!/bin/bash
#
# Port parsing outside the crawled-URL path (#614): the htsserver --port listen
# port, proxytrack's host:port listen arguments, and an ftp:// URL port. All
# three took the port from sscanf("%d"), which range-checks nothing and wraps
# past INT_MAX, so a value naming no real port silently became a plausible one.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
# run_with_timeout: timeout(1) is absent on macOS, and the listen servers block.
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/httrack_portsib.XXXXXX") || exit 1
trap 'rm -rf "$tmp"' EXIT HUP INT QUIT PIPE TERM
# A port the old code accepted only by wrapping: 4295009395 = 2^32 + 42099 and
# 4294967376 = 2^32 + 80 both fold to a plausible listen port. They are the
# load-bearing cases, since a value that merely exceeds 65535 while still
# fitting an int (65616) was already caught by the htsserver range check.
overflowing="4295009395"
# Values sscanf("%d") took as a port that no operator meant: a trailing
# character, a sign, leading whitespace, a decimal point. Each silently listened
# on the prefix it managed to scan.
malformed=("80x" "+80" " 80" "8.0" "")
# --- htsserver --port ------------------------------------------------------
# Its argv[1] is the html root; the option pairs follow. It blocks serving once
# a port is accepted, so bound it; we assert on the output, not the exit status.
websrv() {
run_with_timeout 3 htsserver "$tmp" --port "$1" >"$tmp/web.log" 2>&1 || true
}
web_refused() {
websrv "$1"
grep -q "couldn't set the port number" "$tmp/web.log" ||
! echo "FAIL: #614: htsserver --port '$1' not refused" || exit 1
# it must not have reached the listen socket on some other port
! grep -q "^URL=" "$tmp/web.log" ||
! echo "FAIL: #614: htsserver --port '$1' listened anyway" || exit 1
}
web_accepted() {
local port=$1
: >"$tmp/web.log"
# A bound server blocks, so stop it once it announces, not on a fixed
# deadline: the announce trails a local-hostname resolve that stalls on macOS,
# where a 3s kill landed mid-resolve, before the URL= line ever printed.
local had_m=
case "$-" in *m*) had_m=1 ;; esac
is_windows || set -m
htsserver "$tmp" --port "$port" >"$tmp/web.log" 2>&1 &
local pid=$!
test -n "$had_m" || is_windows || set +m
local waited=0
until grep -qE "^URL=http://.*:$port/|couldn't set the port number|Unable to initialize a temporary server" "$tmp/web.log"; do
test "$waited" -lt 20 || break
sleep 1
waited=$((waited + 1))
done
kill_tree "$pid"
wait "$pid" 2>/dev/null || true
! grep -q "couldn't set the port number" "$tmp/web.log" ||
! echo "FAIL: #614: htsserver --port '$port' wrongly refused" || exit 1
grep -qE "^URL=http://.*:$port/|Unable to initialize a temporary server" "$tmp/web.log" ||
! echo "FAIL: #614: htsserver --port '$port' never reached the listener" || exit 1
}
# this used to listen on 42099
web_refused "$overflowing"
for p in "${malformed[@]}" 0 65536 99999 -1; do
web_refused "$p"
done
# 65535 is a valid port the old "< 65535" bound refused
web_accepted 65535
# --- proxytrack <proxy-addr:port> <ICP-addr:port> --------------------------
# A bad argument falls through to the usage screen; it had no range check at
# all, so 65616 quietly listened on port 80. A valid one binds and blocks.
proxy_refused() {
run_with_timeout 3 proxytrack "127.0.0.1:$1" 127.0.0.1:3130 >"$tmp/pt.log" 2>&1 || true
grep -q "^usage:" "$tmp/pt.log" ||
! echo "FAIL: #614: proxytrack port '$1' not refused" || exit 1
}
proxy_accepted() {
run_with_timeout 3 proxytrack "127.0.0.1:$1" 127.0.0.1:3130 >"$tmp/pt.log" 2>&1 || true
! grep -q "^usage:" "$tmp/pt.log" ||
! echo "FAIL: #614: proxytrack port '$1' wrongly refused" || exit 1
}
for p in "${malformed[@]}" 65616 4294967376 "$overflowing" 0 -1; do
proxy_refused "$p"
done
proxy_accepted 45678
# --- ftp:// URL port -------------------------------------------------------
# A URL port, so it follows the crawled-URL policy: refuse the link rather than
# fold it into range. An ftp:// link in scanned HTML is not followed, so the
# seed has to come from the command line.
ftp_port() {
local out="$tmp/ftp"
rm -rf "$out"
run_with_timeout 30 httrack "ftp://127.0.0.1:$1/x" -O "$out" --quiet -n \
>/dev/null 2>&1 || true
grep -c "Invalid port: $1" "$out/hts-log.txt" 2>/dev/null || true
}
# 65616 truncated to 80 and really did connect there
for p in 65616 99999; do
test "$(ftp_port "$p")" -gt 0 ||
! echo "FAIL: #614: ftp:// port $p not refused" || exit 1
done
# a valid port still reaches the connect attempt
test "$(ftp_port 65535)" -eq 0 ||
! echo "FAIL: #614: ftp:// port 65535 wrongly refused" || exit 1
exit 0

View File

@@ -0,0 +1,11 @@
#!/bin/bash
#
set -euo pipefail
# Stripping a default :80 from a crawled link used to skip a hardcoded 3 chars
# (#627): ":080"/":0080" lost only ":80" and glued the rest onto the host
# (127.0.0.1:080/x -> 127.0.0.10/x), and a value wrapping to 80 as a 32-bit int
# (#614) was stripped as if it were the default. All assertions live in the
# engine self-test.
httrack -O /dev/null -#test=stripport | grep -q "stripport self-test OK"

View File

@@ -0,0 +1,72 @@
#!/bin/bash
#
set -euo pipefail
# #623: url_savename enforces the 236-char path ceiling by cutting the tail of
# the last segment, where the mandatory ".delayed" placeholder marker lives. A
# cut marker fails IS_DELAYED_EXT, so back_delayed_rename never renames the file
# to its final name and the download is lost. The marker must survive the cut.
# statuscode=302 status=-1 = a redirect answer still downloading: no type is
# resolved, so the name gets a ".<id>.delayed" placeholder (see 01_engine-savename).
CEIL=236
httrack_bin=$(cd "$(dirname "$(command -v httrack)")" && pwd)/httrack
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
cd "$scratch"
run() {
"$httrack_bin" -O /dev/null -#test=savename "$@" | sed -n 's/^savename: //p'
}
# A deep path ending in a long segment that overruns the ceiling.
deep="/d1$(printf 'a%.0s' {1..40})/d2$(printf 'b%.0s' {1..40})"
deep="$deep/d3$(printf 'c%.0s' {1..40})/d4$(printf 'd%.0s' {1..40})"
long="$deep/$(printf 'z%.0s' {1..90})"
out="$(run "$long" text/html statuscode=302 status=-1)"
case "$out" in
*.delayed) ;;
*)
echo "FAIL: delayed marker cut by truncation: '$out'"
exit 1
;;
esac
test "${#out}" -le "$CEIL" || {
echo "FAIL: truncated name ${#out} > $CEIL ceiling: '$out'"
exit 1
}
# #133-style hashed name: an all-hex last segment must keep the marker too (the
# ".<id>." tag is not mistaken for part of the hash).
hexseg=$(printf 'a1b2c3d4e5f60718%.0s' {1..8})
hexpath="/d1$(printf 'x%.0s' {1..30})/d2$(printf 'y%.0s' {1..30})/$hexseg"
out="$(run "$hexpath" text/html statuscode=302 status=-1)"
case "$out" in
*.delayed) ;;
*)
echo "FAIL: hashed-name marker cut by truncation: '$out'"
exit 1
;;
esac
test "${#out}" -le "$CEIL" || {
echo "FAIL: hashed name ${#out} > $CEIL ceiling: '$out'"
exit 1
}
# A non-delayed name of the same shape still truncates, with no marker to keep.
out="$(run "$long.html" text/html)"
test "${#out}" -le "$CEIL" || {
echo "FAIL: non-delayed name ${#out} > $CEIL ceiling: '$out'"
exit 1
}
case "$out" in
*.delayed)
echo "FAIL: non-delayed name grew a .delayed marker: '$out'"
exit 1
;;
esac
echo "delayed-truncate OK"

15
tests/69_local-intl-logdir.test Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
#
# A single non-ASCII -O sets both path_html and path_log to "café" (#630). The
# logs (hts-log.txt/hts-err.txt) must land there, not in an ANSI-mangled twin:
# on Windows path_log holds UTF-8 bytes the raw file calls read as the codepage.
# The --errors/--files/--log-found audits all grep logroot=café/hts-log.txt, so
# a log written to the twin fails them. POSIX has no twin, so this bites on the
# Windows CI leg (like test 64). The cache twin is a separate, larger fix.
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" --outdir-intl 'café' --errors 0 --files 5 \
--found 'simple/basic.html' \
--log-found 'mirror complete in' \
httrack 'BASEURL/simple/basic.html'

View File

@@ -3,7 +3,7 @@
# silently drop it from the dist tarball and break "make distcheck".
EXTRA_DIST = $(TESTS) crawl-test.sh run-all-tests.sh check-network.sh \
proxy-https-server.py socks5-server.py proxy-connect-server.py \
proxytestlib.py \
proxytestlib.py tls-stall-server.py \
local-crawl.sh local-server.py testlib.sh server.crt server.key \
server-root/simple/basic.html server-root/simple/link.html \
server-root/stripquery/index.html server-root/stripquery/a.html \
@@ -35,6 +35,7 @@ TESTS = \
01_engine-copyopt.test \
01_engine-crange.test \
01_engine-dns.test \
01_engine-dnstimeout.test \
01_engine-doitlog.test \
01_engine-entities.test \
01_engine-filelist.test \
@@ -58,6 +59,7 @@ TESTS = \
01_engine-pause.test \
01_engine-rcfile.test \
01_engine-reconcile.test \
01_engine-expandhome.test \
01_engine-fsize.test \
01_engine-redirect.test \
01_engine-relative.test \
@@ -69,6 +71,7 @@ TESTS = \
01_engine-status.test \
01_engine-stripquery.test \
01_engine-strsafe.test \
01_engine-topindex.test \
01_engine-urlhack.test \
01_engine-unescape-bounds.test \
01_engine-useragent.test \
@@ -136,6 +139,16 @@ TESTS = \
55_local-chunked.test \
56_local-proxy-noleak.test \
57_local-proxy-connect.test \
58_watchdog.test
58_watchdog.test \
59_local-tls-stall.test \
60_crawl-log-salvage.test \
61_webhttrack-locale.test \
62_lang-integrity.test \
63_webhttrack-home.test \
64_local-intl-outdir.test \
65_port-siblings.test \
66_engine-port80-strip.test \
67_engine-delayed-truncate.test \
69_local-intl-logdir.test
CLEANFILES = check-network_sh.cache

View File

@@ -47,6 +47,8 @@ key="${testdir}/server.key"
tls=
verbose=
html_subdir=
outdir_intl=
rerun=
rerun_args=
rerun_dead=
@@ -132,6 +134,20 @@ while test "$pos" -lt "$nargs"; do
pos=$((pos + 1))
rerun_args="${args[$pos]}"
;;
--html-subdir)
# Mirror into "$out/NAME" (path_html) but keep logs/cache in "$out"
# (path_log): a non-ASCII NAME exercises the -O path encoding (#621)
# without routing the harness-read hts-log.txt through a non-ASCII path.
pos=$((pos + 1))
html_subdir="${args[$pos]}"
;;
--outdir-intl)
# Single non-ASCII -O "$out/NAME": path_html AND path_log are NAME, so
# the logs (and the harness reads of them) go through the non-ASCII path
# (#630). Distinct from --html-subdir, which keeps path_log ASCII.
pos=$((pos + 1))
outdir_intl="${args[$pos]}"
;;
--errors | --errors-content | --files)
audit+=("${args[$pos]}" "${args[$((pos + 1))]}")
pos=$((pos + 1))
@@ -207,12 +223,26 @@ test -n "$ver" || die "could not run httrack"
out="${tmpdir}/crawl"
mkdir "$out" || die "could not create $out"
# path_html holds the mirror + index; path_log holds hts-cache/hts-log.txt.
# Default: both are "$out". --html-subdir moves path_html to "$out/NAME" while
# path_log (logroot) stays "$out"; --outdir-intl moves both to "$out/NAME".
mirrorroot="$out"
logroot="$out"
odir="$out"
if test -n "$html_subdir"; then
mirrorroot="${out}/${html_subdir}"
odir="${mirrorroot},${out}"
elif test -n "$outdir_intl"; then
mirrorroot="${out}/${outdir_intl}"
logroot="$mirrorroot"
odir="$mirrorroot"
fi
# Localhost is fast; disable the rate/bandwidth safety limits but keep a
# max-time backstop so a hang cannot wedge the suite.
declare -a moreargs=(--quiet --max-time=120 --timeout=30 --disable-security-limits --robots=0)
log="${tmpdir}/log"
info "running httrack ${hts[*]}"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" "${moreargs[@]}" "${hts[@]}" >"$log" 2>&1 &
httrack -O "$odir" --user-agent="httrack $ver local ($(uname -mrs))" "${moreargs[@]}" "${hts[@]}" >"$log" 2>&1 &
crawlpid=$!
wait "$crawlpid"
crawlres=$?
@@ -224,12 +254,12 @@ test "$crawlres" -eq 0 || ! result "httrack exited $crawlres" || {
exit 1
}
result "OK"
grep -iE "^[0-9:]*[[:space:]]Error:" "${out}/hts-log.txt" >&2
grep -iE "^[0-9:]*[[:space:]]Error:" "${logroot}/hts-log.txt" >&2
# --- optional second pass: re-mirror into the same dir (cache/update path) ----
if test -n "$rerun"; then
info "re-running httrack (update pass)"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" \
httrack -O "$odir" --user-agent="httrack $ver local ($(uname -mrs))" \
"${moreargs[@]}" "${hts[@]}" >"${log}.2" 2>&1 &
crawlpid=$!
wait "$crawlpid"
@@ -243,7 +273,7 @@ if test -n "$rerun"; then
# The update summary reports "files updated"; a fresh crawl never does. Assert
# it so a regression that bypasses the cache (re-crawls fresh) can't pass.
info "checking update used the cache"
if grep -aqE "mirror complete in .*files updated" "${out}/hts-log.txt"; then
if grep -aqE "mirror complete in .*files updated" "${logroot}/hts-log.txt"; then
result "OK"
else
result "update pass did not report cache activity"
@@ -257,7 +287,7 @@ fi
if test -n "$rerun_args"; then
read -ra extra <<<"$rerun_args"
info "re-running httrack with ${rerun_args}"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" \
httrack -O "$odir" --user-agent="httrack $ver local ($(uname -mrs))" \
"${moreargs[@]}" "${hts[@]}" "${extra[@]}" >"${log}.2" 2>&1 &
crawlpid=$!
wait "$crawlpid"
@@ -275,11 +305,11 @@ if test -n "$rerun_dead"; then
zip="${out}/hts-cache/new.zip"
test -s "$zip" || die "no cache was written by the first pass"
cp "$zip" "${tmpdir}/cache-before.zip"
cp "${out}/hts-log.txt" "${tmpdir}/log-before.txt"
cp "${logroot}/hts-log.txt" "${tmpdir}/log-before.txt"
stop_server "$serverpid"
serverpid=
info "re-running httrack against the stopped server"
httrack -O "$out" --user-agent="httrack $ver local ($(uname -mrs))" \
httrack -O "$odir" --user-agent="httrack $ver local ($(uname -mrs))" \
"${moreargs[@]}" "${hts[@]}" >"${log}.dead" 2>&1 &
crawlpid=$!
wait "$crawlpid" || true
@@ -288,7 +318,7 @@ if test -n "$rerun_dead"; then
# The dead pass must have gone through the no-data rollback, not bailed out
# before the mirror loop (which would leave the cache trivially untouched).
info "checking the dead pass hit the rollback"
if grep -aq "No data seems to have been transferred" "${out}/hts-log.txt"; then
if grep -aq "No data seems to have been transferred" "${logroot}/hts-log.txt"; then
result "OK"
else
result "rollback notice not found in hts-log.txt"
@@ -303,12 +333,12 @@ if test -n "$rerun_dead"; then
exit 1
fi
# Audits below describe the healthy crawl, not the dead pass.
cp "${tmpdir}/log-before.txt" "${out}/hts-log.txt"
cp "${tmpdir}/log-before.txt" "${logroot}/hts-log.txt"
fi
# --- discover the single host root (127.0.0.1_<port> or 127.0.0.1) -----------
hostroot=
for cand in "${out}/127.0.0.1_${port}" "${out}/127.0.0.1"; do
for cand in "${mirrorroot}/127.0.0.1_${port}" "${mirrorroot}/127.0.0.1"; do
if test -d "$cand"; then
hostroot="$cand"
break
@@ -333,19 +363,19 @@ while test "$i" -lt "${#audit[@]}"; do
--errors)
i=$((i + 1))
assert_equals "checking errors" "${audit[$i]}" \
"$(grep -iEc "^[0-9:]*[[:space:]]Error:" "${out}/hts-log.txt")"
"$(grep -iEc "^[0-9:]*[[:space:]]Error:" "${logroot}/hts-log.txt")"
;;
--errors-content)
i=$((i + 1))
total=$(grep -icE "^[0-9:]*[[:space:]]Error:" "${out}/hts-log.txt")
total=$(grep -icE "^[0-9:]*[[:space:]]Error:" "${logroot}/hts-log.txt")
# transient network failures (statuscode -2..-6) flake on busy loopback;
# the code parens are followed by " at link" or " after N retries at link"
transient=$(grep -cE '\(-[2-6]\) (at link|after )' "${out}/hts-log.txt" || true)
transient=$(grep -cE '\(-[2-6]\) (at link|after )' "${logroot}/hts-log.txt" || true)
assert_equals "checking content errors" "${audit[$i]}" "$((total - transient))"
;;
--files)
i=$((i + 1))
nFiles=$(grep -E "^HTTrack Website Copier/[^ ]* mirror complete in " "${out}/hts-log.txt" |
nFiles=$(grep -E "^HTTrack Website Copier/[^ ]* mirror complete in " "${logroot}/hts-log.txt" |
sed -e 's/.*[[:space:]]\([^ ]*\)[[:space:]]files written.*/\1/g')
assert_equals "checking files" "${audit[$i]}" "$nFiles"
;;
@@ -376,7 +406,7 @@ while test "$i" -lt "${#audit[@]}"; do
--log-found)
i=$((i + 1))
info "checking log matches ${audit[$i]}"
if grep -aqE "${audit[$i]}" "${out}/hts-log.txt"; then result "OK"; else
if grep -aqE "${audit[$i]}" "${logroot}/hts-log.txt"; then result "OK"; else
result "not in log"
exit 1
fi
@@ -384,7 +414,7 @@ while test "$i" -lt "${#audit[@]}"; do
--log-not-found)
i=$((i + 1))
info "checking log lacks ${audit[$i]}"
if grep -aqE "${audit[$i]}" "${out}/hts-log.txt"; then
if grep -aqE "${audit[$i]}" "${logroot}/hts-log.txt"; then
result "present in log"
exit 1
else result "OK"; fi

View File

@@ -18,6 +18,15 @@ PROXY_LOG = "proxy.log"
ORIGIN_LOG = "origin-headers.log"
def bind_ephemeral():
"""Listening socket on a free loopback port, and that port."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
return srv, srv.getsockname()[1]
def pipe(src, dst):
"""Relay bytes one way until EOF, then tear both ends down."""
try:
@@ -113,11 +122,7 @@ def handle_client(conn, logdir, mode, default_port):
def start_proxy(logdir, mode, default_port):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
port = srv.getsockname()[1]
srv, port = bind_ephemeral()
def accept_loop():
while True:

View File

@@ -19,11 +19,12 @@ import threading
# python3 -P (PYTHONSAFEPATH) drops the script's own directory from sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from proxytestlib import pipe # noqa: E402
from proxytestlib import bind_ephemeral, pipe # noqa: E402
# The one name the proxy answers for; a .invalid TLD never resolves (RFC 6761),
# so a locally-resolving client could not reach us -- success proves remote DNS.
REMOTE_HOST = b"socks-origin.invalid"
AUTH_VERSION = 0x01 # RFC 1929 sub-negotiation version
# index links the subpages so an -r3 crawl reuses one keep-alive socket
LINKS = "".join('<a href="/p%d.html">%d</a>' % (i, i) for i in range(1, 6))
ORIGIN_BODY = ("<html><body>ORIGIN-PAGE-563 " + LINKS + "</body></html>").encode()
@@ -104,6 +105,12 @@ def negotiate_auth(conn, logdir):
if 0x02 in methods: # prefer user/pass so the auth test exercises RFC 1929
conn.sendall(b"\x05\x02")
(subver,) = recvn(conn, 1)
# RFC 1929's version byte is 0x01, not SOCKS5's 0x05: a real proxy
# rejects a mismatch instead of tunnelling anyway
if subver != AUTH_VERSION:
log(logdir, "AUTHVER-BAD %d" % subver)
conn.sendall(bytes([AUTH_VERSION, 0x01])) # sub-negotiation failure
return False
(ulen,) = recvn(conn, 1)
uname = recvn(conn, ulen)
(plen,) = recvn(conn, 1)
@@ -180,11 +187,7 @@ def handle_socks(conn, logdir, mode):
def start_socks(logdir, mode):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
port = srv.getsockname()[1]
srv, port = bind_ephemeral()
def serve():
while True:

View File

@@ -44,6 +44,25 @@ stop_server() {
return 0
}
# Dump and clear the crawl logs a hard-killed test leaves in TMPDIR (its cleanup
# trap never ran): hts-log.txt alone records "More than N seconds passed.. giving
# up", so a wedge past --max-time is undiagnosable without it (#605).
dump_crawl_logs() {
local d f
for d in "${TMPDIR:-/tmp}"/httrack_local.*; do
test -d "$d" || continue
for f in "$d/crawl/hts-log.txt" "$d/log" "$d/log.2"; do
test -f "$f" || continue
# Leading newline: the killed test's last line has no terminator.
printf '\n--- %s (last 200 lines)\n' "$f"
tail -n 200 "$f"
done
# so a later test's dump cannot re-report this one; never fatal, the
# caller is already handling a failure and Windows may still hold a file
rm -rf "$d" || true
done
}
# Kill a backgrounded job and its whole descendant tree. POSIX: the caller must
# have put the job in its own process group (run_with_timeout does) so we signal
# the group; a bare kill would orphan the grandchildren. Windows: the tree is

43
tests/tls-stall-server.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Peers that accept a connection and never speak TLS (#607).
Modes: "direct" stalls the handshake straight away; "proxy <secs>" answers a
CONNECT after <secs> before stalling, so the handshake starts on a clock the
connect has already eaten into. Prints "PORT <n>" once listening.
Usage: tls-stall-server.py [direct | proxy <secs>]
"""
import os
import sys
import threading
import time
# python3 -P (PYTHONSAFEPATH) drops the script's own directory from sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from proxytestlib import bind_ephemeral # noqa: E402
held = [] # keep every socket open: a close would fail the handshake outright
def stall(conn, delay):
if delay is not None:
rfile = conn.makefile("rb")
while rfile.readline() not in (b"\r\n", b"\n", b""):
pass
time.sleep(delay)
conn.sendall(b"HTTP/1.0 200 Connection established\r\n\r\n")
held.append(conn)
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "direct"
delay = float(sys.argv[2]) if mode == "proxy" else None
srv, port = bind_ephemeral()
sys.stdout.reconfigure(newline="\n") # Windows would emit \r\n
print("PORT %d" % port, flush=True)
while True:
conn, _ = srv.accept()
threading.Thread(target=stall, args=(conn, delay), daemon=True).start()
main()