Compare commits

..

8 Commits

Author SHA1 Message Date
Xavier Roche
245159b5c1 single-file: make the mark unforgeable and self-delimiting
The in-band marker was the common root of four injection and correctness
defects, so replace it rather than patch the channels. The mark is now
#!<16-hex>.<class>.<len>, where the hex is 64 CSPRNG bits drawn once per run
and never written to disk: a site cannot spell one, so no sanitiser has to
keep hostile bytes away from it and the channel a forgery arrives through
stops mattering. <len> is the reference's byte length as emitted, which
retires the backward scan and the escaping contract it rested on, and leaves
an author's fragment untouched on the far side of the mark. <class> is the
referencing context, checked against the resolved type so a stylesheet
reference that lands on something else fails loudly instead of inlining it.

-%M and --single-file are now refused together; they were silently accepted,
with --single-file doing nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:23:49 +02:00
Xavier Roche
fa2d5d7bc2 single-file: re-seed the fuzz corpus onto marked inputs
The eight seeds carried no marks, so the harness had degraded to a scan that
finds nothing: it reached 107 coverage points against 341 for the new seeds,
and an off-by-one planted in the back-walk went undetected. The stylesheets
the harness lays down were unmarked too, so its recursion was unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:06:34 +02:00
Xavier Roche
74280472bb single-file: make the mark unforgeable, strip it from assets, check its contract
Three holes the prototype left. htsparse now disarms any mark already in a
fetched document before it parses it, so a page shipping the literal string
can no longer make the pass inline a mirrored file into its own prose. The
pass strips the marks from non-HTML files once every page has been expanded,
so a mirrored stylesheet ships no engine bookkeeping. And the no-delimiter
contract the back-walk depends on is now checked where it is made and reported
where it is consumed, instead of surfacing as a reference that quietly failed
to inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:06:34 +02:00
Xavier Roche
36b9be497c single-file: format the touched lines
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:05:16 +02:00
Xavier Roche
1300d297aa single-file: drop the fragment and query htsparse writes after the mark
<img src="a.svg#frag"> saved as a.svg#!htsinline#frag, and the pass left the
fragment glued to the data: URI. Consume it, and put it back when the reference
falls through to a link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:05:16 +02:00
Xavier Roche
039ff23ff8 single-file: port the engine self-test onto marked fixtures
The fixture fed unmarked HTML to a scanner that no longer exists. Marking it
keeps 28 of the 32 assertions; the four that changed are quoting, which is now
preserved rather than normalised, and a page with nothing to inline, which is
mark-transparent rather than byte-transparent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:05:16 +02:00
Xavier Roche
072f5e6528 single-file: mark inlinable references in htsparse instead of rescanning the mirror
htsparse appends a "#!htsinline" fragment to every reference it saves that
--single-file may inline, so the end-of-mirror pass becomes a substitution over
those marks and parses no HTML and no CSS of its own. A fragment rather than a
scheme, so the mirror still resolves if the pass never runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 09:05:16 +02:00
Xavier Roche
de8c0eebfc Three hand-rolled copies of the unlink-then-rename fallback, with two return conventions (#754)
* One unlink-then-rename helper for the three copies

replace_file() (htsback.c), wacz_rename_over() (htswarc.c) and the inline
block in singlefile_rewrite_file() each worked around Windows' non-clobbering
rename, with two opposite return conventions and only one of the three
converting path separators. Copying the wrong one gets you an inverted success
test.

hts_rename_over() replaces all four call sites: hts_boolean return, fconv() on
both paths. It lands in htsname.c rather than the htstools.c the issue named,
to stay clear of an in-flight PR over that file.

The wacz test now runs a cacheless second pass over a poisoned copy of the
package the first pass wrote, so repackaging has to clobber an existing file.
43 and 74 also assert the failure warnings stay out of the log, which is all
an inverted return leaves behind at those two sites.

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

* rename helper: move hts_rename_over to htstools.c

Issue #726 asked for htstools.c; it went to htsname.c only because #718 held
the file at the time. Kept internal, not HTSEXT_API: htscore.h pulls in both
headers, so every call site reaches it either way.

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

* format: drop the trailing blank line left by the move

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

---------

Signed-off-by: Xavier Roche <xroche@gmail.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:58:20 +02:00
36 changed files with 883 additions and 906 deletions

View File

@@ -173,7 +173,10 @@ AC_CHECK_TYPE(sa_family_t, [], [AC_DEFINE([sa_family_t], [uint16_t], [sa_family_
AX_CHECK_ALIGNED_ACCESS_REQUIRED
# check for various headers
AC_CHECK_HEADERS([execinfo.h sys/ioctl.h])
AC_CHECK_HEADERS([execinfo.h sys/ioctl.h sys/random.h])
## CSPRNG for the --single-file mark; /dev/urandom is the fallback
AC_CHECK_FUNCS([getrandom])
### zlib (mandatory)
CHECK_ZLIB()

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
a.png#!htsinline

View File

@@ -0,0 +1,5 @@
#!htsinline#!htsinline#!htsinline
=#!htsinline=
"#!htsinline"
a.png#!htsinlin
#!htsinlin

View File

@@ -0,0 +1 @@
#!htsinline

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,10 +95,13 @@ static void sf_init(void) {
sf_write("a.png", png, sizeof(png) - 1);
sf_write("big.png", big, sizeof(big));
sf_text("j.js", "var x=1;\n");
/* @import plus a url(), so an inlined stylesheet recurses and its own
relative reference is rebased. */
sf_text("s.css", "@import url(sub/b.css);\ndiv{background:url(a.png)}\n");
sf_text("sub/b.css", "p{background:url(../a.png)}\n");
/* Marked, so an inlined stylesheet recurses into its own marks and its
un-inlinable reference is rebased; unmarked assets leave the target as a
bare scan that reaches nothing. */
sf_text("s.css", "@import url(sub/b.css" SINGLEFILE_MARK ");\n"
"div{background:url(a.png" SINGLEFILE_MARK ")}\n"
"p{background:url(big.png" SINGLEFILE_MARK ")}\n");
sf_text("sub/b.css", "p{background:url(../a.png" SINGLEFILE_MARK ")}\n");
}
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {

View File

@@ -585,14 +585,6 @@ static int create_back_tmpfile(httrackp *opt, lien_back *const back,
return 0;
}
/* Move src onto dst; RENAME does not clobber an existing target on Windows. */
static hts_boolean replace_file(const char *src, const char *dst) {
if (RENAME(src, dst) == 0)
return HTS_TRUE;
(void) UNLINK(dst);
return RENAME(src, dst) == 0 ? HTS_TRUE : HTS_FALSE;
}
/* Commit or restore a re-fetch backup (#77 follow-up): a re-fetch over an
existing file moved the good copy to back->tmpfile before truncating url_sav.
commit keeps the new file and drops the backup; else restore it so an aborted
@@ -610,7 +602,7 @@ static void back_finalize_backup(httrackp *opt, lien_back *const back,
}
/* On failure keep the backup: an orphaned temp beats losing the good copy.
*/
if (!replace_file(back->tmpfile, back->url_sav))
if (!hts_rename_over(back->tmpfile, back->url_sav))
hts_log_print(opt, LOG_WARNING | LOG_ERRNO,
"could not restore %s; previous copy kept as %s",
back->url_sav, back->tmpfile);
@@ -741,7 +733,7 @@ int back_finalize(httrackp * opt, cache_back * cache, struct_back * sback,
"Read error when decompressing");
}
UNLINK(unpacked);
} else if (replace_file(unpacked, back[p].url_sav)) {
} else if (hts_rename_over(unpacked, back[p].url_sav)) {
/* The temp bypassed filecreate(), which is what chmods. */
#ifndef _WIN32
chmod(back[p].url_sav, HTS_ACCESS_FILE);

View File

@@ -1980,9 +1980,10 @@ int httpmirror(char *url1, httrackp * opt) {
}
// ATTENTION C'EST ICI QU'ON SAUVE LE FICHIER!!
// A failed transfer has no body: r.adr holds debris from the aborted
// read, which would destroy the copy being re-fetched (#748).
if (r.statuscode > 0 && (r.adr != NULL || r.size == 0)) {
// An empty body must not overwrite the file when the transfer failed
// (statuscode <= 0, e.g. an -M hard-stop): it would truncate a good
// copy to 0 (#77 follow-up).
if (r.adr != NULL || (r.size == 0 && r.statuscode > 0)) {
file_notify(opt, urladr(), urlfil(), savename(), 1, 1, r.notmodified);
if (filesave(opt, r.adr, (int) r.size, savename(), urladr(), urlfil()) !=
0) {

View File

@@ -1221,6 +1221,16 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
opt->mimehtml = 0;
com++;
}
if (opt->mimehtml && opt->single_file) {
HTS_PANIC_PRINTF(
"-%M and --single-file are two ways to make one "
"self-contained file, so pick one: MIME (-%M) carries "
"text parts without the base64 tax and stores a shared "
"asset once; single-file HTML opens anywhere by "
"double-click.");
htsmain_free();
return -1;
}
break;
case 'k':
opt->nokeepalive = 0;
@@ -1832,6 +1842,16 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
com++;
}
}
if (opt->single_file && opt->mimehtml) {
HTS_PANIC_PRINTF(
"-%M and --single-file are two ways to make one "
"self-contained file, so pick one: MIME (-%M) carries "
"text parts without the base64 tax and stores a shared "
"asset once; single-file HTML opens anywhere by "
"double-click.");
htsmain_free();
return -1;
}
break;
case 'm': // sitemap / sitemap-url: seed the crawl from sitemaps
if (*(com + 1) == 'u') { // --sitemap-url URL: explicit sitemap

View File

@@ -6037,6 +6037,7 @@ HTSEXT_API httrackp *hts_create_opt(void) {
opt->changes_state = NULL;
opt->single_file = HTS_FALSE;
opt->single_file_max_size = SINGLEFILE_DEFAULT_MAX_SIZE;
opt->singlefile_state = NULL;
StringCopy(opt->why_url, "");
opt->pause_min_ms = 0;
opt->pause_max_ms = 0;
@@ -6191,6 +6192,7 @@ HTSEXT_API void hts_free_opt(httrackp * opt) {
StringFree(opt->warc_file);
StringFree(opt->sitemap_url);
hts_sitemap_free(opt); /* backstop: httpmirror's early-return paths */
singlefile_free(opt);
hts_changes_free_opt(opt);

View File

@@ -564,6 +564,8 @@ struct httrackp {
rather than in htsoptstate because that struct is embedded by value, so
growing it would shift every httrackp field declared after it. */
void *sitemap_state; /**< hts_sitemap_state*, or NULL. Tail: ABI */
void *singlefile_state; /**< hts_singlefile_state*, or NULL; holds this run's
mark secret, never persisted. Tail: ABI */
};
/* Running statistics for a mirror. */

View File

@@ -59,6 +59,7 @@ Please visit our Website: http://www.httrack.com
// parser
#include "htsparse.h"
#include "htssinglefile.h"
#include "htsback.h"
// arrays
@@ -384,6 +385,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
FILE *fp = NULL; // fichier écrit localement
const char *html = r->adr; // pointeur (on parcours)
const char *lastsaved; // adresse du dernier octet sauvé + 1
hts_boolean sf_may_mark = HTS_FALSE; // --single-file: mark this one?
hts_log_print(opt, LOG_DEBUG, "scanning file %s%s (%s)..", urladr(), urlfil(),
savename());
@@ -399,6 +401,13 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
r->adr[i] = ' ';
}
}
/* --single-file: RFC 2046 makes the generator responsible for a
boundary that cannot collide with what it encapsulates. Same duty
here, and the same negligible odds: only chance can put this run's
secret in a fetched document, since no site can spell it. */
if (opt->single_file) {
sf_may_mark = singlefile_may_mark(opt, r->adr, (size_t) r->size);
}
}
// Indexing!
@@ -3043,10 +3052,32 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
if ((opt->getmode & HTS_GETMODE_HTML) && (ptr > 0)) {
/* --single-file: tag the reference for the
end-of-mirror pass. A fragment, so a mirror left
marked by an interrupted run still browses. */
const char sf_class =
(opt->single_file && sf_may_mark && !in_media &&
p_type == 0 && !p_searchMETAURL)
? singlefile_ref_class(
intag_start_valid ? intag_name : NULL,
tag_attr_start)
: 0;
const size_t sf_start = TypedArraySize(output_buffer);
// écrire le lien modifié, relatif
// Note: escape all chars, even >127 (no UTF)
HT_ADD_HTMLESCAPED_FULL(tempo);
/* Measured on what was appended, not on tempo: the
escape above is free to change the length. */
if (sf_class != 0) {
char sf_mark[SINGLEFILE_MARK_MAX];
HT_ADD(singlefile_mark(
opt, sf_mark, sizeof(sf_mark), sf_class,
TypedArraySize(output_buffer) - sf_start));
}
// Add query-string, for informational purpose only
// Useless, because all parameters-pages are saved into different targets
if (opt->includequery) {

View File

@@ -5201,11 +5201,47 @@ static hts_boolean sf_try_put(const char *dir, const char *rel,
return HTS_TRUE;
}
/* Expand a fixture: \001<ref>\002 becomes <ref> plus this run's mark for it.
The secret is random per run, so a fixture cannot spell a mark itself. */
static void sf_expand_fixture(httrackp *opt, const char *in, size_t len,
String *out) {
size_t i, start = 0;
StringClear(*out);
for (i = 0; i < len; i++) {
if (in[i] == '\001') {
start = StringLength(*out);
} else if (in[i] == '\002') {
char mark[SINGLEFILE_MARK_MAX];
StringCat(*out,
singlefile_mark(opt, mark, sizeof(mark), SINGLEFILE_CLASS_ANY,
StringLength(*out) - start));
} else {
StringAddchar(*out, in[i]);
}
}
}
static void sf_put(const char *dir, const char *rel, const void *data,
size_t len) {
assertf(sf_try_put(dir, rel, data, len));
}
/* sf_put() for a text fixture, expanding its \001<ref>\002 delimiters. Binary
fixtures must not go through here: sf_png carries those very bytes. */
static size_t sf_put_marked(httrackp *opt, const char *dir, const char *rel,
const void *data, size_t len) {
String body = STRING_EMPTY;
size_t written;
sf_expand_fixture(opt, (const char *) data, len, &body);
assertf(sf_try_put(dir, rel, StringBuff(body), StringLength(body)));
written = StringLength(body);
StringFree(body);
return written;
}
/* Number of times needle occurs in hay. */
static int sf_count(const char *hay, const char *needle) {
const size_t l = strlen(needle);
@@ -5315,75 +5351,80 @@ static const char sf_png[] = "\x89PNG\r\n\x1a\n\x00\x01\x02\xff";
static const char sf_page[] =
"<html><head>\n"
"<link rel=\"stylesheet\" href=\"css/main.css\">\n"
"<link rel=\"canonical\" href=\"other.html\">\n"
"<link rel=\"stylesheet\" href=\"\001css/main.css\002\">\n"
"<link rel=\"canonical\" href=\"\001other.html\002\">\n"
"<title>t</title>\n"
"<style>body { background: url(\"img/a%20b.png\"); }</style>\n"
"<style>body { background: url(\"\001img/a%20b.png\002\"); }</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=\"\001img/a%20b.png\002\" srcset=\"\001img/a%20b.png\002 "
"1x, \001img/big.png\002 2x\">\n"
"<link rel=\"icon\" href=\"\001icon.png\002\">\n"
"<link rel=\"preload\" as=\"font\" href=\"\001font/f.woff2\002\">\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"
"<input type=\"image\" src=\"\001img/in.png\002\">\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"
"<img src=\"\001img/ph.png\002\" data-src=\"\001img/lz.png\002\" "
"data-srcset=\"\001img/lz2.png\002 2x\" "
"lowsrc=\"\001img/low.png\002\">\n"
"<object data=\"\001img/ob.png\002\"></object>\n"
"<embed src=\"\001img/em.png\002\">\n"
"<img data-src=\"\001other.html\002\">\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"
"<video poster=\"\001img/po.png\002\" controls>"
"<source src=\"\001v.mp4\002\" type=\"video/mp4\"></video>\n"
"<svg><image href=\"\001img/sv.png\002\"/></svg>\n"
"<table background=\"\001img/bg.png\002\"><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"
"<img src=\"\001../escape.png\002\">\n"
"<img src=\"\001../img/a%20b.png\002\">\n"
"<a href=\"img/a%20b.png\">link</a>\n"
"<script src=\"js/app.js\"></script>\n"
"<script src=\"\001js/app.js\002\"></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"
"<img src=\"\001missing.png\002\" >\n"
"<!--><img src=\"\001img/a%20b.png\002\">\n"
"<div style=\"background:url(\001img/a%20b.png\002)\"></div>\n"
"<div style='content:\"x\"; "
"background:url(\001img/a%20b.png\002)'></div>\n"
"</body></html>\n";
/* Lay a small mirror down under root. */
static void sf_fixture(const char *root) {
static void sf_fixture(httrackp *opt, 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 \"\001sub/nested.css\002\";\n"
"@import url(\"\001sub/two.css\002\");\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"
"@font-face { font-family: f; src: url(\001../font/f.woff2\002); }\n"
"body { background: url(\001../img/a%20b.png\002); }\n"
"div { background: url(\001../img/big.png\002); }\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 nested[] =
"div { background: url(\001../../img/a%20b.png\002); }\n";
static const char two[] =
"p { background: url(\001../../img/a%20b.png\002); }\n";
static const char deep[] = "<html><head><link rel=\"stylesheet\" "
"href=\"\001../../css/main.css\002\">\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_marked(opt, root, "page.html", sf_page, sizeof(sf_page) - 1);
sf_put_marked(opt, 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_marked(opt, root, "css/main.css", css, sizeof(css) - 1);
sf_put_marked(opt, root, "css/sub/nested.css", nested, sizeof(nested) - 1);
sf_put_marked(opt, 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));
@@ -5421,7 +5462,7 @@ static void sf_fixture(const char *root) {
}
StringCat(wide,
" title=\"> <img src=img/a%20b.png> \">end</p></body></html>");
sf_put(root, "wide.html", StringBuff(wide), StringLength(wide));
sf_put_marked(opt, root, "wide.html", StringBuff(wide), StringLength(wide));
StringFree(wide);
}
sf_put(root, "v.mp4",
@@ -5447,7 +5488,7 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
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);
sf_fixture(opt, root);
fconcat(page, sizeof(page), root, "page.html");
/* Cap between the small assets and big.png. */
@@ -5515,13 +5556,10 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
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");
/* Only the marked reference is touched: the value keeps its own quoting, so
nothing can be emitted that the attribute could not already hold. */
sf_check(strstr(out, "style='content:\"x\"; background:url(data:") != NULL,
"style attribute re-quoted instead of substituted in place");
sf_check(strstr(out, "img/big.png 2x") != NULL, "over-cap asset inlined");
sf_check(strstr(out, " 1x") != NULL, "srcset descriptor lost");
@@ -5541,7 +5579,7 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
"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,
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");
@@ -5605,7 +5643,7 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
/* Same page, a cap above big.png: it now inlines. */
fconcat(root, sizeof(root), argv[0], "mirror2/");
sf_fixture(root);
sf_fixture(opt, root);
fconcat(page, sizeof(page), root, "page.html");
opt->single_file_max_size = 1024 * 1024;
sf_check(singlefile_rewrite_file(opt, root, page),
@@ -5619,21 +5657,37 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
/* 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);
sf_fixture(opt, 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");
/* The page is still rewritten: nothing inlines, but the marks must go. */
sf_check(singlefile_rewrite_file(opt, root, page),
"one-byte cap left the marks in place");
{
char *capped = readfile_utf8(page);
/* Only the two data: URIs the fixture itself ships. */
sf_check(capped != NULL && sf_count(capped, ";base64,") == 2,
"one-byte cap inlined");
freet(capped);
}
{
String verbatim = STRING_EMPTY;
StringClear(verbatim);
(void) singlefile_rewrite_html(opt, root, page, sf_page,
sizeof(sf_page) - 1,
String marked = STRING_EMPTY;
sf_expand_fixture(opt, sf_page, sizeof(sf_page) - 1, &marked);
(void) singlefile_rewrite_html(opt, root, page, StringBuff(marked),
StringLength(marked),
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");
/* Mark-transparent, not byte-transparent: a reference that cannot be
inlined loses its mark and keeps everything else. */
sf_check(StringLength(verbatim) < StringLength(marked),
"a page with nothing to inline kept its marks");
sf_check(strstr(StringBuff(verbatim), singlefile_intro(opt)) == NULL,
"an un-inlinable reference kept its mark");
StringFree(marked);
StringFree(verbatim);
}
(void) outlen;
@@ -5642,26 +5696,29 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
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;
static const char bomb_css[] =
"@import \"\001b.css\002\";@import \"\001b.css\002\";"
"@import \"\001b.css\002\";@import \"\001b.css\002\";\n";
static const char bomb_html[] =
"<html><head>"
"<link rel=\"stylesheet\" href=\"\001b.css\002\">"
"</head></html>\n";
size_t css_len;
String small = STRING_EMPTY, large = STRING_EMPTY, bomb = STRING_EMPTY;
fconcat(root, sizeof(root), argv[0], "bomb/");
sf_put(root, "b.css", bomb_css, css_len);
css_len = sf_put_marked(opt, root, "b.css", bomb_css, sizeof(bomb_css) - 1);
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,
sf_expand_fixture(opt, bomb_html, sizeof(bomb_html) - 1, &bomb);
(void) singlefile_rewrite_html(opt, root, page, StringBuff(bomb),
StringLength(bomb), (LLint) css_len * 3,
&small);
(void) singlefile_rewrite_html(opt, root, page, bomb_html,
sizeof(bomb_html) - 1,
SINGLEFILE_MAX_PAGE_SIZE, &large);
(void) singlefile_rewrite_html(opt, root, page, StringBuff(bomb),
StringLength(bomb), 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");
@@ -5671,6 +5728,7 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
"the per-page budget was not charged as each asset was taken");
StringFree(small);
StringFree(large);
StringFree(bomb);
}
if (!sf_colon_ok)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -41,8 +41,14 @@ Please visit our Website: http://www.httrack.com
#include "htsstrings.h"
#include "htscharset.h"
#ifdef _WIN32
/* before <stdlib.h>, which is what declares rand_s under it */
#define _CRT_RAND_S
#include "windows.h"
#else
#include <errno.h>
#ifdef HAVE_SYS_RANDOM_H
#include <sys/random.h>
#endif
#include <dirent.h>
#ifdef HAVE_UNISTD_H
#include <unistd.h>
@@ -1443,3 +1449,59 @@ HTSEXT_API hts_boolean hts_findissystem(find_handle find) {
}
return 0;
}
hts_boolean hts_random_bytes(void *buf, size_t len) {
unsigned char *const p = (unsigned char *) buf;
#ifdef _WIN32
size_t i;
/* rand_s() is the CRT's wrapper over the system CSPRNG, and unlike
BCryptGenRandom it needs no extra import library. */
for (i = 0; i < len; i += sizeof(unsigned int)) {
const size_t left = len - i;
unsigned int v;
if (rand_s(&v) != 0)
return HTS_FALSE;
memcpy(p + i, &v, left < sizeof(v) ? left : sizeof(v));
}
return HTS_TRUE;
#else
size_t got = 0;
FILE *fp;
#ifdef HAVE_GETRANDOM
while (got < len) {
const ssize_t n = getrandom(p + got, len - got, 0);
if (n < 0) {
if (errno == EINTR)
continue;
break; /* pre-3.17 kernel or a seccomp filter: try /dev/urandom */
}
got += (size_t) n;
}
if (got == len)
return HTS_TRUE;
#endif
fp = fopen("/dev/urandom", "rb");
if (fp == NULL)
return HTS_FALSE;
got += fread(p + got, 1, len - got, fp);
fclose(fp);
return got == len ? HTS_TRUE : HTS_FALSE;
#endif
}
hts_boolean hts_rename_over(const char *src, const char *dst) {
char csrc[CATBUFF_SIZE], cdst[CATBUFF_SIZE];
fconv(csrc, sizeof(csrc), src);
fconv(cdst, sizeof(cdst), dst);
if (RENAME(csrc, cdst) == 0)
return HTS_TRUE;
/* RENAME does not clobber an existing target on Windows. */
(void) UNLINK(cdst);
return RENAME(csrc, cdst) == 0 ? HTS_TRUE : HTS_FALSE;
}

View File

@@ -137,6 +137,14 @@ HTSEXT_API hts_boolean hts_findisdir(find_handle find);
HTSEXT_API hts_boolean hts_findisfile(find_handle find);
HTSEXT_API hts_boolean hts_findissystem(find_handle find);
/* Move src onto dst, replacing an existing dst; HTS_TRUE on success. Both
paths are fconv()'d. */
hts_boolean hts_rename_over(const char *src, const char *dst);
/* Fill buf with len cryptographically strong random bytes. HTS_FALSE when no
such source is available: the caller must fail, never fall back to rand(). */
hts_boolean hts_random_bytes(void *buf, size_t len);
#endif
#endif

View File

@@ -964,16 +964,6 @@ static zipFile wacz_zip_open(const char *path) {
return zipOpen2_64(path, 0 /*create*/, NULL, &ff);
}
/* Move src onto dst (UTF-8/Windows-safe); RENAME won't clobber on Windows, so
fall back to unlink+rename. Returns 0 on success. */
static int wacz_rename_over(const char *src, const char *dst) {
char cs[CATBUFF_SIZE], cd[CATBUFF_SIZE];
if (RENAME(fconv(cs, sizeof(cs), src), fconv(cd, sizeof(cd), dst)) == 0)
return 0;
(void) UNLINK(fconv(cd, sizeof(cd), dst));
return RENAME(fconv(cs, sizeof(cs), src), fconv(cd, sizeof(cd), dst));
}
/* Package the segment(s) + .cdx + a generated pages.jsonl into <base>.wacz at
crawl end (the archive file(s) and .cdx are already closed on disk). */
static void warc_wacz_package(warc_writer *w) {
@@ -1092,7 +1082,7 @@ static void warc_wacz_package(warc_writer *w) {
hts_log_print(w->opt, LOG_WARNING,
"WACZ: packaging failed, kept existing %s untouched",
waczpath);
} else if (wacz_rename_over(tmppath, waczpath) != 0) {
} else if (!hts_rename_over(tmppath, waczpath)) {
(void) UNLINK(fconv(catbuff, sizeof(catbuff), tmppath));
hts_log_print(w->opt, LOG_WARNING | LOG_ERRNO,
"WACZ: could not finalize %s", waczpath);

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ set -euo pipefail
bash "$top_srcdir/tests/local-crawl.sh" \
--rerun-args '--update -M400000' \
--log-found 'More than 400000 bytes have been transferred.. giving up' \
--log-not-found 'could not restore' \
--found bigtrunc/slow.bin \
--file-min-bytes bigtrunc/slow.bin 655360 \
--file-min-bytes bigtrunc/fast.bin 655360 \

View File

@@ -5,11 +5,16 @@
# real gate: STORE-mode entries, the fixed layout, recomputed sha256 digests and
# the datapackage-digest chain (plus py-wacz/pywb when importable). The crawl
# skips cleanly on a build without OpenSSL (no conformant SHA-256 -> no package).
#
# The cacheless second pass repackages over the .wacz the first one left: the
# clobber, not the happy rename, is what breaks when a platform refuses to
# rename onto an existing file (#726).
set -eu
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 --wacz-validate \
--rerun-args '-C0' --log-not-found 'could not finalize' \
--found 'mini304/index.html' --found 'mini304/page.html' \
httrack 'BASEURL/mini304/index.html' --warc-file warc-out --wacz

View File

@@ -94,7 +94,10 @@ expect_counts_match() {
echo "OK"
}
lines() { printf '%s\n' "$@" | sort; }
digest() { cksum <"$1" | tr -d '[:space:]'; }
# A connection killed before the status line surfaces differently per platform
# (macOS truncates the file, #748; Linux leaves it), so reset.bin is asserted on
# its own and kept out of the exact lists.
listed_but_reset() { listed "$1" | grep -v '/reset\.bin$' | sort; }
# --- pass 1: nothing to compare against --------------------------------------
httrack "${common[@]}" -O "$out" --purge-old=0 "${base}/changes/index.html" \
@@ -117,7 +120,6 @@ grep -aq "first crawl" "${out}/hts-log.txt" || {
}
host="127.0.0.1_${port}"
resetdigest=$(digest "${out}/${host}/changes/reset.bin")
# A leftover from some earlier failed attempt, at a name pass 2 fetches for the
# first time: on disk, but never part of the previous mirror, so it is new.
printf 'leftover junk' >"${out}/${host}/changes/fresh.html"
@@ -142,19 +144,21 @@ expect "gone is doomed.html and the old redirect target" \
expect "changed is exactly the four moved resources" \
"$(lines "${host}/changes/coded.bin" "${host}/changes/index.html" \
"${host}/changes/moved.bin" "${host}/changes/moved.html")" \
"$(listed changed | sort)"
"$(listed_but_reset changed)"
# Re-served byte for byte, 200, no Last-Modified, no ETag. flaky.bin gets there
# through a failed transfer and a retry, so its file is notified twice;
# redirtarget.html arrives behind a 302. sized.html has a byte-identical
# payload, but the rewritten link to the renamed redirect target makes the file
# on disk change length. reset.bin never completes a transfer (#746), so its
# previous copy stands.
expect "unchanged is exactly the seven stable resources" \
# on disk change length.
expect "unchanged is exactly the six stable resources" \
"$(lines "${host}/changes/codedstable.bin" "${host}/changes/flaky.bin" \
"${host}/changes/redirtarget.html" "${host}/changes/reset.bin" \
"${host}/changes/sized.html" "${host}/changes/stable.bin" \
"${host}/changes/stable.html")" \
"$(listed unchanged | sort)"
"${host}/changes/redirtarget.html" "${host}/changes/sized.html" \
"${host}/changes/stable.bin" "${host}/changes/stable.html")" \
"$(listed_but_reset unchanged)"
# reset.bin never completes a transfer, so it drops out of new.lst with its
# mirrored copy still there. Whatever else it is, it is not a deletion.
expect "a failed re-fetch is never reported gone" "" \
"$(listed gone | grep '/reset\.bin$' || true)"
expect_counts_match "pass 2 counts match its lists"
# Every mirrored file appears once and only once, across all four buckets.
@@ -188,15 +192,19 @@ test -f "${out}/${host}/changes/doomed.html" || {
exit 1
}
echo "OK"
expect "a failed transfer keeps its bytes" "$resetdigest" \
"$(digest "${out}/${host}/changes/reset.bin")"
printf '[a failed transfer keeps its file] ..\t'
test -f "${out}/${host}/changes/reset.bin" || {
echo "FAIL: reset.bin lost its previous copy"
exit 1
}
echo "OK"
# --- pass 3: the same run with purging on ------------------------------------
httrack "${common[@]}" -O "$out" --update "${base}/changes/index.html" \
>"${tmpdir}/log3" 2>&1
expect "the report says files were purged" "true" "$(field purged)"
expect "gone is the page that only pass 2 linked" \
"${host}/changes/transient.html" "$(listed gone | sort)"
"${host}/changes/transient.html" "$(listed_but_reset gone)"
printf '[a purged file is off disk] ..\t'
test ! -f "${out}/${host}/changes/transient.html" || {
echo "FAIL: transient.html survived --purge-old"

View File

@@ -37,6 +37,11 @@ cat >"${doc}/index.html" <<'EOF'
<img src="pixel.svg" data-src="lazy.svg" alt="l">
<script src="app.js"></script>
<a href="other.html">other</a>
<p>id=pixel.svg#!htsinline end</p>
<link rel="stylesheet" href="style_noext">
<script src="app_noext"></script>
<img src="pixel.svg#icon">
<a href="pixel.svg">raw</a>
</body></html>
EOF
cat >"${doc}/other.html" <<'EOF'
@@ -50,6 +55,8 @@ cat >"${doc}/nested.css" <<'EOF'
div { background: url(pixel.svg); }
EOF
printf 'var app = 1;\n' >"${doc}/app.js"
printf 'body { color: red; }\n' >"${doc}/style_noext"
printf 'var noext = 1;\n' >"${doc}/app_noext"
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' \
@@ -257,12 +264,107 @@ 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"
echo OK
printf '[a page cannot forge a mark] ..\t'
# The page ships what the old in-band marker looked like, against a real
# mirrored asset. The mark now carries a per-run secret the site cannot spell,
# so the text is not a mark and is not even rewritten: byte-identical to the
# mirror taken without --single-file. pixel.svg is inlined elsewhere on the
# page, so the encoder was demonstrably willing.
forged=$(sed -n 's/.*<p>\(id=[^<]*\)<\/p>.*/\1/p' "$page")
test "$forged" = 'id=pixel.svg#!htsinline end' || {
echo "FAIL: the page was altered: $forged"
exit 1
}
grep -q 'data:image/svg+xml;base64,' "$page" || {
echo "FAIL: nothing was inlined at all, so the probe proves nothing"
exit 1
}
echo OK
printf '[a mark cannot ride in on a header] ..\t'
# htsparse echoes the Content-Type charset into a <meta>; a forged mark there
# bypasses anything that scans the body. Unforgeable means the channel does
# not matter, so this asserts on the channel the body sweep could never see.
grep -q 'charset=127' "$page" || true
if grep -q 'content="text/html;charset=data:' "$page"; then
echo "FAIL: a charset-borne mark was expanded"
exit 1
fi
echo OK
printf '[assets keep no marks] ..\t'
if grep -rlF '#!' "${out}/127.0.0.1_${port}" 2>/dev/null | grep -q .; then
marked=$(grep -rlF '#!' "${out}/127.0.0.1_${port}" | tr '\n' ' ')
# the fixture's own literal is expected in index.html only
test "$marked" = "${out}/127.0.0.1_${port}/index.html " || {
echo "FAIL: marks survived in $marked"
exit 1
}
fi
echo OK
printf '[an unrecognised type still inlines from its context] ..\t'
# style_noext and app_noext have no usable extension, so only the referencing
# context says what they are. plain.css/plain.js are the control: they inline
# either way, so a failure here is the context path and not the encoder.
grep -q 'href="data:text/css;base64,' "$page" || {
echo "FAIL: no stylesheet inlined at all"
exit 1
}
if grep -q 'href="style_noext"' "$page"; then
echo "FAIL: extensionless stylesheet was not inlined"
exit 1
fi
if grep -q 'src="app_noext"' "$page"; then
echo "FAIL: extensionless script was not inlined"
exit 1
fi
echo OK
printf '[a fragment survives onto the data: URI] ..\t'
# An SVG fragment selects a view, so dropping it renders the wrong thing (#766).
grep -q 'data:image/svg+xml;base64,[A-Za-z0-9+/=]*#icon' "$page" || {
echo "FAIL: the fragment was dropped"
exit 1
}
echo OK
printf '[a page link is not inlined even when it names an asset] ..\t'
# <a href> to an image: only the deny rule stops this, since the MIME gate
# would happily take it. Without the rule the anchor becomes a data: URI.
grep -q '<a href="pixel.svg">raw</a>' "$page" || {
echo "FAIL: an anchor to an inlinable asset was rewritten"
exit 1
}
echo OK
printf '[-%%M refuses to pair with --single-file] ..\t'
both=$(httrack -O "${tmpdir}/both" --quiet --robots=0 --retries=0 -%M \
--single-file "${base}/index.html" 2>&1 || true)
case "$both" in
*"pick one"*) echo OK ;;
*)
echo "FAIL: the combination was accepted: $both"
exit 1
;;
esac
printf '[--changes and --single-file together] ..\t'
# Both hook end-of-mirror. Expansion must run first, so the report sees the
# inlined size rather than the marked intermediate.
chg="${tmpdir}/chg"
mkdir "$chg"
httrack "${common[@]}" --single-file --changes "${base}/index.html" \
>"${tmpdir}/log6" 2>&1
chgpage="${chg}/127.0.0.1_${port}/index.html"
test ! -f "$chgpage" || grep -q 'base64,' "$chgpage" || {
echo "FAIL: --changes suppressed the inlining"
exit 1
}
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

View File

@@ -1,28 +0,0 @@
#!/bin/bash
#
# A re-fetch cut mid-header must leave the mirrored file alone (#748): unfixed,
# the aborted read's leftover buffer lands on it. err.bin is the control, an
# HTTP 500 on the same resource already keeping its copy; stay.bin answers
# normally with a new body, so a fix that stopped overwriting anything fails.
#
# Purging is off: an update purge deletes both files for an unrelated reason
# (#746), which would hide what this asserts.
set -euo pipefail
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" \
--rerun-args '--update --purge-old=0' \
--log-found 'after 2 retries at link .*keep/page\.html' \
--log-found 'after 2 retries at link .*keep/data\.bin' \
--file-matches 'keep/page.html' 'KEEP-PAGE-V1' \
--file-not-matches 'keep/page.html' 'HTTP/1\.0 200' \
--file-matches 'keep/data.bin' 'KEEP-BIN-V1' \
--file-not-matches 'keep/data.bin' 'HTTP/1\.0 200' \
--file-min-bytes 'keep/data.bin' 2048 \
--file-matches 'keep/err.bin' 'KEEP-ERR-V1' \
--file-min-bytes 'keep/err.bin' 2048 \
--file-matches 'keep/stay.bin' 'KEEP-STAY-V2' \
--file-min-bytes 'keep/stay.bin' 2048 \
httrack 'BASEURL/keep/index.html' --retries=2

View File

@@ -194,7 +194,6 @@ TESTS = \
92_local-proxytrack-ndx-fields.test \
93_local-changes.test \
94_local-single-file.test \
95_local-sitemap.test \
96_local-refetch-keep.test
95_local-sitemap.test
CLEANFILES = check-network_sh.cache

View File

@@ -57,6 +57,8 @@ rerun_dead=
tmpdir=
serverpid=
crawlpid=
wacz_poisoned=
wacz_poison="stale-wacz-that-a-second-pass-must-replace"
function warning {
echo "** $*" >&2
@@ -272,6 +274,14 @@ if test -n "$warc_validate"; then
test -z "$w1" || cp "$w1" "${tmpdir}/warc-pass1.gz"
fi
# Poison the first-pass .wacz when a second pass follows: repackaging moves the
# new archive over it, so the marker must be gone afterwards (#726). Poisoning
# beats comparing the two packages, which can come out byte-identical.
if test -n "$wacz_validate" && test -n "${rerun}${rerun_args}"; then
wacz_poisoned=$(find "$mirrorroot" -maxdepth 2 -name '*.wacz' 2>/dev/null | sort | tail -n1)
test -z "$wacz_poisoned" || echo "$wacz_poison" >"$wacz_poisoned"
fi
# --- optional second pass: re-mirror into the same dir (cache/update path) ----
if test -n "$rerun"; then
info "re-running httrack (update pass)"
@@ -416,6 +426,12 @@ if test -n "$wacz_validate"; then
fi
die "no .wacz file produced under $mirrorroot"
fi
if test -n "$wacz_poisoned"; then
info "checking the second pass replaced the .wacz"
grep -q "$wacz_poison" "$wacz_poisoned" 2>/dev/null &&
die "stale .wacz kept: $wacz_poisoned"
result "OK"
fi
validator=$(nativepath "${testdir}/wacz-validate.py")
info "validating WACZ package"
"$python" "$validator" "$(nativepath "$wacz")" >&2 || die "WACZ validation failed"

View File

@@ -1000,62 +1000,6 @@ class Handler(SimpleHTTPRequestHandler):
v = 1 if self.refetch_pass() == 1 else 2
self.send_raw(b"<html><body><p>STAY-V%d</p></body></html>" % v, "text/html")
# --- re-fetch cut mid-header, so nothing is stored (#746, #748) ---------
KEEP_PAGE = b"<html><body><p>KEEP-PAGE-V1</p></body></html>"
KEEP_BIN = b"KEEP-BIN-V1\n" + b"\x51\x52\x53\x54" * 512
KEEP_ERR = b"KEEP-ERR-V1\n" + b"\x61\x62\x63\x64" * 512
def send_cut_headers(self):
"""Hang up mid-header: the response never becomes parseable."""
self.close_connection = True
try:
self.wfile.write(b"HTTP/1.0 200 OK\r\nContent-Ty")
self.wfile.flush()
except OSError:
pass
self.connection.close()
def route_keep_index(self):
self.refetch_pass()
self.send_html(
'\t<a href="page.html">page</a>\n'
'\t<a href="data.bin">data</a>\n'
'\t<a href="err.bin">err</a>\n'
'\t<a href="stay.bin">stay</a>\n'
)
def route_keep_page(self):
if self.refetch_pass() == 1:
self.send_raw(self.KEEP_PAGE, "text/html")
else:
self.send_cut_headers()
def route_keep_data(self):
if self.refetch_pass() == 1:
self.send_raw(self.KEEP_BIN, "application/octet-stream")
else:
self.send_cut_headers()
# Control: an HTTP error on the same resource already keeps the copy, so
# the cut-header routes above must end up indistinguishable from it.
def route_keep_err(self):
if self.refetch_pass() == 1:
self.send_raw(self.KEEP_ERR, "application/octet-stream")
else:
self.send_response(500)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", "0")
self.end_headers()
# Control: answers normally, with a new body on pass 2, so a fix that
# stopped overwriting mirrored files altogether would be caught.
def route_keep_stay(self):
v = 1 if self.refetch_pass() == 1 else 2
self.send_raw(
b"KEEP-STAY-V%d\n" % v + b"\x71\x72\x73\x74" * 512,
"application/octet-stream",
)
# Echo what httrack advertised, so a crawl can assert the header.
def route_codec_ae(self):
self.send_raw(
@@ -2006,11 +1950,6 @@ class Handler(SimpleHTTPRequestHandler):
"/uptrunc/page.html": route_uptrunc_page,
"/uptrunc/file.bin": route_uptrunc_file,
"/uptrunc/stay.html": route_uptrunc_stay,
"/keep/index.html": route_keep_index,
"/keep/page.html": route_keep_page,
"/keep/data.bin": route_keep_data,
"/keep/err.bin": route_keep_err,
"/keep/stay.bin": route_keep_stay,
"/types/index.html": route_types_index,
"/types/control.php": route_types,
"/types/photo.png": route_types,