Compare commits

..

32 Commits

Author SHA1 Message Date
Xavier Roche
8b72e3dddf Merge origin/master into feat/single-file
Master took test 92 and added an expected Windows skip; the single-file test
moves to 93 and both skip lists are combined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 23:31:03 +02:00
Xavier Roche
9484b32ecd An .ndx entry's two URL halves overflow the buffer they share (#744)
Both binput calls pass HTS_URLMAXSIZE, but they write into one
line[HTS_URLMAXSIZE * 2] and binput puts its NUL at s[max]. A first field
that fills its whole bound leaves the second writing line[2048], one past
the array. ASan reports a stack-buffer-overflow on a crafted .ndx. Bound
the second by what the first left.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:10 +02:00
Xavier Roche
1d647bfecd PT_GetTime's gmtime-failure fallback would print a day of 00 (#742)
* PT_GetTime's failure fallback formats as day 00

An all-zero struct tm has tm_mday == 0, and the ARC filedesc line prints
tm_mday raw, so a gmtime failure would emit "...0100" where the day
belongs. Use the epoch, as PT_SaveCache__Arc_Fun already does for an
unparseable Last-Modified.

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

* The fallback is reachable, so test it

I claimed no reachable trigger. Wrong: file_timestamp() passes st_mtime
through untouched, and a cache whose mtime is past gmtime's range takes
the fallback. Only the ARC loader is safe, because it overwrites the
timestamp with the filedesc line's own 4-digit-year date.

Test 88 sets an out-of-range mtime on a zip cache and pins the emitted
date; without the fix it reads 19000100000000.

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

* Register the Windows skip, and make the skip path real

The Windows job pins an exact expected-skip list, so a new conditional
skip fails it; test 88 skips there because NTFS will not hold an mtime
past gmtime's range. Its own skip logic was also dead code: "rc=0 || rc=$?"
never runs the right-hand side, and only set -e was carrying python's 77
through. Capture the status properly and tell a clamp apart from a real
failure.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:19:51 +02:00
Xavier Roche
5ee188ba1a ci: register the single-file test's Windows skip
Its GUI half needs htsserver, which the Windows job does not build, so the test
now exits 77 there instead of reporting a pass for assertions it never ran. The
skip list is pinned, so it has to be declared. Both Windows jobs reported
fail=0; only the list check was red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 23:19:29 +02:00
Xavier Roche
b2dc012263 Route ProxyTrack's remaining raw strcpy through the wrappers (#743)
Fourteen sites, all safe today: literals into sized fields, and two
same-sized array copies. The three writing "" through r->location, a
char *, become a direct terminator, since strcpybuff would have taken its
pointer path and lost the bound.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:59:43 +02:00
Xavier Roche
d3a915641f tests: move the Windows note down to the gate it explains
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 22:59:29 +02:00
Xavier Roche
aa1131982b Three hand-written copies of the same clipping contract (#741)
* Fold the three copies of the clip idiom into strclipbuff()

htscache.c and the two readers in proxy/store.c each spelled out
clear-then-strlncatbuff, in binaries that share no code. A helper in
htssafe.h states the contract once, and evaluates its arguments once: the
macro form expanded refvalue and refvalue_size twice, and (refvalue_size)
- 1 would have wrapped to SIZE_MAX had any call site ever passed 0.

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

* Pin the capacity-2 boundary

The cases jumped from the degenerate capacity 1 straight to 8, so a
defect confined to small-but-not-degenerate sizes passed: clipping a
two-byte destination to the empty string instead of one character.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:58:58 +02:00
Xavier Roche
15e2c701a5 single-file: document the inlined-stylesheet limitation in the CLI guide
Recorded in htssinglefile.h already; the user-facing guide is where someone
raising --single-file-max-size will look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 22:58:02 +02:00
Xavier Roche
6bd4209c94 Merge origin/master into feat/single-file
Master took test 91, so the single-file test moves to 92.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 22:52:54 +02:00
Xavier Roche
f72e7ebe96 htsserver: an unauthenticated GET of a directory spins the server forever (#724)
* htsserver: a request naming a directory spins the server forever

fopen() succeeds on a directory on POSIX and every read from it fails with
EISDIR without ever raising EOF, so smallserver()'s "while (!feof(fp))" serving
loop never terminates. GET /server/ needs no session id and no project to reach
it, and the accept loop is single-threaded, so one unauthenticated local request
wedges WebHTTrack for good.

Refuse a directory before fopen() so the 404 branch answers, rather than only
ending the loop: a loop-only fix would serve every directory as an empty 200.
The two loops fed a client-influenced path also stop on ferror(), since a read
that fails for any other reason spins the same way.

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

* htsserver: whitelist regular files instead of blacklisting directories

fexist() (htsserver.h) is the stat + S_ISREG predicate this file already uses
for the "file-exists:" template op, so gate the serving fopen() on it rather
than on a fresh negated is_directory(). Refusing only directories still handed
FIFOs to the same code path, where fopen() blocks with no writer and kills the
single-threaded accept loop for good.

Test 91 gains the FIFO case and a POST that loads a project whose
hts-cache/winprofile.ini is a directory: that fopen() sits ahead of every guard,
so it covers the ferror() check on the project-load read loop.

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

* htsserver: fold the two comments above the serve guard into one

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:49:59 +02:00
Xavier Roche
a228cede8a Merge origin/master into feat/single-file
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 22:49:52 +02:00
Xavier Roche
b5db9bb7ed single-file: restore the mirror file mode, and give the guards real coverage
The rewrite spools to a .sfnew temp and renames it over the page, which
bypasses filecreate() and with it the HTS_ACCESS_FILE chmod the engine puts on
every other mirrored file. Under a restrictive umask the pages came out 0600
while their assets stayed 0644, so a mirror served by a webserver or shared
with a group lost read access on exactly the pages. chmod the spool before the
rename.

Two guards had no coverage: deleting the scheme/data: check in sf_resolve left
both the self-test and the crawl test passing, because the fixtures resolved to
paths that were absent either way, and the per-page inline budget was never
exercised. The fixtures now plant a file where each guard's removal would land
the walk, and a self-importing stylesheet measures the budget against a
large-budget control. Charging that budget after the nested rewrite instead of
before let an @import chain spend what its ancestors had already claimed and
drove it negative; charge it up front and refund on failure.

The attribute table missed the lazy-loading attributes hts_detect[] already
downloads, so a modern page inlined almost nothing: add data-src, data-srcset,
lowsrc, object@data and embed@src, and record why the rest stay links.

The new web GUI checkbox had no hidden companion input, so it could be ticked
but never cleared, which is what #725 fixed for every other box. Master's test
90 catches it once the branch merges.

Adds a libFuzzer harness over the rewriter, since it re-serializes hostile
HTML, and renames the crawl test to 91 now that master holds 87 and 90.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 22:45:18 +02:00
Xavier Roche
3b53bf85f2 A posted project path overflows htsserver's "save settings" error messages (#723)
* htsserver: clip the "save settings" failure messages

The three sprintf(tmp[1024], ...) sites reporting a failed profile save
quote a project path composed from the posted "path" and "projname"
fields, neither of them bounded. A sid-authenticated save with a
1200-byte path smashes the stack buffer and takes the server down.

Route them through a SET_ERRORF() that formats into a fixed buffer and
absorbs the truncation once, so the message clips instead of aborting:
the text comes from the client, and a quieter denial of service is no
better than a loud one.

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

* tests: reach the second message without a 1030-byte mkdir

The tree the case needed came within a few bytes of macOS's PATH_MAX
once /var resolves to /private/var. A symlinked hts-cache gets there
instead: structcheck() lets a non-directory through, and opening
winprofile.ini under it fails with ENOTDIR whatever the uid.

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

* htssafe: one clipping printf helper for the three failf wrappers

htsblk_failf(), PT_Element_failf() and htsserver's format_error() all had
the same body; slprintfbuff_clip() now owns it. A (void) cast on
slprintfbuff() is no substitute: GCC warns through warn_unused_result.

vslprintfbuff() also empties dest before formatting, so a vsnprintf that
fails outright cannot leave the caller publishing uninitialized stack.

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

* tests: assert the clipped length, not just the message text

Two of the three sites only checked that the message appeared, which the
old sprintf did equally well; each now compares the rendered message
against the 1023-byte clip. The failing-write branch no longer needs
/dev/full either: the server runs under a file-size limit, so macOS gets
the same coverage.

Renumbered 86 to 89, which no other pending change claims.

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

* tests: build the oversized profile without a 16k printf width

The macOS leg reached the third message with no error set at all, so the
write the file-size limit was supposed to break had gone through. Build the
profile by doubling a 1024-wide printf, assert its length, and assert the
init file came out short, so a limit that does not bite names itself
instead of surfacing as a missing message.

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

* tests: build the long paths from the physical temp directory

That is what broke the macOS leg: TMPDIR there resolves through the /var
symlink, so the 1020-byte init file the third case opens was really 1028
bytes to the kernel and the open failed with ENAMETOOLONG. Both cases sit
within a few bytes of macOS's 1024-byte PATH_MAX, so the paths have to be
measured after resolution.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:25:52 +02:00
Xavier Roche
37ee129810 Merge origin/master into feat/single-file
Test 87 was taken by master (proxytrack-nodate) and 90 by the web GUI
checkbox test, so the single-file test moves to 91.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 22:12:43 +02:00
Xavier Roche
ce7dcfa9de Options ticked on by default cannot be un-ticked in the web GUI (#725)
* Options ticked on by default cannot be un-ticked in the web GUI

An unchecked HTML checkbox posts nothing, so htsserver never overwrites the
value it already holds. Every box in the wizard is one-way: once the stored
value is "1", whether htsserver seeded it at startup, a loaded profile set it,
or the user ticked it earlier in the session, un-ticking and submitting leaves
the option on and draws the box ticked again. Only four boxes, all in
option1.html, carried the companion hidden field that guards against this.

Add it to every remaining bare checkbox, and switch cookies and parsejava to
${ztest:...} so a cleared box emits --cookies=0 / --parse-java=0; ${test:...}
renders nothing at all when the value is empty, which is not "off" for an
option the engine turns on by default.

index, urlhack and keep-alive are deliberately left alone: their long options
are declared "single" in htsalias.c and optalias_check drops the =value, so
--index=0 resolves to -I and turns the option back on. That parser bug is
pre-existing and needs its own fix.

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

* tests: pin last-write-wins and cover every checkbox

The runtime leg posted each name once, so a first-wins body parser would
have passed while the fix did nothing in a real browser: post the
duplicated name in both orders and assert the last value wins. Replace the
four hand-written option cases with a table covering all 28 non-skipped
boxes, asserting the command-line token and the Windows-profile key each
state emits, plus a completeness check so a new box cannot slip through
unexercised.

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

* tests: rename the loop variable shadowing the scanned page

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:08:46 +02:00
Xavier Roche
069573edc3 Test assertions read a padded or truncated reply as a clean security verdict (#728)
* tests: a failed request must not read as a clean security verdict

Under pipefail, "request | grep -q MARKER && fail" skips the fail when the
request itself errors: the leak checks in tests 78 and 85 then pass without
ever having run. Capture the reply first and fail loudly if it never arrived.

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

* AGENTS.md: record the fail-open assertion shape

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

* tests: a reply that proves nothing must not read as a clean verdict

The previous commit converted some of the fail-open assertions and left three.
78's refusal loop still piped into "grep -q ... && fail": grep -q exits on the
first match and SIGPIPEs the producer, so under pipefail a hostile reply that
pads its Location past the 64 KB pipe buffer suppresses the failure exactly as
a dead probe would. 85's fetch() only required a non-empty reply, so a
truncated body or a 302 to the file passed the leak checks marker-free, and no
assertion looked at the status line at all. 78's store probe had no emptiness
guard, so an empty page read as "the store was not written".

Match from here-strings throughout, give fetch() the status each caller
expects, and route 78's store probe through a helper that requires a served
page. 77's X-Injected check had the same shape.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:08:10 +02:00
Xavier Roche
a75f437df9 lienrelatif() reads one byte before its stack buffer on an empty path (#729)
The trim that walks back to the last '/' starts at `curr + strlen(curr) - 1`,
which is `curr - 1` when the path is empty. The loop then dereferences it.

An empty path is reachable today: the pre-pass that strips a query does
`strncatbuff(newcurr_fil, curr_fil, a - curr_fil)`, so any `curr_fil` starting
with '?' hands the walk an empty string. `-#test=relative "dir/page.html" "?x"`
under ASan reports the underflow.

The read is one byte and the loop stops immediately either way, so the guard
changes no output: over the 484 ordered pairs of a 22-value path corpus, run
against builds that force the byte before the buffer to 0 and to '/', the
guarded and unguarded results are identical.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:06:11 +02:00
Xavier Roche
06dc54fa44 single-file: translate the new GUI strings into the remaining 28 locales (#739)
The feature PR added the four LANG_SINGLEFILE* entries to lang.def with English
and Francais only; every other locale fell back to English in the WebHTTrack
form. Each file is written in its own declared charset.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:04:47 +02:00
Xavier Roche
783f6ee1f5 AGENTS.md: record what the msg[80] hardening batch taught (#737)
Five PRs across the engine and ProxyTrack turned up the same few traps
more than once, and none of them are obvious from the code.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:39:57 +02:00
Xavier Roche
913caf68be Clear the last three compiler warnings (#733)
* Clear the last three compiler warnings

finalurl was sized for one of the two URLs it concatenates. The IIS-bug
example callback overwrites a suffix in place with a same-length
replacement and must not terminate the string, which is memcpy, not
strncpy. The coucal bench's if/else chain has no final else, so result was
only initialised on the paths gcc could not prove exhaustive.

A clean build now reports zero warnings.

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

* Bound the IIS suffix copy by what matched, not by the table

Copying strlen(replacement) leaves the "MUST be the same sizes" comment as
the only thing standing between a future table edit and an overflow. j is
the number of bytes just matched in the destination, so copying j is safe
whatever the table holds.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:37:52 +02:00
Xavier Roche
59660102d6 A cache field wider than ours aborts the engine instead of clipping (#732)
* A cache field wider than ours aborts the engine instead of clipping

The read-side ZIP_READFIELD_STRING used strlcpybuff, and the whole *_safe_
family aborts on overflow rather than truncating. Since the header line is
bounded only by HTS_URLMAXSIZE and msg is 80 bytes, a cache written by
another build, or a corrupt one, kills the crawl outright. The corrupt-cache
self-test already promises "rejected per-entry, never crash".

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

* Pin the clip to each field's own capacity

Review found one case exercised only msg[80], so a hardcoded clip length
passed. lastmodified[64] is narrower, and no single constant satisfies
both. The forged replacement was also one byte longer than the line it
overwrote, which only worked because corrupt_patch copies exactly the
pattern length.

Also stop claiming another build's cache can trigger this: the writer
emits each field from the same struct the reader fills, so it takes a
corrupt cache.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:37:38 +02:00
Xavier Roche
e96399910b Share the in-progress display struct instead of copying it (#734)
t_StatsBuffer was defined twice, byte for byte, in httrack.h and htsweb.h,
with NStatsBuffer duplicated alongside. httrack and htsserver each keep
their own array, so nothing catches the two drifting apart, and the last
change to the struct had to be applied to both by hand. Both now include
src/htsstats.h; sizes and offsets are unchanged.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:19:01 +02:00
Xavier Roche
52d0ab2356 An entry with no usable Last-Modified crashes proxytrack --convert (#731)
* Cached entry with no usable date crashes the ARC writer

PT_SaveCache__Arc_Fun dereferenced convert_time_rfc822() straight into the
record line, so any entry whose Last-Modified is absent or unparseable took
proxytrack --convert down. The sibling caller a thousand lines up already
guards the same call; this one fills in the epoch instead.

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

* Assert the archive date, not just the entry

Review found the test blind to the two mutants that matter: a guard firing
unconditionally clobbers every valid date to the epoch, and one that skips
the year and day emits a month and day of 00. Grepping only for the URL saw
neither. Assert the date field exactly, and add a valid-date case so the
untouched path is pinned too.

A bare "Last-Modified: 0" crashes the same way, so it joins the cases.

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

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:18:58 +02:00
Xavier Roche
42072f54b0 tests: check the sprintfbuff master just made warn_unused_result
#722 gave slprintfbuff the attribute, so the over-wide-tag fixture's call
became the one warning this branch adds over master's baseline. Handle it the
way that PR's own self-test code does; the buffer cannot truncate here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 18:59:38 +02:00
Xavier Roche
5f3787eb28 Merge origin/master into feat/single-file
Master took test number 86 for the proxytrack cache-longfields test; both
that and 87_local-single-file.test now sit in TESTS. The only conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 18:56:04 +02:00
Xavier Roche
4a62cfb75b tests: stand the single-file GUI check down where htsserver is absent
The Windows job builds httrack.exe only, and reaches this file through its
*_local-*.test glob, so requiring htsserver failed the whole test there even
though every crawl assertion had passed. Skip that half instead, and run the
engine self-test before it so the skip cannot swallow it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 18:34:38 +02:00
Xavier Roche
906906efa7 tests: cover the option plumbing review found untested
copy_htsopt's two new fields, including the >0 guard that must not let an
unset source clear the target's default; -%Z0; and a rejected
--single-file-max-size argument falling back to the built-in cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 18:17:54 +02:00
Xavier Roche
f50307d878 singlefile: tighten two comments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 18:14:53 +02:00
Xavier Roche
c6b7c407ed Fix the parser and path bugs review found, and cover them
sf_relative_from() indexed one byte past from_dir's terminator whenever the
page's own directory was a prefix of the asset path, which is the ordinary
mirror layout: an out-of-bounds read that also miscounted the ../ prefix and
emitted a broken link. Two review agents hit the same ASan trace; the branch
had no test because every nested asset in the fixture was under the cap.

Also from review: an escaped quote no longer ends a CSS string early (which
exposed its contents to the url()/@import scanner), @import url(...) now
inlines like the quoted form, a raw-text element ends only on a real end tag
rather than any prefix of one, an over-wide tag is copied through by the same
quote-aware scan instead of a second quote-blind one, <!--> is an empty
comment, whitespace before a tag's > survives, and a page cannot inline more
than SINGLEFILE_MAX_PAGE_SIZE, which bounds the multiplicative @import
fan-out. A failed encode no longer leaves a payload-less data: prefix behind,
and base_dir is matched against the root on a component boundary.

sf_readfile now wraps a new readfile2_utf8() rather than being a fifth copy
of the readfile family. Docs place --single-file beside -%M instead of
implying it supersedes it: MHT stays the better container, this wins on
opening anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 18:10:48 +02:00
Xavier Roche
a1226401e0 tests: renumber the single-file test to 87, run the self-test last
Master took 82 through 85 while this branch was open. The engine self-test
also moves to the end of the script: it and the crawl assertions cover
different ground, and failing first hid the crawl half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 17:52:28 +02:00
Xavier Roche
71ca31e2ec Merge origin/master into feat/single-file
Master's #709 took test number 82, so the single-file crawl test moves to 86.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-26 17:48:43 +02:00
Xavier Roche
b90d699d2e Single-file output is MHT, which browsers no longer open
Add --single-file (-%Z): after the mirror completes, rewrite every saved
page in place with its stylesheets, scripts, images and fonts embedded as
data: URIs, while links between pages stay relative. The mirror remains a
browsable tree and each page also stands alone.

This cannot reuse the -%M path: MHT streams a MIME part per file as it is
saved, but a data: URI needs the asset's bytes when the page is written,
and pages are normally saved before their assets are fetched. The new pass
runs over the finished tree at the tail of httpmirror(), after the update
purge.

Audio, video, page-to-page links and anything over --single-file-max-size
(10 MB default) keep an ordinary link. References carrying a scheme are
skipped, which covers data: and makes a second --update run a no-op.
Resolution is clamped to the mirror root: the HTML is hostile input.

htsopt.h gains two tail-appended fields; VERSION_INFO is untouched.

Closes #713

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-26 17:45:48 +02:00
91 changed files with 3870 additions and 149 deletions

View File

@@ -226,8 +226,10 @@ jobs:
# Every gate here exits 77, so an all-skipped suite would report green having
# tested nothing: pin the skips, and floor the passes in case the glob empties.
# footer-overflow skips on Windows (needs a path past MAX_PATH); crange pending #581;
# webdav-mime needs a reapable background listener, which MSYS cannot give it.
expected_skips=" 01_engine-footer-overflow.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test"
# webdav-mime needs a reapable background listener, which MSYS cannot give it;
# badmtime needs a filesystem that stores an mtime past gmtime's range;
# single-file ends on a GUI half needing htsserver, which this job does not build.
expected_skips=" 01_engine-footer-overflow.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 93_local-single-file.test"
[ "$pass" -ge 90 ] || { echo "::error::only $pass tests passed ($skip skipped)"; exit 1; }
[ "$skipped" = "$expected_skips" ] || { echo "::error::unexpected skips:$skipped"; exit 1; }
[ "$fail" -eq 0 ] || { echo "::error::failing:$failed"; exit 1; }

View File

@@ -26,6 +26,12 @@ the operational checklist: toolchain, invariants, and how to ship a change.
check`, or `PATH="<bld>/src:$PATH"` for a manual run.
- Give new `.test` scripts `set -e`: the older ones predate the rule, so several
`local-crawl.sh` calls with no `set -e` report PASS on any non-last failure.
- Never assert with `cmd | grep -q MARKER && fail`. Under `pipefail` the
pipeline is non-zero both when `cmd` fails and when `grep -q` matches early
and SIGPIPEs it, so the `&&` never fires and a probe that proved nothing reads
as "marker absent". Capture the reply, assert the status line it must carry
(an empty, truncated or redirected one is marker-free too), then match with a
here-string.
## Hard invariants
- **Generated autotools files are NOT in git.** `configure`, every
@@ -48,6 +54,16 @@ the operational checklist: toolchain, invariants, and how to ship a change.
- Bounds-check every copy. Overflow-safe form: put the untrusted value alone,
`untrusted < limit - controlled` — never `controlled + untrusted < limit`,
which can wrap and pass.
- **Abort or clip is a decision, not a default.** The `*_safe_` helpers
(`strcpybuff`, `strlcpybuff`, `strcatbuff`) **abort** on overflow. Right for
our own data, wrong for anything read back from a cache, a header or the
wire, where it trades a memory smash for a crash on malformed input. Clip
with `dst[0] = '\0'; strlncatbuff(dst, src, size, size - 1)`.
- **A warning class is not the unsafe set.** `-Wformat-truncation` fires only on
a *bounded* `snprintf` whose return is discarded, so an unbounded `sprintf`
into the same buffer never appears on it. Before scoping a hardening pass off
compiler output, grep the unguarded forms yourself (`\bsprintf\s*\(`,
`\bstrcpy\s*\(`, `\bstrcat\s*\(`).
## C conventions
- **Use the `*t` allocator wrappers, never raw libc** (`htssafe.h`):
@@ -84,6 +100,17 @@ Before pushing, and when reviewing others, don't skim for bugs:
layout/ABI, cache/wire format, or a security path? A static or unit check
isn't enough; exercise the wrong behavior at runtime. Claude Code:
`/review-recipe`.
- **Poison a canary, never compare it against zero.** Checking that a
neighbouring field is still `'\0'` cannot see the stray NUL an off-by-one
terminator writes — the exact bug the canary is there for. Fill it with a
non-zero byte, and prove it by killing both the stray-`'X'` and the
stray-NUL mutant. Neither ASan nor `_FORTIFY_SOURCE` sees an overflow that
lands inside the same struct.
- **Overshoot every destination, not one.** A bounds test that oversizes a
single field cannot tell a per-field bound from a one-size-fits-all one, nor
from a fix that bounds that field and leaves its neighbours raw. Exercise
each destination the path touches, spanning at least two capacities, and
check what the code actually emits before writing the expected values.
## Commits
- **Sign-off is mandatory.** Every commit carries a `Signed-off-by` trailer:

View File

@@ -2,7 +2,7 @@
if FUZZERS
noinst_PROGRAMS = fuzz-charset fuzz-meta fuzz-idna fuzz-entities \
fuzz-unescape fuzz-filters fuzz-url fuzz-header fuzz-cachendx \
fuzz-htsparse
fuzz-htsparse fuzz-singlefile
endif
AM_CPPFLAGS = \
@@ -27,6 +27,7 @@ fuzz_url_SOURCES = fuzz-url.c fuzz.h
fuzz_header_SOURCES = fuzz-header.c fuzz.h
fuzz_cachendx_SOURCES = fuzz-cachendx.c fuzz.h
fuzz_htsparse_SOURCES = fuzz-htsparse.c fuzz.h
fuzz_singlefile_SOURCES = fuzz-singlefile.c fuzz.h
# List corpus files explicitly: automake does not expand EXTRA_DIST globs.
EXTRA_DIST = README.md run-fuzzers.sh \
@@ -47,4 +48,8 @@ EXTRA_DIST = README.md run-fuzzers.sh \
corpus/cachendx/regress-overadvance.bin \
corpus/cachendx/regress-truncated-entry.bin \
corpus/htsparse/basic.html corpus/htsparse/script-inscript.html \
corpus/htsparse/meta-usemap.html corpus/htsparse/malformed.html
corpus/htsparse/meta-usemap.html corpus/htsparse/malformed.html \
corpus/singlefile/img-src.html corpus/singlefile/link-rel.html \
corpus/singlefile/style-block.html corpus/singlefile/style-attr.html \
corpus/singlefile/srcset.html corpus/singlefile/rawtext.html \
corpus/singlefile/malformed.html corpus/singlefile/many-attrs.html

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
<img src="unterminated.png
<div style="background:url(a.png">
<style>@import url(
<!-- unterminated comment
<a href=

View File

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

View File

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

View File

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

View File

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

View File

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

131
fuzz/fuzz-singlefile.c Normal file
View File

@@ -0,0 +1,131 @@
/* ------------------------------------------------------------ */
/*
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) 2026 Xavier Roche and other contributors
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Ethical use: we kindly ask that you NOT use this software to harvest email
addresses or to collect any other private information about people. Doing so
would dishonor our work and waste the many hours we have spent on it.
Please visit our Website: http://www.httrack.com
*/
/* Fuzz the --single-file rewriter (htssinglefile.c): hostile HTML walked
through the tag, CSS url()/@import and srcset parsers, then re-serialized.
The resolver is aimed at a private temp tree, so the inlining half (MIME
guess, base64, nested stylesheet) is reached and nothing else on disk is. */
#include "fuzz.h"
#include "httrack-library.h"
#include "htssinglefile.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
/* Between a.png and big.png, so one input reaches both the inline path and the
over-cap fallback. */
#define FUZZ_SF_CAP 64
static char sf_root[512];
static char sf_page[600];
/* The asset tree, in removal order: the subdirectory comes after its file. */
static const char *const sf_files[] = {"a.png", "big.png", "j.js", "s.css",
"sub/b.css", "sub", NULL};
static void sf_cleanup(void) {
char path[700];
int i;
for (i = 0; sf_files[i] != NULL; i++) {
snprintf(path, sizeof(path), "%s/%s", sf_root, sf_files[i]);
(void) remove(path);
}
(void) remove(sf_root);
}
/* A missing asset would silently reduce the target to its parser half. */
static void sf_write(const char *name, const char *data, size_t len) {
char path[700];
FILE *fp;
snprintf(path, sizeof(path), "%s/%s", sf_root, name);
fp = fopen(path, "wb");
if (fp == NULL || fwrite(data, 1, len, fp) != len)
abort();
fclose(fp);
}
static void sf_text(const char *name, const char *data) {
sf_write(name, data, strlen(data));
}
static void sf_init(void) {
static const char png[] = "\x89PNG\r\n\x1a\n";
static const char big[4096] = "\x89PNG";
const char *tmp = getenv("TMPDIR");
char path[700];
hts_init();
snprintf(sf_root, sizeof(sf_root), "%s/httrack-fuzz-sf-XXXXXX",
tmp != NULL && tmp[0] != '\0' ? tmp : "/tmp");
if (mkdtemp(sf_root) == NULL)
abort();
atexit(sf_cleanup);
snprintf(sf_page, sizeof(sf_page), "%s/page.html", sf_root);
snprintf(path, sizeof(path), "%s/sub", sf_root);
if (mkdir(path, 0700) != 0)
abort();
sf_write("a.png", png, sizeof(png) - 1);
sf_write("big.png", big, sizeof(big));
sf_text("j.js", "var x=1;\n");
/* @import plus a url(), so an inlined stylesheet recurses and its own
relative reference is rebased. */
sf_text("s.css", "@import url(sub/b.css);\ndiv{background:url(a.png)}\n");
sf_text("sub/b.css", "p{background:url(../a.png)}\n");
}
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
static int inited = 0;
String out = STRING_EMPTY;
httrackp *opt;
/* Exact-length, unterminated: the rewriter is span-based, so ASan bounds a
read past html_len instead of it landing on a terminator. */
char *html = malloct(size != 0 ? size : 1);
if (!inited) {
sf_init();
inited = 1;
}
memcpy(html, data, size);
opt = hts_create_opt();
opt->log = opt->errlog = NULL;
opt->single_file_max_size = FUZZ_SF_CAP;
StringClear(out);
(void) singlefile_rewrite_html(opt, sf_root, sf_page, html, size,
SINGLEFILE_MAX_PAGE_SIZE, &out);
StringFree(out);
freet(html);
hts_free_opt(opt);
return 0;
}

View File

@@ -300,6 +300,7 @@ ones it kept so the local copy browses offline. These options tune both halves.<
<tr><td><tt>--preserve (-%p), --disable-passwords (-%x)</tt></td><td>Leave HTML untouched (no rewriting), and strip passwords out of saved links.</td></tr>
<tr><td><tt>--extended-parsing (-%P), --parse-java (-j)</tt></td><td>Aggressive link discovery, and how much script content is parsed for links.</td></tr>
<tr><td><tt>--mime-html (-%M)</tt></td><td>Save the whole mirror as a single MIME-encapsulated <tt>.mht</tt> archive (<tt>index.mht</tt>).</td></tr>
<tr><td><tt>--single-file (-%Z), --single-file-max-size N</tt></td><td>Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded as <tt>data:</tt> URIs. Assets over the cap (10&nbsp;MB by default) keep their link, as do audio, video, and the links from one page to another. A sibling of <tt>-%M</tt>, not a replacement: see the recipe below for which to pick.</td></tr>
<tr><td><tt>--index (-I), --build-top-index (-%i), --search-index (-%I)</tt></td><td>Build a per-mirror index, a top index across projects, and a searchable keyword index.</td></tr>
</table>
@@ -482,6 +483,25 @@ add <tt>--warc-cdx</tt> for a sorted CDXJ index, or <tt>--wacz</tt> to bundle th
archive, index and pages into one WACZ for replay tools such as
replayweb.page.</small></p>
<h4>Pages you can hand to someone as one file</h4>
<p><tt>httrack https://example.com/ --single-file --path mydir</tt><br>
<small>Rewrites each saved page after the crawl so its stylesheets, scripts,
images and fonts are embedded as <tt>data:</tt> URIs. The mirror stays a normal
browsable tree, with links between pages relative and the assets still on disk,
but any single <tt>.html</tt> file can now be mailed or archived on its own.
Raise or lower the 10&nbsp;MB per-asset limit with
<tt>--single-file-max-size N</tt>; anything over it, plus audio and video, keeps
its link. One caveat if you raise it: an inlined stylesheet becomes a
<tt>data:</tt> URL, whose path is opaque, so an asset it referenced that stayed
over the cap no longer resolves from inside it. Raising the cap past that asset
embeds it too and the question goes away.<br>
Reach for this when the file has to open for someone you cannot make assumptions
about: it is plain HTML and needs no add-on. Reach for <tt>--mime-html</tt>
instead when a Chromium-family browser is a given and the mirror is large: MIME
carries text parts without the base64 tax, keeps each resource's original URL,
and stores a shared asset once rather than re-embedding it in every page that
uses it.</small></p>
<h4>HTTrack as a fetch tool</h4>
<p><tt>httrack --get https://host/file.bin --path tmp</tt><br>
<small><tt>--get</tt> fetches one file with cache, index, depth, cookies and robots

View File

@@ -90,9 +90,10 @@ offline browser : copy websites to a local directory</p>
] [ <b>-NN, --structure[=N]</b> ] [ <b>-%N,
--delayed-type-check</b> ] [ <b>-%D,
--cached-delayed-type-check</b> ] [ <b>-%M, --mime-html</b>
] [ <b>-LN, --long-names[=N]</b> ] [ <b>-KN,
--keep-links[=N]</b> ] [ <b>-x, --replace-external</b> ] [
<b>-%x, --disable-passwords</b> ] [ <b>-%q,
] [ <b>-%Z, --single-file</b> ] [ <b>-LN,
--long-names[=N]</b> ] [ <b>-KN, --keep-links[=N]</b> ] [
<b>-x, --replace-external</b> ] [ <b>-%x,
--disable-passwords</b> ] [ <b>-%q,
--include-query-string</b> ] [ <b>-%g, --strip-query</b> ] [
<b>-o, --generate-errors</b> ] [ <b>-X, --purge-old[=N]</b>
] [ <b>-%p, --preserve</b> ] [ <b>-%T, --utf8-conversion</b>
@@ -648,6 +649,24 @@ don&rsquo;t wait) (--cached-delayed-type-check)</p></td></tr>
<td width="4%">
<p>-%Z</p></td>
<td width="5%"></td>
<td width="82%">
<p>after the mirror, rewrite each saved page with its
stylesheets, scripts, images and fonts inlined as data:
URIs, so any page opens by double-click anywhere (links
between pages stay relative; audio and video stay links);
--single-file-max-size N caps each asset (default 10485760
bytes). %M is the better container where a Chromium-family
browser is a given: one archive, no base64 tax on text, a
shared asset stored once (--single-file)</p></td></tr>
<tr valign="top" align="left">
<td width="9%"></td>
<td width="4%">
<p>-%t</p></td>
<td width="5%"></td>
<td width="82%">

View File

@@ -98,6 +98,9 @@ ${do:end-if}
<input type="hidden" name="redirect" value="">
<input type="hidden" name="closeme" value="">
<!-- clear if not checked -->
<input type="hidden" name="ftpprox" value="">
${LANG_PROXYTYPE}
<select name="proxytype"
title='${html:LANG_PROXYTYPETIP}' onMouseOver="info('${html:LANG_PROXYTYPETIP}'); return true" onMouseOut="info('&nbsp;'); return true"

View File

@@ -98,6 +98,14 @@ ${do:end-if}
<input type="hidden" name="redirect" value="">
<input type="hidden" name="closeme" value="">
<!-- clear if not checked -->
<input type="hidden" name="errpage" value="">
<input type="hidden" name="external" value="">
<input type="hidden" name="hidepwd" value="">
<input type="hidden" name="hidequery" value="">
<input type="hidden" name="nopurge" value="">
<input type="hidden" name="singlefile" value="">
${LANG_I33}
<br>
<select name="build"
@@ -136,6 +144,13 @@ ${listid:build:LISTDEF_3}
<tr><td><input type="checkbox" name="nopurge" ${checked:nopurge}
title='${html:LANG_I1a}' onMouseOver="info('${html:LANG_I1a}'); return true" onMouseOut="info('&nbsp;'); return true"
> ${LANG_I57}</td></tr>
<tr><td><input type="checkbox" name="singlefile" ${checked:singlefile}
title='${html:LANG_SINGLEFILETIP}' onMouseOver="info('${html:LANG_SINGLEFILETIP}'); return true" onMouseOut="info('&nbsp;'); return true"
> ${LANG_SINGLEFILE}</td></tr>
<tr><td>${LANG_SINGLEFILEMAX}
<input name="singlefilemax" value="${singlefilemax}" size="12"
title='${html:LANG_SINGLEFILEMAXTIP}' onMouseOver="info('${html:LANG_SINGLEFILEMAXTIP}'); return true" onMouseOut="info('&nbsp;'); return true"
></td></tr>
</table>
<tr><td>

View File

@@ -98,6 +98,9 @@ ${do:end-if}
<input type="hidden" name="redirect" value="">
<input type="hidden" name="closeme" value="">
<!-- clear if not checked -->
<input type="hidden" name="windebug" value="">
${LANG_I40c}
<br>

View File

@@ -98,6 +98,11 @@ ${do:end-if}
<input type="hidden" name="redirect" value="">
<input type="hidden" name="closeme" value="">
<!-- clear if not checked -->
<input type="hidden" name="ka" value="">
<input type="hidden" name="remt" value="">
<input type="hidden" name="rems" value="">
<table border="0" width="100%" cellspacing="0">
<tr><td>

View File

@@ -98,6 +98,17 @@ ${do:end-if}
<input type="hidden" name="redirect" value="">
<input type="hidden" name="closeme" value="">
<!-- clear if not checked -->
<input type="hidden" name="cookies" value="">
<input type="hidden" name="parsejava" value="">
<input type="hidden" name="updhack" value="">
<input type="hidden" name="urlhack" value="">
<input type="hidden" name="keepwww" value="">
<input type="hidden" name="keepslashes" value="">
<input type="hidden" name="keepqueryorder" value="">
<input type="hidden" name="toler" value="">
<input type="hidden" name="http10" value="">
<input type="checkbox" name="cookies" ${checked:cookies}
title='${html:LANG_I1b}' onMouseOver="info('${html:LANG_I1b}'); return true" onMouseOut="info('&nbsp;'); return true"
> ${LANG_I58}

View File

@@ -98,6 +98,13 @@ ${do:end-if}
<input type="hidden" name="redirect" value="">
<input type="hidden" name="closeme" value="">
<!-- clear if not checked -->
<input type="hidden" name="warc" value="">
<input type="hidden" name="norecatch" value="">
<input type="hidden" name="logf" value="">
<input type="hidden" name="index" value="">
<input type="hidden" name="index2" value="">
<input type="checkbox" name="cache2" ${checked:cache2}
title='${html:LANG_I1e}' onMouseOver="info('${html:LANG_I1e}'); return true" onMouseOut="info('&nbsp;'); return true"
> ${LANG_I61}

View File

@@ -143,6 +143,8 @@ ${do:copy:StripQuery:stripquery}
${do:copy:StoreAllInCache:cache2}
${do:copy:Warc:warc}
${do:copy:WarcFile:warcfile}
${do:copy:SingleFile:singlefile}
${do:copy:SingleFileMaxSize:singlefilemax}
${do:copy:LogType:logtype}
${do:copy:UseHTTPProxyForFTP:ftpprox}
${do:copy:ProxyType:proxytype}

View File

@@ -114,7 +114,7 @@ ${do:end-if}
${/* Real commands and ini file generated below */}
<!-- engine commandline -->
<!-- engine commandline; ztest so a cleared default-on option still emits its disabling flag -->
${do:output-mode:html}
<textarea name="command" cols="50" rows="4" style="visibility:hidden">
httrack \
@@ -175,8 +175,8 @@ ${/* -m<n> resets the html limit, so the bare form must precede the -m,<n> one *
\
${unquoted:url2}
\
${test:cookies:--cookies=0:}
${test:parsejava:--parse-java=0:}
${ztest:cookies:--cookies=0:}
${ztest:parsejava:--parse-java=0:}
${test:updhack:--updatehack}
${test:urlhack:--urlhack=0:--urlhack}
${test:keepwww:--keep-www-prefix}
@@ -190,6 +190,8 @@ ${/* -m<n> resets the html limit, so the bare form must precede the -m,<n> one *
${test:cache2:--store-all-in-cache}
${test:warc:--warc}
${test:warcfile:--warc-file "}${arg:warcfile}${test:warcfile:"}
${test:singlefile:--single-file}
${test:singlefilemax:--single-file-max-size=}${unquoted:singlefilemax}
${test:norecatch:--do-not-recatch}
${test:logf:--single-log}
${test:logtype:::--extra-log:--debug-log}
@@ -242,6 +244,8 @@ StripQuery=${stripquery}
StoreAllInCache=${ztest:cache2:0:1}
Warc=${ztest:warc:0:1}
WarcFile=${warcfile}
SingleFile=${ztest:singlefile:0:1}
SingleFileMaxSize=${singlefilemax}
LogType=${logtype}
UseHTTPProxyForFTP=${ztest:ftpprox:0:1}
ProxyType=${proxytype}

View File

@@ -1042,3 +1042,11 @@ LANG_WARCFILE
WARC archive name:
LANG_WARCFILETIP
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
LANG_SINGLEFILE
Inline assets as data: URIs (self-contained pages)
LANG_SINGLEFILETIP
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
LANG_SINGLEFILEMAX
Largest inlined asset (bytes):
LANG_SINGLEFILEMAXTIP
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Èìå íà WARC àðõèâà:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Íåçàäúëæèòåëíî áàçîâî èìå çà WARC àðõèâà; îñòàâåòå ïðàçíî çà àâòîìàòè÷íî èìåíóâàíå â èçõîäíàòà äèðåêòîðèÿ.
Inline assets as data: URIs (self-contained pages)
Âãðàæäàíå íà ðåñóðñèòå êàòî data: URI (ñàìîñòîÿòåëíè ñòðàíèöè)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Ñëåä çàâúðøâàíå íà îãëåäàëîòî âñÿêà çàïàçåíà ñòðàíèöà ñå ïðåçàïèñâà ñ âãðàäåíè ñòèëîâå, ñêðèïòîâå, èçîáðàæåíèÿ è øðèôòîâå, òàêà ÷å ñòðàíèöàòà äà ìîæå äà ñå îòâîðè è ñàìîñòîÿòåëíî.
Largest inlined asset (bytes):
Íàé-ãîëÿì âãðàäåí ðåñóðñ (áàéòîâå):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Ðåñóðñ íàä òîçè ðàçìåð çàïàçâà îáèêíîâåíà âðúçêà; îñòàâåòå ïðàçíî çà ñòîéíîñòòà ïî ïîäðàçáèðàíå îò 10485760 áàéòà.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Nombre del archivo WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Nombre base opcional para el archivo WARC; déjelo en blanco para nombrarlo automáticamente en el directorio de salida.
Inline assets as data: URIs (self-contained pages)
Incrustar los recursos como URI data: (páginas autónomas)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Una vez terminada la copia, reescribir cada página guardada con sus hojas de estilo, scripts, imágenes y fuentes incrustadas, de modo que una página también pueda abrirse por sí sola.
Largest inlined asset (bytes):
Tamaño máximo del recurso incrustado (bytes):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Un recurso mayor que este tamaño conserva un enlace normal; déjelo vacío para el valor predeterminado de 10485760 bytes.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Název archivu WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Volitelný základní název archivu WARC; ponechte prázdné pro automatické pojmenování ve výstupním adresáøi.
Inline assets as data: URIs (self-contained pages)
Vložit zdroje jako URI data: (samostatné stránky)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Po dokonèení zrcadlení pøepsat každou uloženou stránku s vloženými styly, skripty, obrázky a písmy, aby ji bylo možné otevøít i samostatnì.
Largest inlined asset (bytes):
Nejvìtší vložený zdroj (bajty):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Zdroj vìtší než tato velikost si ponechá bìžný odkaz; ponechte prázdné pro výchozí hodnotu 10485760 bajtù.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC 封存檔名稱:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
WARC 封存檔的選用基本名稱;留空則於輸出目錄中自動命名。
Inline assets as data: URIs (self-contained pages)
將資源內嵌為 data: URI獨立網頁
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
鏡射完成後,重寫每個已儲存的網頁,將樣式表、指令碼、圖片與字型內嵌其中,讓網頁也能單獨開啟。
Largest inlined asset (bytes):
內嵌資源大小上限(位元組):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
超過此大小的資源會保留一般連結;留空則使用預設的 10485760 位元組。

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC 归档名称:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
WARC 归档的可选基本名称;留空则在输出目录中自动命名。
Inline assets as data: URIs (self-contained pages)
将资源内嵌为 data: URI独立网页
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
镜像完成后,重写每个已保存的网页,将样式表、脚本、图片和字体内嵌其中,使网页也能单独打开。
Largest inlined asset (bytes):
内嵌资源大小上限(字节):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
超过此大小的资源会保留普通链接;留空则使用默认的 10485760 字节。

View File

@@ -966,3 +966,11 @@ WARC archive name:
Naziv WARC arhive:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Neobavezni osnovni naziv WARC arhive; ostavite prazno za automatsko imenovanje u izlaznom direktoriju.
Inline assets as data: URIs (self-contained pages)
Ugradi resurse kao data: URI-je (samostalne stranice)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Nakon dovr¹etka zrcaljenja ponovno zapi¹i svaku spremljenu stranicu s ugraðenim stilskim listovima, skriptama, slikama i fontovima, tako da se stranica mo¾e otvoriti i zasebno.
Largest inlined asset (bytes):
Najveæi ugraðeni resurs (bajtovi):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Resurs veæi od ove velièine zadr¾ava obiènu poveznicu; ostavite prazno za zadanih 10485760 bajtova.

View File

@@ -1012,3 +1012,11 @@ WARC archive name:
Navn på WARC-arkiv:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Valgfrit basisnavn til WARC-arkivet; lad feltet stå tomt for automatisk navngivning i outputmappen.
Inline assets as data: URIs (self-contained pages)
Indlejr ressourcer som data:-URI'er (selvstændige sider)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Når spejlingen er færdig, omskrives hver gemt side med dens typografiark, scripts, billeder og skrifttyper indlejret, så en side også kan åbnes alene.
Largest inlined asset (bytes):
Største indlejrede ressource (byte):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
En ressource over denne størrelse beholder et almindeligt link; lad feltet stå tomt for standardværdien på 10485760 byte.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Name des WARC-Archivs:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Optionaler Basisname für das WARC-Archiv; leer lassen, um es automatisch im Ausgabeverzeichnis zu benennen.
Inline assets as data: URIs (self-contained pages)
Ressourcen als data:-URIs einbetten (eigenständige Seiten)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Nach Abschluss der Spiegelung jede gespeicherte Seite mit eingebetteten Stylesheets, Skripten, Bildern und Schriftarten neu schreiben, sodass eine Seite auch für sich allein geöffnet werden kann.
Largest inlined asset (bytes):
Größte eingebettete Ressource (Bytes):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Eine Ressource über dieser Größe behält einen gewöhnlichen Link; leer lassen für den Standardwert von 10485760 Bytes.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC-arhiivi nimi:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
WARC-arhiivi valikuline põhinimi; jäta tühjaks, et see väljundkataloogis automaatselt nimetada.
Inline assets as data: URIs (self-contained pages)
Manusta ressursid data: URI-dena (iseseisvad lehed)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Pärast peegelduse lõppu kirjuta iga salvestatud leht ümber, manustades selle laaditabelid, skriptid, pildid ja fondid, nii et lehte saab avada ka eraldi.
Largest inlined asset (bytes):
Suurim manustatud ressurss (baiti):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Sellest suurem ressurss säilitab tavalise lingi; jäta tühjaks vaikeväärtuse 10485760 baiti jaoks.

View File

@@ -1012,3 +1012,11 @@ WARC archive name:
WARC archive name:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Inline assets as data: URIs (self-contained pages)
Inline assets as data: URIs (self-contained pages)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Largest inlined asset (bytes):
Largest inlined asset (bytes):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.

View File

@@ -966,3 +966,11 @@ WARC archive name:
WARC-arkiston nimi:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Valinnainen WARC-arkiston perusnimi; jätä tyhjäksi, jotta se nimetään automaattisesti tulostehakemistoon.
Inline assets as data: URIs (self-contained pages)
Upota resurssit data:-URI-osoitteina (itsenäiset sivut)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Kun peilaus on valmis, kirjoita jokainen tallennettu sivu uudelleen niin, että sen tyylitiedostot, komentosarjat, kuvat ja fontit upotetaan, jolloin sivun voi avata myös yksinään.
Largest inlined asset (bytes):
Suurin upotettu resurssi (tavua):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Tätä suurempi resurssi säilyttää tavallisen linkin; jätä tyhjäksi, jolloin käytetään oletusarvoa 10485760 tavua.

View File

@@ -1012,3 +1012,11 @@ WARC archive name:
Nom de l'archive WARC :
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Nom de base optionnel pour l'archive WARC ; laissez vide pour le générer automatiquement dans le répertoire de sortie.
Inline assets as data: URIs (self-contained pages)
Intégrer les ressources en URI data: (pages autonomes)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Une fois le miroir terminé, réécrire chaque page enregistrée avec ses feuilles de style, scripts, images et polices intégrés, afin qu'une page puisse aussi être ouverte seule.
Largest inlined asset (bytes):
Taille maximale d'une ressource intégrée (octets) :
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Au-delà de cette taille, la ressource reste un lien ordinaire ; laissez vide pour la valeur par défaut de 10485760 octets.

View File

@@ -966,3 +966,11 @@ WARC archive name:
Όνομα αρχείου WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Προαιρετικό βασικό όνομα για το αρχείο WARC. Αφήστε το κενό για αυτόματη ονομασία στον κατάλογο εξόδου.
Inline assets as data: URIs (self-contained pages)
Ενσωμάτωση των πόρων ως URI data: (αυτόνομες σελίδες)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Μόλις ολοκληρωθεί το αντίγραφο, κάθε αποθηκευμένη σελίδα ξαναγράφεται με ενσωματωμένα τα φύλλα στυλ, τα σενάρια, τις εικόνες και τις γραμματοσειρές της, ώστε η σελίδα να μπορεί να ανοίξει και μόνη της.
Largest inlined asset (bytes):
Μέγιστος ενσωματωμένος πόρος (byte):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Πόρος μεγαλύτερος από αυτό το μέγεθος διατηρεί κανονικό σύνδεσμο. Αφήστε το κενό για την προεπιλογή των 10485760 byte.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Nome dell'archivio WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Nome di base facoltativo per l'archivio WARC; lascia vuoto per assegnarlo automaticamente nella directory di output.
Inline assets as data: URIs (self-contained pages)
Incorpora le risorse come URI data: (pagine autonome)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Al termine della copia, riscrive ogni pagina salvata incorporando fogli di stile, script, immagini e caratteri, in modo che una pagina possa essere aperta anche da sola.
Largest inlined asset (bytes):
Dimensione massima della risorsa incorporata (byte):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Una risorsa oltre questa dimensione mantiene un collegamento normale; lasciare vuoto per il valore predefinito di 10485760 byte.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC アーカイブ名:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
WARC アーカイブの任意のベース名。空欄にすると出力ディレクトリ内で自動的に名前が付けられます。
Inline assets as data: URIs (self-contained pages)
リソースを data: URI として埋め込む (単独で開けるページ)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
ミラーの完了後、保存された各ページを、スタイルシート、スクリプト、画像、フォントを埋め込んだ形で書き直します。ページ単体でも開けるようになります。
Largest inlined asset (bytes):
埋め込む最大サイズ (バイト):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
このサイズを超えるリソースは通常のリンクのままになります。空欄にすると既定値の 10485760 バイトになります。

View File

@@ -964,3 +964,11 @@ WARC archive name:
¸ÜÕ ÝÐ WARC ÐàåØÒÐâÐ:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
¸×ÑÞàÝÞ ÞáÝÞÒÝÞ ØÜÕ ×Ð WARC ÐàåØÒÐâÐ; ÞáâÐÒÕâÕ ßàÐ×ÝÞ ×Ð ÐÒâÞÜÐâáÚÞ ØÜÕÝãÒÐúÕ ÒÞ Ø×ÛÕ×ÝØÞâ ÔØàÕÚâÞàØãÜ.
Inline assets as data: URIs (self-contained pages)
²ÓàÐÔØ ÓØ àÕáãàáØâÕ ÚÐÚÞ data: URI (áÐÜÞáâÞøÝØ áâàÐÝØæØ)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
¿Þ ×ÐÒàèãÒÐúÕ ÝÐ ÞÓÛÕÔÐÛÞâÞ, áÕÚÞøÐ ×ÐçãÒÐÝÐ áâàÐÝØæÐ áÕ ßàÕߨèãÒÐ áÞ ÒÓàÐÔÕÝØ áâØÛáÚØ ÛØáâÞÒØ, áÚàØßâØ, áÛØÚØ Ø äÞÝâÞÒØ, ×Ð ÔÐ ÜÞÖÕ áâàÐÝØæÐâÐ ÔÐ áÕ ÞâÒÞàØ Ø áÐÜÞáâÞøÝÞ.
Largest inlined asset (bytes):
½ÐøÓÞÛÕÜ ÒÓàÐÔÕÝ àÕáãàá (ÑÐøâØ):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
ÀÕáãàá ßÞÓÞÛÕÜ ÞÔ ÞÒÐÐ ÓÞÛÕÜØÝÐ ×ÐÔàÖãÒÐ ÞÑØçÝÐ ÒàáÚÐ; ÞáâÐÒÕâÕ ßàÐ×ÝÞ ×Ð áâÐÝÔÐàÔÝØâÕ 10485760 ÑÐøâØ.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC archívum neve:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
A WARC archívum opcionális alapneve; hagyja üresen az automatikus elnevezéshez a kimeneti könyvtárban.
Inline assets as data: URIs (self-contained pages)
Erõforrások beágyazása data: URI-ként (önálló oldalak)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
A tükrözés befejezése után minden mentett oldal újraírása a stíluslapok, parancsfájlok, képek és betûtípusok beágyazásával, így az oldal önmagában is megnyitható.
Largest inlined asset (bytes):
Legnagyobb beágyazott erõforrás (bájt):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Az ennél nagyobb erõforrás közönséges hivatkozás marad; hagyja üresen a 10485760 bájtos alapértelmezéshez.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Naam van WARC-archief:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Optionele basisnaam voor het WARC-archief; laat leeg om het automatisch een naam te geven in de uitvoermap.
Inline assets as data: URIs (self-contained pages)
Bronnen insluiten als data:-URI's (op zichzelf staande pagina's)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Zodra de spiegeling klaar is, elke opgeslagen pagina herschrijven met ingesloten stijlbladen, scripts, afbeeldingen en lettertypen, zodat een pagina ook los geopend kan worden.
Largest inlined asset (bytes):
Grootste ingesloten bron (bytes):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Een bron boven deze grootte houdt een gewone koppeling; laat leeg voor de standaardwaarde van 10485760 bytes.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Navn på WARC-arkiv:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Valgfritt basisnavn for WARC-arkivet; la feltet stå tomt for automatisk navngivning i utdatamappen.
Inline assets as data: URIs (self-contained pages)
Bygg inn ressurser som data:-URI-er (selvstendige sider)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Når speilingen er ferdig, skrives hver lagrede side om med stilark, skript, bilder og skrifter innebygd, slik at en side også kan åpnes alene.
Largest inlined asset (bytes):
Største innebygde ressurs (byte):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
En ressurs over denne størrelsen beholder en vanlig lenke; la feltet stå tomt for standardverdien på 10485760 byte.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Nazwa archiwum WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Opcjonalna nazwa bazowa archiwum WARC; pozostaw puste, aby nazwaæ je automatycznie w katalogu wyj¶ciowym.
Inline assets as data: URIs (self-contained pages)
Osad¼ zasoby jako identyfikatory data: URI (samodzielne strony)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Po zakoñczeniu tworzenia kopii przepisz ka¿d± zapisan± stronê z osadzonymi arkuszami stylów, skryptami, obrazami i czcionkami, aby stronê mo¿na by³o otworzyæ równie¿ samodzielnie.
Largest inlined asset (bytes):
Najwiêkszy osadzony zasób (bajty):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Zasób wiêkszy ni¿ ten rozmiar zachowuje zwyk³y odno¶nik; pozostaw puste, aby u¿yæ domy¶lnych 10485760 bajtów.

View File

@@ -1012,3 +1012,11 @@ WARC archive name:
Nome do arquivo WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Nome base opcional para o arquivo WARC; deixe em branco para nomeá-lo automaticamente no diretório de saída.
Inline assets as data: URIs (self-contained pages)
Incorporar os recursos como URIs data: (páginas autônomas)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Ao terminar o espelhamento, reescrever cada página salva com suas folhas de estilo, scripts, imagens e fontes incorporadas, para que a página também possa ser aberta sozinha.
Largest inlined asset (bytes):
Maior recurso incorporado (bytes):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Um recurso acima desse tamanho mantém um link comum; deixe em branco para o padrão de 10485760 bytes.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Nome do arquivo WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Nome base opcional para o arquivo WARC; deixe em branco para o nomear automaticamente no diretório de saída.
Inline assets as data: URIs (self-contained pages)
Incorporar os recursos como URI data: (páginas autónomas)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Depois de terminado o espelho, reescrever cada página guardada com as suas folhas de estilo, scripts, imagens e tipos de letra incorporados, para que a página também possa ser aberta sozinha.
Largest inlined asset (bytes):
Maior recurso incorporado (bytes):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Um recurso acima deste tamanho mantém uma ligação normal; deixe em branco para o valor predefinido de 10485760 bytes.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Numele arhivei WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Nume de baza optional pentru arhiva WARC; lasati gol pentru a-l denumi automat in directorul de iesire.
Inline assets as data: URIs (self-contained pages)
Încorporeaza resursele ca URI data: (pagini de sine statatoare)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Dupa terminarea copiei, fiecare pagina salvata este rescrisa cu foile de stil, scripturile, imaginile si fonturile încorporate, astfel încât pagina sa poata fi deschisa si singura.
Largest inlined asset (bytes):
Cea mai mare resursa încorporata (octeti):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
O resursa mai mare decât aceasta dimensiune pastreaza o legatura obisnuita; lasati gol pentru valoarea implicita de 10485760 de octeti.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Èìÿ WARC-àðõèâà:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Íåîáÿçàòåëüíîå áàçîâîå èìÿ WARC-àðõèâà; îñòàâüòå ïóñòûì äëÿ àâòîìàòè÷åñêîãî èìåíîâàíèÿ â âûõîäíîì êàòàëîãå.
Inline assets as data: URIs (self-contained pages)
Âñòðàèâàòü ðåñóðñû êàê data: URI (ñàìîñòîÿòåëüíûå ñòðàíèöû)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Ïîñëå çàâåðøåíèÿ çåðêàëèðîâàíèÿ êàæäàÿ ñîõðàí¸ííàÿ ñòðàíèöà ïåðåçàïèñûâàåòñÿ ñî âñòðîåííûìè òàáëèöàìè ñòèëåé, ñöåíàðèÿìè, èçîáðàæåíèÿìè è øðèôòàìè, ÷òîáû ñòðàíèöó ìîæíî áûëî îòêðûòü îòäåëüíî.
Largest inlined asset (bytes):
Íàèáîëüøèé âñòðàèâàåìûé ðåñóðñ (áàéòû):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Ðåñóðñ áîëüøå ýòîãî ðàçìåðà ñîõðàíÿåò îáû÷íóþ ññûëêó; îñòàâüòå ïóñòûì äëÿ çíà÷åíèÿ ïî óìîë÷àíèþ 10485760 áàéò.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Názov archívu WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Voliteµný základný názov archívu WARC; ponechajte prázdne pre automatické pomenovanie vo výstupnom adresári.
Inline assets as data: URIs (self-contained pages)
Vlo¾i» zdroje ako URI data: (samostatné stránky)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Po dokonèení zrkadlenia prepísa» ka¾dú ulo¾enú stránku s vlo¾enými ¹týlmi, skriptmi, obrázkami a písmami, aby sa stránka dala otvori» aj samostatne.
Largest inlined asset (bytes):
Najväè¹í vlo¾ený zdroj (bajty):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Zdroj väè¹í ne¾ táto veµkos» si ponechá be¾ný odkaz; ponechajte prázdne pre predvolených 10485760 bajtov.

View File

@@ -964,3 +964,11 @@ WARC archive name:
Ime arhiva WARC:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Neobvezno osnovno ime arhiva WARC; pustite prazno za samodejno poimenovanje v izhodni mapi.
Inline assets as data: URIs (self-contained pages)
Vgradi vire kot data: URI (samostojne strani)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Ko je zrcaljenje koncano, se vsaka shranjena stran prepise z vgrajenimi slogovnimi listi, skripti, slikami in pisavami, tako da jo je mogoce odpreti tudi samostojno.
Largest inlined asset (bytes):
Najvecji vgrajeni vir (bajti):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Vir, vecji od te velikosti, ohrani obicajno povezavo; pustite prazno za privzetih 10485760 bajtov.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC-arkivets namn:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Valfritt basnamn för WARC-arkivet; lämna tomt för att namnge det automatiskt i utdatakatalogen.
Inline assets as data: URIs (self-contained pages)
Bädda in resurser som data:-URI:er (fristående sidor)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
När speglingen är klar skrivs varje sparad sida om med sina formatmallar, skript, bilder och teckensnitt inbäddade, så att en sida också kan öppnas för sig.
Largest inlined asset (bytes):
Största inbäddade resurs (byte):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
En resurs över den här storleken behåller en vanlig länk; lämna tomt för standardvärdet 10485760 byte.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC arþivi adý:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
WARC arþivi için isteðe baðlý temel ad; çýktý dizininde otomatik adlandýrma için boþ býrakýn.
Inline assets as data: URIs (self-contained pages)
Kaynaklarý data: URI olarak göm (kendi kendine yeten sayfalar)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Yansýlama bittiðinde, kaydedilen her sayfa stil sayfalarý, betikleri, görselleri ve yazý tipleri gömülü olarak yeniden yazýlýr; böylece sayfa tek baþýna da açýlabilir.
Largest inlined asset (bytes):
En büyük gömülü kaynak (bayt):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Bu boyutun üzerindeki bir kaynak sýradan baðlantýsýný korur; 10485760 baytlýk varsayýlan için boþ býrakýn.

View File

@@ -964,3 +964,11 @@ WARC archive name:
²ì'ÿ WARC-àðõ³âó:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
Íåîáîâ'ÿçêîâà áàçîâà íàçâà WARC-àðõ³âó; çàëèøòå ïîðîæí³ì äëÿ àâòîìàòè÷íîãî íàéìåíóâàííÿ ó âèõ³äíîìó êàòàëîç³.
Inline assets as data: URIs (self-contained pages)
Âáóäîâóâàòè ðåñóðñè ÿê data: URI (ñàìîñò³éí³ ñòîð³íêè)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
ϳñëÿ çàâåðøåííÿ äçåðêàëþâàííÿ êîæíà çáåðåæåíà ñòîð³íêà ïåðåçàïèñóºòüñÿ ç âáóäîâàíèìè òàáëèöÿìè ñòèë³â, ñöåíàð³ÿìè, çîáðàæåííÿìè òà øðèôòàìè, ùîá ñòîð³íêó ìîæíà áóëî â³äêðèòè îêðåìî.
Largest inlined asset (bytes):
Íàéá³ëüøèé âáóäîâàíèé ðåñóðñ (áàéòè):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Ðåñóðñ, á³ëüøèé çà öåé ðîçì³ð, çáåð³ãຠçâè÷àéíå ïîñèëàííÿ; çàëèøòå ïîðîæí³ì äëÿ òèïîâîãî çíà÷åííÿ 10485760 áàéò³â.

View File

@@ -964,3 +964,11 @@ WARC archive name:
WARC arxivi nomi:
Optional base name for the WARC archive; leave blank to auto-name it under the output directory.
WARC arxivi uchun ixtiyoriy asosiy nom; chiqish katalogida avtomatik nomlash uchun bo'sh qoldiring.
Inline assets as data: URIs (self-contained pages)
Resurslarni data: URI sifatida joylash (mustaqil sahifalar)
Once the mirror is finished, rewrite every saved page with its stylesheets, scripts, images and fonts embedded, so a page can also be opened on its own.
Nusxalash tugagach, har bir saqlangan sahifa uslublar jadvallari, skriptlar, rasmlar va shriftlar joylangan holda qayta yoziladi, shunda sahifani alohida ham ochish mumkin.
Largest inlined asset (bytes):
Eng katta joylangan resurs (bayt):
An asset above this size keeps an ordinary link; leave blank for the 10485760-byte default.
Bu olchamdan katta resurs oddiy havolani saqlab qoladi; standart 10485760 bayt uchun bosh qoldiring.

View File

@@ -71,7 +71,9 @@ static int mysavename(t_hts_callbackarg * carg, httrackp * opt,
for(j = 0; iisBogus[i][j] == a[j] && iisBogus[i][j] != '\0'; j++) ;
if (iisBogus[i][j] == '\0'
&& (a[j] == '\0' || a[j] == '/' || a[j] == '\\')) {
strncpy(a, iisBogusReplace[i], strlen(iisBogusReplace[i]));
/* j bytes matched, so j fit: copying j cannot overrun whatever the
table holds, and the tail must survive untouched */
memcpy(a, iisBogusReplace[i], (size_t) j);
break;
}
}

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 "23 July 2026" "httrack website copier"
.TH httrack 1 "26 July 2026" "httrack website copier"
.SH NAME
httrack \- offline browser : copy websites to a local directory
.SH SYNOPSIS
@@ -40,6 +40,7 @@ httrack \- offline browser : copy websites to a local directory
[ \fB\-%N, \-\-delayed\-type\-check\fR ]
[ \fB\-%D, \-\-cached\-delayed\-type\-check\fR ]
[ \fB\-%M, \-\-mime\-html\fR ]
[ \fB\-%Z, \-\-single\-file\fR ]
[ \fB\-LN, \-\-long\-names[=N]\fR ]
[ \fB\-KN, \-\-keep\-links[=N]\fR ]
[ \fB\-x, \-\-replace\-external\fR ]
@@ -198,6 +199,8 @@ delayed type check, don't make any link test but wait for files download to star
cached delayed type check, don't wait for remote type during updates, to speedup them (%D0 wait, * %D1 don't wait) (\-\-cached\-delayed\-type\-check)
.IP \-%M
generate a RFC MIME\-encapsulated full\-archive (.mht) (\-\-mime\-html)
.IP \-%Z
after the mirror, rewrite each saved page with its stylesheets, scripts, images and fonts inlined as data: URIs, so any page opens by double\-click anywhere (links between pages stay relative; audio and video stay links); \-\-single\-file\-max\-size N caps each asset (default 10485760 bytes). %M is the better container where a Chromium\-family browser is a given: one archive, no base64 tax on text, a shared asset stored once (\-\-single\-file)
.IP \-%t
keep the original file extension, don't rewrite it from the MIME type (%t0 rewrite)
.IP \-LN

View File

@@ -48,7 +48,7 @@ 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 htsstats.h \
htscmdline.c htscmdline.h \
htsurlport.c htsurlport.h
proxytrack_SOURCES = proxy/main.c \
@@ -66,7 +66,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \
htscmdline.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 htswarc.c htsproxy.c htszlib.c htswrap.c htsconcat.c \
htsmd5.c htscodec.c htswarc.c htssinglefile.c htsproxy.c htszlib.c htswrap.c htsconcat.c \
htsmodules.c htscharset.c punycode.c htsencoding.c htssniff.c \
md5.c \
minizip/ioapi.c minizip/mztools.c minizip/unzip.c minizip/zip.c \
@@ -77,7 +77,7 @@ libhttrack_la_SOURCES = htscore.c htsparse.c htsback.c htscache.c \
htshelp.h htsindex.h htslib.h htsurlport.h htsmd5.h \
htsmodules.h htsname.h htsnet.h htssniff.h \
htsopt.h htsrobots.h htsthread.h \
htstools.h htswizard.h htswrap.h htscodec.h htswarc.h htsproxy.h htszlib.h \
htstools.h htswizard.h htswrap.h htscodec.h htswarc.h htssinglefile.h htsproxy.h htszlib.h \
htsstrings.h htsarrays.h httrack-library.h \
htscharset.h punycode.h htsencoding.h \
htsentities.h htsentities.sh htsbasiccharsets.sh htscodepages.h \
@@ -87,7 +87,7 @@ libhttrack_la_LIBADD = $(THREADS_LIBS) $(ZLIB_LIBS) $(BROTLI_LIBS) $(ZSTD_LIBS)
libhttrack_la_CFLAGS = $(AM_CFLAGS) -DLIBHTTRACK_EXPORTS -DZLIB_CONST
libhttrack_la_LDFLAGS = $(AM_LDFLAGS) -version-info $(VERSION_INFO)
EXTRA_DIST = httrack.h webhttrack \
EXTRA_DIST = httrack.h htsstats.h webhttrack \
version.rc \
libhttrack.rc \
httrack.rc \

View File

@@ -123,6 +123,10 @@ const char *hts_optalias[][4] = {
{"warc-cdxj", "-%rc", "single", ""},
{"wacz", "-%rz", "single",
"package the WARC archive, CDXJ index and pages as a WACZ file"},
{"single-file", "-%Z", "single",
"after the mirror, inline each page's assets as data: URIs"},
{"single-file-max-size", "-%Zs", "param1",
"per-asset cap for --single-file, in bytes (implies it; default 10485760)"},
{"why", "-%Y", "param1",
"explain which filter rule accepts or rejects a URL, then exit"},
{"pause", "-%G", "param1",

View File

@@ -142,10 +142,12 @@ struct cache_back_zip_entry {
int compressionMethod;
};
/* A corrupt cache can carry a field wider than ours; clipping it keeps the
entry, where aborting would take the crawl down. */
#define ZIP_READFIELD_STRING(line, value, refline, refvalue, refvalue_size) \
do { \
if (line[0] != '\0' && strfield2(line, refline)) { \
strlcpybuff(refvalue, value, refvalue_size); \
(void) strclipbuff(refvalue, refvalue_size, value); \
line[0] = '\0'; \
} \
} while (0)
@@ -1295,12 +1297,17 @@ char *readfile2(const char *fil, LLint * size) {
}
/* Note: utf-8 */
char *readfile_utf8(const char *fil) {
char *readfile_utf8(const char *fil) { return readfile2_utf8(fil, NULL); }
/* Note: utf-8 */
char *readfile2_utf8(const char *fil, LLint *size) {
char *adr = NULL;
char catbuff[CATBUFF_SIZE];
const LLint len = fsize_utf8(fil);
const size_t buflen = len >= 0 ? llint_to_size_t(len) : (size_t) -1;
if (size != NULL)
*size = len;
if (buflen != (size_t) -1) { // exists, and is addressable (see readfile2)
FILE *const fp = FOPEN(fconv(catbuff, sizeof(catbuff), fil), "rb");

View File

@@ -1195,6 +1195,12 @@ int cache_legacy_refused_selftest(httrackp *opt, const char *dir) {
/* --- read-side corruption injection --------------------------------------- */
/* 100 'A's: a placeholder header line long enough to be overwritten by a
forged, over-long X-StatusMessage of the same byte length. */
#define CORRUPT_LONG_ETAG \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
/* canary read back intact after each corruption; victim gets the byte surgery
*/
#define CORRUPT_ADR "corrupt.example.com"
@@ -1219,6 +1225,24 @@ static void corrupt_build(httrackp *opt) {
selftest_close(&cache);
}
/* Like corrupt_build, but the victim carries a 100-char Etag placeholder. */
static void corrupt_build_longetag(httrackp *opt) {
cache_back cache;
memset(corrupt_body_a, 'a', sizeof(corrupt_body_a) - 1);
memset(corrupt_body_b, 'b', sizeof(corrupt_body_b) - 1);
remove(reconcile_st_path(opt, "hts-cache/new.zip"));
remove(reconcile_st_path(opt, "hts-cache/old.zip"));
selftest_open_for_write(&cache, opt);
store_entry(opt, &cache, CORRUPT_ADR, "/canary.html", "canary.html", 200,
"OK", "text/html", "utf-8", "", "", "", "", corrupt_body_a,
strlen(corrupt_body_a));
store_entry(opt, &cache, CORRUPT_ADR, "/victim.html", "victim.html", 200,
"OK", "text/html", "utf-8", "", CORRUPT_LONG_ETAG, "", "",
corrupt_body_b, strlen(corrupt_body_b));
selftest_close(&cache);
}
/* Like corrupt_build, but the victim carries a 20-char Etag whose header line
is later overwritten with a forged oversized X-Size (same byte length). */
static void corrupt_build_etag(httrackp *opt) {
@@ -1403,6 +1427,48 @@ static int corrupt_expect_disk_header(httrackp *opt, LLint wantsize,
return fail;
}
/* An over-long field from a foreign cache must clip, not abort: the entry
still reads, clipped to capacity, and the canary survives. */
static int corrupt_expect_victim_clipped(httrackp *opt, size_t wantmsg,
size_t wantlastmod, const char *what) {
cache_back cache;
htsblk v, c;
char BIGSTK lv[HTS_URLMAXSIZE * 2];
char BIGSTK lc[HTS_URLMAXSIZE * 2];
int fail = 0;
selftest_open_for_read(&cache, opt);
lv[0] = lc[0] = '\0';
v = cache_readex(opt, &cache, CORRUPT_ADR, "/victim.html", "", lv, NULL, 1);
if (v.statuscode != 200) {
fprintf(stderr, "%s: %s: status %d, expected 200\n", selftest_tag, what,
v.statuscode);
fail++;
}
if (wantmsg != (size_t) -1 && strlen(v.msg) != wantmsg) {
fprintf(stderr, "%s: %s: msg len %u, expected %u\n", selftest_tag, what,
(unsigned) strlen(v.msg), (unsigned) wantmsg);
fail++;
}
if (wantlastmod != (size_t) -1 && strlen(v.lastmodified) != wantlastmod) {
fprintf(stderr, "%s: %s: lastmodified len %u, expected %u\n", selftest_tag,
what, (unsigned) strlen(v.lastmodified), (unsigned) wantlastmod);
fail++;
}
c = cache_readex(opt, &cache, CORRUPT_ADR, "/canary.html", "", lc, NULL, 1);
if (c.statuscode != 200) {
fprintf(stderr, "%s: %s: canary tainted (status %d)\n", selftest_tag, what,
c.statuscode);
fail++;
}
if (v.adr != NULL)
freet(v.adr);
if (c.adr != NULL)
freet(c.adr);
selftest_close(&cache);
return fail;
}
/* One zip corruption case: build, patch, then check victim+canary in-session.
*/
static int corrupt_case_zip(httrackp *opt, const char *pat, const char *rep,
@@ -1439,6 +1505,31 @@ int cache_corruption_selftest(httrackp *opt, const char *dir) {
failures += corrupt_expect_victim(opt, "Cache Read Error : Read Data",
"garbled deflate stream");
/* A corrupt cache can hold a field wider than ours. Clipping keeps the
entry; aborting would take the crawl down. Overwrite the placeholder Etag
line in place, same byte length, so the zip offsets stay intact. */
corrupt_build_longetag(opt);
corrupt_patch(opt, "Etag: " CORRUPT_LONG_ETAG, 106,
"X-StatusMessage: " /* 17 + 89 = 106 */
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1, 1);
failures +=
corrupt_expect_victim_clipped(opt, sizeof(((htsblk *) 0)->msg) - 1,
(size_t) -1, "over-long X-StatusMessage");
/* lastmodified[64] is narrower than msg[80]: one hardcoded clip length
cannot satisfy both. */
corrupt_build_longetag(opt);
corrupt_patch(opt, "Etag: " CORRUPT_LONG_ETAG, 106,
"Last-Modified: " /* 15 + 91 = 106 */
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1, 1);
failures += corrupt_expect_victim_clipped(
opt, (size_t) -1, sizeof(((htsblk *) 0)->lastmodified) - 1,
"over-long Last-Modified");
/* An X-Size above INT_MAX is positive as int64 (slips a bare sign check) but
truncates negative in the (int) cast the malloc uses: a wraparound alloc.
cache_add asserts size fits an int, so such a value only reaches the reader

View File

@@ -40,6 +40,7 @@ Please visit our Website: http://www.httrack.com
/* File defs */
#include "htscore.h"
#include "htswarc.h"
#include "htssinglefile.h"
/* specific definitions */
#include "htsbase.h"
@@ -2182,6 +2183,10 @@ int httpmirror(char *url1, httrackp * opt) {
}
// fin purge!
/* --single-file: inline each page's assets now the tree is final, after the
purge has deleted whatever this run dropped. */
singlefile_process_mirror(opt);
// Indexation
if (opt->kindex)
index_finish(StringBuff(opt->path_html), opt->kindex);
@@ -3643,6 +3648,10 @@ HTSEXT_API int copy_htsopt(const httrackp * from, httrackp * to) {
to->warc_cdx = from->warc_cdx;
to->warc_wacz = from->warc_wacz;
to->single_file = from->single_file;
if (from->single_file_max_size > 0)
to->single_file_max_size = from->single_file_max_size;
if (from->pause_max_ms > 0) {
to->pause_min_ms = from->pause_min_ms;
to->pause_max_ms = from->pause_max_ms;

View File

@@ -409,6 +409,8 @@ char *readfile2(const char *fil, LLint * size);
char *readfile_utf8(const char *fil);
char *readfile2_utf8(const char *fil, LLint *size);
char *readfile_or(const char *fil, const char *defaultdata);
/* Backing (download-slot) scheduler. Operate on the back[] ring (struct_back).

View File

@@ -1795,6 +1795,36 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
StringCopy(opt->warc_file, WARC_AUTONAME);
}
break;
case 'Z': // single-file: inline each page's assets as data: URIs
if (*(com + 1) == 's') { // --single-file-max-size N
com++;
if ((na + 1 >= argc) || (argv[na + 1][0] == '-')) {
HTS_PANIC_PRINTF(
"Option single-file-max-size needs a blank "
"space and a size");
htsmain_free();
return -1;
}
na++;
{ // reject non-numeric/negative/overflow; keep the default
char *end;
LLint v;
errno = 0;
v = strtoll(argv[na], &end, 10);
if (isdigit((unsigned char) argv[na][0]) && *end == '\0' &&
errno != ERANGE && v > 0)
opt->single_file_max_size = v;
}
opt->single_file = HTS_TRUE;
} else {
opt->single_file = HTS_TRUE;
if (*(com + 1) == '0') {
opt->single_file = HTS_FALSE;
com++;
}
}
break;
case 'Y': // why: explain the filter verdict for a URL, no crawl
if ((na + 1 >= argc) || (argv[na + 1][0] == '-')) {
HTS_PANIC_PRINTF("Option why needs a blank space and a URL");

View File

@@ -433,7 +433,8 @@ void help_catchurl(const char *dest_path) {
}
// former URL!
{
char BIGSTK finalurl[HTS_URLMAXSIZE * 2];
/* url and dest are each HTS_URLMAXSIZE*2, plus the POSTTOK marker */
char BIGSTK finalurl[HTS_URLMAXSIZE * 4 + 32];
inplace_escape_check_url(dest, sizeof(dest));
snprintf(finalurl, sizeof(finalurl), "%s" POSTTOK "file:%s", url, dest);
@@ -534,6 +535,13 @@ void help(const char *app, int more) {
infomsg
(" %D cached delayed type check, don't wait for remote type during updates, to speedup them (%D0 wait, * %D1 don't wait)");
infomsg(" %M generate a RFC MIME-encapsulated full-archive (.mht)");
infomsg(" %Z after the mirror, rewrite each saved page with its "
"stylesheets, scripts, images and fonts inlined as data: URIs, so "
"any page opens by double-click anywhere (links between pages stay "
"relative; audio and video stay links); --single-file-max-size N "
"caps each asset (default 10485760 bytes). %M is the better "
"container where a Chromium-family browser is a given: one archive, "
"no base64 tax on text, a shared asset stored once");
infomsg(" %t keep the original file extension, don't rewrite it from the "
"MIME type (%t0 rewrite)");
infomsg

View File

@@ -37,6 +37,7 @@ Please visit our Website: http://www.httrack.com
#include "htscore.h"
#include "htswarc.h"
#include "htssinglefile.h"
/* specific definitions */
#include "htsbase.h"
@@ -2537,15 +2538,6 @@ void fil_simplifie(char *f) {
}
}
void htsblk_failf(htsblk *r, const char *fmt, ...) {
va_list args;
va_start(args, fmt);
// deliberate clip: the reason is quoted from a remote reply
(void) vslprintfbuff(r->msg, sizeof(r->msg), fmt, args);
va_end(args);
}
// fermer liaison fichier ou socket
void deletehttp(htsblk * r) {
#if HTS_DEBUG_CLOSESOCK
@@ -6020,6 +6012,8 @@ HTSEXT_API httrackp *hts_create_opt(void) {
StringCopy(opt->cookies_file, "");
StringCopy(opt->warc_file, "");
opt->warc_max_size = 0; /* no rotation unless --warc-max-size sets it */
opt->single_file = HTS_FALSE;
opt->single_file_max_size = SINGLEFILE_DEFAULT_MAX_SIZE;
StringCopy(opt->why_url, "");
opt->pause_min_ms = 0;
opt->pause_max_ms = 0;

View File

@@ -201,7 +201,8 @@ T_SOC newhttp_addr(httrackp *opt, const char *iadr, htsblk *retour, int port,
int waitconnect, int addr_index, int *addr_count);
/* Clips the formatted failure reason into r->msg, which also round-trips
through the cache as X-StatusMessage. Leaves r->statuscode to the caller. */
void htsblk_failf(htsblk *r, const char *fmt, ...) HTS_PRINTF_FUN(2, 3);
#define htsblk_failf(R, ...) \
slprintfbuff_clip((R)->msg, sizeof((R)->msg), __VA_ARGS__)
HTS_INLINE void deletehttp(htsblk * r);
HTS_INLINE int deleteaddr(htsblk * r);

View File

@@ -547,6 +547,12 @@ struct httrackp {
archive. Tail: ABI */
hts_boolean warc_wacz; /**< --wacz: package archive+index+pages as a WACZ zip
(implies --warc + --warc-cdx). Tail: ABI */
hts_boolean single_file; /**< --single-file: once the mirror is done, rewrite
each saved page with its assets inlined as
data: URIs. Tail: ABI */
LLint single_file_max_size; /**< --single-file-max-size: per-asset cap in
bytes; a bigger asset stays a link.
Tail: ABI */
};
/* Running statistics for a mirror. */

View File

@@ -460,6 +460,25 @@ static HTS_INLINE HTS_UNUSED const char *htsbuff_str(const htsbuff *b) {
return b->buf;
}
/**
* Copy src into dest (capacity size, NUL included), truncating to fit and
* always NUL-terminating. Unlike strlcpybuff() it never aborts, so it suits a
* value read back from a cache, a header or the wire, where refusing the whole
* record is worse than keeping a clipped one. Returns HTS_TRUE if it all fit;
* callers that clip on purpose ignore that, so it is not HTS_CHECK_RESULT.
*/
static HTS_INLINE HTS_UNUSED hts_boolean strclipbuff(char *dest, size_t size,
const char *src) {
size_t len, copy;
assertf(dest != NULL && src != NULL && size != 0);
len = strlen(src);
copy = len < size ? len : size - 1;
memcpy(dest, src, copy);
dest[copy] = '\0';
return copy == len ? HTS_TRUE : HTS_FALSE;
}
/**
* Callers that deliberately ignore truncation use this instead of
* slprintfbuff(), so it is not HTS_CHECK_RESULT.
@@ -469,6 +488,9 @@ static HTS_INLINE HTS_UNUSED HTS_PRINTF_FUN(3, 0) hts_boolean
int ret;
assertf(dest != NULL && size != 0);
/* a vsnprintf failing outright may write nothing at all, leaving whatever
the caller had on the stack for it to publish */
dest[0] = '\0';
ret = vsnprintf(dest, size, fmt, args);
/* pre-C99 runtimes (msvcrt _vsnprintf) return -1 and do not terminate */
dest[size - 1] = '\0';
@@ -492,6 +514,20 @@ static HTS_INLINE HTS_UNUSED HTS_CHECK_RESULT HTS_PRINTF_FUN(3, 4) hts_boolean
return ret;
}
/**
* slprintfbuff() for diagnostics quoting remote or client text, which are
* meant to be clipped: nothing to act on, hence not HTS_CHECK_RESULT. A (void)
* cast on slprintfbuff() is no substitute, GCC warns through it.
*/
static HTS_INLINE HTS_UNUSED HTS_PRINTF_FUN(3, 4) void slprintfbuff_clip(
char *dest, size_t size, const char *fmt, ...) {
va_list args;
va_start(args, fmt);
(void) vslprintfbuff(dest, size, fmt, args);
va_end(args);
}
/**
* slprintfbuff() over the in-scope array ARR (capacity = sizeof(ARR)).
* On GCC/Clang a pointer is a compile error; use slprintfbuff() for those.

View File

@@ -60,6 +60,7 @@ Please visit our Website: http://www.httrack.com
#include "htscodec.h"
#include "htsproxy.h"
#include "htswarc.h"
#include "htssinglefile.h"
#if HTS_USEZLIB
#include "htszlib.h"
#endif
@@ -624,6 +625,66 @@ static int string_safety_selftests(void) {
return 1;
}
/* strclipbuff: truncate-and-report, never abort. Same canary shape; the
destination is poisoned before every call so a case cannot pass on the
previous one's bytes. */
{
struct {
char dst[8];
char canary[8];
} s;
memset(&s, '#', sizeof(s));
#define POISON_DST() memset(s.dst, '#', sizeof(s.dst))
/* well under capacity: no padding, nothing eaten off the end */
POISON_DST();
if (!strclipbuff(s.dst, sizeof(s.dst), "abc") || strcmp(s.dst, "abc") != 0)
return 1;
/* exact fit: capacity - 1 characters plus the NUL */
POISON_DST();
if (!strclipbuff(s.dst, sizeof(s.dst), "1234567") ||
strcmp(s.dst, "1234567") != 0)
return 1;
/* one over, then far over: clipped, terminated, reported. The expected
bytes differ from the case above, so a write-nothing implementation
cannot pass on the leftovers. */
POISON_DST();
if (strclipbuff(s.dst, sizeof(s.dst), "abcdefgh") ||
strcmp(s.dst, "abcdefg") != 0)
return 1;
POISON_DST();
if (strclipbuff(s.dst, sizeof(s.dst), "0123456789abcdef") ||
strcmp(s.dst, "0123456") != 0)
return 1;
/* degenerate capacity 1: only the NUL fits. Capacity 2 pins the boundary
between that and the sizes above: one character plus the NUL. */
POISON_DST();
if (strclipbuff(s.dst, 1, "x") || s.dst[0] != '\0')
return 1;
POISON_DST();
if (strclipbuff(s.dst, 2, "yz") || strcmp(s.dst, "y") != 0)
return 1;
/* the empty string fits any non-zero capacity */
POISON_DST();
if (!strclipbuff(s.dst, sizeof(s.dst), "") || s.dst[0] != '\0')
return 1;
/* a byte over 0x7f must not end the copy early */
POISON_DST();
if (!strclipbuff(s.dst, sizeof(s.dst), "\xff\xfe") ||
strcmp(s.dst, "\xff\xfe") != 0)
return 1;
#undef POISON_DST
if (memcmp(s.canary, "########", sizeof(s.canary)) != 0)
return 1;
}
/* htsblk_failf: clips a reason quoted from a remote reply into msg[] and
touches nothing else in the block */
{
@@ -1525,7 +1586,7 @@ static int st_hashtable(httrackp *opt, int argc, char **argv) {
size_t i;
for (i = bench[loop].offset; i < (size_t) count;
i += bench[loop].modulus) {
int result;
int result = 0; /* no final else: an unknown type reports failure */
FMT();
if (bench[loop].type == DO_ADD || bench[loop].type == DO_DRY_ADD) {
size_t k;
@@ -1680,6 +1741,20 @@ static int st_copyopt(httrackp *opt, int argc, char **argv) {
if (strcmp(StringBuff(to->warc_file), "run.warc.gz") != 0)
err = 1;
/* single_file pair: the cap is guarded by >0, so an unset source must not
overwrite the default the target already carries */
from->single_file = HTS_TRUE;
from->single_file_max_size = 4096;
to->single_file = HTS_FALSE;
to->single_file_max_size = SINGLEFILE_DEFAULT_MAX_SIZE;
copy_htsopt(from, to);
if (!to->single_file || to->single_file_max_size != 4096)
err = 1;
from->single_file_max_size = 0;
copy_htsopt(from, to);
if (to->single_file_max_size != 4096)
err = 1;
/* #185 pause pair: copied when enabled (max>0), the 0 sentinel skips */
from->pause_min_ms = 5000;
from->pause_max_ms = 10000;
@@ -4815,6 +4890,520 @@ static int st_warc_wacz(httrackp *opt, int argc, char **argv) {
}
#endif
/* ------------------------------------------------------------ */
/* --single-file */
/* ------------------------------------------------------------ */
static int sf_err = 0;
/* Set when the filesystem accepted a ':' in a name; Windows never does. */
static hts_boolean sf_colon_ok = HTS_FALSE;
static void sf_check(int ok, const char *what) {
if (!ok) {
fprintf(stderr, "singlefile: %s\n", what);
sf_err++;
}
}
/* Write rel (a '/'-separated path under dir), creating the directories.
Returns HTS_FALSE if the name is one the filesystem will not take. */
static hts_boolean sf_try_put(const char *dir, const char *rel,
const void *data, size_t len) {
char BIGSTK path[HTS_URLMAXSIZE * 2];
char catbuff[CATBUFF_SIZE];
FILE *fp;
fconcat(path, sizeof(path), dir, rel);
structcheck_utf8(path);
fp = FOPEN(fconv(catbuff, sizeof(catbuff), path), "wb");
if (fp == NULL)
return HTS_FALSE;
assertf(len == 0 || fwrite(data, 1, len, fp) == len);
fclose(fp);
return HTS_TRUE;
}
static void sf_put(const char *dir, const char *rel, const void *data,
size_t len) {
assertf(sf_try_put(dir, rel, data, len));
}
/* Number of times needle occurs in hay. */
static int sf_count(const char *hay, const char *needle) {
const size_t l = strlen(needle);
int n = 0;
const char *p = hay;
while ((p = strstr(p, needle)) != NULL) {
n++;
p += l;
}
return n;
}
/* The base64 payload following the first occurrence of prefix, up to the first
byte outside the base64 alphabet. NULL if prefix is absent. */
static const char *sf_payload(const char *hay, const char *prefix,
size_t *len) {
const char *p = strstr(hay, prefix);
size_t n = 0;
if (p == NULL)
return NULL;
p += strlen(prefix);
while (p[n] != '\0' && (isalnum((unsigned char) p[n]) || p[n] == '+' ||
p[n] == '/' || p[n] == '='))
n++;
*len = n;
return p;
}
/* Independent base64 decoder: the round-trip check must not lean on code64().
*/
static unsigned char *sf_unb64(const char *s, size_t len, size_t *outlen) {
static const char alpha[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
unsigned char *out = (unsigned char *) malloct(len / 4 * 3 + 4);
unsigned int acc = 0;
size_t i, n = 0;
int bits = 0;
if (out == NULL)
return NULL;
for (i = 0; i < len; i++) {
const char *const p = s[i] != '\0' ? strchr(alpha, s[i]) : NULL;
if (s[i] == '=')
break;
if (p == NULL) {
freet(out);
return NULL;
}
acc = (acc << 6) | (unsigned int) (p - alpha);
bits += 6;
if (bits >= 8) {
bits -= 8;
out[n++] = (unsigned char) ((acc >> bits) & 0xff);
}
}
*outlen = n;
return out;
}
/* Decoded payload of the first data: URI with that MIME, as a NUL-terminated
buffer the caller freet()s. NULL when absent or undecodable. */
static char *sf_decode(const char *hay, const char *mime, size_t *outlen) {
char prefix[128];
size_t len = 0, dlen = 0;
const char *b64;
unsigned char *raw;
snprintf(prefix, sizeof(prefix), "data:%s;base64,", mime);
b64 = sf_payload(hay, prefix, &len);
if (b64 == NULL)
return NULL;
raw = sf_unb64(b64, len, &dlen);
if (raw == NULL)
return NULL;
raw[dlen] = '\0';
if (outlen != NULL)
*outlen = dlen;
return (char *) raw;
}
/* How many data: URIs of that MIME nest inside each other, starting at hay. */
static int sf_nesting(const char *hay, const char *mime) {
char *cur = strdupt(hay);
int n = 0;
while (cur != NULL) {
char *const inner = sf_decode(cur, mime, NULL);
freet(cur);
cur = inner;
if (inner != NULL)
n++;
}
return n;
}
/* Above the tag parser's attribute limit, so the fixture crosses it. */
#define SF_ST_MAX_ATTRS 64
/* The 12-byte asset: high bytes and an embedded NUL, so a text-shaped copy
would be caught. */
static const char sf_png[] = "\x89PNG\r\n\x1a\n\x00\x01\x02\xff";
#define SF_PNG_LEN 12
static const char sf_page[] =
"<html><head>\n"
"<link rel=\"stylesheet\" href=\"css/main.css\">\n"
"<link rel=\"canonical\" href=\"other.html\">\n"
"<title>t</title>\n"
"<style>body { background: url(\"img/a%20b.png\"); }</style>\n"
"</head><body>\n"
"<img src=\"img/a%20b.png\" srcset=\"img/a%20b.png 1x, img/big.png 2x\">\n"
"<link rel=\"icon\" href=\"icon.png\">\n"
"<link rel=\"preload\" as=\"font\" href=\"font/f.woff2\">\n"
"<img src=\"data:image/gif;base64,QUJD\">\n"
/* Each has a real file where its guard's removal would land it; without
that they stay links either way, the target merely being absent. */
"<img src=\"http://example.com/x.png\">\n"
"<img src=\"//example.com/x.png\">\n"
"<input type=\"image\" src=\"img/in.png\">\n"
/* Lazy loading: src is the placeholder, the real image rides data-src. */
"<img src=\"img/ph.png\" data-src=\"img/lz.png\" "
"data-srcset=\"img/lz2.png 2x\" lowsrc=\"img/low.png\">\n"
"<object data=\"img/ob.png\"></object>\n"
"<embed src=\"img/em.png\">\n"
"<img data-src=\"other.html\">\n"
/* What a first pass emits: re-resolving it is what a second pass must not
do, and the fallback type would inline whatever the walk found. */
"<link rel=\"stylesheet\" href=\"data:text/css;base64,QUJD\">\n"
"<video poster=\"img/po.png\" controls>"
"<source src=\"v.mp4\" type=\"video/mp4\"></video>\n"
"<svg><image href=\"img/sv.png\"/></svg>\n"
"<table background=\"img/bg.png\"><tr><td>x</td></tr></table>\n"
/* The second is what bites: drop the clamp and its leading ".." lands it
back on <root>/img/a b.png. The first can only 404 either way. */
"<img src=\"../escape.png\">\n"
"<img src=\"../img/a%20b.png\">\n"
"<a href=\"img/a%20b.png\">link</a>\n"
"<script src=\"js/app.js\"></script>\n"
"<script>var s = \"</scripting>\"; var t = \"<img src='img/a%20b.png'>\";"
"</script>\n"
"<img src=\"missing.png\" >\n"
"<!--><img src=\"img/a%20b.png\">\n"
"<div style=\"background:url(img/a%20b.png)\"></div>\n"
"<div style='content:\"x\"; background:url(img/a%20b.png)'></div>\n"
"</body></html>\n";
/* Lay a small mirror down under root. */
static void sf_fixture(const char *root) {
/* The over-cap url() is what drives the rebase fallback: a reference an
inlined stylesheet could not embed has to come out relative to the page,
not to the stylesheet, or it dangles. */
static const char css[] =
"@import \"sub/nested.css\";\n"
"@import url(\"sub/two.css\");\n"
"@import \"a\\\"url(../img/a b.png)b.css\";\n"
"@font-face { font-family: f; src: url(../font/f.woff2); }\n"
"body { background: url(../img/a b.png); }\n"
"div { background: url(../img/big.png); }\n"
"/* url(../img/never.png) */\n";
static const char nested[] = "div { background: url(../../img/a b.png); }\n";
static const char two[] = "p { background: url(../../img/a b.png); }\n";
static const char deep[] =
"<html><head><link rel=\"stylesheet\" href=\"../../css/main.css\">\n"
"</head><body>d</body></html>\n";
static const char js[] = "var app = 1;\n";
char big[4096];
memset(big, 'B', sizeof(big));
sf_put(root, "page.html", sf_page, sizeof(sf_page) - 1);
sf_put(root, "deep/sub/page.html", deep, sizeof(deep) - 1);
sf_put(root, "other.html", "<html>o</html>", 14);
sf_put(root, "css/main.css", css, sizeof(css) - 1);
sf_put(root, "css/sub/nested.css", nested, sizeof(nested) - 1);
sf_put(root, "css/sub/two.css", two, sizeof(two) - 1);
sf_put(root, "js/app.js", js, sizeof(js) - 1);
sf_put(root, "img/a b.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/big.png", big, sizeof(big));
sf_put(root, "img/in.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/po.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/sv.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/bg.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/ph.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/lz.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/lz2.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/low.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/ob.png", sf_png, SF_PNG_LEN);
sf_put(root, "img/em.png", sf_png, SF_PNG_LEN);
sf_put(root, "icon.png", sf_png, SF_PNG_LEN);
sf_put(root, "font/f.woff2", "wOF2\x00\x01", 6);
/* Where a guard's removal would land each reference that has to stay a
link. The colon-bearing two are impossible on Windows, where the scheme
guard then only gets the weaker "the link survived" check. */
sf_put(root, "img/never.png", sf_png, SF_PNG_LEN);
sf_put(root, "example.com/x.png", sf_png, SF_PNG_LEN);
sf_colon_ok =
sf_try_put(root, "http:/example.com/x.png", sf_png, SF_PNG_LEN) &&
sf_try_put(root, "data:text/css;base64,QUJD", "p{}", 3);
{ /* More attributes than the tag parser records, with a '>' inside a quoted
value: the give-up path must not rescan quote-blind. */
String wide = STRING_EMPTY;
int n;
StringCopy(wide, "<html><body><p");
for (n = 0; n <= SF_ST_MAX_ATTRS; n++) {
char one[32];
assertf(sprintfbuff(one, " a%d=1", n));
StringCat(wide, one);
}
StringCat(wide,
" title=\"> <img src=img/a%20b.png> \">end</p></body></html>");
sf_put(root, "wide.html", StringBuff(wide), StringLength(wide));
StringFree(wide);
}
sf_put(root, "v.mp4",
"\x00\x00\x00\x18"
"ftypisom",
12);
}
/* -#test=singlefile <dir>: rewrite a hand-built mirror and check what gets
inlined, what must keep its link, the per-asset cap, and idempotence. */
static int st_singlefile(httrackp *opt, int argc, char **argv) {
char BIGSTK root[HTS_URLMAXSIZE];
char BIGSTK page[HTS_URLMAXSIZE * 2];
const LLint saved_cap = opt->single_file_max_size;
char *out, *css, *nested;
size_t outlen = 0, len = 0;
if (argc < 1) {
fprintf(stderr, "singlefile: needs a writable directory\n");
return 1;
}
sf_err = 0;
sf_put(argv[0], "escape.png", sf_png,
SF_PNG_LEN); /* just outside the mirror */
fconcat(root, sizeof(root), argv[0], "mirror/");
sf_fixture(root);
fconcat(page, sizeof(page), root, "page.html");
/* Cap between the small assets and big.png. */
opt->single_file_max_size = 1024;
sf_check(singlefile_rewrite_file(opt, root, page),
"first pass changed nothing");
out = readfile_utf8(page);
assertf(out != NULL);
/* Inlined, and the payload is the file's exact bytes. */
{
char *img = sf_decode(out, "image/png", &len);
sf_check(img != NULL && len == SF_PNG_LEN &&
memcmp(img, sf_png, SF_PNG_LEN) == 0,
"image payload does not round-trip");
freet(img);
}
{
char *js = sf_decode(out, "application/x-javascript", &len);
sf_check(js != NULL && len == 13 && memcmp(js, "var app = 1;\n", 13) == 0,
"script payload does not round-trip");
freet(js);
}
sf_check(strstr(out, "href=\"data:text/css;base64,") != NULL,
"stylesheet not inlined into the link");
{
char *font = sf_decode(out, "font/woff2", &len);
sf_check(font != NULL && len == 6 && memcmp(font, "wOF2\x00\x01", 6) == 0,
"rel=preload font payload does not round-trip");
freet(font);
}
/* Every other (tag, attribute) rule in the table. */
sf_check(strstr(out, "icon.png") == NULL, "rel=icon not inlined");
sf_check(strstr(out, "img/in.png") == NULL, "input src not inlined");
sf_check(strstr(out, "img/po.png") == NULL, "video poster not inlined");
sf_check(strstr(out, "img/sv.png") == NULL, "svg image href not inlined");
sf_check(strstr(out, "img/bg.png") == NULL,
"legacy background attribute not inlined");
sf_check(strstr(out, "img/ob.png") == NULL, "object data not inlined");
sf_check(strstr(out, "img/em.png") == NULL, "embed src not inlined");
sf_check(strstr(out, "img/ph.png") == NULL, "lazy placeholder not inlined");
sf_check(strstr(out, "img/lz.png") == NULL, "data-src not inlined");
sf_check(strstr(out, "img/lz2.png") == NULL, "data-srcset not inlined");
sf_check(strstr(out, "img/low.png") == NULL, "lowsrc not inlined");
/* The class gate, not the attribute name, is what keeps a page out. */
sf_check(strstr(out, "data-src=\"other.html\"") != NULL,
"data-src pointing at a page was inlined");
sf_check(strstr(out, "<a href=\"img/a%20b.png\">") != NULL, "anchor inlined");
sf_check(strstr(out, "href=\"other.html\"") != NULL, "rel=canonical inlined");
sf_check(strstr(out, "src=\"v.mp4\"") != NULL, "video source inlined");
sf_check(strstr(out, "src=\"http://example.com/x.png\"") != NULL,
"absolute URL rewritten");
sf_check(strstr(out, "src=\"//example.com/x.png\"") != NULL,
"site-root-relative URL rewritten");
sf_check(sf_count(out, "QUJD") == 2, "existing data: URI not preserved");
sf_check(strstr(out, "src=\"../escape.png\"") != NULL,
"a reference outside the mirror was resolved");
sf_check(strstr(out, "src=\"../img/a%20b.png\"") != NULL,
"a leading .. was dropped instead of rejected");
sf_check(strstr(out, "var t = \"<img src='img/a%20b.png'>\";") != NULL,
"script body rewritten past a </scripting> lookalike");
/* Nothing an attribute value cannot hold: url() stays unquoted, and a quote
that was already in the CSS is escaped. */
sf_check(strstr(out, "url(\"data:") == NULL,
"a quoted url() would end a style attribute");
sf_check(strstr(out, "style=\"content:&quot;x&quot;; background:url(data:") !=
NULL,
"quote inside a rewritten style attribute not escaped");
sf_check(strstr(out, "img/big.png 2x") != NULL, "over-cap asset inlined");
sf_check(strstr(out, " 1x") != NULL, "srcset descriptor lost");
sf_check(sf_count(out, "img/a%20b.png") ==
3, /* the anchor, the script body, and the ".." one */
"an inlinable reference was left as a link");
/* The inlined stylesheet carries its own @import and url() inlined. */
css = sf_decode(out, "text/css", NULL);
sf_check(css != NULL, "stylesheet payload undecodable");
if (css != NULL) {
sf_check(strstr(css, "@import \"data:text/css;base64,") != NULL,
"@import not inlined");
sf_check(strstr(css, "url(data:image/png;base64,") != NULL,
"url() in stylesheet not inlined");
sf_check(strstr(css, "url(../img/never.png)") != NULL,
"url() inside a CSS comment was rewritten");
sf_check(strstr(css, "url(data:font/woff2;base64,") != NULL,
"@font-face src not inlined");
sf_check(strstr(css, "@import url(data:text/css;base64,") != NULL,
"@import url() form not inlined");
sf_check(strstr(css, "url(../img/a b.png)b.css") != NULL,
"url() inside a string with an escaped quote was rewritten");
/* The over-cap url() read ../img/big.png from css/; this page sits at the
root, so it has to come back out as img/big.png or it dangles. */
sf_check(strstr(css, "url(img/big.png)") != NULL,
"over-cap url() not rebased onto the page's directory");
nested = sf_decode(css, "text/css", NULL);
sf_check(nested != NULL &&
strstr(nested, "url(data:image/png;base64,") != NULL,
"url() in the @import'ed stylesheet not inlined");
freet(nested);
freet(css);
}
/* A tag with more attributes than the parser records comes back byte for
byte, quoted '>' and all, instead of being re-scanned as markup. */
{
char BIGSTK wide[HTS_URLMAXSIZE * 2];
char *before, *after;
fconcat(wide, sizeof(wide), root, "wide.html");
before = readfile_utf8(wide);
assertf(before != NULL);
(void) singlefile_rewrite_file(opt, root, wide);
after = readfile_utf8(wide);
sf_check(after != NULL && strcmp(before, after) == 0,
"an over-wide tag was rewritten");
freet(after);
freet(before);
}
/* The same stylesheet from two directories down, where the rebase has to
climb: the emitted path must stay inside the mirror and name the file. */
{
char BIGSTK deep[HTS_URLMAXSIZE * 2];
char *dout, *dcss;
fconcat(deep, sizeof(deep), root, "deep/sub/page.html");
sf_check(singlefile_rewrite_file(opt, root, deep),
"deep page not rewritten");
dout = readfile_utf8(deep);
assertf(dout != NULL);
dcss = sf_decode(dout, "text/css", NULL);
sf_check(dcss != NULL && strstr(dcss, "url(../../img/big.png)") != NULL,
"over-cap url() not rebased from a nested page");
freet(dcss);
freet(dout);
}
/* Idempotence: a second pass must find nothing and leave the bytes alone. */
sf_check(!singlefile_rewrite_file(opt, root, page), "second pass rewrote");
{
char *again = readfile_utf8(page);
sf_check(again != NULL && strcmp(again, out) == 0,
"second pass changed the page");
freet(again);
}
freet(out);
/* Same page, a cap above big.png: it now inlines. */
fconcat(root, sizeof(root), argv[0], "mirror2/");
sf_fixture(root);
fconcat(page, sizeof(page), root, "page.html");
opt->single_file_max_size = 1024 * 1024;
sf_check(singlefile_rewrite_file(opt, root, page),
"raised-cap pass changed nothing");
out = readfile_utf8(page);
assertf(out != NULL);
sf_check(strstr(out, "img/big.png 2x") == NULL,
"asset under the raised cap still a link");
freet(out);
/* Nothing inlines, so the rewriter must be byte transparent. Assert on its
output: the file it declined to write could not have changed regardless. */
fconcat(root, sizeof(root), argv[0], "mirror3/");
sf_fixture(root);
fconcat(page, sizeof(page), root, "page.html");
opt->single_file_max_size = 1;
sf_check(!singlefile_rewrite_file(opt, root, page), "one-byte cap inlined");
{
String verbatim = STRING_EMPTY;
StringClear(verbatim);
(void) singlefile_rewrite_html(opt, root, page, sf_page,
sizeof(sf_page) - 1,
SINGLEFILE_MAX_PAGE_SIZE, &verbatim);
sf_check(StringLength(verbatim) == sizeof(sf_page) - 1 &&
memcmp(StringBuff(verbatim), sf_page, sizeof(sf_page) - 1) ==
0,
"a page with nothing to inline was re-serialized differently");
StringFree(verbatim);
}
(void) outlen;
/* The per-page budget against a self-importing stylesheet. The large-budget
run is the control: it proves the fan-out is real, so the small one was
cut short by the budget and not by the fixture. */
{
static const char bomb_css[] = "@import \"b.css\";@import \"b.css\";"
"@import \"b.css\";@import \"b.css\";\n";
static const char bomb_html[] = "<html><head>"
"<link rel=\"stylesheet\" href=\"b.css\">"
"</head></html>\n";
const size_t css_len = sizeof(bomb_css) - 1;
String small = STRING_EMPTY, large = STRING_EMPTY;
fconcat(root, sizeof(root), argv[0], "bomb/");
sf_put(root, "b.css", bomb_css, css_len);
fconcat(page, sizeof(page), root, "page.html");
opt->single_file_max_size = 1024 * 1024;
StringClear(small);
StringClear(large);
(void) singlefile_rewrite_html(opt, root, page, bomb_html,
sizeof(bomb_html) - 1, (LLint) css_len * 3,
&small);
(void) singlefile_rewrite_html(opt, root, page, bomb_html,
sizeof(bomb_html) - 1,
SINGLEFILE_MAX_PAGE_SIZE, &large);
sf_check(StringLength(large) > 4096, "the @import bomb did not fan out");
sf_check(StringLength(small) < StringLength(large) / 8,
"the per-page budget did not cut the fan-out short");
/* Three files fit in three file-lengths only if the budget is charged
before each nested rewrite; charging after measured five levels. */
sf_check(sf_nesting(StringBuff(small), "text/css") == 3,
"the per-page budget was not charged as each asset was taken");
StringFree(small);
StringFree(large);
}
if (!sf_colon_ok)
printf("singlefile: ':' is not a legal filename here, so the scheme guard "
"only gets the weaker check\n");
opt->single_file_max_size = saved_cap;
printf("singlefile: %s\n", sf_err ? "FAIL" : "OK");
return sf_err;
}
// -#test=longpath <dir>: round-trip a >MAX_PATH (260) file through the file
// wrappers, exercising hts_pathToUCS2's \\?\ prefixing on Windows (#133).
static int st_longpath(httrackp *opt, int argc, char **argv) {
@@ -5323,6 +5912,9 @@ static const struct selftest_entry {
{"warc-wacz", "<dir>", "--wacz package: layout, STORE mode, sha256 digests",
st_warc_wacz},
#endif
{"singlefile", "<dir>",
"--single-file: what is inlined, the per-asset cap, idempotence",
st_singlefile},
};
static void list_selftests(void) {

View File

@@ -318,6 +318,18 @@ typedef struct {
error_redirect = "/server/error.html"; \
} while(0)
/* Longest error message shown on the error page; the rest is clipped. */
#define ERROR_MESSAGE_MAX 1024
/* SET_ERROR() with a printf format. Clips: these messages quote posted fields,
whose length the client picks. */
#define SET_ERRORF(...) \
do { \
char errbuf[ERROR_MESSAGE_MAX]; \
slprintfbuff_clip(errbuf, sizeof(errbuf), __VA_ARGS__); \
SET_ERROR(errbuf); \
} while (0)
/* Longest "sid" value worth unescaping: the expected one is an md5 hex digest,
so anything near this is already invalid and is rejected unread. */
#define SID_VALUE_MAX 64
@@ -725,7 +737,7 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
fp = fopen(StringBuff(fspath), "rb");
if (fp) {
/* Read file */
while(!feof(fp)) {
while (!feof(fp) && !ferror(fp)) {
char *str = line;
char *pos;
@@ -879,28 +891,18 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
commandEnd = 1;
}
} else {
char tmp[1024];
sprintf(tmp,
"Unable to write %d bytes in the the init file %s",
count, StringBuff(fspath));
SET_ERROR(tmp);
SET_ERRORF(
"Unable to write %d bytes in the the init file %s",
count, StringBuff(fspath));
}
fclose(fp);
} else {
char tmp[1024];
sprintf(tmp, "Unable to create the init file %s",
StringBuff(fspath));
SET_ERROR(tmp);
SET_ERRORF("Unable to create the init file %s",
StringBuff(fspath));
}
} else {
char tmp[1024];
sprintf(tmp,
"Unable to create the directory structure in %s",
StringBuff(fspath));
SET_ERROR(tmp);
SET_ERRORF("Unable to create the directory structure in %s",
StringBuff(fspath));
}
} else {
@@ -978,10 +980,10 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
}
}
/* path itself may hold ".." (webhttrack passes "<bin>/../share"), so
only the untrusted halves are checked: file here, website above. */
if (fsfile[0] && strstr(file, "..") == NULL
&& (fp = fopen(fsfile, "rb"))) {
/* Regular files only: reading a directory or FIFO never ends, and
"path" may hold "..", so only the untrusted halves are checked. */
if (fsfile[0] && strstr(file, "..") == NULL && fexist(fsfile) &&
(fp = fopen(fsfile, "rb"))) {
char ok[] =
"HTTP/1.0 200 OK\r\n" "Connection: close\r\n"
"Server: httrack-small-server\r\n" "Content-type: text/html\r\n"
@@ -1040,7 +1042,7 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
int outputmode = 0;
StringMemcat(headers, ok, sizeof(ok) - 1);
while(!feof(fp)) {
while (!feof(fp) && !ferror(fp)) {
char *str = line;
int prevlen = (int) StringLength(output);
int nocr = 0;
@@ -1474,14 +1476,15 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
while(!feof(fp)) {
int n = (int) fread(line, 1, sizeof(line) - 2, fp);
if (n > 0) {
StringMemcat(output, line, n);
if (n <= 0) {
break; /* short read: EOF or error, never a retry */
}
StringMemcat(output, line, n);
}
}
fclose(fp);
} else if (strcmp(file, "/ping") == 0
|| strncmp(file, "/ping?", 6) == 0) {
} else if (strcmp(file, "/ping") == 0 ||
strncmp(file, "/ping?", 6) == 0) {
char error_hdr[] =
"HTTP/1.0 200 Pong\r\n" "Server: httrack small server\r\n"
"Content-type: text/html\r\n";

1160
src/htssinglefile.c Normal file

File diff suppressed because it is too large Load Diff

84
src/htssinglefile.h Normal file
View File

@@ -0,0 +1,84 @@
/* ------------------------------------------------------------ */
/*
HTTrack Website Copier, Offline Browser for Windows and Unix
Copyright (C) 2026 Xavier Roche and other contributors
SPDX-License-Identifier: GPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Ethical use: we kindly ask that you NOT use this software to harvest email
addresses or to collect any other private information about people. Doing so
would dishonor our work and waste the many hours we have spent on it.
Please visit our Website: http://www.httrack.com
*/
/* ------------------------------------------------------------ */
/* --single-file: post-mirror pass embedding each page's assets as data: URIs.
Internal, not installed. Unlike -%M (one MHT archive for the whole mirror)
it rewrites the saved pages in place and keeps page-to-page links relative,
so the mirror stays browsable and every page also stands alone.
Limitation: an inlined stylesheet becomes a data: URL, whose path is opaque,
so an asset inside it that stayed a link (over the cap) no longer resolves.
Raise --single-file-max-size past it to inline it too. */
/* ------------------------------------------------------------ */
#ifndef HTS_SINGLEFILE_DEFH
#define HTS_SINGLEFILE_DEFH
#include "htsopt.h"
#include "htsstrings.h"
#ifdef __cplusplus
extern "C" {
#endif
/* --single-file-max-size default: a bigger asset stays an ordinary link. */
#define SINGLEFILE_DEFAULT_MAX_SIZE (10 * 1024 * 1024)
/* Never base64 more than this, whatever the cap: code64() sizes in int. */
#define SINGLEFILE_HARD_MAX_SIZE (256 * 1024 * 1024)
/* Total bytes one page may inline. Nested @import fans out multiplicatively,
so a few hundred bytes of hostile CSS can otherwise ask for gigabytes. */
#define SINGLEFILE_MAX_PAGE_SIZE (64 * 1024 * 1024)
/* Rewrite every HTML page the mirror produced. No-op unless opt->single_file;
call once the tree is final, after the update purge. */
void singlefile_process_mirror(httrackp *opt);
/* Rewrite one HTML document held in memory, appending the result to out.
root is the mirror directory that references may not escape; page_path is
the document's own path under it (both UTF-8, '/' or native separators).
page_budget caps the total inlined bytes, since nested @import fans out
multiplicatively; the mirror pass passes SINGLEFILE_MAX_PAGE_SIZE.
Returns HTS_TRUE if at least one reference was replaced; out may still
differ from the input when that is HTS_FALSE, since a style or srcset value
is re-serialized in place. */
hts_boolean singlefile_rewrite_html(httrackp *opt, const char *root,
const char *page_path, const char *html,
size_t html_len, LLint page_budget,
String *out);
/* singlefile_rewrite_html() over the file at page_path, rewritten in place.
Returns HTS_TRUE if the file changed. */
hts_boolean singlefile_rewrite_file(httrackp *opt, const char *root,
const char *page_path);
#ifdef __cplusplus
}
#endif
#endif

63
src/htsstats.h Normal file
View File

@@ -0,0 +1,63 @@
/* ------------------------------------------------------------ */
/*
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: in-progress display rows, shared by httrack and htsserver .h */
/* Author: Xavier Roche */
/* ------------------------------------------------------------ */
#ifndef HTS_STATS_DEFH
#define HTS_STATS_DEFH
#include "htsglobal.h"
/* One row of the "in progress" display, shared so the CLI (httrack) and the
web GUI (htsserver) cannot drift apart: each fills its own array. */
#define NStatsBuffer 14
#ifndef HTS_DEF_FWSTRUCT_t_StatsBuffer
#define HTS_DEF_FWSTRUCT_t_StatsBuffer
typedef struct t_StatsBuffer t_StatsBuffer;
#endif
struct t_StatsBuffer {
char name[1024];
char file[1024];
char state[288]; // a short label plus back->info[256]
char BIGSTK url_sav[HTS_URLMAXSIZE * 2]; // pour cancel
char BIGSTK url_adr[HTS_URLMAXSIZE * 2];
char BIGSTK url_fil[HTS_URLMAXSIZE * 2];
LLint size;
LLint sizetot;
int offset;
//
int back;
//
int actived; // pour disabled
};
#endif

View File

@@ -35,26 +35,10 @@ Please visit our Website: http://www.httrack.com
#include "htsglobal.h"
#include "htscore.h"
#include "htsstats.h"
#define NStatsBuffer 14
#define MAX_LEN_INPROGRESS 40
typedef struct t_StatsBuffer {
char name[1024];
char file[1024];
char state[288]; // a short label plus back->info[256]
char url_sav[HTS_URLMAXSIZE * 2]; // pour cancel
char url_adr[HTS_URLMAXSIZE * 2];
char url_fil[HTS_URLMAXSIZE * 2];
LLint size;
LLint sizetot;
int offset;
//
int back;
//
int actived; // pour disabled
} t_StatsBuffer;
typedef struct t_InpInfo {
int ask_refresh;
int refresh;

View File

@@ -230,8 +230,7 @@ static hts_boolean vt_size_refresh(void) {
*/
#define STYLE_STATVALUES VT_BOLD
#define STYLE_STATTEXT VT_UNBOLD
#define STYLE_STATRESET VT_UNBOLD
#define NStatsBuffer 14
#define STYLE_STATRESET VT_UNBOLD
/* Rows the stats block and "Current job" take above the in-progress list. */
#define NStatsHeaderRows 7

View File

@@ -36,26 +36,7 @@ Please visit our Website: http://www.httrack.com
#include "htsglobal.h"
#include "htscore.h"
#include "htssafe.h"
#ifndef HTS_DEF_FWSTRUCT_t_StatsBuffer
#define HTS_DEF_FWSTRUCT_t_StatsBuffer
typedef struct t_StatsBuffer t_StatsBuffer;
#endif
struct t_StatsBuffer {
char name[1024];
char file[1024];
char state[288]; // a short label plus back->info[256]
char BIGSTK url_sav[HTS_URLMAXSIZE * 2]; // pour cancel
char BIGSTK url_adr[HTS_URLMAXSIZE * 2];
char BIGSTK url_fil[HTS_URLMAXSIZE * 2];
LLint size;
LLint sizetot;
int offset;
//
int back;
//
int actived; // pour disabled
};
#include "htsstats.h"
#ifndef HTS_DEF_FWSTRUCT_t_InpInfo
#define HTS_DEF_FWSTRUCT_t_InpInfo

View File

@@ -131,6 +131,7 @@
<ClCompile Include="htsproxy.c" />
<ClCompile Include="htsrobots.c" />
<ClCompile Include="htsselftest.c" />
<ClCompile Include="htssinglefile.c" />
<ClCompile Include="htssniff.c" />
<ClCompile Include="htsthread.c" />
<ClCompile Include="htstools.c" />

View File

@@ -820,8 +820,8 @@ static PT_Element proxytrack_process_DAV_Request(PT_Indexes indexes,
elt->size = StringLength(response);
elt->adr = StringAcquire(&response);
elt->statuscode = 207; /* Multi-Status */
strcpy(elt->charset, "utf-8");
strcpy(elt->contenttype, "text/xml");
strcpybuff(elt->charset, "utf-8");
strcpybuff(elt->contenttype, "text/xml");
strcpybuff(elt->msg, "Multi-Status");
StringFree(response);
@@ -877,8 +877,8 @@ static PT_Element proxytrack_process_HTTP_List(PT_Indexes indexes,
elt->size = StringLength(html);
elt->adr = StringAcquire(&html);
elt->statuscode = HTTP_OK;
strcpy(elt->charset, "iso-8859-1");
strcpy(elt->contenttype, "text/html");
strcpybuff(elt->charset, "iso-8859-1");
strcpybuff(elt->contenttype, "text/html");
strcpybuff(elt->msg, "OK");
StringFree(html);
return elt;

View File

@@ -368,7 +368,11 @@ HTS_UNUSED static struct tm PT_GetTime(time_t t) {
if (tm != NULL)
return *tm;
else {
/* an all-zero tm has tm_mday == 0, which the ARC date field prints as a
day of "00"; the epoch is the conventional "date unknown" */
memset(&tmbuf, 0, sizeof(tmbuf));
tmbuf.tm_year = 70;
tmbuf.tm_mday = 1;
return tmbuf;
}
}

View File

@@ -405,8 +405,8 @@ PT_Element PT_Index_HTML_BuildRootInfo(PT_Indexes indexes) {
elt->size = StringLength(html);
elt->adr = StringAcquire(&html);
elt->statuscode = HTTP_OK;
strcpy(elt->charset, "iso-8859-1");
strcpy(elt->contenttype, "text/html");
strcpybuff(elt->charset, "iso-8859-1");
strcpybuff(elt->contenttype, "text/html");
strcpybuff(elt->msg, "OK");
StringFree(html);
return elt;
@@ -829,16 +829,8 @@ PT_Element PT_ElementNew(void) {
}
/* ProxyTrack's htsblk_failf(): a clipped, diagnostic-only failure reason. */
static void PT_Element_failf(PT_Element r, const char *fmt, ...)
HTS_PRINTF_FUN(2, 3);
static void PT_Element_failf(PT_Element r, const char *fmt, ...) {
va_list args;
va_start(args, fmt);
(void) vslprintfbuff(r->msg, sizeof(r->msg), fmt, args);
va_end(args);
}
#define PT_Element_failf(R, ...) \
slprintfbuff_clip((R)->msg, sizeof((R)->msg), __VA_ARGS__)
PT_Element PT_ReadCache(PT_Index index, const char *url, int flags) {
if (index != NULL && SAFE_INDEX(index)) {
@@ -879,13 +871,11 @@ static PT_Element PT_ReadCache__New(PT_Index index, const char *url, int flags)
(headersSize) += (int) strlen(headers + headersSize); \
} while(0)
/* refvalue_size is mandatory: the cache line is bounded only by the line
buffer, not by the destination. Clip rather than reject, since an
engine-written field can be wider than ours. */
buffer, not by the destination. */
#define ZIP_READFIELD_STRING(line, value, refline, refvalue, refvalue_size) \
do { \
if (line[0] != '\0' && strfield2(line, refline)) { \
(refvalue)[0] = '\0'; \
strlncatbuff(refvalue, value, refvalue_size, (refvalue_size) - 1); \
(void) strclipbuff(refvalue, refvalue_size, value); \
line[0] = '\0'; \
} \
} while (0)
@@ -1042,7 +1032,7 @@ static PT_Element PT_ReadCache__New_u(PT_Index index_, const char *url,
previous_save[0] = previous_save_[0] = '\0';
memset(r, 0, sizeof(_PT_Element));
r->location = location_default;
strcpy(r->location, "");
r->location[0] = '\0';
if (strncmp(url, "http://", 7) == 0)
url += 7;
hash_pos_return = coucal_read(index->hash, url, &hash_pos);
@@ -1127,7 +1117,7 @@ static PT_Element PT_ReadCache__New_u(PT_Index index_, const char *url,
int pathLen = (int) strlen(index->path);
if (pathLen > 0 && strncmp(previous_save_, index->path, pathLen) == 0) { // old (<3.40) buggy format
strcpy(previous_save, previous_save_);
strcpybuff(previous_save, previous_save_);
}
// relative ? (hack)
else if (index->safeCache || (previous_save_[0] != '/' // /home/foo/bar.gif
@@ -1554,7 +1544,7 @@ static int PT_LoadCache__Old(PT_Index index_, const char *filename) {
cache->version = (int) (firstline[8] - '0'); // cache 1.x
if (cache->version <= 5) {
a += cache_brstr(a, firstline, sizeof(firstline));
strcpy(cache->lastmodified, firstline);
strcpybuff(cache->lastmodified, firstline);
} else {
fclose(cache->dat);
cache->dat = NULL;
@@ -1571,7 +1561,7 @@ static int PT_LoadCache__Old(PT_Index index_, const char *filename) {
} else { // Vieille version du cache
/* */
cache->version = 0; // cache 1.0
strcpy(cache->lastmodified, firstline);
strcpybuff(cache->lastmodified, firstline);
}
/* Create hash table for the cache (MUCH FASTER!) */
@@ -1587,7 +1577,14 @@ static int PT_LoadCache__Old(PT_Index index_, const char *filename) {
a++;
/* read "host/file" */
a += binput(a, line, HTS_URLMAXSIZE);
a += binput(a, line + strlen(line), HTS_URLMAXSIZE);
{
/* binput writes its NUL at s[max], so the second field must be
bounded by what is left of line[], not by the same constant
*/
const size_t used = strlen(line);
a += binput(a, line + used, (int) (sizeof(line) - used - 1));
}
/* read position */
a += binput(a, linepos, 200);
sscanf(linepos, "%d", &pos);
@@ -1684,7 +1681,7 @@ static PT_Element PT_ReadCache__Old_u(PT_Index index_, const char *url,
previous_save[0] = previous_save_[0] = '\0';
memset(r, 0, sizeof(_PT_Element));
r->location = location_default;
strcpy(r->location, "");
r->location[0] = '\0';
if (strncmp(url, "http://", 7) == 0)
url += 7;
hash_pos_return = coucal_read(cache->hash, url, &hash_pos);
@@ -1791,7 +1788,7 @@ static PT_Element PT_ReadCache__Old_u(PT_Index index_, const char *url,
int pathLen = (int) strlen(index->path);
if (pathLen > 0 && strncmp(previous_save_, index->path, pathLen) == 0) { // old (<3.40) buggy format
strcpy(previous_save, previous_save_);
strcpybuff(previous_save, previous_save_);
}
// relative ? (hack)
else if (index->safeCache || (previous_save_[0] != '/' // /home/foo/bar.gif
@@ -2168,8 +2165,7 @@ int PT_LoadCache__Arc(PT_Index index_, const char *filename) {
#define HTTP_READFIELD_STRING(line, value, refline, refvalue, refvalue_size) \
do { \
if (line[0] != '\0' && strfield2(line, refline)) { \
(refvalue)[0] = '\0'; \
strlncatbuff(refvalue, value, refvalue_size, (refvalue_size) - 1); \
(void) strclipbuff(refvalue, refvalue_size, value); \
line[0] = '\0'; \
} \
} while (0)
@@ -2208,7 +2204,7 @@ static PT_Element PT_ReadCache__Arc_u(PT_Index index_, const char *url,
location_default[0] = '\0';
memset(r, 0, sizeof(_PT_Element));
r->location = location_default;
strcpy(r->location, "");
r->location[0] = '\0';
if (strncmp(url, "http://", 7) == 0)
url += 7;
hash_pos_return = coucal_read(index->hash, url, &hash_pos);
@@ -2380,8 +2376,18 @@ static int PT_SaveCache__Arc_Fun(void *arg, const char *url, PT_Element element)
PT_SaveCache__Arc_t *st = (PT_SaveCache__Arc_t *) arg;
FILE *const fp = st->fp;
struct tm *tm = convert_time_rfc822(&st->buff, element->lastmodified);
struct tm unknown_date;
int size_headers;
/* a cached entry with no parseable Last-Modified must not take the writer
down; the epoch is the conventional "date unknown" */
if (tm == NULL) {
memset(&unknown_date, 0, sizeof(unknown_date));
unknown_date.tm_year = 70;
unknown_date.tm_mday = 1;
tm = &unknown_date;
}
sprintf(st->headers,
"HTTP/1.0 %d %s"
"\r\n"
@@ -2417,7 +2423,7 @@ static int PT_SaveCache__Arc_Fun(void *arg, const char *url, PT_Element element)
if (element->adr != NULL) {
domd5mem(element->adr, element->size, st->md5, 1);
} else {
strcpy(st->md5, "-");
strcpybuff(st->md5, "-");
}
fprintf(fp,
/* nl */

View File

@@ -146,11 +146,13 @@ sid=$(scrape_sid "${port}")
resp=$(post_redirect '/foo
X-Injected: pwned' "${port}" "${sid}") || fail "no response to a CRLF redirect value"
stop
printf '%s' "${resp}" | grep -qi 'X-Injected' &&
# Here-string, not a pipe: grep -q SIGPIPEs the producer on the first match, and
# pipefail then turns the hit into a silent pass.
grep -qi 'X-Injected' <<<"${resp}" &&
fail "CRLF in the redirect value reached the response"
test "$(printf '%s' "${resp}" | grep -ci '^Location:')" -eq 0 ||
test "$(grep -ci '^Location:' <<<"${resp}")" -eq 0 ||
fail "CRLF redirect still emitted a Location header"
test "$(printf '%s' "${resp}" | grep -c '^HTTP/1\.')" -eq 1 ||
test "$(grep -c '^HTTP/1\.' <<<"${resp}")" -eq 1 ||
fail "CRLF redirect did not yield exactly one status line"
# Loopback unless asked otherwise. Assert the socket, not the announcement.

View File

@@ -103,9 +103,10 @@ sid=$(scrape_sid "${port}")
test "${#sid}" -eq 32 || fail "did not scrape a 32-hex sid from the page (got '${sid}')"
# Accept: the legitimate flow still works. "redirect" is the cheapest field
# with a reply that is visible in the headers.
# with a reply that is visible in the headers. Matched from a here-string, not a
# pipe: grep -q SIGPIPEs the producer, and pipefail then buries the verdict.
resp=$(request "${port}" "sid=${sid}&redirect=/accepted")
printf '%s' "${resp}" | grep -q '^Location: /accepted' ||
grep -q '^Location: /accepted' <<<"${resp}" ||
fail "a body carrying the correct sid was refused"
# Refuse: missing and wrong. A missing one is the regression under test — the
@@ -114,12 +115,12 @@ printf '%s' "${resp}" | grep -q '^Location: /accepted' ||
for bad in "redirect=/nosid" "sid=&redirect=/empty" \
"sid=00000000000000000000000000000000&redirect=/wrong"; do
resp=$(request "${port}" "${bad}")
printf '%s' "${resp}" | grep -q '^Location:' &&
grep -q '^Location:' <<<"${resp}" &&
fail "body accepted without a valid sid: ${bad}"
# A refusal has to be a well-formed reply, not a headerless fragment: the
# release build used to emit only Content-length, which reads as a protocol
# error to any client and hides the reason.
printf '%s' "${resp}" | grep -q '^HTTP/1\.0 403 ' ||
grep -q '^HTTP/1\.0 403 ' <<<"${resp}" ||
fail "refusal was not a 403: ${bad}"
done
@@ -127,16 +128,23 @@ done
# applied to one global key store that later requests render from, and the
# command dispatcher reads it from there. Probe the store itself — step3
# interpolates ${projname} into its title — rather than the refused reply.
title() { request "$1" "" step3; }
store_page() {
# a dead probe or a non-page reply must not read as "the store was not written"
page=$(request "$1" "" step3) || fail "the key store probe failed"
grep -q '^HTTP/1\.0 200 ' <<<"${page}" ||
fail "the key store probe did not answer: '$(head -1 <<<"${page}")'"
}
request "${port}" "projname=UNAUTHWRITE" >/dev/null 2>&1 || true
title "${port}" | grep -q 'UNAUTHWRITE' &&
store_page "${port}"
grep -q 'UNAUTHWRITE' <<<"${page}" &&
fail "a body without a valid sid was written to the key store"
# Paired accept case: without it the assertion above passes even if the server
# simply ignores every body, which would prove nothing.
request "${port}" "sid=${sid}&projname=AUTHWRITE" >/dev/null 2>&1 || true
title "${port}" | grep -q 'AUTHWRITE' ||
store_page "${port}"
grep -q 'AUTHWRITE' <<<"${page}" ||
fail "a body carrying the correct sid was not written to the key store"
echo "PASS"

View File

@@ -90,6 +90,15 @@ sys.stdout.write(out.decode("latin-1"))' "$1" "$2" "$3"
get() { request "$1" "$2" ""; }
post() { request "$1" "" "$2"; }
# GET $2 into ${reply}, requiring status $3. Captured, not piped: a dead request
# reads as marker-absent, and so do a truncated body and a 302 to the file.
reply=
fetch() {
reply=$(get "$1" "$2") || fail "the request for $2 failed"
grep -q "^HTTP/1\.0 $3 " <<<"${reply}" ||
fail "$2: wanted a $3 reply, got '$(head -1 <<<"${reply}")'"
}
url=$(start)
port=$(portof "${url}")
srv=$(srvpid)
@@ -109,13 +118,14 @@ echo "LOGMARKER" >"${base}/proj/hts-log.txt"
# with a component over NAME_MAX (mkdir refuses it whatever the uid). Must precede
# any successful save: commandEnd then swaps the error page for the finished one.
get "${port}" /server/style.css >/dev/null # leaves a path behind in fsfile
post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=x&path=${base}/$(printf '%0300d' 0)&projname=p" |
grep -q '^Location: /server/error.html' ||
saved=$(post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=x&path=${base}/$(printf '%0300d' 0)&projname=p")
grep -q '^Location: /server/error.html' <<<"${saved}" ||
fail "the refused save did not redirect to the error page"
# No project yet, so no root to serve from.
post "${port}" "sid=${sid}&projpath=${base}/" >/dev/null
get "${port}" /website/secret.txt | grep -q SECRETMARKER &&
fetch "${port}" /website/secret.txt 404
grep -q SECRETMARKER <<<"${reply}" &&
fail "a posted projpath served a file outside any project"
# Positive control: step4's "save settings" flow registers the project without
@@ -126,24 +136,29 @@ body="${body}&path=${base}&projname=proj&projpath=${base}/proj/"
post "${port}" "${body}" >/dev/null
test -f "${base}/proj/hts-cache/winprofile.ini" ||
fail "the project was not registered: $(cat "${log}")"
get "${port}" /website/hts-log.txt | grep -q LOGMARKER ||
fetch "${port}" /website/hts-log.txt 200
grep -q LOGMARKER <<<"${reply}" ||
fail "the registered project's mirror is not served"
# Same request with the root repointed: the project is legitimate, projpath is
# not what decides where the bytes come from.
post "${port}" "sid=${sid}&projpath=${base}/" >/dev/null
get "${port}" /website/secret.txt | grep -q SECRETMARKER &&
fetch "${port}" /website/secret.txt 404
grep -q SECRETMARKER <<<"${reply}" &&
fail "a posted projpath repointed the served root"
post "${port}" "sid=${sid}&projpath=/etc/" >/dev/null
get "${port}" /website/passwd | grep -q '^root:' &&
fetch "${port}" /website/passwd 404
grep -q '^root:' <<<"${reply}" &&
fail "a posted projpath read an arbitrary system file"
# A ".." anywhere in the recorded root would escape the mirror on every later
# request, so the save must be refused and the previous root kept.
post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=x&path=${base}/proj&projname=.." >/dev/null
get "${port}" /website/secret.txt | grep -q SECRETMARKER &&
fetch "${port}" /website/secret.txt 404
grep -q SECRETMARKER <<<"${reply}" &&
fail "a '..' in the saved project path escaped the mirror root"
get "${port}" /website/hts-log.txt | grep -q LOGMARKER ||
fetch "${port}" /website/hts-log.txt 200
grep -q LOGMARKER <<<"${reply}" ||
fail "rejecting the '..' root also lost the previous one"
# The root is now the server's own, but it is still built from two posted
@@ -162,7 +177,7 @@ test -f "${fspath}/hts-cache/winprofile.ini" ||
fail "the long-path project was not registered: $(cat "${log}")"
get "${port}" "/website/${longurl}" >/dev/null 2>&1 || true
alive "${srv}" || fail "an over-long project path crashed the server: $(cat "${log}")"
get "${port}" /server/index.html | grep -q '200 OK' ||
fail "the server stopped answering after the over-long project path"
# Not just alive: still answering.
fetch "${port}" /server/index.html 200
echo "PASS"

View File

@@ -0,0 +1,51 @@
#!/bin/bash
# A cached entry whose Last-Modified is missing or unparseable must still
# convert: the ARC writer used to dereference the parsed date unchecked.
set -euo pipefail
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
# $1 Last-Modified value (empty = omit the header), $2 expected archive date,
# $3 label. The date is asserted exactly: a guard that fires unconditionally
# would clobber the valid case, and one that skips the year/day would emit a
# month and day of 00.
run_case() {
lastmod=$1
wantdate=$2
what=$3
{
printf 'HTTP/1.1 200 OK\r\n'
printf 'Content-Type: text/html\r\n'
test -z "$lastmod" || printf 'Last-Modified: %s\r\n' "$lastmod"
printf 'Content-Length: 5\r\n\r\n'
} >"$dir/hdr"
printf 'hello' >"$dir/body"
alen=$(($(wc -c <"$dir/hdr") + $(wc -c <"$dir/body")))
{
printf 'filedesc://t.arc 0.0.0.0 20250101000000 text/plain 200 - - 0 t.arc 9\n'
printf '2 0 test\n'
printf '\n\n'
printf 'http://example.com/p.html 0.0.0.0 20250101000000 text/html 200 - - 0 t.arc %d\n' "$alen"
cat "$dir/hdr" "$dir/body"
} >"$dir/in.arc"
proxytrack --convert "$dir/out.arc" "$dir/in.arc" >/dev/null 2>&1 || {
echo "FAIL: proxytrack crashed on $what" >&2
exit 1
}
grep -aq "^http://example.com/p.html 0.0.0.0 ${wantdate} " "$dir/out.arc" || {
echo "FAIL: $what: expected archive date ${wantdate}, got:" >&2
grep -a '^http://example.com/' "$dir/out.arc" >&2 || echo "(entry dropped)" >&2
exit 1
}
}
# the epoch stands in for "date unknown"
run_case '' 19700101000000 'a missing Last-Modified'
run_case 'not a date at all' 19700101000000 'an unparseable Last-Modified'
run_case '0' 19700101000000 'a bare 0 Last-Modified'
# a parseable date must still come through untouched
run_case 'Sun, 06 Nov 1994 08:49:37 GMT' 19941106084937 'a valid Last-Modified'

View File

@@ -0,0 +1,69 @@
#!/bin/bash
# A cache file whose mtime is beyond what gmtime can represent must not put a
# day of "00" in the ARC filedesc date: PT_GetTime fell back to an all-zero tm.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
python=$(find_python) || {
echo "python3 not found; skipping" >&2
exit 77
}
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
rc=0
"$python" - "$dir/in.zip" <<'EOF' || rc=$?
import os, sys, zipfile
body = b"hello world"
meta = (
"X-In-Cache: 1\r\n"
"X-StatusCode: 200\r\n"
"X-StatusMessage: OK\r\n"
"X-Size: %d\r\n"
"Content-Type: text/html\r\n"
"Last-Modified: Wed, 01 Jan 2025 00:00:00 GMT\r\n"
"X-Save: out/page.html\r\n" % len(body)
).encode()
zi = zipfile.ZipInfo("example.com/page.html")
zi.compress_type = zipfile.ZIP_STORED
zi.extra = meta
with zipfile.ZipFile(sys.argv[1], "w") as z:
z.writestr(zi, body)
# past gmtime's range; the index timestamp is this file's mtime
huge = 4611686018427387903
try:
os.utime(sys.argv[1], (huge, huge))
except OSError:
sys.exit(77)
if int(os.stat(sys.argv[1]).st_mtime) < huge:
sys.exit(77) # filesystem clamped it, nothing to test
EOF
test "$rc" -eq 0 || {
test "$rc" -eq 77 || {
echo "FAIL: could not build the cache (python exit $rc)" >&2
exit 1
}
echo "filesystem will not hold an out-of-range mtime; skipping" >&2
exit 77
}
proxytrack --convert "$dir/out.arc" "$dir/in.zip" >/dev/null 2>&1 || {
echo "FAIL: proxytrack failed on a cache with an out-of-range mtime" >&2
exit 1
}
# field 3 of the filedesc line is YYYYMMDDhhmmss; the epoch stands in for
# "unknown", and 14 digits with a real day is the point (00 is what broke)
date=$(head -n1 "$dir/out.arc" | awk '{ print $3 }')
test "$date" = 19700101000000 || {
echo "FAIL: filedesc date is '$date', expected 19700101000000" >&2
exit 1
}

View File

@@ -0,0 +1,174 @@
#!/bin/bash
#
# The "save settings" failure messages quote a project path the request body
# supplies, so composing them must clip instead of running off a fixed buffer.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
distdir=${top_srcdir:-$(cd "${testdir}/.." && pwd)}
distdir=$(cd "${distdir}" && pwd)
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v htsserver >/dev/null || fail "no htsserver in PATH"
command -v python3 >/dev/null || {
echo "python3 not found; skipping" >&2
exit 77
}
# ERROR_MESSAGE_MAX - 1 in htsserver.c.
msgmax=1023
srv=
log=$(mktemp)
# Physical path: macOS resolves TMPDIR through /var -> private/var, and the
# paths built below sit within a few bytes of its 1024-byte PATH_MAX.
base=$(cd "$(mktemp -d)" && pwd -P)
cleanup() {
test -z "${srv}" || kill -9 "${srv}" 2>/dev/null || true
rm -f "${log}"
rm -rf "${base}"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
freeport() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
# Echo the announced URL. Runs in a command substitution, so it is a
# subshell and cannot export the pid: the caller reads it back with srvpid.
start() {
local port url
port=$(freeport)
: >"${log}"
(
trap '' TERM TTOU XFSZ
# Caps regular-file writes, which is how the failing-write branch below
# is reached without /dev/full; the announce rides a pipe, exempt.
ulimit -f 8
exec htsserver "${distdir}/" --port "${port}" 2>&1
) | cat >"${log}" &
for _ in $(seq 1 40); do
url=$(sed -n 's/^URL=//p' "${log}" 2>/dev/null) && test -n "${url}" && break
sleep 0.25
done
test -n "${url:-}" || fail "htsserver did not come up: $(cat "${log}")"
echo "${url}"
}
srvpid() { sed -n 's/^PID=//p' "${log}" | head -1; }
alive() { kill -0 "$1" 2>/dev/null; }
portof() { echo "${1##*:}" | tr -d /; }
# Raw request to 127.0.0.1:$1: GET the path $2, or POST the body $3 to / when
# $2 is empty. Prints the reply.
request() {
python3 -c 'import socket, sys
port, path, body = int(sys.argv[1]), sys.argv[2], sys.argv[3]
if path:
req = "GET %s HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" % path
else:
req = ("POST / HTTP/1.0\r\nHost: 127.0.0.1\r\n"
"Content-type: application/x-www-form-urlencoded\r\n"
"Content-length: %d\r\n\r\n%s" % (len(body), body))
s = socket.create_connection(("127.0.0.1", port), 10)
s.settimeout(30)
s.sendall(req.encode())
out = b""
while True:
b = s.recv(65536)
if not b:
break
out += b
s.close()
sys.stdout.write(out.decode("latin-1"))' "$1" "$2" "$3"
}
get() { request "$1" "$2" ""; }
post() { request "$1" "" "$2"; }
# Echo a path under the root $1 exactly $2 bytes long, in NAME_MAX-sized parts.
padpath() {
local root=$1 want=$2 p=$1 last
while test $((${#p} + 203)) -lt "${want}"; do
p="${p}/$(printf '%0200d' 0)"
done
last=$((want - ${#p} - 1))
test "${last}" -ge 1 || fail "cannot build a ${want}-byte path under ${root}"
printf '%s/%0*d' "${p}" "${last}" 0
}
# Fail unless the error page renders the message opening with $1 about project
# path $2, clipped to msgmax. error.html puts ${error} alone on its own line.
check_clipped() {
local prefix=$1 full=$((${#1} + ${#2})) page line shown
test "${full}" -gt "${msgmax}" ||
fail "a ${full}-byte message fits in ${msgmax}: it cannot show a clip"
page=$(get "${port}" /server/error.html | tr -d '\r')
line=$(printf '%s\n' "${page}" | grep "^${prefix}") || {
shown=$(printf '%s\n' "${page}" | grep '^Unable to ' | cut -c1-80 || true)
fail "the error page reports '${shown:-nothing}', want: ${prefix}"
}
test "${#line}" -eq "${msgmax}" ||
fail "the message rendered ${#line} bytes, want ${full} clipped to ${msgmax}"
}
url=$(start)
port=$(portof "${url}")
srv=$(srvpid)
test -n "${srv}" || fail "htsserver did not report its pid"
# Every request body is gated by the session id (78_webhttrack-sid.test).
sid=$(get "${port}" /server/index.html |
sed -n 's/.*name="sid" value="\([0-9a-f]*\)".*/\1/p' | head -1)
test "${#sid}" -eq 32 || fail "did not scrape a 32-hex sid (got '${sid}')"
save() {
post "${port}" "sid=${sid}&command=httrack&command_do=save&winprofile=$2&path=$1&projname=$3"
}
# A project path too long to be created at all.
path=$(padpath "${base}/nodir" 1098)
save "${path}" x p >/dev/null
alive "${srv}" ||
fail "an over-long project path crashed the server: $(cat "${log}")"
check_clipped "Unable to create the directory structure in " "${path}/p"
# A creatable project path whose hts-cache is not a directory, so the init file
# under it cannot be opened.
path=$(padpath "${base}/isdir" 993)
mkdir -p "${path}/p"
ln -s /dev/null "${path}/p/hts-cache"
save "${path}" x p >/dev/null
alive "${srv}" || fail "an unwritable init file crashed the server: $(cat "${log}")"
check_clipped "Unable to create the init file " "${path}/p"
# The init file opens, then the write fails: the profile is past both stdio's
# buffer, so fwrite() reaches the descriptor, and the server's file-size limit.
path=$(padpath "${base}/full" 993)
mkdir -p "${path}/p/hts-cache"
profile=$(printf '%01024d' 0)
profile=${profile}${profile}${profile}${profile}
profile=${profile}${profile}${profile}${profile}
test "${#profile}" -eq 16384 || fail "built a ${#profile}-byte profile, want 16384"
save "${path}" "${profile}" p >/dev/null
alive "${srv}" || fail "a short init file write crashed the server: $(cat "${log}")"
written=$(wc -c <"${path}/p/hts-cache/winprofile.ini" | tr -d ' ')
test "${written}" -lt "${#profile}" ||
fail "the file-size limit did not stop the write (${written} bytes landed)"
check_clipped "Unable to write ${#profile} bytes in the the init file " "${path}/p"
get "${port}" /server/index.html | grep -q '200 OK' ||
fail "the server stopped answering after the refused saves"
echo "PASS"

View File

@@ -0,0 +1,196 @@
#!/bin/bash
#
# An unchecked box posts nothing and the stored "1" survives: hence a hidden
# companion per box, plus a disabling flag for the default-on ones.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
distdir=${top_srcdir:-$(cd "${testdir}/.." && pwd)}
distdir=$(cd "${distdir}" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v htsserver >/dev/null || fail "no htsserver in PATH"
python=$(find_python) || {
echo "python3 not found; skipping" >&2
exit 77
}
work=$(mktemp -d "${TMPDIR:-/tmp}/webhttrack_checkbox.XXXXXX") || fail "no tmpdir"
srvlog=$(mktemp)
srv=
srvpid=
cleanup() {
# htsserver keeps SIGTERM ignored across its exec, so only -9 reaps it.
test -z "${srvpid}" || kill -9 "${srvpid}" 2>/dev/null || true
test -z "${srv}" || kill -9 "${srv}" 2>/dev/null || true
wait "${srv}" 2>/dev/null || true # absorb bash's async "Killed" notice
rm -rf "${work}" "${srvlog}"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
# An isolated HOME keeps a stray ~/.httrack.ini out of the served settings.
sport=$("${python}" -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()')
(
trap '' TERM TTOU
export HOME="${work}"
exec htsserver "${distdir}/" --port "${sport}" >"${srvlog}" 2>&1
) &
srv=$!
for _ in $(seq 1 40); do
url=$(sed -n 's/^URL=//p' "${srvlog}") && test -n "${url}" && break
kill -0 "${srv}" 2>/dev/null || break
sleep 0.25
done
test -n "${url:-}" || fail "htsserver did not start: $(cat "${srvlog}")"
srvpid=$(sed -n 's/^PID=//p' "${srvlog}") # absent on Windows
"${python}" - "${url}" "${distdir}" <<'PY' || fail "checkbox clearing is broken (see above)"
import glob, os, re, sys, urllib.parse, urllib.request
url, srcdir = sys.argv[1].rstrip("/"), sys.argv[2]
rc = 0
def check(ok, what):
global rc
print(("ok: " if ok else "FAIL: ") + what)
if not ok:
rc = 1
# cache/cache2 are left to the separate rework of the dead "Cache" seed.
skip = {"cache", "cache2"}
page_of = {}
boxes = 0
for path in sorted(glob.glob(os.path.join(srcdir, "html", "server", "option*.html"))):
page = open(path, "rb").read().decode("latin-1")
cleared = dict((m.group(1), m.start()) for m in
re.finditer(r'<input type="hidden" name="([^"]+)" value="">', page))
for m in re.finditer(r'<input type="checkbox" name="([^"]+)"', page):
boxes += 1
if m.group(1) in skip:
continue
page_of[m.group(1)] = os.path.basename(path)
at = cleared.get(m.group(1))
check(at is not None and at < m.start(), "%s: %s is cleared before it is drawn"
% (os.path.basename(path), m.group(1)))
# Control: a regex that stopped matching would leave nothing to assert on.
check(boxes >= 25, "the option pages were scanned (%d checkboxes)" % boxes)
sid = None
def get(path):
# The UI is served ISO-8859-1, so decode, do not assume UTF-8.
return urllib.request.urlopen(url + path, timeout=20).read().decode("latin-1")
def post(fields):
global sid
if sid is None:
m = re.search(r'name="sid" value="([0-9a-f]+)"', get("/server/index.html"))
if m is None:
sys.exit("no session id in server/index.html")
sid = m.group(1)
body = "&".join("%s=%s" % (k, urllib.parse.quote(v)) for k, v in
[("sid", sid)] + fields)
req = urllib.request.Request(url + "/server/step4.html",
data=body.encode("latin-1"), method="POST")
return urllib.request.urlopen(req, timeout=20).read().decode("latin-1")
def textarea(page, name):
m = re.search(r'<textarea name="%s".*?>(.*?)</textarea>' % name, page, re.S)
if m is None:
sys.exit("no %s textarea in the rendered step4.html" % name)
return m.group(1)
def checked(page, name):
return re.search(r'name="%s"[ \t]*checked' % name, get("/server/" + page)) is not None
# What each state puts on the command line (None: nothing) and its profile key.
# Only a value-taking alias can carry a disabling flag: -I and -%I are "single"
# (htsalias.c), so a --index=0 would read back as the bare enabling flag.
BOXES = [
# field profile key set cleared
("parseall", "ParseAll", "--near", None),
("link", "Near", "--test", None),
("testall", "Test", "--extended-parsing", None),
# htmlfirst emits --priority=7, which the scan-priority list also emits.
("htmlfirst", "HTMLFirst", None, None),
("errpage", "NoErrorPages", "--generate-errors=0", None),
("external", "NoExternalPages", "--replace-external", None),
("hidepwd", "NoPwdInPages", "--disable-passwords", None),
("hidequery", "NoQueryStrings", "--include-query-string=0", None),
("nopurge", "NoPurgeOldFiles", "--purge-old=0", None),
("windebug", None, "--debug-headers", None),
("ka", "KeepAlive", "--keep-alive", None),
("remt", "RemoveTimeout", "--host-control=1", None),
("rems", "RemoveRateout", "--host-control=2", None),
("cookies", "Cookies", None, "--cookies=0"),
("parsejava", "ParseJava", None, "--parse-java=0"),
("updhack", "UpdateHack", "--updatehack", None),
("urlhack", "URLHack", "--urlhack", None),
("keepwww", "KeepWww", "--keep-www-prefix", None),
("keepslashes", "KeepSlashes", "--keep-double-slashes", None),
("keepqueryorder", "KeepQueryOrder", "--keep-query-order", None),
("toler", "TolerantRequests", "--tolerant", None),
("http10", "HTTP10", "--http-10", None),
("warc", "Warc", "--warc", None),
("norecatch", "NoRecatch", "--do-not-recatch", None),
("logf", "Log", "--single-log", None),
("index", "Index", None, None),
("index2", "WordIndex", "--search-index", None),
("ftpprox", "UseHTTPProxyForFTP", "--httpproxy-ftp", None),
("singlefile", "SingleFile", "--single-file", None),
]
for field, key, when_set, when_clear in BOXES:
for value, want, unwanted, stored in (("on", when_set, when_clear, "1"),
("", when_clear, when_set, "0")):
rendered = post([(field, value)])
cmd = textarea(rendered, "command").split()
label = "%s %s" % (field, "set" if value else "cleared")
if want:
check(want in cmd, "%s emits %s" % (label, want))
if unwanted:
check(unwanted not in cmd, "%s drops %s" % (label, unwanted))
if key:
# The Windows GUI reads the same option out of the profile.
check("%s=%s" % (key, stored) in textarea(rendered, "winprofile").splitlines(),
"%s writes %s=%s" % (label, key, stored))
check(checked(page_of[field], field) == bool(value),
"%s draws %s box" % (label, "a ticked" if value else "an empty"))
missing = sorted(set(page_of) - set(b[0] for b in BOXES))
check(not missing, "every checkbox is exercised (missing %s)" % missing)
# A browser posts the companion and the ticked box under one name, so the fix
# rests on the body loop keeping the last value; both orders pin that down.
cmd = textarea(post([("cookies", ""), ("cookies", "on")]), "command").split()
check("--cookies=0" not in cmd, "cookies=&cookies=on keeps cookies set")
check(checked("option8.html", "cookies"), "cookies=&cookies=on draws a ticked box")
cmd = textarea(post([("cookies", "on"), ("cookies", "")]), "command").split()
check("--cookies=0" in cmd, "cookies=on&cookies= clears cookies")
check(not checked("option8.html", "cookies"), "cookies=on&cookies= draws an empty box")
sys.exit(rc)
PY
# A leaked htsserver wedges the parallel harness behind a green log.
cleanup
! kill -0 "${srv}" 2>/dev/null || fail "htsserver ${srv} survived"
echo "PASS"

View File

@@ -0,0 +1,181 @@
#!/bin/bash
#
# fopen() succeeds on a directory on POSIX and reading one never reaches EOF; a
# FIFO blocks in fopen() instead. Either used to wedge the single-threaded server.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
distdir=${top_srcdir:-$(cd "${testdir}/.." && pwd)}
distdir=$(cd "${distdir}" && pwd)
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v htsserver >/dev/null || fail "no htsserver in PATH"
command -v python3 >/dev/null || {
echo "python3 not found; skipping" >&2
exit 77
}
srv=
log=$(mktemp)
base=$(mktemp -d)
cleanup() {
test -z "${srv}" || kill -9 "${srv}" 2>/dev/null || true
rm -f "${log}"
rm -rf "${base}"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
# First line only. A "| head -1" would close the pipe early and, under pipefail,
# SIGPIPE the producer into a spurious failure.
firstline() { echo "${1%%$'\n'*}"; }
freeport() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
# Echo the announced URL. Runs in a command substitution, so it is a
# subshell and cannot export the pid: the caller reads it back with srvpid.
start() {
local port url
port=$(freeport)
: >"${log}"
(
trap '' TERM TTOU
exec htsserver "${distdir}/" --port "${port}" >"${log}" 2>&1
) &
for _ in $(seq 1 40); do
url=$(sed -n 's/^URL=//p' "${log}" 2>/dev/null) && test -n "${url}" && break
sleep 0.25
done
test -n "${url:-}" || fail "htsserver did not come up: $(cat "${log}")"
echo "${url}"
}
# The server reports its own pid; the aliveness assertion below hangs off it.
srvpid() { firstline "$(sed -n 's/^PID=//p' "${log}")"; }
alive() { kill -0 "$1" 2>/dev/null; }
portof() { echo "${1##*:}" | tr -d /; }
# GET the path $2 from 127.0.0.1:$1, or POST the body $3 to / when $2 is empty.
# The socket timeout is what makes an unfixed tree fail rather than wedge CI.
request() {
python3 -c 'import socket, sys
port, path, body = int(sys.argv[1]), sys.argv[2], sys.argv[3]
if path:
req = "GET %s HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" % path
else:
req = ("POST / HTTP/1.0\r\nHost: 127.0.0.1\r\n"
"Content-type: application/x-www-form-urlencoded\r\n"
"Content-length: %d\r\n\r\n%s" % (len(body), body))
s = socket.create_connection(("127.0.0.1", port), 10)
s.settimeout(15)
s.sendall(req.encode())
out = b""
try:
while True:
b = s.recv(65536)
if not b:
break
out += b
except socket.timeout:
sys.stderr.write("timed out after %d bytes\n" % len(out))
sys.exit(9)
s.close()
sys.stdout.write(out.decode("latin-1"))' "$1" "$2" "$3"
}
# GET $2, or fail with $3 when the reply never comes.
get() {
local out
out=$(request "$1" "$2" "") || fail "$2 did not answer: $3"
echo "${out}"
}
post() { request "$1" "" "$2"; }
url=$(start)
port=$(portof "${url}")
srv=$(srvpid)
test -n "${srv}" || fail "htsserver did not report its pid"
# Needs no session id and no project: html/server is a directory of the install.
reply=$(get "${port}" /server/ "an unauthenticated request wedged the server")
case "${reply}" in
"HTTP/1.0 404 "*) ;;
*) fail "a GUI directory did not answer 404: $(firstline "${reply}")" ;;
esac
# Every request body is gated by the session id.
reply=$(get "${port}" /server/index.html "the GUI is not served")
sid=$(firstline "$(echo "${reply}" |
sed -n 's/.*name="sid" value="\([0-9a-f]*\)".*/\1/p')")
test "${#sid}" -eq 32 || fail "did not scrape a 32-hex sid (got '${sid}')"
mkdir -p "${base}/proj/sub"
echo "PROBEMARKER" >"${base}/proj/probe.txt"
# mkfifo and ln -s may not work on a Windows checkout, so both stay optional.
refuse=(/website/ /website/sub/)
if mkfifo "${base}/proj/fifo.txt" 2>/dev/null; then
refuse+=(/website/fifo.txt)
fi
ln -s probe.txt "${base}/proj/link.txt" 2>/dev/null || true
# step4's "save settings" flow registers the project without crawling, and that
# is what arms the /website/ root.
body="sid=${sid}&command=httrack&command_do=save&winprofile=x"
body="${body}&path=${base}&projname=proj"
post "${port}" "${body}" >/dev/null || fail "the save request did not answer"
test -f "${base}/proj/hts-cache/winprofile.ini" ||
fail "the project was not registered: $(cat "${log}")"
# Positive control: every assertion below would pass on a server serving nothing.
served() {
case "$(get "$1" "$2" "$3")" in
*PROBEMARKER*) ;;
*) fail "$3" ;;
esac
}
served "${port}" /website/probe.txt "the registered project's mirror is not served"
if test -L "${base}/proj/link.txt"; then
# The guard stats rather than lstats, so a symlinked mirror file still serves.
served "${port}" /website/link.txt "a symlink to a mirror file is not served"
fi
# /website/ is where the GUI's own "browse mirrored site" link points.
for path in "${refuse[@]}"; do
reply=$(get "${port}" "${path}" "the server wedged on ${path}")
case "${reply}" in
"HTTP/1.0 404 "*) ;;
*) fail "${path} did not answer 404: $(firstline "${reply}")" ;;
esac
done
# The accept loop is single-threaded: one spin denies every later request.
alive "${srv}" || fail "the server died: $(cat "${log}")"
served "${port}" /website/probe.txt "the server stopped answering after a directory request"
# This fopen() is reached before any guard, so its read loop must stop on ferror().
mkdir -p "${base}/proj2/hts-cache/winprofile.ini"
reply=$(post "${port}" "sid=${sid}&path=${base}&loadprojname=proj2") ||
fail "a directory winprofile.ini wedged the server"
case "${reply}" in
"HTTP/1.0 3"*) ;;
*) fail "loading a project did not redirect: $(firstline "${reply}")" ;;
esac
alive "${srv}" || fail "the server died: $(cat "${log}")"
served "${port}" /website/probe.txt "the server stopped answering after a project load"
echo "PASS"

View File

@@ -0,0 +1,37 @@
#!/bin/bash
# The two halves of an .ndx entry's URL share one buffer, so the second must be
# bounded by what the first left. The sanitizer CI build turns a regression
# here into a hard stack-buffer-overflow.
set -euo pipefail
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
python=$(find_python) || {
echo "python3 not found; skipping" >&2
exit 77
}
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
# each field fills binput's own bound, so together they run one past line[]
"$python" - "$dir/foo.ndx" <<'EOF'
import sys
with open(sys.argv[1], "w") as f:
f.write("CACHE-1.4\n")
f.write("Wed, 01 Jan 2025 00:00:00 GMT\n")
f.write("h" * 1024 + "\n" + "f" * 1024 + "\n" + "0\n")
# a trailing entry keeps the walk inside the buffer, so this exercises the
# field bound alone
f.write("tail/entry\nx\n1\n")
EOF
: >"$dir/foo.dat" # the reader only proceeds when the sibling .dat opens
proxytrack --convert "$dir/out.arc" "$dir/foo.ndx" >/dev/null 2>&1 || {
echo "FAIL: proxytrack crashed on an .ndx with two full-length URL fields" >&2
exit 1
}

View File

@@ -0,0 +1,379 @@
#!/bin/bash
# Issue #713: --single-file inlines each page's assets as data: URIs. End to
# end over a real crawl, decoding the payloads against what the server served.
set -e
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
server=$(nativepath "${testdir}/local-server.py")
python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_713.XXXXXX") || exit 1
serverpid=
htssrv=
cleanup() {
stop_server "$serverpid"
# htsserver keeps SIGTERM ignored across its exec, so only -9 reaps it.
test -z "$htssrv" || kill -9 "$htssrv" 2>/dev/null || true
wait "$htssrv" 2>/dev/null || true # absorb bash's async "Killed" notice
htssrv=
rm -rf "$tmpdir"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
# --- the site ---------------------------------------------------------------
doc="${tmpdir}/doc"
mkdir -p "$doc"
cat >"${doc}/index.html" <<'EOF'
<html><head>
<link rel="stylesheet" href="style.css">
<link rel="canonical" href="other.html">
</head><body>
<img src="pixel.svg" alt="p">
<img src="big.svg" alt="b">
<img src="pixel.svg" data-src="lazy.svg" alt="l">
<script src="app.js"></script>
<a href="other.html">other</a>
</body></html>
EOF
cat >"${doc}/other.html" <<'EOF'
<html><body><a href="index.html">back</a></body></html>
EOF
cat >"${doc}/style.css" <<'EOF'
@import "nested.css";
body { background: url(pixel.svg); }
EOF
cat >"${doc}/nested.css" <<'EOF'
div { background: url(pixel.svg); }
EOF
printf 'var app = 1;\n' >"${doc}/app.js"
printf '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>\n' \
>"${doc}/pixel.svg"
printf '<svg xmlns="http://www.w3.org/2000/svg"><circle r="1"/></svg>\n' \
>"${doc}/lazy.svg"
{
printf '<svg xmlns="http://www.w3.org/2000/svg"><!--'
head -c 20000 /dev/zero | tr '\0' 'x'
printf '%s\n' '--><rect width="1" height="1"/></svg>'
} >"${doc}/big.svg"
# --- server -----------------------------------------------------------------
serverlog="${tmpdir}/server.log"
: >"$serverlog"
"$python" "$server" --root "$(nativepath "$doc")" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 50); do
line=$(head -n1 "$serverlog" 2>/dev/null)
if test "${line%% *}" == "PORT"; then
port="${line#PORT }"
break
fi
kill -0 "$serverpid" 2>/dev/null || {
echo "server exited early: $(cat "$serverlog")"
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover server port"
exit 1
}
base="http://127.0.0.1:${port}"
which httrack >/dev/null || {
echo "could not find httrack"
exit 1
}
# --- crawl ------------------------------------------------------------------
# The cap sits between pixel.svg and big.svg, so one inlines and one must not.
out="${tmpdir}/crawl"
mkdir "$out"
common=(-O "$out" --quiet --disable-security-limits --robots=0 --timeout=30 --retries=0)
httrack "${common[@]}" --single-file --single-file-max-size 4096 \
"${base}/index.html" >"${tmpdir}/log1" 2>&1
page="${out}/127.0.0.1_${port}/index.html"
test -f "$page" || {
echo "FAIL: no mirrored page at $page"
exit 1
}
# --- what must be inlined, byte for byte ------------------------------------
# The decoder is python, not httrack, so a broken encoder cannot agree with
# itself here.
extract() {
"$python" - "$1" "$2" "$3" <<'PY'
import base64, re, sys
html = open(sys.argv[1], "rb").read().decode("utf-8", "replace")
m = re.search(r'data:%s;base64,([A-Za-z0-9+/=]*)' % re.escape(sys.argv[2]), html)
if m is None:
sys.exit(1)
open(sys.argv[3], "wb").write(base64.b64decode(m.group(1)))
PY
}
printf '[image inlined and identical] ..\t'
extract "$page" "image/svg+xml" "${tmpdir}/got.svg" || {
echo "FAIL: no inlined image"
exit 1
}
cmp -s "${doc}/pixel.svg" "${tmpdir}/got.svg" || {
echo "FAIL: inlined image differs from the served file"
exit 1
}
echo OK
printf '[script inlined and identical] ..\t'
extract "$page" "application/x-javascript" "${tmpdir}/got.js" || {
echo "FAIL: no inlined script"
exit 1
}
cmp -s "${doc}/app.js" "${tmpdir}/got.js" || {
echo "FAIL: inlined script differs from the served file"
exit 1
}
echo OK
printf '[lazy-loaded image inlined] ..\t'
# src is the placeholder a real page ships; the asset rides data-src.
grep -q 'data-src="data:image/svg+xml;base64,' "$page" || {
echo "FAIL: data-src was not inlined"
exit 1
}
if grep -q 'data-src="lazy.svg"' "$page"; then
echo "FAIL: data-src kept its link"
exit 1
fi
test -f "${out}/127.0.0.1_${port}/lazy.svg" || {
echo "FAIL: the lazy image was never downloaded, so nothing was proven"
exit 1
}
echo OK
printf '[stylesheet inlined, recursively] ..\t'
extract "$page" "text/css" "${tmpdir}/got.css" || {
echo "FAIL: no inlined stylesheet"
exit 1
}
grep -q 'url(data:image/svg+xml;base64,' "${tmpdir}/got.css" || {
echo "FAIL: url() inside the inlined stylesheet was not inlined"
exit 1
}
extract "${tmpdir}/got.css" "text/css" "${tmpdir}/got2.css" || {
echo "FAIL: @import was not inlined"
exit 1
}
grep -q 'url(data:image/svg+xml;base64,' "${tmpdir}/got2.css" || {
echo "FAIL: url() inside the @import'ed stylesheet was not inlined"
exit 1
}
echo OK
# --- what must keep its link ------------------------------------------------
printf '[page links stay relative] ..\t'
grep -q 'href="other.html"' "$page" || {
echo "FAIL: the link to another page was not left relative"
exit 1
}
test -f "${out}/127.0.0.1_${port}/other.html" || {
echo "FAIL: the linked page is missing from the mirror"
exit 1
}
echo OK
printf '[over-cap asset stays a link] ..\t'
grep -q 'src="big.svg"' "$page" || {
echo "FAIL: the over-cap asset did not keep its link"
exit 1
}
test -f "${out}/127.0.0.1_${port}/big.svg" || {
echo "FAIL: the over-cap asset is missing from the mirror"
exit 1
}
grep -q 'over the 4096-byte cap' "${out}/hts-log.txt" || {
echo "FAIL: the over-cap asset was not reported in the log"
exit 1
}
echo OK
printf '[the mirror still works] ..\t'
if grep -q 'href="style.css"' "$page"; then
echo "FAIL: the stylesheet link survived"
exit 1
fi
test -f "${out}/127.0.0.1_${port}/style.css" || {
echo "FAIL: the mirror lost the standalone stylesheet"
exit 1
}
echo OK
# --- a second run must not re-encode ----------------------------------------
printf '[idempotent under --update] ..\t'
# HTTrack stamps the time into its own footer comment, so compare without it.
grep -v 'Mirrored from' "$page" >"${tmpdir}/page1.html"
httrack "${common[@]}" --single-file --single-file-max-size 4096 --update \
"${base}/index.html" >"${tmpdir}/log2" 2>&1
grep -v 'Mirrored from' "$page" >"${tmpdir}/page2.html"
cmp -s "${tmpdir}/page1.html" "${tmpdir}/page2.html" || {
echo "FAIL: the second run changed the page"
exit 1
}
# A double-encoded payload would decode to another data: URI, not to the SVG.
extract "$page" "image/svg+xml" "${tmpdir}/got2.svg" || {
echo "FAIL: no inlined image after the second run"
exit 1
}
cmp -s "${doc}/pixel.svg" "${tmpdir}/got2.svg" || {
echo "FAIL: the second run re-encoded the inlined image"
exit 1
}
echo OK
printf '[-%%Z0 and a bad cap] ..\t'
# -%Z0 turns it back off, so the page must come out exactly as the crawl left
# it; a non-numeric cap is rejected and the built-in default used instead.
off="${tmpdir}/off"
mkdir "$off"
httrack -O "$off" --quiet --disable-security-limits --robots=0 --timeout=30 \
--retries=0 -%Z0 "${base}/index.html" >"${tmpdir}/log3" 2>&1
offpage="${off}/127.0.0.1_${port}/index.html"
if grep -q 'base64,' "$offpage"; then
echo "FAIL: -%Z0 still inlined"
exit 1
fi
bad="${tmpdir}/bad"
mkdir "$bad"
httrack -O "$bad" --quiet --disable-security-limits --robots=0 --timeout=30 \
--retries=0 --single-file-max-size notanumber "${base}/index.html" \
>"${tmpdir}/log4" 2>&1
badpage="${bad}/127.0.0.1_${port}/index.html"
# The default cap is 10 MB, so everything here inlines, big.svg included.
grep -q 'data:image/svg+xml;base64,' "$badpage" || {
echo "FAIL: a rejected cap did not fall back to the default"
exit 1
}
if grep -q 'src="big.svg"' "$badpage"; then
echo "FAIL: the default cap did not cover big.svg"
exit 1
fi
echo OK
printf '[the rewritten page keeps the mirror file mode] ..\t'
# The rewrite spools to a temp and renames, bypassing the engine's chmod.
if is_windows; then
echo "SKIP (no POSIX file modes)"
else
umaskdir="${tmpdir}/umask"
mkdir "$umaskdir"
(
umask 077
httrack -O "$umaskdir" --quiet --disable-security-limits --robots=0 \
--timeout=30 --retries=0 --single-file "${base}/index.html" \
>"${tmpdir}/log5" 2>&1
)
umaskroot="${umaskdir}/127.0.0.1_${port}"
mode() { "$python" -c 'import os,sys
print("%04o" % (os.stat(sys.argv[1]).st_mode & 0o777))' "$1"; }
pagemode=$(mode "${umaskroot}/index.html")
assetmode=$(mode "${umaskroot}/big.svg")
test "$pagemode" = "$assetmode" || {
echo "FAIL: page is $pagemode, the asset beside it is $assetmode"
exit 1
}
echo OK
fi
# --- the rewriter in isolation, over a hand-built mirror --------------------
# After the crawl assertions, so a failure here cannot hide them: the two cover
# different ground (this one reaches media, anchors and the mirror-root clamp).
printf '[engine self-test] ..\t'
st=$(httrack -O /dev/null "-#test=singlefile" "${tmpdir}/st/" 2>/dev/null)
case "$st" in
*": OK") echo OK ;;
*)
echo "FAIL: $st"
exit 1
;;
esac
# --- the web GUI must emit the same options ---------------------------------
distdir=$(cd "${top_srcdir}" && pwd)
printf '[project reload maps the keys back] ..\t'
grep -q 'do:copy:SingleFile:singlefile' "${distdir}/html/server/step2.html" || {
echo "FAIL: step2.html does not restore SingleFile"
exit 1
}
grep -q 'do:copy:SingleFileMaxSize:singlefilemax' "${distdir}/html/server/step2.html" || {
echo "FAIL: step2.html does not restore SingleFileMaxSize"
exit 1
}
echo OK
printf '[webhttrack renders the options] ..\t'
# The Windows job builds httrack.exe only and reaches this file through its
# *_local-*.test glob, so report a skip there rather than a pass.
if ! command -v htsserver >/dev/null; then
echo "SKIP (no htsserver in PATH)"
exit 77
fi
sport=$("$python" -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()')
srvlog="${tmpdir}/htsserver.log"
(
trap '' TERM TTOU
export HOME="${tmpdir}/home"
mkdir -p "$HOME"
exec htsserver "${distdir}/" --port "${sport}" >"${srvlog}" 2>&1
) &
htssrv=$!
url=
for _ in $(seq 1 40); do
url=$(sed -n 's/^URL=//p' "$srvlog" 2>/dev/null) && test -n "$url" && break
kill -0 "$htssrv" 2>/dev/null || break
sleep 0.25
done
test -n "${url:-}" || {
echo "FAIL: htsserver did not start: $(cat "$srvlog")"
exit 1
}
"$python" - "$url" <<'PY' || exit 1
import re, sys, urllib.parse, urllib.request
url = sys.argv[1]
form = urllib.request.urlopen(url + "server/index.html", timeout=20).read()
m = re.search(rb'name="sid" value="([0-9a-f]+)"', form)
if not m:
sys.exit("FAIL: no session id in server/index.html")
fields = [("sid", m.group(1).decode()), ("singlefile", "on"),
("singlefilemax", "5000")]
body = "&".join("%s=%s" % (k, urllib.parse.quote(v)) for k, v in fields)
req = urllib.request.Request(url + "server/step4.html",
data=body.encode("latin-1"), method="POST")
page = urllib.request.urlopen(req, timeout=20).read().decode("latin-1")
def textarea(name):
m = re.search(r'<textarea name="%s".*?>(.*?)</textarea>' % name, page, re.S)
if m is None:
sys.exit("FAIL: no %s textarea in the rendered step4.html" % name)
return m.group(1)
cmd = textarea("command")
for want in ("--single-file", "--single-file-max-size=5000"):
if want not in cmd:
sys.exit("FAIL: %s missing from the command line\n%s" % (want, cmd))
ini = textarea("winprofile").replace("\r\n", "\n")
for want in ("\nSingleFile=1\n", "\nSingleFileMaxSize=5000\n"):
if want not in ini:
sys.exit("FAIL: %r missing from winprofile.ini\n%s" % (want, ini))
PY
echo OK

View File

@@ -183,6 +183,13 @@ TESTS = \
83_webhttrack-argescape.test \
84_webhttrack-mirror-verbatim.test \
85_webhttrack-projpath.test \
86_local-proxytrack-cache-longfields.test
86_local-proxytrack-cache-longfields.test \
87_local-proxytrack-nodate.test \
88_local-proxytrack-badmtime.test \
89_webhttrack-error-overflow.test \
90_webhttrack-checkbox-clear.test \
91_webhttrack-directory.test \
92_local-proxytrack-ndx-fields.test \
93_local-single-file.test
CLEANFILES = check-network_sh.cache

View File

@@ -46,11 +46,15 @@ cat >"$stubdir/x-www-browser" <<EOF
echo "stub browser invoked with: \$1" >&2
# Also fetch an option page and require a rendered title='' tooltip: proves the
# option template expands and the \${html:} filter escapes into the attribute.
# option9 additionally proves the WARC control renders with its expanded label.
# option9 additionally proves the WARC control renders with its expanded label,
# and option2 the --single-file pair: on field names plus the absence of an
# unexpanded key, since the default locale here is French.
opturl="\${1%/}/server/option2.html"
warcurl="\${1%/}/server/option9.html"
if body="\$(curl -fsSL --max-time 20 "\$1")" && printf '%s' "\$body" | grep -qai httrack && printf '%s' "\$body" | grep -qaF step2.html &&
opt="\$(curl -fsSL --max-time 20 "\$opturl")" && printf '%s' "\$opt" | grep -qaF "title='" &&
printf '%s' "\$opt" | grep -qaF 'name="singlefile"' && printf '%s' "\$opt" | grep -qaF 'name="singlefilemax"' &&
! printf '%s' "\$opt" | grep -qaF '\${LANG_SINGLEFILE}' &&
warc="\$(curl -fsSL --max-time 20 "\$warcurl")" && printf '%s' "\$warc" | grep -qaF 'name="warcfile"' && printf '%s' "\$warc" | grep -qaF WARC; then
echo PASS >"$marker"
else